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/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/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/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 new file mode 100644 index 0000000..51b0e68 --- /dev/null +++ b/client/src/components/BookCard/BookCard.module.css @@ -0,0 +1,132 @@ +/* ===== 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; +} diff --git a/client/src/components/BookCard/BookCard.tsx b/client/src/components/BookCard/BookCard.tsx new file mode 100644 index 0000000..6461e67 --- /dev/null +++ b/client/src/components/BookCard/BookCard.tsx @@ -0,0 +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'; + +/* ---------- 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