Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion client/src/components/BookCard/BookCard.module.css
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
/* NOTE: “BookCardSkeleton mirrors .item/.card/.text” */

/* ===== List item ===== */
.item {
min-width: 0;
Expand Down Expand Up @@ -107,7 +109,7 @@
}

.starOff {
fill: var(--color-line);
fill: var(--color-star-off);
}

/* ===== Ribbon position (the look lives in ShelfRibbon.module.css) ===== */
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
}
23 changes: 23 additions & 0 deletions client/src/components/BookCardSkeleton/BookCardSkeleton.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<li className={cardStyles.item} aria-hidden="true">
{/* Reuses BookCard's layout classes so spacing can't drift out of sync */}
<div className={cardStyles.card}>
{/* ===== Cover placeholder ===== */}
<div className={skeletonStyles.cover} />
{/* ===== Text placeholders ===== */}
<div className={cardStyles.text}>
<div className={skeletonStyles.title} />
<div className={skeletonStyles.meta} />
</div>
</div>
</li>
);
}
42 changes: 26 additions & 16 deletions client/src/components/BookGrid/BookGrid.tsx
Original file line number Diff line number Diff line change
@@ -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<string>; // which books are on My shelf
ratings: Record<string, number>; // bookId → the user's own rating (missing = unrated)
shelf: Map<Book['id'], ShelfEntry>; // 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)
<ul className={`${styles.grid} ${showShelfPlank ? styles.withShelfPlank : ''}`}>
{books.map((book) => (
<BookCard
key={book.id}
book={book}
isSaved={savedIds.has(book.id)}
myRating={ratings[book.id]}
onToggleSaved={() => 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) => <BookCardSkeleton key={i} />)
: books.map((book) => {
const entry = shelf.get(book.id);
return (
<BookCard
key={book.id}
book={book}
isSaved={entry !== undefined}
myRating={entry?.rating}
onToggleSaved={() => onToggleSaved(book.id)}
showShelfPlank={showShelfPlank}
/>
);
})}
</ul>
);
}
4 changes: 2 additions & 2 deletions client/src/components/Button/Button.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
4 changes: 2 additions & 2 deletions client/src/components/Button/Button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -12,7 +12,7 @@ type ButtonProps = {

export function Button({
children,
variant = 'primary',
variant = 'default',
size = 'narrow',
type = 'button',
onClick,
Expand Down
2 changes: 1 addition & 1 deletion client/src/components/ConfirmDialog/ConfirmDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ export function ConfirmDialog({
</h2>
<p className={styles.message}>{message}</p>
<div className={styles.actions}>
<Button variant="secondary" onClick={onConfirm}>
<Button variant="emphasis" onClick={onConfirm}>
{confirmLabel}
</Button>
<Button variant="text" onClick={onCancel}>
Expand Down
23 changes: 23 additions & 0 deletions client/src/components/EmptyState/EmptyState.module.css
Original file line number Diff line number Diff line change
@@ -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);
}
24 changes: 24 additions & 0 deletions client/src/components/EmptyState/EmptyState.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className={styles.wrapper}>
<Heading className={styles.title}>{title}</Heading>
<p className={styles.message}>{message}</p>
{/* Only rendered when the page gives an action */}
{action && <Button onClick={action.onClick}>{action.label}</Button>}
</div>
);
}
23 changes: 23 additions & 0 deletions client/src/components/ErrorState/ErrorState.module.css
Original file line number Diff line number Diff line change
@@ -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);
}
22 changes: 22 additions & 0 deletions client/src/components/ErrorState/ErrorState.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div role="alert" className={styles.wrapper}>
<Heading className={styles.title}>Something went wrong</Heading>
<p className={styles.message}>{message}</p>
<Button onClick={onRetry}>Try again</Button>
</div>
);
}
36 changes: 36 additions & 0 deletions client/src/components/LoadingState/LoadingState.module.css
Original file line number Diff line number Diff line change
@@ -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;
}
17 changes: 17 additions & 0 deletions client/src/components/LoadingState/LoadingState.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div role="status" className={announceOnly ? undefined : styles.wrapper}>
{!announceOnly && <span className={styles.dot} aria-hidden="true" />}
<span className={styles.visuallyHidden}>{label}</span>
</div>
);
}
2 changes: 1 addition & 1 deletion client/src/components/MyNotesPanel/MyNotesPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ export function MyNotesPanel({
</p>
</div>
<div className={styles.actions}>
<Button type="submit" variant="secondary" disabled={draft.trim() === ''}>
<Button type="submit" variant="emphasis" disabled={draft.trim() === ''}>
Save note
</Button>
<Button variant="text" onClick={cancelEditing}>
Expand Down
Loading