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 (
+
+ {/* Reuses BookCard's layout classes so spacing can't drift out of sync */}
+
+ {/* ===== Cover placeholder ===== */}
+
+ {/* ===== Text placeholders ===== */}
+
+
+
+ );
+}
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}
-
+
{confirmLabel}
diff --git a/client/src/components/EmptyState/EmptyState.module.css b/client/src/components/EmptyState/EmptyState.module.css
new file mode 100644
index 0000000..68113a1
--- /dev/null
+++ b/client/src/components/EmptyState/EmptyState.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/EmptyState/EmptyState.tsx b/client/src/components/EmptyState/EmptyState.tsx
new file mode 100644
index 0000000..d6135bd
--- /dev/null
+++ b/client/src/components/EmptyState/EmptyState.tsx
@@ -0,0 +1,24 @@
+import { Button } from '../Button/Button';
+import styles from './EmptyState.module.css';
+
+// ===== Props =====
+type EmptyStateProps = {
+ title: string; // "No books found", "Couldn't find this book"
+ message: string; // "Try a different search"
+ action?: { label: string; onClick: () => void }; // "Clear filters", "Back to browse"
+ headingLevel?: 1 | 2; // 1 when this IS the whole page (book not found), otherwise 2
+};
+
+// ===== EmptyState: the query worked, but there is nothing to show =====
+export function EmptyState({ title, message, action, headingLevel = 2 }: EmptyStateProps) {
+ const Heading = headingLevel === 1 ? 'h1' : 'h2';
+
+ return (
+
+
{title}
+
{message}
+ {/* Only rendered when the page gives an action */}
+ {action &&
{action.label} }
+
+ );
+}
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}
+
Try again
+
+ );
+}
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 && }
+ {label}
+
+ );
+}
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({
-
+
Save note
diff --git a/client/src/pages/BookDetailPage/BookDetailPage.tsx b/client/src/pages/BookDetailPage/BookDetailPage.tsx
index 9dc872b..bc1dbe8 100644
--- a/client/src/pages/BookDetailPage/BookDetailPage.tsx
+++ b/client/src/pages/BookDetailPage/BookDetailPage.tsx
@@ -1,5 +1,5 @@
import { useState } from 'react';
-import { useParams } from 'react-router-dom';
+import { useNavigate, useParams } from 'react-router-dom';
import { BookCover } from '../../components/BookCover/BookCover';
import { ConfirmDialog } from '../../components/ConfirmDialog/ConfirmDialog';
import { Eyebrow } from '../../components/Eyebrow/Eyebrow';
@@ -7,12 +7,16 @@ import { MyNotesPanel } from '../../components/MyNotesPanel/MyNotesPanel';
import { ShelfRibbon } from '../../components/ShelfRibbon/ShelfRibbon';
import { type ShelfEntry, mockBooks, mockShelfEntries } from '../../data/mockBooks';
import styles from './BookDetailPage.module.css';
+import { LoadingState } from '../../components/LoadingState/LoadingState';
+import { ErrorState } from '../../components/ErrorState/ErrorState';
+import { EmptyState } from '../../components/EmptyState/EmptyState';
// Which confirm popup is open (null = none)
type PendingConfirm = 'removeFromShelf' | 'deleteNote' | null;
export function BookDetailPage() {
const { id } = useParams<{ id: string }>();
+ const navigate = useNavigate();
// ===== TEMPORARY! TODO: replace with a query by id once the backend is ready =====
const book = mockBooks.find((b) => b.id === id);
@@ -25,7 +29,34 @@ export function BookDetailPage() {
const [pendingConfirm, setPendingConfirm] = useState(null);
- if (!book) return Book not found.
;
+ // ===== TEMPORARY: fake loading and error states, replace with the query's `loading` and `error` =====
+ const isLoading = false;
+ const isError = false;
+
+ // ===== Page-level states =====
+ // Order matters: while loading there is no `book` yet, and a failed query has none either.
+ // Neither is "not found", so those two are checked first.
+ if (isLoading) return ;
+
+ if (isError) {
+ return (
+ console.log('TEMPORARY: replace with the query refetch')}
+ />
+ );
+ }
+
+ if (!book) {
+ return (
+ navigate('/') }}
+ />
+ );
+ }
// ===== Shelf actions =====
// Ribbon: add an empty entry, or remove it. Removing deletes the rating and note too,
diff --git a/client/src/pages/BrowsePage/BrowsePage.module.css b/client/src/pages/BrowsePage/BrowsePage.module.css
index c4685ac..7c6a346 100644
--- a/client/src/pages/BrowsePage/BrowsePage.module.css
+++ b/client/src/pages/BrowsePage/BrowsePage.module.css
@@ -25,23 +25,9 @@
opacity: 0.75;
}
-.grid {
- list-style: none;
- margin: 0;
- padding: 0;
- display: grid;
- grid-template-columns: repeat(2, minmax(0, 1fr));
- gap: 1.5rem 1rem;
-}
-
@media (min-width: 40rem) {
/*tablets and bigger devices*/
.page {
padding: 2rem 1.5rem;
}
-
- .grid {
- grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
- gap: 2rem 1.5rem;
- }
}
diff --git a/client/src/pages/BrowsePage/BrowsePage.tsx b/client/src/pages/BrowsePage/BrowsePage.tsx
index 013ebc0..aa86efc 100644
--- a/client/src/pages/BrowsePage/BrowsePage.tsx
+++ b/client/src/pages/BrowsePage/BrowsePage.tsx
@@ -1,8 +1,9 @@
import { useState } from 'react';
import type { Book } from '../../types/book';
-import { BookCard } from '../../components/BookCard/BookCard';
+import { BookGrid } from '../../components/BookGrid/BookGrid';
import { mockBooks, mockShelfEntries, type ShelfEntry } from '../../data/mockBooks';
import styles from './BrowsePage.module.css';
+import { LoadingState } from '../../components/LoadingState/LoadingState';
export function BrowsePage() {
const [shelf, setShelf] = useState>(
@@ -23,6 +24,9 @@ export function BrowsePage() {
const books = mockBooks;
+ // ===== TEMPORARY: fake loading state, replace with the query's `loading` =====
+ const isLoading = false;
+
return (
@@ -31,20 +35,8 @@ export function BrowsePage() {
{books.length} books
-
- {books.map((book) => {
- const entry = shelf.get(book.id);
- return (
- toggleSaved(book.id)}
- />
- );
- })}
-
+ {isLoading && }
+
);
}