diff --git a/Library-Management-System-Version-2/src/main/resources/static/css/pages.css b/Library-Management-System-Version-2/src/main/resources/static/css/pages.css index 0bc71f2..d452d9a 100644 --- a/Library-Management-System-Version-2/src/main/resources/static/css/pages.css +++ b/Library-Management-System-Version-2/src/main/resources/static/css/pages.css @@ -2,32 +2,32 @@ React client's palette in ../../../../frontend/src/styles.css. */ :root { - --bg: #f6f7f9; - --surface: #ffffff; - --border: #e2e5ea; - --text: #1c2024; - --muted: #6b7280; - --accent: #2f5fd0; - --accent-text: #ffffff; - --ok-bg: #e7f5ec; - --ok-text: #1a7f42; - --error-bg: #fdeceb; - --error-text: #b4291f; + --bg: #e9ece6; + --surface: #f4f5f2; + --border: #d5d9cf; + --text: #191c1a; + --muted: #5c665f; + --accent: #1d4235; + --accent-text: #f4f5f2; + --ok-bg: #dde7e0; + --ok-text: #1d4235; + --error-bg: #f8e2e0; + --error-text: #b4322a; } @media (prefers-color-scheme: dark) { :root { - --bg: #14161a; - --surface: #1c1f24; - --border: #2c313a; - --text: #e8eaed; - --muted: #9aa2ae; - --accent: #7aa2f7; - --accent-text: #14161a; - --ok-bg: #16301f; - --ok-text: #6ed08d; - --error-bg: #351b1a; - --error-text: #f08a80; + --bg: #101613; + --surface: #171f1b; + --border: #2a352f; + --text: #e8e6de; + --muted: #98a49c; + --accent: #8cc0a5; + --accent-text: #101613; + --ok-bg: #1a2f25; + --ok-text: #8cc0a5; + --error-bg: #33191a; + --error-text: #e8867d; } } diff --git a/frontend/design/tokens.test.ts b/frontend/design/tokens.test.ts new file mode 100644 index 0000000..0e95519 --- /dev/null +++ b/frontend/design/tokens.test.ts @@ -0,0 +1,73 @@ +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { describe, expect, it } from 'vitest' + +/* + * This lives outside src/ on purpose. It reads the stylesheet off disk, which makes it Node code, + * and src/ is compiled by tsconfig.app.json with the node types deliberately withheld so that a + * stray `process` in browser code is a compile error. Importing the file as `?raw` from inside src/ + * does not work either: Vitest stubs CSS out and hands back an empty string. + * + * Resolved from the working directory rather than import.meta.url, which Vitest serves over http + * and fileURLToPath will not take. + */ +const styles = readFileSync(resolve(process.cwd(), 'src/styles.css'), 'utf8') + +/** + * The identity is a contract, not a suggestion. + * + * Every component in the app is written once against token *names*, so a half-applied palette does + * not break anything loudly - it just quietly renders the old product in a few places. These + * assertions are the thing that notices. See docs/superpowers/specs/2026-08-22-direction-c-spine.md. + */ +const lower = styles.toLowerCase() + +describe('the direction C token layer', () => { + it('declares the five named colours of the palette', () => { + const palette = { + stacks: '#18241e', + desk: '#f4f5f2', + 'shelf light': '#e8e6de', + interactive: '#1d4235', + overdue: '#b4322a', + } + + for (const [role, hex] of Object.entries(palette)) { + expect(lower, `${role} (${hex}) is missing`).toContain(hex) + } + }) + + it('names the stacks and the desk as scopes, so no component is written twice', () => { + expect(styles).toMatch(/--stacks:/) + // `.stacks` opens a rule of its own or shares one - the navigation is always in the stacks. + expect(styles).toMatch(/^\.stacks[\s,{]/m) + }) + + it('declares three type roles and no fourth', () => { + expect(styles).toContain("--font-display: 'Fraunces Variable'") + expect(styles).toContain("--font-body: 'Public Sans Variable'") + expect(styles).toContain("--font-mono: 'IBM Plex Mono'") + }) + + it('pins the display axes the spec calls for', () => { + expect(styles).toContain("--display-axes: 'opsz' 120, 'wght' 500, 'SOFT' 40, 'WONK' 1") + }) + + it('turns off faux bold, which a variable face does not need', () => { + expect(styles).toMatch(/font-synthesis:\s*none/) + }) + + it('keeps no trace of the blue-and-grey palette it replaced', () => { + for (const retired of ['#2f5fd0', '#7aa2f7', '#2850b4', '#93b4ff']) { + expect(lower, `${retired} is still here`).not.toContain(retired) + } + }) + + it('still answers to a dark colour scheme', () => { + expect(styles).toMatch(/@media \(prefers-color-scheme: dark\)/) + }) + + it('offers the screen-reader-only utility the shelf is built on', () => { + expect(styles).toMatch(/^\.sr-only\s*\{/m) + }) +}) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index c0df3ce..faeaf7f 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,6 +8,9 @@ "name": "frontend", "version": "0.0.0", "dependencies": { + "@fontsource-variable/fraunces": "^5.3.0", + "@fontsource-variable/public-sans": "^5.3.0", + "@fontsource/ibm-plex-mono": "^5.3.0", "react": "^19.2.8", "react-dom": "^19.2.8", "react-router-dom": "^7.18.2" @@ -343,6 +346,33 @@ } } }, + "node_modules/@fontsource-variable/fraunces": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource-variable/fraunces/-/fraunces-5.3.0.tgz", + "integrity": "sha512-9BYGySn4AHEJdgp9Z28tQ3X+laJMEOITXkQarZXeloWQZDq5oOvXJ3kDA8c7MGIfpogIaZfjrQBqmda8POOCKA==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource-variable/public-sans": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource-variable/public-sans/-/public-sans-5.3.0.tgz", + "integrity": "sha512-AVfkmAt50BMXWpOO21FAntiJFKGX6xTc2dSL8dxtDteONe9IuRXJWGbs0EbG955vAMCq23ENeuopuW87cGWDSQ==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/ibm-plex-mono": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/ibm-plex-mono/-/ibm-plex-mono-5.3.0.tgz", + "integrity": "sha512-eTgnZjZEGk1QtD3ZstF+Vclo2HLAni8YMy34/DxllwZvyz1lR/1RF/xTiAquOBO7MvqBx8D2Ig2WCPMVfdZu7Q==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index 8c21fc4..e8b5041 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -28,6 +28,9 @@ "vitest": "^4.1.10" }, "dependencies": { + "@fontsource-variable/fraunces": "^5.3.0", + "@fontsource-variable/public-sans": "^5.3.0", + "@fontsource/ibm-plex-mono": "^5.3.0", "react": "^19.2.8", "react-dom": "^19.2.8", "react-router-dom": "^7.18.2" diff --git a/frontend/src/components/BookCover.test.tsx b/frontend/src/components/BookCover.test.tsx new file mode 100644 index 0000000..e0ef6d3 --- /dev/null +++ b/frontend/src/components/BookCover.test.tsx @@ -0,0 +1,95 @@ +import { describe, expect, it } from 'vitest' +import { fireEvent, render } from '@testing-library/react' +import { BookCover } from './BookCover' + +/** The cover is decorative (`alt=""`), so it has no role to find it by. */ +function coverIn(container: HTMLElement): HTMLImageElement | null { + return container.querySelector('img') +} + +/** Open Library answers a missing cover with a 1x1 image, so a successful load is not proof. */ +function loadWithWidth(image: HTMLImageElement, naturalWidth: number) { + Object.defineProperty(image, 'naturalWidth', { value: naturalWidth, configurable: true }) + fireEvent.load(image) +} + +describe('BookCover', () => { + it('asks by cover id first, which resolves faster than an ISBN', () => { + const { container } = render() + + expect(coverIn(container)).toHaveAttribute( + 'src', + 'https://covers.openlibrary.org/b/id/12345-M.jpg', + ) + }) + + it('falls back to the ISBN when the cover id does not resolve', () => { + const { container } = render() + + fireEvent.error(coverIn(container)!) + + expect(coverIn(container)).toHaveAttribute( + 'src', + 'https://covers.openlibrary.org/b/isbn/9780441478125-M.jpg', + ) + }) + + it('strips punctuation out of the ISBN address', () => { + const { container } = render() + + expect(coverIn(container)).toHaveAttribute( + 'src', + 'https://covers.openlibrary.org/b/isbn/9780441478125-M.jpg', + ) + }) + + it("treats Open Library's 1x1 placeholder as a miss, not a hit", () => { + const { container } = render() + + loadWithWidth(coverIn(container)!, 1) + + expect(coverIn(container)).toHaveAttribute( + 'src', + 'https://covers.openlibrary.org/b/isbn/9780441478125-M.jpg', + ) + }) + + it('keeps a cover that actually arrived', () => { + const { container } = render() + + loadWithWidth(coverIn(container)!, 180) + + expect(coverIn(container)).toHaveAttribute( + 'src', + 'https://covers.openlibrary.org/b/id/12345-M.jpg', + ) + }) + + it('shows the fallback rather than a broken image once every source has failed', () => { + const { container } = render( + } + />, + ) + + fireEvent.error(coverIn(container)!) + fireEvent.error(coverIn(container)!) + + expect(coverIn(container)).toBeNull() + expect(container.querySelector('[data-testid="no-cover"]')).toBeInTheDocument() + }) + + it('renders nothing at all when there is no fallback to render', () => { + const { container } = render() + + expect(container).toBeEmptyDOMElement() + }) + + it('passes its class through, so each caller sizes its own cover', () => { + const { container } = render() + + expect(coverIn(container)).toHaveClass('discover-cover') + }) +}) diff --git a/frontend/src/components/BookCover.tsx b/frontend/src/components/BookCover.tsx new file mode 100644 index 0000000..155920d --- /dev/null +++ b/frontend/src/components/BookCover.tsx @@ -0,0 +1,49 @@ +import { useState } from 'react' +import type { ReactNode } from 'react' + +interface BookCoverProps { + isbn: string + /** The catalogue's own cover id, where it gave one: it resolves faster than an ISBN lookup. */ + coverId?: number | null + className?: string + /** + * What to render once no source has resolved. Left null on the shelf, where the cloth behind the + * cover is a perfectly good answer; Discover passes its placeholder so cards stay aligned. + */ + fallback?: ReactNode +} + +/** + * A cover, addressed by cover id where the catalogue gave one and by ISBN otherwise - the search + * endpoint often omits `cover_i` for editions that do have a cover. Falls back rather than leaving + * a broken image when neither resolves. + * + * Which source it is on is per-mount state, so callers must keep this under a key that changes with + * the book. Both of them already do: Discover keys its cards, and the shelf only ever mounts one of + * these, inside the selected spine. + */ +export function BookCover({ isbn, coverId = null, className, fallback = null }: BookCoverProps) { + const sources = [ + coverId ? `https://covers.openlibrary.org/b/id/${coverId}-M.jpg` : null, + isbn ? `https://covers.openlibrary.org/b/isbn/${isbn.replace(/[^0-9Xx]/g, '')}-M.jpg` : null, + ].filter((url): url is string => Boolean(url)) + + const [attempt, setAttempt] = useState(0) + + if (attempt >= sources.length) return <>{fallback} + + return ( + setAttempt((n) => n + 1)} + onLoad={(event) => { + if (event.currentTarget.naturalWidth <= 1) setAttempt((n) => n + 1) + }} + /> + ) +} diff --git a/frontend/src/components/Shelf.test.tsx b/frontend/src/components/Shelf.test.tsx new file mode 100644 index 0000000..a9786e6 --- /dev/null +++ b/frontend/src/components/Shelf.test.tsx @@ -0,0 +1,188 @@ +import { describe, expect, it, vi } from 'vitest' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { Shelf } from './Shelf' +import { clothFor, spineHeight } from '../lib/cloth' +import type { Book } from '../types/domain' + +function book(overrides: Partial = {}): Book { + return { + bookId: 'book-cities', + title: 'Invisible Cities', + isbn: '9780156453806', + publicationYear: 1972, + createdAt: '2026-01-04', + authors: [{ authorId: 'a1', name: 'Italo Calvino', bio: '' }], + available: true, + ...overrides, + } +} + +const piranesi = book({ + bookId: 'book-piranesi', + title: 'Piranesi', + isbn: '9781635575637', + authors: [{ authorId: 'a2', name: 'Susanna Clarke', bio: '' }], + available: false, +}) + +function renderShelf(props: Partial[0]> = {}) { + const onSelect = vi.fn() + const view = render( + , + ) + return { ...view, onSelect } +} + +describe('the shelf', () => { + it('is a list, so a screen reader reads it as one', () => { + renderShelf() + + expect(screen.getByRole('list', { name: 'Books on the shelf' })).toBeInTheDocument() + expect(screen.getAllByRole('listitem')).toHaveLength(2) + }) + + it('is made of native buttons, so tab, Enter and Space work unaided', () => { + renderShelf() + + expect(screen.getAllByRole('button')).toHaveLength(2) + }) + + it('shows a book that is in as its title, set down the spine', () => { + const { container } = renderShelf() + + expect(container.querySelector('.spine-title')).toHaveTextContent('Invisible Cities') + }) + + it('names the author and the status for a screen reader', () => { + renderShelf() + + expect( + screen.getByRole('button', { name: 'Invisible Cities Italo Calvino, on shelf' }), + ).toBeInTheDocument() + }) + + it('renders a borrowed book as a gap stamped with its due date', () => { + const { container } = renderShelf({ dueDates: new Map([['book-piranesi', '2026-09-12']]) }) + + const gap = container.querySelector('.spine.is-out') + expect(gap).toBeInTheDocument() + expect(gap?.querySelector('.spine-due')).toHaveTextContent('DUE 12 SEP') + expect(gap?.querySelector('.spine-title')).toBeNull() + }) + + /** The gap's visible text is the stamp, so its accessible name has to carry the title. */ + it('tells a screen reader whose gap it is and when it is due', () => { + renderShelf({ dueDates: new Map([['book-piranesi', '2026-09-12']]) }) + + expect( + screen.getByRole('button', { name: 'DUE 12 SEP Piranesi, Susanna Clarke, on loan, due 12 September' }), + ).toBeInTheDocument() + }) + + it('still shows the gap when nobody will say when it is due', () => { + const { container } = renderShelf() + + expect(container.querySelector('.spine.is-out .spine-due')).toHaveTextContent('ON LOAN') + expect(screen.getByRole('button', { name: 'ON LOAN Piranesi, Susanna Clarke, on loan' })).toBeInTheDocument() + }) + + it('opens the book that was clicked', async () => { + const { onSelect } = renderShelf() + + await userEvent.click(screen.getByRole('button', { name: /Invisible Cities/ })) + + expect(onSelect).toHaveBeenCalledWith('book-cities') + }) + + it('opens a gap too, because a gap is still a book', async () => { + const { onSelect } = renderShelf() + + await userEvent.click(screen.getByRole('button', { name: /Piranesi/ })) + + expect(onSelect).toHaveBeenCalledWith('book-piranesi') + }) + + it('turns the selected book face-out and fetches only that one cover', () => { + const { container } = renderShelf({ selectedId: 'book-cities' }) + + const covers = container.querySelectorAll('img') + expect(covers).toHaveLength(1) + expect(covers[0]).toHaveAttribute( + 'src', + 'https://covers.openlibrary.org/b/isbn/9780156453806-M.jpg', + ) + expect(container.querySelector('.spine.is-selected')).toBeInTheDocument() + }) + + /** + * The whole point of the design: a borrowed book is not on the shelf, so there is nothing there + * to turn around. Selecting it lights the gap and opens the panel, and that is all. + */ + it('leaves a selected gap as a gap', () => { + const { container } = renderShelf({ selectedId: 'book-piranesi' }) + + expect(container.querySelectorAll('img')).toHaveLength(0) + expect(container.querySelector('.spine.is-out.is-selected')).toBeInTheDocument() + expect(container.querySelector('.spine-face')).toBeNull() + }) + + it('marks the selected spine as pressed rather than inventing a role', () => { + renderShelf({ selectedId: 'book-cities' }) + + expect(screen.getByRole('button', { name: /Invisible Cities/ })).toHaveAttribute( + 'aria-pressed', + 'true', + ) + expect(screen.getByRole('button', { name: /Piranesi/ })).toHaveAttribute( + 'aria-pressed', + 'false', + ) + }) + + it('binds each spine from its own ISBN', () => { + const { container } = renderShelf() + + const spine = container.querySelector('.spine:not(.is-out)') + expect(spine?.style.getPropertyValue('--cloth')).toBe(clothFor('9780156453806')) + expect(spine?.style.getPropertyValue('--spine-height')).toBe( + `${spineHeight('9780156453806')}px`, + ) + }) + + it('falls back to the book id when the catalogue has no ISBN', () => { + const { container } = renderShelf({ books: [book({ isbn: '' })] }) + + const spine = container.querySelector('.spine') + expect(spine?.style.getPropertyValue('--cloth')).toBe(clothFor('book-cities')) + }) + + it('holds the shelf open while a page is in flight, without animating anything in', () => { + const { container } = renderShelf({ loading: true, books: [] }) + + expect(screen.getByRole('list', { name: 'Books on the shelf' })).toHaveAttribute( + 'aria-busy', + 'true', + ) + expect(container.querySelectorAll('.spine.is-loading').length).toBeGreaterThan(0) + expect(screen.queryAllByRole('button')).toHaveLength(0) + }) + + it('leaves the board standing when there is nothing on it', () => { + const { container } = renderShelf({ + books: [], + emptyState:

The catalogue is empty

, + }) + + expect(container.querySelector('.shelf-board')).toBeInTheDocument() + expect(screen.getByText('The catalogue is empty')).toBeInTheDocument() + expect(screen.queryAllByRole('button')).toHaveLength(0) + }) +}) diff --git a/frontend/src/components/Shelf.tsx b/frontend/src/components/Shelf.tsx new file mode 100644 index 0000000..417fbcc --- /dev/null +++ b/frontend/src/components/Shelf.tsx @@ -0,0 +1,161 @@ +import type { CSSProperties, ReactNode } from 'react' +import { clothFor, spineHeight } from '../lib/cloth' +import { BookCover } from './BookCover' +import type { Book } from '../types/domain' + +interface ShelfProps { + books: Book[] + /** The face-out book, or the lit gap. Survives closing the panel, so the shelf remembers. */ + selectedId: string | null + /** Book id to due date. Partial by design - see ../hooks/useDueDates.ts. */ + dueDates: Map + loading: boolean + onSelect: (bookId: string) => void + /** Shown on the bare board when there is nothing to shelve. */ + emptyState?: ReactNode +} + +/** Enough slots to hold the board open at its usual height while a page is in flight. */ +const LOADING_SLOTS = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l'] + +/** + * A due date is a calendar day, not an instant. Reading `2026-09-12` with `new Date` would place it + * at midnight UTC and hand back the 11th to anyone west of it, so the parts are read directly. + */ +function parseDay(value: string): Date | null { + const parts = /^(\d{4})-(\d{2})-(\d{2})/.exec(value) + if (parts) return new Date(Number(parts[1]), Number(parts[2]) - 1, Number(parts[3])) + + const parsed = new Date(value) + return Number.isNaN(parsed.valueOf()) ? null : parsed +} + +/** + * The stamp down a gap: "DUE 12 SEP". + * + * en-GB rather than the reader's locale, because the stamp sits in a fixed vertical slot and + * "SEPTEMBER 12, 2026" would not fit in it. The product is written in British English anyway. + */ +function stamp(dueDate: string | null): string { + const day = dueDate ? parseDay(dueDate) : null + if (!day) return 'ON LOAN' + + const date = day.toLocaleDateString('en-GB', { day: '2-digit' }) + // Trimmed to three letters: en-GB abbreviates September to "Sept", and the slot is cut for a + // stamp of one width. Every other month is three letters already, so nothing else changes. + const month = day.toLocaleDateString('en-GB', { month: 'short' }).slice(0, 3).toUpperCase() + + return `DUE ${date} ${month}` +} + +/** What a screen reader is told beyond the text already on the spine. */ +function spoken(book: Book, dueDate: string | null): string { + const authors = book.authors?.map((author) => author.name).join(', ') + const day = dueDate ? parseDay(dueDate) : null + + const where = book.available + ? 'on shelf' + : day + ? `on loan, due ${day.toLocaleDateString('en-GB', { day: 'numeric', month: 'long' })}` + : 'on loan' + + return [authors, where].filter(Boolean).join(', ') +} + +/** + * One page of the catalogue, as a shelf. + * + * A borrowed book is not a spine wearing a status pill - it is a gap, with its due date stamped + * down it. That is the one thing this component exists to say, and everything else stays quiet to + * pay for it. The vertical text is a CSS treatment only: the text itself stays selectable, findable + * with ctrl-F and read in order. + * + * See ../../../docs/superpowers/specs/2026-08-22-direction-c-spine.md. + */ +export function Shelf({ + books, + selectedId, + dueDates, + loading, + onSelect, + emptyState, +}: ShelfProps) { + if (loading) { + return ( +
+
    + {LOADING_SLOTS.map((slot) => ( +
  • + +
  • + ))} +
+
+ ) + } + + if (books.length === 0) { + return
{emptyState}
+ } + + return ( +
+
    + {books.map((book) => { + // A book with no ISBN is bound from its id instead: every spine gets a binding. + const seed = book.isbn || book.bookId + const out = !book.available + const due = dueDates.get(book.bookId) ?? null + const selected = book.bookId === selectedId + // A borrowed book is not there to be turned around. + const faceOut = selected && !out + + return ( +
  • + +
  • + ) + })} +
+
+ ) +} diff --git a/frontend/src/hooks/useBooksView.test.ts b/frontend/src/hooks/useBooksView.test.ts new file mode 100644 index 0000000..288bd8b --- /dev/null +++ b/frontend/src/hooks/useBooksView.test.ts @@ -0,0 +1,58 @@ +import { act, renderHook } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import { BOOKS_VIEW_KEY, useBooksView } from './useBooksView' + +describe('useBooksView', () => { + it('opens the shelf for a member, who came to browse', () => { + const { result } = renderHook(() => useBooksView(false)) + + expect(result.current[0]).toBe('shelf') + }) + + it('opens the list for the desk, which came to work', () => { + const { result } = renderHook(() => useBooksView(true)) + + expect(result.current[0]).toBe('list') + }) + + it('lets a choice beat the role default', () => { + localStorage.setItem(BOOKS_VIEW_KEY, 'shelf') + + const { result } = renderHook(() => useBooksView(true)) + + expect(result.current[0]).toBe('shelf') + }) + + it('remembers the choice past the end of the tab', () => { + const { result } = renderHook(() => useBooksView(false)) + + act(() => result.current[1]('list')) + + expect(result.current[0]).toBe('list') + expect(localStorage.getItem(BOOKS_VIEW_KEY)).toBe('list') + }) + + it('ignores a stored value that is not a view', () => { + localStorage.setItem(BOOKS_VIEW_KEY, 'carousel') + + const { result } = renderHook(() => useBooksView(true)) + + expect(result.current[0]).toBe('list') + }) + + /** Private browsing can refuse storage outright, and that is not a reason to refuse the choice. */ + it('still switches when the browser will not remember anything', () => { + const original = Storage.prototype.setItem + Storage.prototype.setItem = () => { + throw new Error('storage is disabled') + } + + try { + const { result } = renderHook(() => useBooksView(true)) + act(() => result.current[1]('shelf')) + expect(result.current[0]).toBe('shelf') + } finally { + Storage.prototype.setItem = original + } + }) +}) diff --git a/frontend/src/hooks/useBooksView.ts b/frontend/src/hooks/useBooksView.ts new file mode 100644 index 0000000..212f649 --- /dev/null +++ b/frontend/src/hooks/useBooksView.ts @@ -0,0 +1,42 @@ +import { useCallback, useState } from 'react' + +export type BooksView = 'shelf' | 'list' + +/** + * localStorage, deliberately, where the session lives in sessionStorage. The session is scoped to + * the tab on purpose - a shared machine should not resume as whoever signed in last - but how + * somebody prefers to look at a catalogue is not a secret and should outlive the tab. + */ +export const BOOKS_VIEW_KEY = 'library.books.view' + +function stored(): BooksView | null { + try { + const value = localStorage.getItem(BOOKS_VIEW_KEY) + return value === 'shelf' || value === 'list' ? value : null + } catch { + // Private browsing can refuse storage outright. The role default is a fine answer. + return null + } +} + +/** + * Shelf or list, remembered. + * + * A member came to browse and gets the shelf; the desk came to work and gets the table. The default + * follows the role rather than making everyone choose, but once somebody does choose, the choice is + * theirs and it sticks. + */ +export function useBooksView(isAdmin: boolean): [BooksView, (view: BooksView) => void] { + const [view, setView] = useState(() => stored() ?? (isAdmin ? 'list' : 'shelf')) + + const choose = useCallback((next: BooksView) => { + setView(next) + try { + localStorage.setItem(BOOKS_VIEW_KEY, next) + } catch { + // Not being able to remember the choice is no reason to refuse it. + } + }, []) + + return [view, choose] +} diff --git a/frontend/src/hooks/useDueDates.test.ts b/frontend/src/hooks/useDueDates.test.ts new file mode 100644 index 0000000..40dc328 --- /dev/null +++ b/frontend/src/hooks/useDueDates.test.ts @@ -0,0 +1,120 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { renderHook, waitFor } from '@testing-library/react' +import { useDueDates } from './useDueDates' + +const fetchMock = vi.fn() + +function respond(status: number, body: unknown) { + return new Response(typeof body === 'string' ? body : JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }) +} + +function page(data: unknown[]) { + return respond(200, { data, totalPages: 1, currentPage: 0, totalItems: data.length }) +} + +const activeLoan = { + transactionId: 't1', + customerId: 'c1', + bookId: 'book-piranesi', + borrowDate: '2026-08-01', + dueDate: '2026-09-12', + returnDate: null, + extended: false, +} + +const settledLoan = { + ...activeLoan, + transactionId: 't2', + bookId: 'book-returned', + returnDate: '2026-08-20', +} + +function requestedPaths(): string[] { + return fetchMock.mock.calls.map(([input]) => String(input)) +} + +beforeEach(() => { + fetchMock.mockReset() + vi.stubGlobal('fetch', fetchMock) +}) + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('useDueDates', () => { + it('reads every active loan in the library for the desk', async () => { + fetchMock.mockResolvedValue(page([activeLoan])) + + const { result } = renderHook(() => useDueDates('library')) + + await waitFor(() => expect(result.current.get('book-piranesi')).toBe('2026-09-12')) + expect(requestedPaths()[0]).toContain('/admin/loans?activeOnly=true') + }) + + it('reads only their own loans for a member', async () => { + fetchMock.mockResolvedValue(page([activeLoan])) + + const { result } = renderHook(() => useDueDates('mine')) + + await waitFor(() => expect(result.current.size).toBe(1)) + expect(requestedPaths()[0]).toContain('/transactions/me') + }) + + /** A staff account with no membership can see neither, so it must not ask for either. */ + it('asks for nothing when the account can see no loans', async () => { + const { result } = renderHook(() => useDueDates('none')) + + await waitFor(() => expect(result.current.size).toBe(0)) + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('ignores loans that have already come back', async () => { + fetchMock.mockResolvedValue(page([activeLoan, settledLoan])) + + const { result } = renderHook(() => useDueDates('library')) + + await waitFor(() => expect(result.current.size).toBe(1)) + expect(result.current.has('book-returned')).toBe(false) + }) + + /** + * The gap is the fact that matters, and it does not come from here. A shelf that cannot name a + * date still shows every gap, so a failure has to be silent rather than fatal. + */ + it('comes back empty rather than throwing when the request fails', async () => { + fetchMock.mockResolvedValue(respond(503, { message: 'The library server is not responding.' })) + + const { result } = renderHook(() => useDueDates('library')) + + await waitFor(() => expect(fetchMock).toHaveBeenCalled()) + expect(result.current.size).toBe(0) + }) + + it('asks again when a book is borrowed or returned', async () => { + fetchMock.mockResolvedValue(page([activeLoan])) + + const { result, rerender } = renderHook(({ key }) => useDueDates('library', key), { + initialProps: { key: 0 }, + }) + await waitFor(() => expect(result.current.size).toBe(1)) + + fetchMock.mockResolvedValue(page([])) + rerender({ key: 1 }) + + await waitFor(() => expect(result.current.size).toBe(0)) + expect(fetchMock).toHaveBeenCalledTimes(2) + }) + + it('looks at one bounded page of loans, never one request per gap', async () => { + fetchMock.mockResolvedValue(page([activeLoan])) + + renderHook(() => useDueDates('library')) + + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)) + expect(requestedPaths()[0]).toMatch(/size=100\b/) + }) +}) diff --git a/frontend/src/hooks/useDueDates.ts b/frontend/src/hooks/useDueDates.ts new file mode 100644 index 0000000..4a1977a --- /dev/null +++ b/frontend/src/hooks/useDueDates.ts @@ -0,0 +1,57 @@ +import { useEffect, useState } from 'react' +import { transactionsApi } from '../api/services' + +/** + * Which loans this account is allowed to see: every one in the library, only its own, or none at + * all - a staff account holds no membership, so there is nothing of its own to read. + */ +export type LoanScope = 'library' | 'mine' | 'none' + +/** One bounded look at the loan list. Deep enough for a real shelf, capped so it cannot run away. */ +const LOAN_SCAN_SIZE = 100 + +/** + * Due dates for the books on the shelf, keyed by book id. + * + * The paginated catalogue says a book is out but not when it is due back, and the shelf stamps that + * date down the gap where the book should be. Asking /books/{id} for each gap would be twenty-odd + * requests a page, which is exactly what the cover art already refuses to do - so this asks once. + * + * Whatever the one request does not cover, the shelf reads as "ON LOAN", which is still true. The + * gap itself is the fact that matters and it never depends on this resolving. + */ +export function useDueDates(scope: LoanScope, refreshKey = 0): Map { + const [dueDates, setDueDates] = useState>(new Map()) + + useEffect(() => { + if (scope === 'none') return + + let cancelled = false + + const loans = + scope === 'library' + ? transactionsApi.allLoans(true, 0, LOAN_SCAN_SIZE) + : transactionsApi.mine(0, LOAN_SCAN_SIZE) + + loans + .then((page) => { + if (cancelled) return + const found = new Map() + for (const loan of page.data) { + // activeOnly is the server's word for it; a returned loan still has a due date in it. + if (!loan.returnDate && loan.dueDate) found.set(loan.bookId, loan.dueDate) + } + setDueDates(found) + }) + .catch(() => { + // Silent on purpose: see above. An error notice here would be about a decoration. + if (!cancelled) setDueDates(new Map()) + }) + + return () => { + cancelled = true + } + }, [scope, refreshKey]) + + return dueDates +} diff --git a/frontend/src/lib/cloth.test.ts b/frontend/src/lib/cloth.test.ts new file mode 100644 index 0000000..e2efdcc --- /dev/null +++ b/frontend/src/lib/cloth.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest' +import { CLOTHS, SPINE_MAX_HEIGHT, SPINE_MIN_HEIGHT, clothFor, spineHeight } from './cloth' + +/** Enough distinct seeds to say something about the spread rather than about one book. */ +const seeds = Array.from({ length: 400 }, (_, index) => `97814${String(index).padStart(6, '0')}`) + +describe('binding cloth', () => { + it('offers exactly the eight cloths the design specifies', () => { + expect(CLOTHS).toHaveLength(8) + expect(new Set(CLOTHS).size).toBe(8) + for (const cloth of CLOTHS) { + expect(cloth).toMatch(/^#[0-9a-f]{6}$/) + } + }) + + it('binds the same book the same way every time', () => { + expect(clothFor('9780441478125')).toBe(clothFor('9780441478125')) + expect(spineHeight('9780441478125')).toBe(spineHeight('9780441478125')) + }) + + it('only ever uses a cloth from the palette', () => { + for (const seed of seeds) { + expect(CLOTHS).toContain(clothFor(seed)) + } + }) + + it('uses all eight cloths rather than favouring a few', () => { + const used = new Set(seeds.map(clothFor)) + expect(used.size).toBe(8) + }) + + it('keeps every spine inside the height band', () => { + for (const seed of seeds) { + const height = spineHeight(seed) + expect(height).toBeGreaterThanOrEqual(SPINE_MIN_HEIGHT) + expect(height).toBeLessThanOrEqual(SPINE_MAX_HEIGHT) + expect(Number.isInteger(height)).toBe(true) + } + }) + + it('reaches both ends of the band, so the shelf is not flat', () => { + const heights = seeds.map(spineHeight) + expect(Math.min(...heights)).toBe(SPINE_MIN_HEIGHT) + expect(Math.max(...heights)).toBe(SPINE_MAX_HEIGHT) + }) + + /** + * The point of drawing height separately: if it were derived from the same number as the colour, + * every book in one cloth would be exactly as tall as every other, and the shelf would visibly + * repeat in eight-book blocks. + */ + it('draws height independently of colour', () => { + for (const cloth of CLOTHS) { + const heights = new Set(seeds.filter((seed) => clothFor(seed) === cloth).map(spineHeight)) + expect(heights.size).toBeGreaterThan(1) + } + }) + + it('survives a book with no ISBN to hash', () => { + expect(CLOTHS).toContain(clothFor('')) + expect(spineHeight('')).toBeGreaterThanOrEqual(SPINE_MIN_HEIGHT) + }) + + /** + * The catalogue holds ISBNs both hyphenated and bare - the ISBN lookup writes one form, a typed + * entry the other. They are the same book, so they must be the same binding. + */ + it('binds one book one way however its ISBN is punctuated', () => { + expect(clothFor('978-0-441-47812-5')).toBe(clothFor('9780441478125')) + expect(spineHeight('978-0-441-47812-5')).toBe(spineHeight('9780441478125')) + expect(clothFor('978 0 441 47812 5')).toBe(clothFor('9780441478125')) + }) + + /** A book with no ISBN is seeded from its id instead, which is a UUID and mostly letters. */ + it('keeps letters in the seed, so an id works where an ISBN is missing', () => { + const id = '9f8c1b2a-7d3e-4c5f-a6b7-c8d9e0f1a2b3' + expect(CLOTHS).toContain(clothFor(id)) + expect(clothFor(id)).not.toBe(clothFor('9f8c1b2a')) + }) +}) diff --git a/frontend/src/lib/cloth.ts b/frontend/src/lib/cloth.ts new file mode 100644 index 0000000..aed7da7 Binary files /dev/null and b/frontend/src/lib/cloth.ts differ diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 3043bfd..58c8b2c 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,6 +1,20 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import { App } from './App' + +/* + * The three faces of direction C, self-hosted and bundled by Vite: the app owes nothing to a font + * CDN, works offline, and works from the GitHub Pages subdirectory. + * + * Fraunces needs `full.css` specifically - the SOFT and WONK axes the display setting turns up are + * not in the package's default build. Plex Mono is not a variable face, so its three weights are + * asked for by name. + */ +import '@fontsource-variable/fraunces/full.css' +import '@fontsource-variable/public-sans' +import '@fontsource/ibm-plex-mono/400.css' +import '@fontsource/ibm-plex-mono/500.css' +import '@fontsource/ibm-plex-mono/600.css' import './styles.css' const container = document.getElementById('root') diff --git a/frontend/src/pages/BooksPage.tsx b/frontend/src/pages/BooksPage.tsx index 399103d..7fc9dc0 100644 --- a/frontend/src/pages/BooksPage.tsx +++ b/frontend/src/pages/BooksPage.tsx @@ -6,30 +6,64 @@ import { BookDetail } from '../components/BookDetail' import { BookForm } from '../components/BookForm' import { CatalogImport } from '../components/CatalogImport' import { Pagination } from '../components/Pagination' +import { Shelf } from '../components/Shelf' import { EmptyState, SearchBox, SkeletonRows } from '../components/TableStates' import { ErrorNotice } from '../components/ErrorNotice' import { useApiCall } from '../hooks/useApiCall' +import { useBooksView } from '../hooks/useBooksView' +import { useDueDates } from '../hooks/useDueDates' +import type { BooksView } from '../hooks/useBooksView' +import type { LoanScope } from '../hooks/useDueDates' import type { Book, Page } from '../types/domain' const DEFAULT_PAGE_SIZE = 25 +/** Two ways to look at one catalogue. Neither is a mode: the data underneath is identical. */ +function ViewToggle({ view, onView }: { view: BooksView; onView: (view: BooksView) => void }) { + return ( +
+ {(['shelf', 'list'] as const).map((option) => ( + + ))} +
+ ) +} + export function BooksPage() { - const { isAdmin } = useAuth() + const { isAdmin, session } = useAuth() const [term, setTerm] = useState('') const [query, setQuery] = useState('') const [page, setPage] = useState(0) const [size, setSize] = useState(DEFAULT_PAGE_SIZE) const [reloadKey, setReloadKey] = useState(0) + // Two ideas, not one: `selected` is where the shelf remembers you were, `detail` is whether the + // panel is open. Closing the panel must not put the shelf back to nothing selected. const [selectedId, setSelectedId] = useState(null) + const [detailId, setDetailId] = useState(null) const [editing, setEditing] = useState(null) const [adding, setAdding] = useState(false) const [importing, setImporting] = useState(false) const [actionError, setActionError] = useState(null) + const [view, setView] = useBooksView(isAdmin) const { data, error, loading, run } = useApiCall>() const columns = isAdmin ? 6 : 5 const refresh = useCallback(() => setReloadKey((key) => key + 1), []) + // The desk may read every loan; a member may read their own; a staff account without a + // membership may read neither, and must not ask. + const loanScope: LoanScope = isAdmin ? 'library' : session?.customerId ? 'mine' : 'none' + // Only the shelf stamps dates on gaps, so the list view does not pay for the request. + const dueDates = useDueDates(view === 'shelf' ? loanScope : 'none', reloadKey) + // Debounced so typing does not fire a request per keystroke. useEffect(() => { const timer = setTimeout(() => setQuery(term.trim()), 300) @@ -65,6 +99,11 @@ export function BooksPage() { } } + function open(bookId: string) { + setSelectedId(bookId) + setDetailId(bookId) + } + function goToPage(next: number) { setPage(next) window.scrollTo({ top: 0, behavior: 'smooth' }) @@ -72,113 +111,168 @@ export function BooksPage() { const books = data?.data ?? [] const showSkeleton = loading && !data + const onLoan = books.filter((book) => !book.available).length + const shelf = view === 'shelf' + + const emptyTitle = query ? 'No books match that search' : 'The catalogue is empty' + const emptyBody = query + ? 'Try a different title, ISBN or author — or look for it under Discover more.' + : 'Add a book, or stock the shelves from Discover more.' + + const head = ( +
+
+ {shelf && ( +

+ Holdings · {(data?.totalItems ?? 0).toLocaleString()}{' '} + {data?.totalItems === 1 ? 'volume' : 'volumes'} + {/* Per page, because one page is one shelf - and the catalogue-wide figure is not + something the paginated endpoint knows. */} + {onLoan > 0 && ` · ${onLoan} out on this shelf`} +

+ )} +

{shelf ? 'The shelves' : 'Books'}

+

+ {shelf + ? 'A gap is a book someone is reading.' + : 'Everything on the shelves, and what is currently on loan.'} +

+
+
+ + + Discover more + + {isAdmin && ( + <> + + + + )} +
+
+ ) + + const notice = (error || actionError) && ( + { + setActionError(null) + refresh() + }} + /> + ) return (
-
-
-

Books

-

Everything on the shelves, and what is currently on loan.

-
-
- - Discover more - - {isAdmin && ( - <> - - - - )} + {shelf ? ( +
+ {head} + + {notice} + {emptyBody}} + />
-
- - - - {(error || actionError) && ( - { - setActionError(null) - refresh() - }} - /> - )} + ) : ( + <> + {head} + + {notice} -
-
- - - - - - - - - {isAdmin && - - - {showSkeleton && } - - {!showSkeleton && - books.map((book) => ( - setSelectedId(book.bookId)} - onKeyDown={(event) => { - if (event.key === 'Enter' || event.key === ' ') { - event.preventDefault() - setSelectedId(book.bookId) - } - }} - > - - - - {/* The catalogue does not always know a year, and "0" is not an answer. */} - - - {isAdmin && ( - - )} +
+
+
TitleAuthorISBNYearStatus} -
- {book.title} - - - {book.authors?.map((author) => author.name).join(', ') || '—'} - - {book.isbn}{book.publicationYear > 0 ? book.publicationYear : '—'} - - {book.available ? 'Available' : 'On loan'} - - event.stopPropagation()}> - - -
+ + + + + + + + {isAdmin && - ))} - -
TitleAuthorISBNYearStatus}
-
+ + + {showSkeleton && } - {!showSkeleton && books.length === 0 && ( - - {query - ? 'Try a different title, ISBN or author — or look for it under Discover more.' - : 'Add a book, or stock the shelves from Discover more.'} - - )} -
+ {!showSkeleton && + books.map((book) => ( + open(book.bookId)} + onKeyDown={(event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault() + open(book.bookId) + } + }} + > + + {book.title} + + + + {book.authors?.map((author) => author.name).join(', ') || '—'} + + + {book.isbn} + {/* The catalogue does not always know a year, and "0" is not an answer. */} + {book.publicationYear > 0 ? book.publicationYear : '—'} + + + {book.available ? 'Available' : 'On loan'} + + + {isAdmin && ( + event.stopPropagation()}> + + + + )} + + ))} + + + + + {!showSkeleton && books.length === 0 && ( + {emptyBody} + )} + + + )} {!showSkeleton && ( )} - {selectedId && ( + {detailId && ( setSelectedId(null)} + bookId={detailId} + onClose={() => setDetailId(null)} onChanged={refresh} onEdit={(book) => { - setSelectedId(null) + setDetailId(null) setEditing(book) }} /> )} - {importing && ( - setImporting(false)} onImported={refresh} /> - )} + {importing && setImporting(false)} onImported={refresh} />} {(adding || editing) && ( Boolean(url)) - - const [attempt, setAttempt] = useState(0) - - if (attempt >= sources.length) { - return