diff --git a/client/src/components/BookCard/BookCard.module.css b/client/src/components/BookCard/BookCard.module.css index f67485c..752b6fe 100644 --- a/client/src/components/BookCard/BookCard.module.css +++ b/client/src/components/BookCard/BookCard.module.css @@ -1,3 +1,5 @@ +/* NOTE: “BookCardSkeleton mirrors .item/.card/.text” */ + /* ===== List item ===== */ .item { min-width: 0; @@ -107,7 +109,7 @@ } .starOff { - fill: var(--color-line); + fill: var(--color-star-off); } /* ===== Ribbon position (the look lives in ShelfRibbon.module.css) ===== */ diff --git a/client/src/components/BookCardSkeleton/BookCardSkeleton.module.css b/client/src/components/BookCardSkeleton/BookCardSkeleton.module.css new file mode 100644 index 0000000..560c8e5 --- /dev/null +++ b/client/src/components/BookCardSkeleton/BookCardSkeleton.module.css @@ -0,0 +1,45 @@ +/* ===== Shared look of every placeholder piece ===== */ +.cover, +.title, +.meta { + background: var(--color-line); +} + +/* ===== Cover: same box as BookCover ===== */ +.cover { + width: 100%; + aspect-ratio: 2 / 3; + border-radius: 3px 7px 7px 3px; +} + +/* ===== Title bar: one line of BookCard's title ===== */ +/* Height = font size x line-height, taken from the same tokens as .title */ +.title { + height: calc(var(--text-lg) * 1.2); + width: 80%; + border-radius: var(--radius-sm); +} + +/* ===== Meta bar: one line of BookCard's meta ===== */ +.meta { + height: calc(var(--text-sm) * var(--leading)); + width: 50%; + border-radius: var(--radius-sm); +} + +/* ===== Pulse ===== */ +/* Only animates for users who haven't asked for reduced motion; + everyone else sees static boxes */ +@media (prefers-reduced-motion: no-preference) { + .cover, + .title, + .meta { + animation: pulse 1.5s ease-in-out infinite; + } +} + +@keyframes pulse { + 50% { + opacity: 0.5; + } +} diff --git a/client/src/components/BookCardSkeleton/BookCardSkeleton.tsx b/client/src/components/BookCardSkeleton/BookCardSkeleton.tsx new file mode 100644 index 0000000..3092b8a --- /dev/null +++ b/client/src/components/BookCardSkeleton/BookCardSkeleton.tsx @@ -0,0 +1,23 @@ +import cardStyles from '../BookCard/BookCard.module.css'; +import skeletonStyles from './BookCardSkeleton.module.css'; + +// ===== Props ===== +// TODO 1: No props for now (a `showShelfPlank` prop can come when My shelf needs skeletons) + +// ===== BookCardSkeleton: placeholder in the shape of a BookCard ===== +export function BookCardSkeleton() { + return ( + + ); +} diff --git a/client/src/components/BookGrid/BookGrid.tsx b/client/src/components/BookGrid/BookGrid.tsx index 91d1b6f..e2c5040 100644 --- a/client/src/components/BookGrid/BookGrid.tsx +++ b/client/src/components/BookGrid/BookGrid.tsx @@ -1,38 +1,48 @@ import { BookCard } from '../BookCard/BookCard'; import type { Book } from '../../types/book'; import styles from './BookGrid.module.css'; +import type { ShelfEntry } from '../../data/mockBooks'; +import { BookCardSkeleton } from '../BookCardSkeleton/BookCardSkeleton'; -/* ---------- Props: what a page must provide ---------- */ +// ===== How many placeholders to show while loading ===== +const SKELETON_COUNT = 8; + +// ===== Props ===== type BookGridProps = { books: Book[]; - savedIds: Set; // which books are on My shelf - ratings: Record; // bookId → the user's own rating (missing = unrated) + shelf: Map; // bookId → shelf entry (rating, note), present = on My shelf onToggleSaved: (bookId: string) => void; - showShelfPlank?: boolean; // decorative shelf plank under each row; off on Browse, on for My shelf + showShelfPlank?: boolean; + isLoading?: boolean; }; /* ---------- BookGrid: one list, one card per book ---------- */ // Doesn't know which page it's on. The page decides which books to pass and whether to show the shelf plank. export function BookGrid({ books, - savedIds, - ratings, + shelf, onToggleSaved, showShelfPlank = false, + isLoading = false, }: BookGridProps) { return ( // The modifier class holds the CSS that only the shelf needs (clipping)
    - {books.map((book) => ( - onToggleSaved(book.id)} // the grid binds the id, so a card doesn't need to know it - showShelfPlank={showShelfPlank} - /> - ))} + {isLoading + ? Array.from({ length: SKELETON_COUNT }, (_, i) => ) + : books.map((book) => { + const entry = shelf.get(book.id); + return ( + onToggleSaved(book.id)} + showShelfPlank={showShelfPlank} + /> + ); + })}
); } diff --git a/client/src/components/Button/Button.module.css b/client/src/components/Button/Button.module.css index 9e1eb1a..7001c0f 100644 --- a/client/src/components/Button/Button.module.css +++ b/client/src/components/Button/Button.module.css @@ -7,13 +7,13 @@ cursor: pointer; } -.primary { +.default { border-color: var(--color-focus); background: transparent; color: var(--color-focus); } -.secondary { +.emphasis { border-color: var(--color-accent); background: var(--color-accent); color: var(--color-on-accent); diff --git a/client/src/components/Button/Button.tsx b/client/src/components/Button/Button.tsx index bffcf9c..a73d30c 100644 --- a/client/src/components/Button/Button.tsx +++ b/client/src/components/Button/Button.tsx @@ -2,7 +2,7 @@ import styles from './Button.module.css'; type ButtonProps = { children: React.ReactNode; - variant?: 'primary' | 'secondary' | 'text'; + variant?: 'default' | 'emphasis' | 'text'; size?: 'narrow' | 'wide'; type?: 'button' | 'submit'; onClick?: () => void; @@ -12,7 +12,7 @@ type ButtonProps = { export function Button({ children, - variant = 'primary', + variant = 'default', size = 'narrow', type = 'button', onClick, diff --git a/client/src/components/ConfirmDialog/ConfirmDialog.tsx b/client/src/components/ConfirmDialog/ConfirmDialog.tsx index eec9d0c..18c70ad 100644 --- a/client/src/components/ConfirmDialog/ConfirmDialog.tsx +++ b/client/src/components/ConfirmDialog/ConfirmDialog.tsx @@ -47,7 +47,7 @@ export function ConfirmDialog({

{message}

- } +
+ ); +} diff --git a/client/src/components/ErrorState/ErrorState.module.css b/client/src/components/ErrorState/ErrorState.module.css new file mode 100644 index 0000000..68113a1 --- /dev/null +++ b/client/src/components/ErrorState/ErrorState.module.css @@ -0,0 +1,23 @@ +/* ===== Layout ===== */ +.wrapper { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--space-3); + padding: var(--space-8) 0; + text-align: center; +} + +/* ===== Text ===== */ +.title { + margin: 0; + font-family: var(--font-heading); + font-size: var(--text-lg); + font-weight: 600; + line-height: 1.2; +} + +.message { + margin: 0; + color: var(--color-text-soft); +} diff --git a/client/src/components/ErrorState/ErrorState.tsx b/client/src/components/ErrorState/ErrorState.tsx new file mode 100644 index 0000000..065da3b --- /dev/null +++ b/client/src/components/ErrorState/ErrorState.tsx @@ -0,0 +1,22 @@ +import { Button } from '../Button/Button'; +import styles from './ErrorState.module.css'; + +// ===== Props ===== +type ErrorStateProps = { + message: string; + onRetry: () => void; + headingLevel?: 1 | 2; // 1 when this replaces the whole page (book detail), otherwise 2 +}; + +// ===== ErrorState: the query failed, offer a way to try again ===== +export function ErrorState({ message, onRetry, headingLevel = 2 }: ErrorStateProps) { + const Heading = headingLevel === 1 ? 'h1' : 'h2'; + + return ( +
+ Something went wrong +

{message}

+ +
+ ); +} diff --git a/client/src/components/LoadingState/LoadingState.module.css b/client/src/components/LoadingState/LoadingState.module.css new file mode 100644 index 0000000..5b491ed --- /dev/null +++ b/client/src/components/LoadingState/LoadingState.module.css @@ -0,0 +1,36 @@ +/* ===== Layout ===== */ +.wrapper { + display: flex; + justify-content: center; + padding: var(--space-8) 0; +} + +/* ===== Dot (pulses only if motion is allowed) ===== */ +.dot { + width: 1rem; + height: 1rem; + border-radius: 50%; + background: var(--color-accent); +} + +@media (prefers-reduced-motion: no-preference) { + .dot { + animation: pulse 1.2s ease-in-out infinite; + } +} + +@keyframes pulse { + 50% { + opacity: 0.3; + } +} + +/* ===== Hidden from sight, not from screen readers ===== */ +.visuallyHidden { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; +} diff --git a/client/src/components/LoadingState/LoadingState.tsx b/client/src/components/LoadingState/LoadingState.tsx new file mode 100644 index 0000000..fd057a5 --- /dev/null +++ b/client/src/components/LoadingState/LoadingState.tsx @@ -0,0 +1,17 @@ +import styles from './LoadingState.module.css'; + +// ===== Props ===== +type LoadingStateProps = { + label?: string; // read aloud by screen readers, never shown + announceOnly?: boolean; // no dot: for places that already show book card skeletons +}; + +// ===== LoadingState ===== +export function LoadingState({ label = 'Loading…', announceOnly = false }: LoadingStateProps) { + return ( +
+ {!announceOnly &&
+ ); +} diff --git a/client/src/components/MyNotesPanel/MyNotesPanel.tsx b/client/src/components/MyNotesPanel/MyNotesPanel.tsx index 0a61597..234d45f 100644 --- a/client/src/components/MyNotesPanel/MyNotesPanel.tsx +++ b/client/src/components/MyNotesPanel/MyNotesPanel.tsx @@ -129,7 +129,7 @@ export function MyNotesPanel({

-