From 13cd8024fced6aa00084726a4a3c02e2bb6e2223 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ilse=20=C3=85sbakk=20Resell?= Date: Wed, 30 Sep 2026 10:25:27 +0200 Subject: [PATCH 1/7] wip/create design scaffolding Basic setup for design elements and components, to be continued on a different machine --- client/src/App.css | 0 client/src/tokens.css | 0 2 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 client/src/App.css create mode 100644 client/src/tokens.css diff --git a/client/src/App.css b/client/src/App.css new file mode 100644 index 0000000..e69de29 diff --git a/client/src/tokens.css b/client/src/tokens.css new file mode 100644 index 0000000..e69de29 From cbeb2a52066c05246283d3ce904c4ce64af3866a Mon Sep 17 00:00:00 2001 From: ilsea Date: Wed, 30 Sep 2026 14:51:05 +0200 Subject: [PATCH 2/7] feat: add global .css files and style documentation Create/move all global .css files into styles/ folder - index.css - tokens.css - base.css - reset.css Add styles_documentation.md to docs/, describes purpose and rules of global .css files, and other style decisions --- client/src/App.css | 0 client/src/index.css | 111 ----------------------------------- client/src/styles/base.css | 39 ++++++++++++ client/src/styles/index.css | 3 + client/src/styles/reset.css | 20 +++++++ client/src/styles/tokens.css | 61 +++++++++++++++++++ client/src/tokens.css | 0 docs/styles_documentation.md | 33 +++++++++++ 8 files changed, 156 insertions(+), 111 deletions(-) delete mode 100644 client/src/App.css delete mode 100644 client/src/index.css create mode 100644 client/src/styles/base.css create mode 100644 client/src/styles/index.css create mode 100644 client/src/styles/reset.css create mode 100644 client/src/styles/tokens.css delete mode 100644 client/src/tokens.css create mode 100644 docs/styles_documentation.md diff --git a/client/src/App.css b/client/src/App.css deleted file mode 100644 index e69de29..0000000 diff --git a/client/src/index.css b/client/src/index.css deleted file mode 100644 index 5fb3313..0000000 --- a/client/src/index.css +++ /dev/null @@ -1,111 +0,0 @@ -:root { - --text: #6b6375; - --text-h: #08060d; - --bg: #fff; - --border: #e5e4e7; - --code-bg: #f4f3ec; - --accent: #aa3bff; - --accent-bg: rgba(170, 59, 255, 0.1); - --accent-border: rgba(170, 59, 255, 0.5); - --social-bg: rgba(244, 243, 236, 0.5); - --shadow: - rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px; - - --sans: system-ui, 'Segoe UI', Roboto, sans-serif; - --heading: system-ui, 'Segoe UI', Roboto, sans-serif; - --mono: ui-monospace, Consolas, monospace; - - font: 18px/145% var(--sans); - letter-spacing: 0.18px; - color-scheme: light dark; - color: var(--text); - background: var(--bg); - font-synthesis: none; - text-rendering: optimizeLegibility; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - - @media (max-width: 1024px) { - font-size: 16px; - } -} - -@media (prefers-color-scheme: dark) { - :root { - --text: #9ca3af; - --text-h: #f3f4f6; - --bg: #16171d; - --border: #2e303a; - --code-bg: #1f2028; - --accent: #c084fc; - --accent-bg: rgba(192, 132, 252, 0.15); - --accent-border: rgba(192, 132, 252, 0.5); - --social-bg: rgba(47, 48, 58, 0.5); - --shadow: - rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px; - } - - #social .button-icon { - filter: invert(1) brightness(2); - } -} - -#root { - width: 1126px; - max-width: 100%; - margin: 0 auto; - text-align: center; - border-inline: 1px solid var(--border); - min-height: 100svh; - display: flex; - flex-direction: column; - box-sizing: border-box; -} - -body { - margin: 0; -} - -h1, -h2 { - font-family: var(--heading); - font-weight: 500; - color: var(--text-h); -} - -h1 { - font-size: 56px; - letter-spacing: -1.68px; - margin: 32px 0; - @media (max-width: 1024px) { - font-size: 36px; - margin: 20px 0; - } -} -h2 { - font-size: 24px; - line-height: 118%; - letter-spacing: -0.24px; - margin: 0 0 8px; - @media (max-width: 1024px) { - font-size: 20px; - } -} -p { - margin: 0; -} - -code, -.counter { - font-family: var(--mono); - display: inline-flex; - border-radius: 4px; - color: var(--text-h); -} - -code { - font-size: 15px; - line-height: 135%; - padding: 4px 8px; - background: var(--code-bg); -} diff --git a/client/src/styles/base.css b/client/src/styles/base.css new file mode 100644 index 0000000..c43c185 --- /dev/null +++ b/client/src/styles/base.css @@ -0,0 +1,39 @@ +body { + background: var(--color-page); + color: var(--color-text); + font-family: var(--font-body); + font-size: var(--text-md); + line-height: var(--leading); +} + +h1, +h2, +h3 { + font-family: var(--font-heading); + line-height: 1.2; +} + +a { + color: var(--color-accent); +} + +:focus-visible { + outline: 3px solid var(--color-focus); + outline-offset: 2px; +} + +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; +} + +@media (prefers-reduced-motion: reduce) { + * { + transition-duration: 0.001ms !important; + animation-duration: 0.001ms !important; + } +} \ No newline at end of file diff --git a/client/src/styles/index.css b/client/src/styles/index.css new file mode 100644 index 0000000..6222bd5 --- /dev/null +++ b/client/src/styles/index.css @@ -0,0 +1,3 @@ +@import "./tokens.css"; +@import "./reset.css"; +@import "./base.css"; diff --git a/client/src/styles/reset.css b/client/src/styles/reset.css new file mode 100644 index 0000000..167381c --- /dev/null +++ b/client/src/styles/reset.css @@ -0,0 +1,20 @@ +*, +*::before, +*::after { + box-sizing: border-box; +} + +* { + margin: 0; +} + +img { + display: block; + max-width: 100%; +} + +button, +input { + font: inherit; + color: inherit; +} \ No newline at end of file diff --git a/client/src/styles/tokens.css b/client/src/styles/tokens.css new file mode 100644 index 0000000..2e55c55 --- /dev/null +++ b/client/src/styles/tokens.css @@ -0,0 +1,61 @@ +:root { + --color-page: #faf3ee; + --color-card: #fffcfa; + --color-text: #2a1a26; + --color-text-soft: #6a5560; + --color-line: #e8d9d3; + --color-input-border: #8a6f7c; + --color-header: #4a1f4f; + --color-header-text: #fbeef3; + --color-accent: #a3264f; + --color-ribbon: #c2810f; + --color-highlight: #f0b957; + --color-star: #c78a12; + --color-chip-bg: #f6e3ec; + --color-chip-text: #7a1f45; + --color-shelf: #7b5535; + --color-shelf-edge: #9a7050; + + /* TODO:check contrast */ + --color-on-accent: #ffffff; /* text on accent buttons */ + --color-accent-hover: #8a1f42; /* darker accent for hover */ + --color-focus: #4a1f4f; /* focus ring on light areas; use --color-highlight inside the header */ + --color-danger: #b3261e; /* destructive actions, e.g. delete review */ + + /* Generated book cover tones (bg / text pairs) */ + --cover-0-bg: #6a2a5e; --cover-0-fg: #fbeaf3; + --cover-1-bg: #8a1f45; --cover-1-fg: #fdeaf0; + --cover-2-bg: #2d1b3d; --cover-2-fg: #efe3f3; + --cover-3-bg: #34507a; --cover-3-fg: #eaf1fb; + --cover-4-bg: #5a2a6e; --cover-4-fg: #f5e9fb; + --cover-5-bg: #4a2a4a; --cover-5-fg: #f5e6f2; + + /* Fonts (self-hosting comes later; fallbacks work meanwhile) */ + /* TODO: Figure out self-hosting fonts */ + --font-heading: "Fraunces", Georgia, serif; + --font-body: "Figtree", system-ui, sans-serif; + + /* Type scale */ + --text-sm: 0.875rem; + --text-md: 1rem; + --text-lg: 1.25rem; + --text-xl: 1.75rem; + --leading: 1.5; + + /* Spacing (4px base) */ + --space-1: 0.25rem; + --space-2: 0.5rem; + --space-3: 0.75rem; + --space-4: 1rem; + --space-6: 1.5rem; + --space-8: 2rem; + + /* Shape and depth */ + --radius-sm: 6px; + --radius-md: 12px; + --shadow-card: 0 1px 3px rgb(42 26 38 / 0.12); + + /* Layout */ + --tap-target: 2.75rem; /* 44px minimum touch size */ + --content-width: 72rem; +} \ No newline at end of file diff --git a/client/src/tokens.css b/client/src/tokens.css deleted file mode 100644 index e69de29..0000000 diff --git a/docs/styles_documentation.md b/docs/styles_documentation.md new file mode 100644 index 0000000..c6f6acd --- /dev/null +++ b/docs/styles_documentation.md @@ -0,0 +1,33 @@ +# Rules and information regarding styles, design, components + +## 1. Folder structure +``` +src/ +├── styles/ tokens.css, reset.css, base.css, index.css +├── components/ +│ ├── Button/ 3 variants used throughout app +│ ├── BookCover/ image, fallback, color pick +│ ├── BookCard/ BookCard + ribbon + starsdisplay +│ ├── BookGrid/ render auto grid of BookCard, with shelf option +│ ├── SearchField/ draft state, submit on Enter or button +│ ├── FilterPanel/ genre, year, clear, mobile toggle +│ ├── RatingInput/ radio group styled as stars +│ ├── MyNotesPanel/ empty / saved / editing +│ ├── StateMessage/ empty, error, (loading) +│ └── AppShell/ shell + header + nav + footer +└── pages/ + ├── BrowsePage/ includes "Load more" + results count + ├── BookDetailPage/ includes hero, facts, description, ribbon, tags + └── ShelfPage/ includes page heading + sort +``` + +- Global CSS holds only tokens and element defaults. +- Every component owns its own `.module.css` and reads only `var(--token)` values, never raw hex. + +## Global CSS +|File|Purpose|Rules| +|----|-------|-----| +|index.css|Imports the three below in order (tokens, reset, base)|No rules of its own. Order matters because base uses tokens| +|tokens.css|Design decisions, as CSS variables|Only :root{--...}, with no selectors or layout. The only place for raw hex/px values. Name by role (`--color-accent`), not appearance (`--color-purple`). Add a token only when it's a real decision or reused. Keep it tidy and readable!| +|reset.css|Removes browser inconsistencies (box-sixing, margins, image behavior)|Tiny and generic. No colors or fonts, since "brand" values belong to tokens| +|base.css|Default look of plain HTML elements: body, headings, links, focus ring, `.visually-hidden`|Element selectors plus a few utilities only. If you write a specific class here, it belongs in a component module instead!| From d37e7a45355db055b18a0d0134f9276ca4db4c65 Mon Sep 17 00:00:00 2001 From: ilsea Date: Wed, 30 Sep 2026 14:52:26 +0200 Subject: [PATCH 3/7] feat: add Book type to client/ New file: client\src\types\book.ts Book mirrors the current GraphQL schema / Mongoose model --- client/src/types/book.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 client/src/types/book.ts diff --git a/client/src/types/book.ts b/client/src/types/book.ts new file mode 100644 index 0000000..e298d4c --- /dev/null +++ b/client/src/types/book.ts @@ -0,0 +1,14 @@ +/* ---------- Book: mirrors the GraphQL schema / Mongoose model ---------- */ +// TODO: check field names against the backend's typeDefs once they exist +export type Book = { + id: string; // TODO: agree with backend: googleBooksId or Mongo _id + title: string; + subtitle: string; // "" when none + authors: string[]; // [] when none + publishedYear: number | null; // null when unknown + genres: string[]; + description: string; // "" when none + coverURL: string; // "" when none + pageCount: number | null; + language: string; // "" when unknown +}; \ No newline at end of file From 2b02dcd87d35401ddaaacae3e2b1a11d86fcfe7b Mon Sep 17 00:00:00 2001 From: ilsea Date: Wed, 30 Sep 2026 15:00:58 +0200 Subject: [PATCH 4/7] feat: add book components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New files: - BookCover.tsx and .module.css - BookCard.tsx and .module.css - BookGrid.tsx and .module.css - ´BookCover` generates styled color cover for books that are missing a real img cover (URL) - ´BookCard` creates book card used in grid on Browse and My shelf pages, uses select fields from Book type, includes link to book detail page, optional visual shelf plank element, title, author, year, save toggle ribbon button - `BookGrid` creates list of `BookCard`s, renders into auto-fill grid that responds to different screen sizes Add react-router-dom config to .json files in client/, used by BookCard link --- client/package-lock.json | 58 ++++++++ client/package.json | 1 + .../components/BookCard/BookCard.module.css | 131 ++++++++++++++++++ client/src/components/BookCard/BookCard.tsx | 108 +++++++++++++++ .../components/BookCover/BookCover.module.css | 92 ++++++++++++ client/src/components/BookCover/BookCover.tsx | 47 +++++++ .../components/BookGrid/BookGrid.module.css | 19 +++ client/src/components/BookGrid/BookGrid.tsx | 38 +++++ 8 files changed, 494 insertions(+) create mode 100644 client/src/components/BookCard/BookCard.module.css create mode 100644 client/src/components/BookCard/BookCard.tsx create mode 100644 client/src/components/BookCover/BookCover.module.css create mode 100644 client/src/components/BookCover/BookCover.tsx create mode 100644 client/src/components/BookGrid/BookGrid.module.css create mode 100644 client/src/components/BookGrid/BookGrid.tsx diff --git a/client/package-lock.json b/client/package-lock.json index a7b98c2..1ff170e 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -12,6 +12,7 @@ "graphql": "^16.14.2", "react": "^19.2.8", "react-dom": "^19.2.8", + "react-router-dom": "^7.18.4", "rxjs": "^7.8.2", "vitest": "^5.0.1" }, @@ -1563,6 +1564,19 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -2762,6 +2776,44 @@ "react": "^19.3.0" } }, + "node_modules/react-router": { + "version": "7.18.4", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.4.tgz", + "integrity": "sha512-PUPQcMhMGRAslLcvtlPz/kmzBEWPhLdgLFrL7pLNepBL6dX0lWj4WD2cUYVgYCuT3jxvghYFg81cDTj44DhetQ==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.18.4", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.4.tgz", + "integrity": "sha512-yrfmJHIpDG7taCpqKjT1G5B6q3O2K+RN8/fgNf0lTjCwiPbQ0ei6vXX9ZjQR+7ld8Tr7Z5xmyMnZ8YJrphWQUw==", + "license": "MIT", + "dependencies": { + "react-router": "7.18.4" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, "node_modules/rolldown": { "version": "1.2.9", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.9.tgz", @@ -2820,6 +2872,12 @@ "semver": "bin/semver.js" } }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", diff --git a/client/package.json b/client/package.json index 67d1e61..fa3082c 100644 --- a/client/package.json +++ b/client/package.json @@ -16,6 +16,7 @@ "graphql": "^16.14.2", "react": "^19.2.8", "react-dom": "^19.2.8", + "react-router-dom": "^7.18.4", "rxjs": "^7.8.2", "vitest": "^5.0.1" }, diff --git a/client/src/components/BookCard/BookCard.module.css b/client/src/components/BookCard/BookCard.module.css new file mode 100644 index 0000000..f500042 --- /dev/null +++ b/client/src/components/BookCard/BookCard.module.css @@ -0,0 +1,131 @@ +/* ===== List item ===== */ +.item { + min-width: 0; /* lets long titles shrink instead of stretching the grid */ +} + +/* ===== Card container ===== */ +.card { + position: relative; /* anchor for the stretched link and the ribbon */ + display: flex; + flex-direction: column; + gap: var(--space-3); +} + +/* ===== Cover sits above the shelf ===== */ +/* So the cover's shadow falls onto the shelf instead of being painted over + by a neighbouring card's plank */ +.coverWrap { + position: relative; + z-index: 1; +} + +/* ===== Shelf plank (only rendered when showShelf is true) ===== */ +/* The plank itself is just a 10px spacer. The visible shelf is its ::before, + stretched far to both sides. Every card in a row draws the same shelf at the + same height, so they overlap into one continuous line. + BookGrid's .withShelf clips it at the edge of the grid. */ +.plank { + position: relative; + z-index: 0; + height: 10px; +} +.plank::before { + content: ""; + position: absolute; + top: 0; + bottom: 0; + left: -100vw; + right: -100vw; + background: linear-gradient(var(--color-shelf-edge) 0 2px, var(--color-shelf) 2px); + border-bottom: 2px solid rgb(0 0 0 / 0.25); /* opaque "shadow": a real box-shadow would stack across cards */ + pointer-events: none; /* otherwise it blocks clicks on neighbouring cards */ +} + +/* ===== Text ===== */ +.text { + display: flex; + flex-direction: column; + gap: 3px; +} +.title { + margin: 0; + font-family: var(--font-heading); + font-weight: 600; + font-size: var(--text-lg); /* mockup uses 18px; adjust if you like */ + line-height: 1.2; +} +.meta { + font-size: var(--text-sm); + color: var(--color-text-soft); +} + +/* ===== Clickable card: the "stretched link" trick ===== */ +.link { + color: inherit; + text-decoration: none; +} +.link::after { /* invisible layer over the whole card */ + content: ""; + position: absolute; + inset: 0; +} +.card:hover .link { + text-decoration: underline; +} + +/* Focus ring around the whole card instead of only the title text */ +.link:focus-visible { + outline: none; +} +.card:has(.link:focus-visible) { + outline: 2px solid var(--color-focus); + outline-offset: 4px; + border-radius: var(--radius-sm); +} + +/* ===== Stars ===== */ +.stars { + display: flex; + gap: 2px; + align-items: center; +} +.starOn { + fill: var(--color-star); +} +.starOff { + fill: var(--color-line); +} + +/* ===== Ribbon ===== */ +.ribbon { + position: absolute; + top: 0; + right: 4px; + z-index: 2; /* above the cover and the stretched link, so it stays clickable */ + width: 44px; /* 44px-wide tap area; the drawn ribbon is only 24px */ + height: 48px; + display: flex; + justify-content: center; + align-items: flex-start; + padding: 0; + border: 0; + background: none; + cursor: pointer; +} +.ribbon:focus-visible { + outline: 2px solid var(--color-focus); + outline-offset: -2px; +} + +/* Not saved: pale outline ribbon */ +.ribbon path { + fill: rgb(255 253 248 / 0.9); + stroke: var(--color-ribbon); + stroke-width: 1.8; +} +/* Saved: solid ribbon. Styled from aria-pressed, so no extra class is needed */ +.ribbon[aria-pressed="true"] path { + fill: var(--color-ribbon); + stroke: rgb(255 255 255 / 0.8); + stroke-width: 1; +} \ No newline at end of file diff --git a/client/src/components/BookCard/BookCard.tsx b/client/src/components/BookCard/BookCard.tsx new file mode 100644 index 0000000..e189fda --- /dev/null +++ b/client/src/components/BookCard/BookCard.tsx @@ -0,0 +1,108 @@ +import { Link } from "react-router-dom"; +import type { Book } from "../../types/book"; +import { BookCover } from "../BookCover/BookCover"; +import styles from "./BookCard.module.css"; + +/* ---------- Types: the contract with BookGrid and the pages ---------- */ +type BookCardProps = { + book: Book; + isSaved: boolean; // is this book on My shelf? + myRating?: number; // 1–5; undefined = not rated (no stars shown) + onToggleSaved: () => void; + showShelf?: boolean; // draw the decorative shelf plank under the cover +}; + +/* ---------- BookCard: one book ---------- */ +// Renders an
  • , so it must be used inside BookGrid's
      +export function BookCard({ + book, + isSaved, + myRating, + onToggleSaved, + showShelf = false, +}: BookCardProps) { + // Meta line: "Jane Austen · 1813". Year is optional, so build it defensively + const meta = [book.authors.join(", "), book.publishedYear].filter(Boolean).join(" · "); + + return ( +
    • +
      + + {/* Cover + optional shelf plank */} +
      + {/* coverWrap lifts the cover above the shelf so its shadow lands on the plank */} +
      + +
      + {showShelf && + + {/* Text block */} +
      +

      + {/* The only link. Its ::after covers the whole card (see CSS) */} + + {book.title} + +

      + {meta} + {myRating !== undefined && } +
      + + {/* Ribbon: sibling of the link, NOT inside it */} + +
      +
    • + ); +} + +/* ---------- StarRow: read-only display of the user's own rating ---------- */ +function StarRow({ value }: { value: number }) { + return ( + // One image for screen readers, so the five SVGs are hidden + + {[1, 2, 3, 4, 5].map((n) => ( + + ))} + + ); +} + +/* ---------- FavoriteRibbon: bookmark toggle ---------- */ +function FavoriteRibbon({ + title, + isSaved, + onToggle, +}: { + title: string; + isSaved: boolean; + onToggle: () => void; +}) { + return ( + + ); +} \ No newline at end of file diff --git a/client/src/components/BookCover/BookCover.module.css b/client/src/components/BookCover/BookCover.module.css new file mode 100644 index 0000000..13e5f59 --- /dev/null +++ b/client/src/components/BookCover/BookCover.module.css @@ -0,0 +1,92 @@ +.cover { + display: block; + width: 100%; + /* the parent decides the width: card column or 340px hero */ + aspect-ratio: 2 / 3; + object-fit: cover; + border-radius: 3px 7px 7px 3px; + /* book spine on the left */ + box-shadow: + inset 5px 0 0 rgb(0 0 0 / 0.2), + inset 6px 0 0 rgb(255 255 255 / 0.14), + 0 10px 18px -8px rgb(30 27 22 / 0.5); +} + +.generated { + position: relative; + overflow: hidden; + container-type: inline-size; +} + +.generated[data-tone="0"] { + background: var(--cover-0-bg); + color: var(--cover-0-fg); +} + +.generated[data-tone="1"] { + background: var(--cover-1-bg); + color: var(--cover-1-fg); +} + +.generated[data-tone="2"] { + background: var(--cover-2-bg); + color: var(--cover-2-fg); +} + +.generated[data-tone="3"] { + background: var(--cover-3-bg); + color: var(--cover-3-fg); +} + +.generated[data-tone="4"] { + background: var(--cover-4-bg); + color: var(--cover-4-fg); +} + +.generated[data-tone="5"] { + background: var(--cover-5-bg); + color: var(--cover-5-fg); +} + +.frame { + position: absolute; + inset: 11cqw 11cqw 11cqw 13.5cqw; + border: 1px solid var(--color-highlight); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 5cqw; + padding: 0 8cqw; + text-align: center; +} + +.sparkle { + width: 9cqw; + color: var(--color-highlight); +} + +.title { + font-family: var(--font-heading); + font-weight: 600; + font-size: 10cqw; + line-height: 1.12; + text-wrap: balance; + display: -webkit-box; + -webkit-line-clamp: 4; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.rule { + width: 20cqw; + height: 2px; + background: var(--color-highlight); +} + +.author { + font-size: 5.3cqw; + font-weight: 600; + letter-spacing: 0.14em; + text-transform: uppercase; +} \ No newline at end of file diff --git a/client/src/components/BookCover/BookCover.tsx b/client/src/components/BookCover/BookCover.tsx new file mode 100644 index 0000000..8e1a786 --- /dev/null +++ b/client/src/components/BookCover/BookCover.tsx @@ -0,0 +1,47 @@ +import { useState } from "react"; +import styles from "./BookCover.module.css"; + +const TONE_COUNT = 6; + +function toneFor(key: string) { + let hash = 0; + for (const ch of key) hash = (hash * 31 + ch.charCodeAt(0)) >>> 0; + return hash % TONE_COUNT; +} + +type Props = { + id: string; + title: string; + author?: string; + coverUrl?: string; + eager?: boolean; // true for the hero on the detail page +}; + +export function BookCover({ id, title, author, coverUrl, eager = false }: Props) { + const [failed, setFailed] = useState(false); + + if (coverUrl && !failed) { + return ( + setFailed(true)} + /> + ); + } + + return ( + + ); +} \ No newline at end of file diff --git a/client/src/components/BookGrid/BookGrid.module.css b/client/src/components/BookGrid/BookGrid.module.css new file mode 100644 index 0000000..37b7151 --- /dev/null +++ b/client/src/components/BookGrid/BookGrid.module.css @@ -0,0 +1,19 @@ +/* ===== Base grid (both pages) ===== */ +/* auto-fill: as many columns as fit, each at least 10rem wide. + No media queries; gives about 2 columns on a phone and 4 beside the sidebar. */ +.grid { + list-style: none; + margin: 0; + padding: 0; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr)); + gap: 2.75rem 1.5rem; /* row gap, column gap */ +} + +/* ===== Only when the shelf is shown ===== */ +/* Each card's .plank::before (in BookCard.module.css) is stretched far to both + sides; this clips it at the edge of the grid */ +.withShelf { + overflow-x: clip; + padding-inline: var(--space-2); /* room so focus rings and shadows aren't clipped */ +} \ No newline at end of file diff --git a/client/src/components/BookGrid/BookGrid.tsx b/client/src/components/BookGrid/BookGrid.tsx new file mode 100644 index 0000000..34ee970 --- /dev/null +++ b/client/src/components/BookGrid/BookGrid.tsx @@ -0,0 +1,38 @@ +import { BookCard } from "../BookCard/BookCard"; +import type { Book } from "../../types/book"; +import styles from "./BookGrid.module.css"; + +/* ---------- Props: what a page must provide ---------- */ +type BookGridProps = { + books: Book[]; + savedIds: Set; // which books are on My shelf + ratings: Record; // bookId → the user's own rating (missing = unrated) + onToggleSaved: (bookId: string) => void; + showShelf?: boolean; // decorative shelf under each row; off on Browse, on for My shelf +}; + +/* ---------- 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. +export function BookGrid({ + books, + savedIds, + ratings, + onToggleSaved, + showShelf = 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 + showShelf={showShelf} + /> + ))} +
      + ); +} \ No newline at end of file From a2129989d55c694cbc10473e6397df05dd398b33 Mon Sep 17 00:00:00 2001 From: ilsea Date: Wed, 30 Sep 2026 19:19:39 +0200 Subject: [PATCH 5/7] feat: create SearchField component, update tokens.css Use: pass the applied query as `value` (e.g. from the URL "?q="); onSearch fires only on Enter or button, never while typing. --- .../SearchField/SearchField.module.css | 121 ++++++++++++++++++ .../components/SearchField/SearchField.tsx | 56 ++++++++ client/src/styles/tokens.css | 6 +- 3 files changed, 181 insertions(+), 2 deletions(-) create mode 100644 client/src/components/SearchField/SearchField.module.css create mode 100644 client/src/components/SearchField/SearchField.tsx diff --git a/client/src/components/SearchField/SearchField.module.css b/client/src/components/SearchField/SearchField.module.css new file mode 100644 index 0000000..c4c94f0 --- /dev/null +++ b/client/src/components/SearchField/SearchField.module.css @@ -0,0 +1,121 @@ +/* ===== Form: the visible "pane" (border, corners, background) ===== */ +.form { + display: flex; + align-items: center; + flex: 1; + max-width: 520px; + height: var(--tap-target); + padding: 3px; + /* space around the button so it sits inside the pane */ + border: 1px solid color-mix(in srgb, var(--color-header-tint) 40%, transparent); + border-radius: var(--radius-md); + background: color-mix(in srgb, var(--color-header-tint) 10%, transparent); + transition: background-color 0.2s ease, border-color 0.2s ease; +} + +/* Focus: gold border (thin, no glow) */ +.form:has(.input:focus-visible) { + border-color: var(--color-highlight); + +} + +/* ===== Input: borderless and transparent, the form draws the field ===== */ +.input { + flex: 1; + min-width: 0; + /* lets the input shrink on narrow screens */ + height: 100%; + padding: 0 var(--space-2) 0 var(--space-4); + border: 0; + background: transparent; + color: var(--color-header-soft); + caret-color: var(--color-highlight); + font-size: var(--text-md); + /* keep at 16px or more, or iOS zooms in on focus */ + appearance: none; + /* removes Safari's built-in search-field styling */ + color-scheme: dark; + /* makes the browser's built-in clear (x) button light */ +} + +.input::placeholder { + color: color-mix(in srgb, var(--color-header-text) 80%, transparent); + transition: opacity 0.2s ease; +} + +.input:focus-visible { + outline: none; + /* the form's border shows focus instead */ +} + +.input:focus-visible::placeholder { + opacity: 0.6; + /* placeholder steps back once you are typing */ +} + +/* ===== Search button: a small rounded key inset in the field ===== */ +.button { + position: relative; + flex: none; + display: grid; + place-items: center; + height: 100%; + aspect-ratio: 1; + /* square: as wide as the field is tall */ + padding: 0; + border: 0; + border-radius: calc(var(--radius-md) - 3px); + /* form radius minus the 3px padding = corners that nest evenly */ + background: color-mix(in srgb, var(--color-header-soft) 15%, transparent); + color: var(--color-header-soft); + cursor: pointer; + transition: background-color 0.18s ease, color 0.18s ease, transform 0.12s ease; +} + +/* Grows the clickable area to 44px without changing how the button looks */ +.button::after { + content: ""; + position: absolute; + inset: -4px; +} + + +/* Click: small physical press, slightly deeper gold */ +.button:active { + background: color-mix(in srgb, var(--color-highlight) 85%, black); + transform: scale(0.92); +} + +/* Keyboard focus: ring around button */ +.button:focus-visible { + outline: 2px solid var(--color-highlight); + outline-offset: -2px; +} + +/* Respect "reduce motion" system settings */ +@media (prefers-reduced-motion: reduce) { + + .form, + .input::placeholder, + .button { + transition: none; + } + + .button:active { + transform: none; + } +} + +@media (hover: hover) { + + /* Form hover: the border brightens a touch */ + .form:hover { + border-color: color-mix(in srgb, var(--color-chip-bg) 80%, transparent); + } + + /* Button hover: fills gold, icon turns plum */ + .button:hover { + background: var(--color-highlight); + color: var(--color-header); + } +} \ No newline at end of file diff --git a/client/src/components/SearchField/SearchField.tsx b/client/src/components/SearchField/SearchField.tsx new file mode 100644 index 0000000..de10054 --- /dev/null +++ b/client/src/components/SearchField/SearchField.tsx @@ -0,0 +1,56 @@ +import { useId, useState } from "react"; +import styles from "./SearchField.module.css"; + +/* ---------- Props ---------- */ +type Props = { + value: string; // the currently applied search (from the URL) + onSearch: (query: string) => void; +}; + +/* ---------- SearchField: submits on Enter or the search button, never while typing ---------- */ +export function SearchField({ value, onSearch }: Props) { + const id = useId(); + const [draft, setDraft] = useState(value); // what is typed but not yet applied + const [prevValue, setPrevValue] = useState(value); // the applied value we last saw + + /* If the applied search changed elsewhere (Clear all, back button), reset the draft. */ + if (value !== prevValue) { + setPrevValue(value); + setDraft(value); + } + + return ( + // A
      gives Enter-to-submit for free + { + e.preventDefault(); + onSearch(draft.trim()); // empty string = clear the search + }} + > + + + setDraft(e.target.value)} + /> + + {/* Icon-only button, so it needs an aria-label. */} + +
      + ); +} \ No newline at end of file diff --git a/client/src/styles/tokens.css b/client/src/styles/tokens.css index 2e55c55..fd0f257 100644 --- a/client/src/styles/tokens.css +++ b/client/src/styles/tokens.css @@ -9,12 +9,14 @@ --color-header-text: #fbeef3; --color-accent: #a3264f; --color-ribbon: #c2810f; - --color-highlight: #f0b957; + --color-highlight: #eeb34c; --color-star: #c78a12; - --color-chip-bg: #f6e3ec; + --color-chip-bg: #f8bbd2; --color-chip-text: #7a1f45; --color-shelf: #7b5535; --color-shelf-edge: #9a7050; + --color-header-tint: #f3b2cb; + --color-header-soft: #ffd8e6; /* TODO:check contrast */ --color-on-accent: #ffffff; /* text on accent buttons */ From 237dde5799fa254dbff437d559110a24bcb1b508 Mon Sep 17 00:00:00 2001 From: ilsea Date: Wed, 30 Sep 2026 19:20:31 +0200 Subject: [PATCH 6/7] docs: update README and decisions Updated docs to reflect recent changes to components and CSS --- README.md | 29 +++++------------- docs/decisions-and-contracts.md | 53 ++++++++++++++++++++++++--------- 2 files changed, 46 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index 7207bae..e5cc0a2 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ We deliberately left out reviews from other users. There is no user login and no *Preview:* -browse-desktop browse-mobile +browse-desktop browse-mobile The mockup is a single HTML file. GitHub shows HTML files as source code, so **download the file (or clone the repository) and open it in a web browser**. It works offline, but the fonts are loaded from Google Fonts, so without internet it uses fallback fonts. @@ -45,24 +45,8 @@ Links between the pages work in the mockup. Buttons do not. The ratings, notes a **Source:** the [Google Books API](https://developers.google.com/books). We fetch books once and store a cleaned copy in our own database, so the app does not depend on Google while running. The app will be built to be able to handle larger amounts of data than this, which allows us to fetch more books for the app in the future, if we want to. -**What a book looks like** (our contract between backend and frontend): - -```ts -type Book = { - id: string; // Google Books volume id - title: string; - authors: string[]; - publishedYear: number | null; - genres: string[]; // from a fixed list we define - description: string | null; // plain text - coverUrl: string | null; // a placeholder cover is shown if missing - pageCount: number | null; - language: string | null; - myEntry: ShelfEntry | null; // this user's rating and note, if any -}; - -type ShelfEntry = { rating: number | null; note: string | null; updatedAt: string }; -``` +**What a book looks like** +See `docs/decisions.md` **Two kinds of stored data:** 1. **Books:** read-only, filled in by a seed script. @@ -107,18 +91,19 @@ type ShelfEntry = { rating: number | null; note: string | null; updatedAt: strin | State | A state management library (to be decided) | | Quality | ESLint and Prettier, component tests with Vitest, API tests, and an automated end-to-end test | | Deployment | Apache serving the built app under `/project2`, backend on port 3001 | +| Styling | Plain CSS with shared design tokens, and CSS Modules per component | We will reuse existing libraries wherever they make sense and explain the important choices in this README as the project grows. -**Accessibility:** semantic HTML, real buttons and labels, keyboard use, visible focus, enough text contrast, and touch targets of at least 44 px. The bookmark ribbon is a real button with a pressed state, and the star rating is built so it can be used with keyboard and screen readers. +**Accessibility:** semantic HTML, real buttons and labels, keyboard use, visible focus, enough text contrast, and touch targets of at least 44 px. The bookmark ribbon is a real button with a pressed state, and the star rating is built so it can be used with keyboard and screen readers. Each book card contains one link, separate from the bookmark button, so there are no nested buttons. -**Sustainability:** results load in small batches, search waits before it sends requests, covers are small and loaded lazily, and we use few fonts and few font weights. +**Sustainability:** Search is sent only when the user presses Enter or the search button, never while typing. Results load in small batches, covers are small and loaded lazily, and we use few fonts and few font weights. ## 6. How we work - **Four roles, two pairs.** A (Data) and B (API) build the backend. C (Browse and Shelf) and D (Design and Detail) build the frontend. - **We agree on contracts, not code.** The data shapes and operations between the backend and the frontend are fixed in [`docs/decisions.md`](docs/decisions.md). Each side is free to build its part its own way, as long as the results match. Changing a contract needs its own pull request. -- **Git.** Every task is an issue, work happens on branches, and every change goes through a pull request with a review from someone else. Our git wiki has a "Git conventions" page that is ment to help us keep git clean and informational. +- **Git.** Every task is an issue, work happens on branches, and every change goes through a pull request with a review from someone else. Our git wiki has a "Git conventions" page that is meant to help us keep git clean and informational. - **Checklist.** The project requirements and open tasks are in [`docs/project-checklist.md`](docs/project-checklist.md). - **Secrets** are never committed. They live in a `.env` file on the server. - Time logs and each member's contribution are delivered separately in the course's submission system, not in this repository. diff --git a/docs/decisions-and-contracts.md b/docs/decisions-and-contracts.md index fd2e873..d05477a 100644 --- a/docs/decisions-and-contracts.md +++ b/docs/decisions-and-contracts.md @@ -2,23 +2,26 @@ v1: created by Ilse, 29.09.2026 -Mockups: `docs/mockups/` (palette C, light mode). +Mockup: `docs/bookish-mockups.html/` (light mode) ## The contract between backend and frontend ```ts type Book = { - id: string; // Google volume id + id: string; // UNCERTAIN: Google Books id (googleBooksId) or Mongo _id? Must be stable across re-seeds title: string; - authors: string[]; - publishedYear: number | null; - genres: string[]; // from a fixed list (see `genres` query) - description: string | null; // plain text, never HTML - coverUrl: string | null; // null -> the client shows a placeholder cover - pageCount: number | null; - language: string | null; - myEntry: ShelfEntry | null; // for the browser ID sent with the request + subtitle: string; // "" when none + authors: string[]; // [] when none + publishedYear: number | null; // null when unknown + genres: string[]; // UNCERTAIN: from a fixed list (see `genres` query), exact list not decided + description: string; // "" when none; plain text, never HTML + coverURL: string; // "" when none -> the client shows a generated cover + pageCount: number | null; // null when unknown + language: string; // "" when unknown }; + +// UNCERTAIN: how the user's own data reaches the client is not decided. +// Option 1: a `myEntry` field on each Book. Option 2: separate lookups (saved ids + ratings). type ShelfEntry = { rating: number | null; note: string | null; updatedAt: string }; ``` @@ -40,10 +43,10 @@ type Mutation { setNote(bookId: ID!, note: String): Book! # max 1000 characters, null deletes } ``` - Genre filter: a book matches if it has **any** of the selected genres. Search: matches title and author, case-insensitive. Results follow the chosen sort. UserID: Client only knows its own ID. Server stores IDs along with shelf entries, but the client never needs to know about any other IDs than its own, so it's not part of the contract. + ## Decisions that affect several parts | # | Decision | Affects | Consequence | @@ -56,12 +59,34 @@ UserID: Client only knows its own ID. Server stores IDs along with shelf entries | 6 | **Tie-break rule:** every sort ends with `id` as a last tie-breaker (for example: year, then id). The cursor is an opaque string that only the server understands. `totalCount` counts the filtered set. | A, B, C | Without a fixed order for equal values, "Load more" can repeat or skip books. C only passes the cursor back and never builds one. | | 7 | **The data is cleaned when seeded.** Descriptions are plain text, genres come from a fixed list, `publishedDate` becomes `publishedYear`, one edition per book. | A, B, C, D | The client never handles HTML from Google and never has to guess genre names. | | 8 | **Notes are plain text**, max 1000 characters, shown with line breaks kept, never as HTML. | B, D | The server enforces the limit. The client shows a counter. | -| 9 | **Design tokens and shared components.** Colors, spacing, radius and fonts come from `tokens.css`. BookCover, BookCard, ShelfButton and StarRating are reused, not copied. | C, D | Need a new value or variant? Add it to the shared file in a pull request. | +| 9 | **Design tokens and shared components.** Colors, spacing, radius and fonts come from `tokens.css`. Styling is plain CSS: global `tokens.css`, `reset.css` and `base.css`, plus one CSS Module per component. `BookCover`, `BookCard`, `BookGrid`, `SearchField`, `Button`, `AppShell` (and later `ShelfButton` and `StarRating`) are reused, not copied. | C, D | Need a new value or variant? Add it to the shared file in a pull request. | | | 10 | **Secrets** live only in `server/.env` (git-ignored, never committed). `server/.env.example` lists the names. Nothing secret in the client. Anything starting with `VITE_` is public. | Everyone | If a secret is ever committed, tell the group and replace it. Deleting the file later does not help. | | 11 | **Contract changes** (the block above) go in their own pull request, with a heads-up to the people they touch. | Everyone | | +| 12 | **Search is submitted with Enter or the search button, never while typing.** The applied query lives in the URL as `q`. | C, D | No debouncing needed. The search field in the shared header also works on the detail page by navigating to `/?q=…`. A new `q` resets the paging. | +| 13 | **The URL holds the browse state:** `q`, genre(s), `yearFrom`, `yearTo`, `sort`. | C, D | Reload and Back give the same results. The filter panel, sort select and search all read and write the URL. | + +## Pages and routes + +| Route | Page | Owner | Notes | +|---|---|---|---| +| `/` | Browse | C | Search, filters and sort live in the URL | +| `/book/:id` | Book detail | D | `:id` is the book `id` (see open questions) | +| `/shelf` | My shelf | C | Sort RECENT / TITLE_ASC / RATING_DESC, no search | ## Open questions - Database engine (A + B decide). -- CSS approach (Modules or plain files) and fonts (C + D decide). -- User ID handoff between client and server \ No newline at end of file +- Fonts: self-hosting approach for Fraunces and Figtree (C + D decide). +- User ID handoff between client and server. +- **Book `id`:** Google Books id or Mongo `_id`? It becomes the URL, the saved-books key, and the key for ratings and notes, so it must be stable across re-seeds. +- **How the user's own data reaches the client:** a `myEntry` field on each `Book` (the mutations already return `Book!`, which points this way), or separate lookups (saved ids and ratings). Decides the props of `BookGrid`. +- **Removal confirmation:** decision 3 says the client asks before removing a book with a rating or note. Does this apply on the cards too, or only on the detail page? Dialog or inline? +- **Year filter edge case:** what happens if `yearFrom` is greater than `yearTo`? Validate on the client, the server, or both? +- **Genre list:** who fixes the final list, and does it live in the seed script or in its own table? +- **Empty results from filters:** can a filter combination give zero results, or does `books` return counts per genre so the client can hide empty ones? +- **`updatedAt` on `ShelfEntry`:** needed for the RECENT sort on My shelf. Check that the schema exposes it. + +## Changelog + +- v1 (29.09.2026): created by Ilse. +- v2 (date): `Book` type aligned with the backend model. CSS approach decided. Added decisions 12 and 13, the routes table, and more open questions. \ No newline at end of file From a6537c4ac767ad579b0601051638c763a85086fb Mon Sep 17 00:00:00 2001 From: ilsea Date: Wed, 30 Sep 2026 19:26:54 +0200 Subject: [PATCH 7/7] style: run Prettier formatting on changed files --- client/eslint.config.js | 16 +- client/src/App.tsx | 6 +- .../components/BookCard/BookCard.module.css | 25 +-- client/src/components/BookCard/BookCard.tsx | 171 +++++++++--------- .../components/BookCover/BookCover.module.css | 122 ++++++------- client/src/components/BookCover/BookCover.tsx | 23 ++- .../components/BookGrid/BookGrid.module.css | 6 +- client/src/components/BookGrid/BookGrid.tsx | 18 +- .../SearchField/SearchField.module.css | 17 +- .../components/SearchField/SearchField.tsx | 27 ++- client/src/styles/base.css | 32 ++-- client/src/styles/index.css | 6 +- client/src/styles/reset.css | 14 +- client/src/styles/tokens.css | 34 ++-- client/src/types/book.ts | 16 +- client/tsconfig.json | 5 +- client/vite.config.ts | 13 +- 17 files changed, 282 insertions(+), 269 deletions(-) diff --git a/client/eslint.config.js b/client/eslint.config.js index 356db8d..8d061c7 100644 --- a/client/eslint.config.js +++ b/client/eslint.config.js @@ -1,10 +1,10 @@ -import js from '@eslint/js' -import globals from 'globals' -import reactHooks from 'eslint-plugin-react-hooks' -import reactRefresh from 'eslint-plugin-react-refresh' -import tseslint from 'typescript-eslint' -import { defineConfig, globalIgnores } from 'eslint/config' -import prettier from 'eslint-config-prettier' +import js from '@eslint/js'; +import globals from 'globals'; +import reactHooks from 'eslint-plugin-react-hooks'; +import reactRefresh from 'eslint-plugin-react-refresh'; +import tseslint from 'typescript-eslint'; +import { defineConfig, globalIgnores } from 'eslint/config'; +import prettier from 'eslint-config-prettier'; export default defineConfig([ globalIgnores(['dist']), @@ -21,4 +21,4 @@ export default defineConfig([ }, }, prettier, -]) +]); diff --git a/client/src/App.tsx b/client/src/App.tsx index c91e73b..7c3ec04 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -1,7 +1,5 @@ function App() { - return ( -

      🥂Project 2🥂

      - ) + return

      🥂Project 2🥂

      ; } -export default App \ No newline at end of file +export default App; diff --git a/client/src/components/BookCard/BookCard.module.css b/client/src/components/BookCard/BookCard.module.css index f500042..51b0e68 100644 --- a/client/src/components/BookCard/BookCard.module.css +++ b/client/src/components/BookCard/BookCard.module.css @@ -1,11 +1,11 @@ /* ===== List item ===== */ .item { - min-width: 0; /* lets long titles shrink instead of stretching the grid */ + min-width: 0; /* lets long titles shrink instead of stretching the grid */ } /* ===== Card container ===== */ .card { - position: relative; /* anchor for the stretched link and the ribbon */ + position: relative; /* anchor for the stretched link and the ribbon */ display: flex; flex-direction: column; gap: var(--space-3); @@ -30,15 +30,15 @@ height: 10px; } .plank::before { - content: ""; + content: ''; position: absolute; top: 0; bottom: 0; left: -100vw; right: -100vw; background: linear-gradient(var(--color-shelf-edge) 0 2px, var(--color-shelf) 2px); - border-bottom: 2px solid rgb(0 0 0 / 0.25); /* opaque "shadow": a real box-shadow would stack across cards */ - pointer-events: none; /* otherwise it blocks clicks on neighbouring cards */ + border-bottom: 2px solid rgb(0 0 0 / 0.25); /* opaque "shadow": a real box-shadow would stack across cards */ + pointer-events: none; /* otherwise it blocks clicks on neighbouring cards */ } /* ===== Text ===== */ @@ -51,7 +51,7 @@ margin: 0; font-family: var(--font-heading); font-weight: 600; - font-size: var(--text-lg); /* mockup uses 18px; adjust if you like */ + font-size: var(--text-lg); /* mockup uses 18px; adjust if you like */ line-height: 1.2; } .meta { @@ -64,8 +64,9 @@ color: inherit; text-decoration: none; } -.link::after { /* invisible layer over the whole card */ - content: ""; +.link::after { + /* invisible layer over the whole card */ + content: ''; position: absolute; inset: 0; } @@ -101,8 +102,8 @@ position: absolute; top: 0; right: 4px; - z-index: 2; /* above the cover and the stretched link, so it stays clickable */ - width: 44px; /* 44px-wide tap area; the drawn ribbon is only 24px */ + z-index: 2; /* above the cover and the stretched link, so it stays clickable */ + width: 44px; /* 44px-wide tap area; the drawn ribbon is only 24px */ height: 48px; display: flex; justify-content: center; @@ -124,8 +125,8 @@ stroke-width: 1.8; } /* Saved: solid ribbon. Styled from aria-pressed, so no extra class is needed */ -.ribbon[aria-pressed="true"] path { +.ribbon[aria-pressed='true'] path { fill: var(--color-ribbon); stroke: rgb(255 255 255 / 0.8); stroke-width: 1; -} \ No newline at end of file +} diff --git a/client/src/components/BookCard/BookCard.tsx b/client/src/components/BookCard/BookCard.tsx index e189fda..6461e67 100644 --- a/client/src/components/BookCard/BookCard.tsx +++ b/client/src/components/BookCard/BookCard.tsx @@ -1,108 +1,107 @@ -import { Link } from "react-router-dom"; -import type { Book } from "../../types/book"; -import { BookCover } from "../BookCover/BookCover"; -import styles from "./BookCard.module.css"; +import { Link } from 'react-router-dom'; +import type { Book } from '../../types/book'; +import { BookCover } from '../BookCover/BookCover'; +import styles from './BookCard.module.css'; /* ---------- Types: the contract with BookGrid and the pages ---------- */ type BookCardProps = { - book: Book; - isSaved: boolean; // is this book on My shelf? - myRating?: number; // 1–5; undefined = not rated (no stars shown) - onToggleSaved: () => void; - showShelf?: boolean; // draw the decorative shelf plank under the cover + book: Book; + isSaved: boolean; // is this book on My shelf? + myRating?: number; // 1–5; undefined = not rated (no stars shown) + onToggleSaved: () => void; + showShelf?: boolean; // draw the decorative shelf plank under the cover }; /* ---------- BookCard: one book ---------- */ // Renders an
    • , so it must be used inside BookGrid's
        export function BookCard({ - book, - isSaved, - myRating, - onToggleSaved, - showShelf = false, + book, + isSaved, + myRating, + onToggleSaved, + showShelf = false, }: BookCardProps) { - // Meta line: "Jane Austen · 1813". Year is optional, so build it defensively - const meta = [book.authors.join(", "), book.publishedYear].filter(Boolean).join(" · "); + // Meta line: "Jane Austen · 1813". Year is optional, so build it defensively + const meta = [book.authors.join(', '), book.publishedYear].filter(Boolean).join(' · '); - return ( -
      • -
        + return ( +
      • +
        + {/* Cover + optional shelf plank */} +
        + {/* coverWrap lifts the cover above the shelf so its shadow lands on the plank */} +
        + +
        + {showShelf && - {/* Cover + optional shelf plank */} -
        - {/* coverWrap lifts the cover above the shelf so its shadow lands on the plank */} -
        - -
        - {showShelf && + {/* Text block */} +
        +

        + {/* The only link. Its ::after covers the whole card (see CSS) */} + + {book.title} + +

        + {meta} + {myRating !== undefined && } +
        - {/* Text block */} -
        -

        - {/* The only link. Its ::after covers the whole card (see CSS) */} - - {book.title} - -

        - {meta} - {myRating !== undefined && } -
        - - {/* Ribbon: sibling of the link, NOT inside it */} - -
        -
      • - ); + {/* Ribbon: sibling of the link, NOT inside it */} + +
        +
      • + ); } /* ---------- StarRow: read-only display of the user's own rating ---------- */ function StarRow({ value }: { value: number }) { - return ( - // One image for screen readers, so the five SVGs are hidden - - {[1, 2, 3, 4, 5].map((n) => ( - - ))} - - ); + return ( + // One image for screen readers, so the five SVGs are hidden + + {[1, 2, 3, 4, 5].map((n) => ( + + ))} + + ); } /* ---------- FavoriteRibbon: bookmark toggle ---------- */ function FavoriteRibbon({ - title, - isSaved, - onToggle, + title, + isSaved, + onToggle, }: { - title: string; - isSaved: boolean; - onToggle: () => void; + title: string; + isSaved: boolean; + onToggle: () => void; }) { - return ( - - ); -} \ No newline at end of file + return ( + + ); +} diff --git a/client/src/components/BookCover/BookCover.module.css b/client/src/components/BookCover/BookCover.module.css index 13e5f59..2d97f04 100644 --- a/client/src/components/BookCover/BookCover.module.css +++ b/client/src/components/BookCover/BookCover.module.css @@ -1,92 +1,92 @@ .cover { - display: block; - width: 100%; - /* the parent decides the width: card column or 340px hero */ - aspect-ratio: 2 / 3; - object-fit: cover; - border-radius: 3px 7px 7px 3px; - /* book spine on the left */ - box-shadow: - inset 5px 0 0 rgb(0 0 0 / 0.2), - inset 6px 0 0 rgb(255 255 255 / 0.14), - 0 10px 18px -8px rgb(30 27 22 / 0.5); + display: block; + width: 100%; + /* the parent decides the width: card column or 340px hero */ + aspect-ratio: 2 / 3; + object-fit: cover; + border-radius: 3px 7px 7px 3px; + /* book spine on the left */ + box-shadow: + inset 5px 0 0 rgb(0 0 0 / 0.2), + inset 6px 0 0 rgb(255 255 255 / 0.14), + 0 10px 18px -8px rgb(30 27 22 / 0.5); } .generated { - position: relative; - overflow: hidden; - container-type: inline-size; + position: relative; + overflow: hidden; + container-type: inline-size; } -.generated[data-tone="0"] { - background: var(--cover-0-bg); - color: var(--cover-0-fg); +.generated[data-tone='0'] { + background: var(--cover-0-bg); + color: var(--cover-0-fg); } -.generated[data-tone="1"] { - background: var(--cover-1-bg); - color: var(--cover-1-fg); +.generated[data-tone='1'] { + background: var(--cover-1-bg); + color: var(--cover-1-fg); } -.generated[data-tone="2"] { - background: var(--cover-2-bg); - color: var(--cover-2-fg); +.generated[data-tone='2'] { + background: var(--cover-2-bg); + color: var(--cover-2-fg); } -.generated[data-tone="3"] { - background: var(--cover-3-bg); - color: var(--cover-3-fg); +.generated[data-tone='3'] { + background: var(--cover-3-bg); + color: var(--cover-3-fg); } -.generated[data-tone="4"] { - background: var(--cover-4-bg); - color: var(--cover-4-fg); +.generated[data-tone='4'] { + background: var(--cover-4-bg); + color: var(--cover-4-fg); } -.generated[data-tone="5"] { - background: var(--cover-5-bg); - color: var(--cover-5-fg); +.generated[data-tone='5'] { + background: var(--cover-5-bg); + color: var(--cover-5-fg); } .frame { - position: absolute; - inset: 11cqw 11cqw 11cqw 13.5cqw; - border: 1px solid var(--color-highlight); - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: 5cqw; - padding: 0 8cqw; - text-align: center; + position: absolute; + inset: 11cqw 11cqw 11cqw 13.5cqw; + border: 1px solid var(--color-highlight); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 5cqw; + padding: 0 8cqw; + text-align: center; } .sparkle { - width: 9cqw; - color: var(--color-highlight); + width: 9cqw; + color: var(--color-highlight); } .title { - font-family: var(--font-heading); - font-weight: 600; - font-size: 10cqw; - line-height: 1.12; - text-wrap: balance; - display: -webkit-box; - -webkit-line-clamp: 4; - -webkit-box-orient: vertical; - overflow: hidden; + font-family: var(--font-heading); + font-weight: 600; + font-size: 10cqw; + line-height: 1.12; + text-wrap: balance; + display: -webkit-box; + -webkit-line-clamp: 4; + -webkit-box-orient: vertical; + overflow: hidden; } .rule { - width: 20cqw; - height: 2px; - background: var(--color-highlight); + width: 20cqw; + height: 2px; + background: var(--color-highlight); } .author { - font-size: 5.3cqw; - font-weight: 600; - letter-spacing: 0.14em; - text-transform: uppercase; -} \ No newline at end of file + font-size: 5.3cqw; + font-weight: 600; + letter-spacing: 0.14em; + text-transform: uppercase; +} diff --git a/client/src/components/BookCover/BookCover.tsx b/client/src/components/BookCover/BookCover.tsx index 8e1a786..a9f2ef7 100644 --- a/client/src/components/BookCover/BookCover.tsx +++ b/client/src/components/BookCover/BookCover.tsx @@ -1,5 +1,5 @@ -import { useState } from "react"; -import styles from "./BookCover.module.css"; +import { useState } from 'react'; +import styles from './BookCover.module.css'; const TONE_COUNT = 6; @@ -24,19 +24,26 @@ export function BookCover({ id, title, author, coverUrl, eager = false }: Props) return ( setFailed(true)} /> ); } return ( -