diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..6fb50cf --- /dev/null +++ b/.env.example @@ -0,0 +1,6 @@ +# Server +PORT=3001 +DATABASE_URL=postgres://t19:t19@localhost:5432/crate + +# Client (Vite) +VITE_GRAPHQL_URL=http://localhost:3001/graphql diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e3d4467 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,111 @@ +name: CI/CD + +on: + pull_request: + branches: [main] + push: + branches: [main] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + quality: + name: Format, lint, typecheck, test, build + runs-on: [self-hosted, linux, x64] + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + version: 10.33.0 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Check formatting + run: pnpm format:check + + - name: Lint + run: pnpm lint + + - name: Typecheck + run: pnpm typecheck + + - name: Unit tests (server + client) + run: pnpm test + + - name: Build (server + client) + run: pnpm build + + # Bundle budget: fail the build if the main JS chunk creeps past 500 kB. + # This is one of our documented bærekraft measures — see docs/architecture.md. + - name: Enforce client bundle budget + run: | + set -euo pipefail + MAX_BYTES=512000 + for f in client/dist/assets/*.js; do + size=$(stat -c%s "$f") + echo "$f = ${size} bytes" + if [ "$size" -gt "$MAX_BYTES" ]; then + echo "::error::Bundle budget exceeded: $f is ${size} bytes (max ${MAX_BYTES})." + exit 1 + fi + done + + deploy: + name: Deploy to the group VM + needs: quality + if: github.ref == 'refs/heads/main' && github.event_name == 'push' + runs-on: [self-hosted, linux, x64] + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + version: 10.33.0 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build server and client + run: pnpm build + env: + VITE_GRAPHQL_URL: /graphql + + - name: Deploy client to Apache + run: | + set -euo pipefail + find /var/www/html/project2 -mindepth 1 -delete + cp -r client/dist/. /var/www/html/project2/ + chmod -R a+rX /var/www/html/project2 + + - name: Restart the GraphQL API + run: sudo systemctl restart crate-api + + - name: Smoke check + run: | + set -euo pipefail + sleep 2 + curl -fsS -o /dev/null -w 'GET /project2/ : %{http_code}\n' http://localhost/project2/ + curl -fsS -o /dev/null -w 'POST /graphql : %{http_code}\n' \ + -X POST http://localhost/graphql \ + -H 'content-type: application/json' \ + -d '{"query":"{ genres { name } }"}' diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..99a2624 --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +node_modules +dist +coverage +.env +.env.local +*.log +.DS_Store +e2e/test-results +e2e/playwright-report +e2e/.cache +server/src/generated +client/src/gql/graphql.ts +.vscode +.idea diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..47355b5 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,9 @@ +dist +coverage +node_modules +pnpm-lock.yaml +*.min.js +client/src/gql +server/src/generated +e2e/test-results +e2e/playwright-report diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..fe2b548 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,6 @@ +{ + "singleQuote": true, + "trailingComma": "all", + "printWidth": 90, + "semi": true +} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..f9a1d93 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,86 @@ +# AGENTS.md + +Guidance for AI coding agents and human contributors working in this repository. + +## Project + +**Crate** — community album catalog for IT2810 (NTNU) Project 2. Search, filter +and sort albums; open a detail view; persist ratings, reviews and a personal +crate. + +- pnpm workspace: `client/` (React 19 + Vite + TS + Apollo Client), `server/` + (GraphQL Yoga + Drizzle + PostgreSQL), `e2e/` (Playwright + axe-core). +- Node.js 24.x, pnpm 10.x. +- TypeScript strict mode with `noUnusedLocals` and `noUncheckedIndexedAccess`. + +## Commands + +| Command | Purpose | +| --------------------------------------------------------- | ----------------------------------------------- | +| `pnpm install` | Install all workspaces | +| `pnpm dev` | API (:3001) + client (:5173) in watch mode | +| `pnpm build` | Production build of server + client | +| `pnpm lint` | ESLint, must pass with 0 warnings | +| `pnpm format` / `pnpm format:check` | Prettier | +| `pnpm typecheck` | `tsc --noEmit` in every workspace | +| `pnpm test` | Vitest for server + client | +| `pnpm test:e2e` | Playwright (needs API + client preview running) | +| `pnpm db:migrate` / `pnpm db:seed` / `pnpm db:seed:large` | Schema / data | + +Run `pnpm format:check && pnpm lint && pnpm typecheck && pnpm test` before a PR. + +## Architecture rules (do not casually change these) + +1. **Sorting and filtering happen in SQL**, never by rearranging a loaded page + in the client. `searchAlbums` arguments map to `WHERE` / `ORDER BY`. +2. **Pagination is keyset/cursor-based** (`first` + `after`), not `OFFSET`. See + `server/src/search/search.ts`. +3. **Every relation field goes through the per-request DataLoaders** in + `server/src/loaders/index.ts`. Do not call `db.select` from a field resolver. +4. **User input is never interpolated into SQL.** Use drizzle `sql` templates so + values are bound parameters (there is a unit test for this). +5. **Users are simulated**: a UUID in `localStorage` (`t19.crate.user`). No + authentication. Mutations take `userId` + `displayName` and upsert into + `"user"`. +6. **Plain CSS only** (one file, `client/src/App.css`). No CSS framework, no + inline style objects for static styling. Respect `prefers-color-scheme` and + `prefers-reduced-motion`. Visible focus rings are required. +7. **GraphQL schema lives in `server/src/schema.graphql`** and is the contract. + Resolvers must not return shapes that deviate from it. +8. **Accessibility is not optional**: form controls need labels or + `aria-label`, new async content needs `role="status"` + `aria-live="polite"`, + interactive star ratings must be a radio group. + +## Layout + +``` +server/src/ + schema.graphql GraphQL SDL + resolvers/ Query, Mutation and per-type field resolvers + search/ pure WHERE / ORDER BY / cursor builders (unit-tested) + loaders/ DataLoader batchers (N+1 defence) + db/ Drizzle schema, migrations (drizzle/*.sql), seed scripts +client/src/ + api/ Apollo Client instance + GraphQL documents + components/ presentational + container components + hooks/ useDebounce, useSearchState (URL), useUserId + test/ Vitest setup + MSW handlers +e2e/tests/ Playwright journeys (search → filter → sort → review) +docs/ architecture.md, VM-setup.md, AI-USAGE.md +``` + +## Conventions + +- One component per file; props-driven; derived logic in hooks. +- Conventional commits referencing the issue (`feat: #12 add decade facet`). +- Branch names: `feat/-short-name`, `fix/-short-name`. +- Every AI-assisted change is logged in `docs/AI-USAGE.md` and goes through + normal peer review (see `CODEOWNERS`). + +## Infrastructure + +- Backend listens on port **3001**; database is only reachable from the VM. +- Client is built with `base: '/project2/'` and served by Apache at + `http://it2810-19.idi.ntnu.no/project2`. +- CI runs on the self-hosted runner on the group VM. Full VM runbook: + `docs/VM-setup.md`. diff --git a/README.md b/README.md index a181cf8..3b44ef3 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,122 @@ -# T19-Project-2 +# Crate — community album catalog -Repository for team IT2810-H26-T19 in IT2810-H26. +**IT2810 Project 2 · Team T19** · live at `http://it2810-19.idi.ntnu.no/project2` -## Working in this repository +Crate is a searchable catalog of albums. You search by word (case-insensitive), +filter by genre and decade, sort the **whole** result set, page through large +results, open an album to see its tracklist and community reviews, and add +your own rating, review and wantlist ("crate"). -`main` is protected — you cannot push to it directly. Work on a branch and -open a pull request: +--- + +## Quick start + +Requirements: **Node.js 24+** and **pnpm 10+** (see [nvm](https://github.com/nvm-sh/nvm)), +and a local **PostgreSQL 16+** (only needed for the API). ```bash -git checkout -b my-feature -git push -u origin my-feature +# 1. Install workspace dependencies +pnpm install + +# 2. Create a database and user (once) +sudo -u postgres psql <<'SQL' +CREATE USER t19 WITH PASSWORD 't19'; +CREATE DATABASE crate OWNER t19; +SQL + +# 3. Configure the server +cp .env.example .env # adjust DATABASE_URL if needed + +# 4. Create tables and seed a sample catalog +pnpm db:migrate +pnpm db:seed # ~20 curated albums +pnpm db:seed:large # optional: +100k synthetic albums (ALBUM_COUNT=…) + +# 5. Run both apps (API on :3001, client on :5173) +pnpm dev +``` + +Open . + +| Command | Purpose | +| ---------------------------------- | --------------------------------- | +| `pnpm dev` | API + client in watch mode | +| `pnpm build` | Production build of both packages | +| `pnpm lint` / `pnpm format` | ESLint (0 warnings) / Prettier | +| `pnpm typecheck` | TypeScript, no emit | +| `pnpm test` | Vitest for server + client | +| `pnpm test:e2e` | Playwright end-to-end tests | +| `pnpm db:migrate` / `pnpm db:seed` | Schema / sample data | + +Run `pnpm format:check && pnpm lint && pnpm typecheck && pnpm test` before opening a PR. + +--- + +## What the application does + +| Requirement | How it is implemented | +| ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Search on single terms, case-insensitive, no wasted requests | Postgres full-text search (`websearch_to_tsquery('simple', …)` on a GIN-indexed `search_vector`), 300 ms debounce + explicit submit | +| List presentation of large result sets | Infinite scroll with a keyboard-accessible **Load more** button. Cursor (keyset) pagination — see [docs/architecture.md](docs/architecture.md) | +| Detail view | `/album/:id` with tracklist, artist, genres, community reviews | +| Sort and filter on the **whole** result set | Both are SQL `ORDER BY` / `WHERE` clauses. Changing either re-queries the server; nothing is rearranged client-side | +| Persistent user-generated data | Ratings (1–5) + written reviews + a personal crate, all in PostgreSQL. Users are simulated (UUID in `localStorage`) as the lecture allows | +| Universal utforming | Skip link, landmarks, `aria-live` result count, radio-group star rating, visible focus, keyboard-operable everything. `axe-core` runs in the E2E suite | +| Bærekraft | No UI framework, debounced queries, lazy images, `prefers-color-scheme` + `prefers-reduced-motion`, GraphQL fetches only the requested fields | +| GraphQL backend | Hand-written schema + resolvers on GraphQL Yoga. DataLoaders remove the N+1 pattern | +| State management | Apollo Client cache with a `typePolicies` merge for paginated results, plus URL state for query/filters/sort | +| Testing | Vitest (server SQL builders + client components, MSW), Playwright E2E for the full journey | + +--- + +## Architecture + +``` +client/ React 19 + Vite + TypeScript + Apollo Client (SPA) +server/ GraphQL Yoga + Drizzle + PostgreSQL 16 +e2e/ Playwright + axe-core ``` -Before a pull request can be merged: +The database is only reachable from the VM itself. The GraphQL API on port +**3001** is the only thing exposed to the browser — matching the course +requirement that the database and backend run _directly on the VM_ (no Docker, +no external BaaS). + +Decisions and trade-offs (why PostgreSQL and not MongoDB/Neo4j, why cursor +pagination, how N+1 is handled) are written up in +[docs/architecture.md](docs/architecture.md). VM setup is in +[docs/VM-setup.md](docs/VM-setup.md). AI usage is logged in +[docs/AI-USAGE.md](docs/AI-USAGE.md). + +--- + +## Project layout + +``` +server/src/ + schema.graphql GraphQL SDL — the contract + resolvers/ Query/Mutation/field resolvers + search/ WHERE/ORDER BY/cursor builders (pure, unit-tested) + loaders/ per-request DataLoaders (N+1 defence) + db/ Drizzle schema, migrations, seed scripts +client/src/ + api/ Apollo Client + GraphQL documents + components/ SearchBar, FilterPanel, AlbumList, AlbumCard, … + hooks/ useDebounce, useSearchState (URL), useUserId +e2e/tests/ Playwright journeys +docs/ architecture, VM setup, AI usage +``` + +--- + +## Workflow + +1. Pick an issue from the current milestone. +2. Branch from `main` as `feat/-short-name`. +3. Conventional commits referencing the issue. +4. Open a PR. A teammate must approve (see `CODEOWNERS`). +5. Resolve every review conversation, then merge. -1. **A member of IT2810-H26-T19 must approve it.** You cannot approve your own pull - request, and approvals from staff do not count — you review each other's - code. -2. **Every review comment must be marked resolved.** Reply to each one, fix it - or explain why not, then click *Resolve conversation*. -3. **Pushing new commits dismisses the approval**, so if you change something - after being approved, ask for another review. This is deliberate: what was - approved is what gets merged. \ No newline at end of file +CI runs on the self-hosted runner on the group VM: lint, typecheck, unit tests +and a production build must pass before merge. Pushes to `main` deploy the +client build and restart the API. diff --git a/client/index.html b/client/index.html new file mode 100644 index 0000000..6010711 --- /dev/null +++ b/client/index.html @@ -0,0 +1,13 @@ + + + + + + + Crate — album catalog + + +
+ + + diff --git a/client/package.json b/client/package.json new file mode 100644 index 0000000..8774a95 --- /dev/null +++ b/client/package.json @@ -0,0 +1,35 @@ +{ + "name": "@t19/client", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview", + "typecheck": "tsc -b --noEmit", + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": { + "@apollo/client": "^4.1.2", + "graphql": "^16.12.0", + "react": "^19.2.8", + "react-dom": "^19.2.8", + "react-router-dom": "^7.9.5" + }, + "devDependencies": { + "@testing-library/dom": "^10.4.1", + "@testing-library/jest-dom": "^7.0.1", + "@testing-library/react": "^16.3.3", + "@testing-library/user-event": "^14.6.7", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "@vitejs/plugin-react": "^6.1.0", + "jsdom": "^29.1.1", + "msw": "^2.15.0", + "typescript": "~5.9.3", + "vite": "^8.2.2", + "vitest": "^4.1.11" + } +} diff --git a/client/src/App.css b/client/src/App.css new file mode 100644 index 0000000..3e0feee --- /dev/null +++ b/client/src/App.css @@ -0,0 +1,424 @@ +/* Crate — plain CSS. No framework, on purpose: smaller bundle (sustainability) + and full control over focus styles and contrast (accessibility). */ + +:root { + color-scheme: light dark; + --bg: #fafaf8; + --surface: #ffffff; + --text: #1a1a1a; + --muted: #5c5c5c; + --border: #d8d8d4; + --accent: #2f5d50; + --accent-text: #ffffff; + --danger: #8b2222; + --star: #c9a227; + --focus: #1a73e8; + --radius: 8px; + --shadow: 0 1px 2px rgba(0, 0, 0, 0.06); + font-family: + system-ui, + -apple-system, + 'Segoe UI', + Roboto, + sans-serif; + line-height: 1.5; +} + +@media (prefers-color-scheme: dark) { + :root { + --bg: #141414; + --surface: #1e1e1e; + --text: #f2f2f0; + --muted: #a8a8a4; + --border: #333; + --accent: #6fae9c; + --accent-text: #0d0d0d; + --danger: #e07a7a; + --shadow: 0 1px 2px rgba(0, 0, 0, 0.4); + } +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + background: var(--bg); + color: var(--text); +} + +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.skip-link { + position: absolute; + left: -9999px; + top: 0; + background: var(--accent); + color: var(--accent-text); + padding: 0.5rem 1rem; + z-index: 100; +} +.skip-link:focus { + left: 0; +} + +:where(a, button, input, select, textarea, [tabindex]):focus-visible { + outline: 3px solid var(--focus); + outline-offset: 2px; +} + +.app-header { + display: flex; + align-items: baseline; + gap: 1rem; + padding: 1rem 1.5rem; + border-bottom: 1px solid var(--border); + background: var(--surface); +} +.app-header h1 { + margin: 0; + font-size: 1.5rem; +} +.app-header a { + color: inherit; + text-decoration: none; +} +.tagline { + margin: 0; + color: var(--muted); +} + +.app-footer { + padding: 1.5rem; + color: var(--muted); + border-top: 1px solid var(--border); + margin-top: 2rem; +} + +main { + padding: 1.5rem; + max-width: 1200px; + margin: 0 auto; +} + +.search-bar { + display: flex; + gap: 0.5rem; + margin-bottom: 1rem; +} +.search-bar input { + flex: 1; + padding: 0.6rem 0.8rem; + border: 1px solid var(--border); + border-radius: var(--radius); + font-size: 1rem; + background: var(--surface); + color: var(--text); +} +.search-bar button { + padding: 0.6rem 1.2rem; + border: none; + border-radius: var(--radius); + background: var(--accent); + color: var(--accent-text); + font-size: 1rem; + cursor: pointer; +} + +.search-layout { + display: grid; + grid-template-columns: 240px 1fr; + gap: 1.5rem; +} +@media (max-width: 800px) { + .search-layout { + grid-template-columns: 1fr; + } +} + +.filter-panel { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 1rem; + align-self: start; +} +.filter-panel h2 { + font-size: 1rem; + margin: 0 0 0.5rem; +} +.filter-panel section + section { + margin-top: 1.25rem; +} +.facet-list { + list-style: none; + margin: 0; + padding: 0; +} +.facet-list label { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.25rem 0; + cursor: pointer; +} +.facet-count { + margin-left: auto; + color: var(--muted); + font-variant-numeric: tabular-nums; +} + +.toolbar { + display: flex; + justify-content: flex-end; + margin-bottom: 0.75rem; +} +.sort-select { + display: flex; + align-items: center; + gap: 0.5rem; +} +.sort-select select { + padding: 0.4rem; + border-radius: var(--radius); + border: 1px solid var(--border); + background: var(--surface); + color: var(--text); +} + +.result-count { + color: var(--muted); + margin-top: 0; +} + +.album-grid { + list-style: none; + margin: 0; + padding: 0; + display: grid; + gap: 0.75rem; +} + +.album-card { + display: grid; + grid-template-columns: 80px 1fr auto; + gap: 1rem; + align-items: center; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 0.75rem; + box-shadow: var(--shadow); +} +@media (max-width: 600px) { + .album-card { + grid-template-columns: 64px 1fr; + } + .crate-btn { + grid-column: 1 / -1; + } +} + +.album-cover { + width: 80px; + height: 80px; + border-radius: 4px; + background: var(--border); + display: grid; + place-items: center; + overflow: hidden; + font-size: 1.75rem; + color: var(--muted); +} +.album-cover img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.album-body h3 { + margin: 0 0 0.15rem; + font-size: 1.05rem; +} +.album-body h3 a { + color: inherit; +} +.album-artist { + margin: 0; + color: var(--muted); +} +.album-meta { + margin: 0.25rem 0; + color: var(--muted); + font-size: 0.9rem; +} +.album-rating { + display: flex; + align-items: center; + gap: 0.5rem; +} +.rating-count { + color: var(--muted); + font-size: 0.9rem; +} + +.stars { + display: inline-flex; + gap: 0.1rem; + border: 0; + padding: 0; + margin: 0; +} +.star { + color: var(--border); + font-size: 1.1rem; +} +.star.on { + color: var(--star); +} +.stars-input label { + cursor: pointer; +} +.stars-input input { + position: absolute; + opacity: 0; + pointer-events: none; +} +.stars-input label:has(input:focus-visible) { + outline: 3px solid var(--focus); + outline-offset: 2px; + border-radius: 2px; +} + +.crate-btn { + padding: 0.45rem 0.8rem; + border: 1px solid var(--accent); + background: transparent; + color: var(--accent); + border-radius: var(--radius); + cursor: pointer; + white-space: nowrap; +} +.crate-btn.in-crate { + background: var(--accent); + color: var(--accent-text); +} + +.load-more { + display: flex; + justify-content: center; + padding: 1.5rem 0; +} +.load-more button { + padding: 0.6rem 1.5rem; + border: 1px solid var(--border); + background: var(--surface); + color: var(--text); + border-radius: var(--radius); + cursor: pointer; +} +.load-more button:disabled { + opacity: 0.6; + cursor: default; +} + +.empty-hint { + color: var(--muted); +} + +.album-detail header { + margin-bottom: 1.5rem; +} +.tracklist { + list-style: decimal; + padding-left: 1.5rem; +} +.tracklist li { + display: flex; + justify-content: space-between; + padding: 0.2rem 0; + border-bottom: 1px solid var(--border); + max-width: 480px; +} +.track-duration { + color: var(--muted); + font-variant-numeric: tabular-nums; +} + +.review-list { + list-style: none; + padding: 0; +} +.review-list li { + padding: 0.75rem 0; + border-bottom: 1px solid var(--border); +} +.review-meta { + display: flex; + align-items: center; + gap: 0.4rem; + color: var(--muted); + font-size: 0.95rem; +} + +.review-form { + margin-top: 1.5rem; + padding: 1rem; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface); + display: grid; + gap: 0.5rem; + max-width: 560px; +} +.review-form textarea { + width: 100%; + padding: 0.5rem; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--bg); + color: var(--text); + font: inherit; +} +.form-actions { + display: flex; + gap: 0.5rem; +} +.form-actions button { + padding: 0.5rem 1rem; + border: none; + border-radius: var(--radius); + background: var(--accent); + color: var(--accent-text); + cursor: pointer; +} +.form-actions button.danger { + background: transparent; + color: var(--danger); + border: 1px solid var(--danger); +} +.form-error { + color: var(--danger); + margin: 0; +} diff --git a/client/src/App.tsx b/client/src/App.tsx new file mode 100644 index 0000000..2f15f4c --- /dev/null +++ b/client/src/App.tsx @@ -0,0 +1,159 @@ +import { Suspense, lazy, useCallback, useMemo, useState } from 'react'; +import { Route, Routes } from 'react-router-dom'; +import { NetworkStatus } from '@apollo/client'; +import { useQuery, useMutation } from '@apollo/client/react'; +import { SEARCH_ALBUMS, TOGGLE_CRATE } from './api/queries.js'; +import { useSearchState } from './hooks/useSearchState.js'; +import { useDebounce } from './hooks/useDebounce.js'; +import { getIdentity } from './hooks/useUserId.js'; +import { SearchBar } from './components/SearchBar.js'; +import { FilterPanel } from './components/FilterPanel.js'; +import { SortSelect } from './components/SortSelect.js'; +import { AlbumList } from './components/AlbumList.js'; +import type { SearchAlbumsResult } from './types.js'; +import './App.css'; + +// The detail view is a separate route and a separate chunk: the search page +// (what most sessions spend their time on) stays lean, and the review form and +// tracklist code is only downloaded when someone opens an album. +const AlbumDetail = lazy(() => import('./components/AlbumDetail.js')); + +const PAGE_SIZE = 20; + +function SearchPage() { + const state = useSearchState(); + const identity = getIdentity(); + // The query variable is debounced; the input itself is updated immediately + // so typing stays responsive. + const [liveQuery, setLiveQuery] = useState(state.query); + const debouncedQuery = useDebounce(liveQuery, 300); + + const filters = useMemo( + () => ({ + genres: state.genres.length ? state.genres.map(Number) : undefined, + decades: state.decades.length ? state.decades : undefined, + }), + [state.genres, state.decades], + ); + + const { data, loading, fetchMore, refetch, networkStatus } = + useQuery(SEARCH_ALBUMS, { + variables: { + query: debouncedQuery || undefined, + filters: filters.genres || filters.decades ? filters : undefined, + sort: state.sort, + first: PAGE_SIZE, + userId: identity.userId, + }, + notifyOnNetworkStatusChange: true, + // Apollo's cache is the "caching" the lecture asks us to think about: + // going back to a previous search reuses the stored page instead of + // refetching it. + fetchPolicy: 'cache-first', + }); + + const [toggleCrate] = useMutation(TOGGLE_CRATE); + + const result = data?.searchAlbums; + const albums = result?.edges.map((e) => e.node) ?? []; + + const onLoadMore = useCallback(() => { + if (!result?.pageInfo.hasNextPage || networkStatus === NetworkStatus.fetchMore) + return; + fetchMore({ + variables: { + query: debouncedQuery || undefined, + filters: filters.genres || filters.decades ? filters : undefined, + sort: state.sort, + first: PAGE_SIZE, + after: result.pageInfo.endCursor, + userId: identity.userId, + }, + }); + }, [ + result, + fetchMore, + debouncedQuery, + filters, + state.sort, + identity.userId, + networkStatus, + ]); + + const onToggleCrate = useCallback( + async (albumId: string) => { + await toggleCrate({ + variables: { + albumId, + userId: identity.userId, + displayName: identity.displayName, + }, + }); + await refetch(); + }, + [toggleCrate, identity, refetch], + ); + + return ( +
+ { + setLiveQuery(q); + state.setQuery(q); + }} + /> +
+ +
+
+ +
+ +
+
+
+ ); +} + +export default function App() { + return ( + <> + + Skip to results + +
+

+ Crate +

+

Search, review and collect albums.

+
+
+ Loading…

}> + + } /> + } /> + +
+
+
+

IT2810 Project 2 · Team T19

+
+ + ); +} diff --git a/client/src/api/client.ts b/client/src/api/client.ts new file mode 100644 index 0000000..fa43374 --- /dev/null +++ b/client/src/api/client.ts @@ -0,0 +1,27 @@ +import { ApolloClient, InMemoryCache, HttpLink } from '@apollo/client'; + +const uri = import.meta.env.VITE_GRAPHQL_URL ?? '/graphql'; + +export const client = new ApolloClient({ + link: new HttpLink({ uri }), + cache: new InMemoryCache({ + typePolicies: { + Query: { + fields: { + // Cursor pagination accumulates edges across "load more" fetches + // instead of replacing the list, which is what infinite scroll needs. + searchAlbums: { + keyArgs: ['query', 'filters', 'sort'], + merge(existing, incoming, { args }) { + if (!args?.after) return incoming; + return { + ...incoming, + edges: [...(existing?.edges ?? []), ...incoming.edges], + }; + }, + }, + }, + }, + }, + }), +}); diff --git a/client/src/api/queries.ts b/client/src/api/queries.ts new file mode 100644 index 0000000..9f37553 --- /dev/null +++ b/client/src/api/queries.ts @@ -0,0 +1,142 @@ +import { gql } from '@apollo/client'; + +/** + * One query does the whole search view: page of albums + total count + facet + * counts. Fetching facets in the same request keeps the filter panel in sync + * with the result set and avoids a second round-trip (the lecture's + * over/under-fetching point). + */ +export const SEARCH_ALBUMS = gql` + query SearchAlbums( + $query: String + $filters: AlbumFilters + $sort: AlbumSort + $first: Int + $after: String + $userId: ID! + ) { + searchAlbums( + query: $query + filters: $filters + sort: $sort + first: $first + after: $after + ) { + totalCount + pageInfo { + endCursor + hasNextPage + } + genreFacets { + genre { + id + name + } + count + } + decadeFacets { + decade + count + } + edges { + cursor + node { + id + title + year + coverUrl + averageRating + reviewCount + artist { + id + name + } + genres { + id + name + } + inCrate(userId: $userId) + } + } + } + } +`; + +export const ALBUM_DETAIL = gql` + query AlbumDetail($id: ID!, $userId: ID!) { + album(id: $id) { + id + title + year + coverUrl + averageRating + reviewCount + artist { + id + name + country + formedYear + } + genres { + id + name + } + tracks { + id + number + title + durationSeconds + } + reviews { + id + rating + text + createdAt + user { + id + displayName + } + } + myReview(userId: $userId) { + id + rating + text + } + inCrate(userId: $userId) + } + } +`; + +export const UPSERT_REVIEW = gql` + mutation UpsertReview( + $albumId: ID! + $rating: Int! + $text: String + $userId: ID! + $displayName: String! + ) { + upsertReview( + albumId: $albumId + rating: $rating + text: $text + userId: $userId + displayName: $displayName + ) { + id + rating + text + } + } +`; + +export const DELETE_REVIEW = gql` + mutation DeleteReview($albumId: ID!, $userId: ID!) { + deleteReview(albumId: $albumId, userId: $userId) + } +`; + +export const TOGGLE_CRATE = gql` + mutation ToggleCrate($albumId: ID!, $userId: ID!, $displayName: String!) { + toggleCrate(albumId: $albumId, userId: $userId, displayName: $displayName) + } +`; diff --git a/client/src/components/AlbumCard.tsx b/client/src/components/AlbumCard.tsx new file mode 100644 index 0000000..5209322 --- /dev/null +++ b/client/src/components/AlbumCard.tsx @@ -0,0 +1,53 @@ +import { Link } from 'react-router-dom'; +import type { AlbumSummary } from '../types.js'; +import { StarRating } from './StarRating.js'; + +type Props = { + album: AlbumSummary; + onToggleCrate: (id: string) => void; +}; + +export function AlbumCard({ album, onToggleCrate }: Props) { + return ( +
+ +
+

+ {album.title} +

+

{album.artist.name}

+

+ {album.year ?? 'Year unknown'} + {album.genres.length > 0 && ( + <> · {album.genres.map((g) => g.name).join(', ')} + )} +

+
+ + + {album.reviewCount === 0 + ? 'No reviews yet' + : `${album.reviewCount} review${album.reviewCount === 1 ? '' : 's'}`} + +
+
+ +
+ ); +} diff --git a/client/src/components/AlbumDetail.tsx b/client/src/components/AlbumDetail.tsx new file mode 100644 index 0000000..b7da867 --- /dev/null +++ b/client/src/components/AlbumDetail.tsx @@ -0,0 +1,167 @@ +import { useQuery, useMutation } from '@apollo/client/react'; +import { Link, useParams } from 'react-router-dom'; +import { + ALBUM_DETAIL, + DELETE_REVIEW, + TOGGLE_CRATE, + UPSERT_REVIEW, +} from '../api/queries.js'; +import type { AlbumDetail as AlbumDetailData } from '../types.js'; +import { getIdentity } from '../hooks/useUserId.js'; +import { StarRating } from './StarRating.js'; +import { ReviewForm } from './ReviewForm.js'; + +function formatDuration(seconds: number | null): string { + if (seconds == null) return '—'; + const m = Math.floor(seconds / 60); + const s = seconds % 60; + return `${m}:${String(s).padStart(2, '0')}`; +} + +/** + * Detail view for one album: tracklist, community reviews and the current + * user's own review + crate toggle. The query nests artist, tracks, genres and + * reviews in a single GraphQL request — the point of GraphQL compared to the + * REST N+1 pattern from the lecture. + * + * Exported as default so App.tsx can lazy-load it as a separate chunk. + */ +export default function AlbumDetail() { + const { id } = useParams<{ id: string }>(); + const identity = getIdentity(); + + const { data, loading, error, refetch } = useQuery(ALBUM_DETAIL, { + variables: { id, userId: identity.userId }, + skip: !id, + }); + + const [upsertReview] = useMutation(UPSERT_REVIEW); + const [deleteReview] = useMutation(DELETE_REVIEW); + const [toggleCrate] = useMutation(TOGGLE_CRATE); + + if (loading) return

Loading album…

; + if (error) return

Could not load this album.

; + const album = data?.album; + if (!album) return

Album not found.

; + + return ( +
+

+ ← Back to results +

+
+

{album.title}

+

+ {album.artist.name} + {album.artist.country && <> · {album.artist.country}} + {album.year != null && <> · {album.year}} +

+
+ + + {album.reviewCount === 0 + ? 'No reviews yet' + : ` ${album.averageRating?.toFixed(1)} from ${album.reviewCount} review${album.reviewCount === 1 ? '' : 's'}`} + +
+

+ {album.genres.map((g: { name: string }) => g.name).join(' · ')} +

+ +
+ +
+

Tracklist

+
    + {album.tracks.map( + (t: { + id: string; + number: number; + title: string; + durationSeconds: number | null; + }) => ( +
  1. + {t.title} + + {formatDuration(t.durationSeconds)} + +
  2. + ), + )} +
+
+ +
+

Reviews

+ {album.reviews.length === 0 && ( +

No reviews yet. Be the first.

+ )} +
    + {album.reviews.map( + (r: { + id: string; + rating: number; + text: string | null; + createdAt: string; + user: { displayName: string }; + }) => ( +
  • +

    + {r.user.displayName} ·{' '} + ·{' '} + +

    + {r.text &&

    {r.text}

    } +
  • + ), + )} +
+ + { + await upsertReview({ + variables: { + albumId: album.id, + rating, + text, + userId: identity.userId, + displayName: identity.displayName, + }, + }); + await refetch(); + }} + onDelete={async () => { + await deleteReview({ + variables: { albumId: album.id, userId: identity.userId }, + }); + await refetch(); + }} + /> +
+
+ ); +} diff --git a/client/src/components/AlbumList.tsx b/client/src/components/AlbumList.tsx new file mode 100644 index 0000000..f2dc5e2 --- /dev/null +++ b/client/src/components/AlbumList.tsx @@ -0,0 +1,77 @@ +import { useEffect, useRef } from 'react'; +import type { AlbumSummary } from '../types.js'; +import { AlbumCard } from './AlbumCard.js'; + +type Props = { + albums: AlbumSummary[]; + hasNextPage: boolean; + loading: boolean; + totalCount: number; + query: string; + onLoadMore: () => void; + onToggleCrate: (id: string) => void; +}; + +/** + * Result list with "load more" plus an IntersectionObserver so the next page + * is fetched when the sentinel scrolls into view. A button is kept alongside + * the observer because infinite scroll alone is a keyboard trap (the brief + * requires universal utforming). + */ +export function AlbumList({ + albums, + hasNextPage, + loading, + totalCount, + query, + onLoadMore, + onToggleCrate, +}: Props) { + const sentinel = useRef(null); + + useEffect(() => { + const el = sentinel.current; + if (!el || !hasNextPage || loading) return; + const observer = new IntersectionObserver( + (entries) => { + if (entries.some((e) => e.isIntersecting)) onLoadMore(); + }, + { rootMargin: '400px' }, + ); + observer.observe(el); + return () => observer.disconnect(); + }, [hasNextPage, loading, onLoadMore]); + + return ( +
+

+ {loading && albums.length === 0 + ? 'Searching…' + : `${totalCount} album${totalCount === 1 ? '' : 's'}${query ? ` matching “${query}”` : ''}`} +

+ + {albums.length === 0 && !loading && ( +

+ No albums matched. Try a shorter search word or clear a filter — the counts in + the filter panel show what each choice would return. +

+ )} + +
    + {albums.map((a) => ( +
  • + +
  • + ))} +
+ + {hasNextPage && ( +
+ +
+ )} +
+ ); +} diff --git a/client/src/components/FilterPanel.tsx b/client/src/components/FilterPanel.tsx new file mode 100644 index 0000000..159a8fd --- /dev/null +++ b/client/src/components/FilterPanel.tsx @@ -0,0 +1,77 @@ +type GenreFacet = { genre: { id: string; name: string }; count: number }; +type DecadeFacet = { decade: number; count: number }; + +type Props = { + genreFacets: GenreFacet[]; + decadeFacets: DecadeFacet[]; + selectedGenres: string[]; + selectedDecades: number[]; + onToggleGenre: (id: string) => void; + onToggleDecade: (decade: number) => void; +}; + +/** + * Faceted filter panel. + * + * Two deliberate UX choices from the lecture's "fallgruver" list: + * - categories that would match zero albums are hidden (never show a filter + * that leads to an empty result set); + * - each option shows how many albums it would yield, so the user can see the + * effect of a filter before clicking it. + */ +export function FilterPanel({ + genreFacets, + decadeFacets, + selectedGenres, + selectedDecades, + onToggleGenre, + onToggleDecade, +}: Props) { + return ( + + ); +} diff --git a/client/src/components/ReviewForm.tsx b/client/src/components/ReviewForm.tsx new file mode 100644 index 0000000..f167587 --- /dev/null +++ b/client/src/components/ReviewForm.tsx @@ -0,0 +1,88 @@ +import { useState } from 'react'; +import { StarRating } from './StarRating.js'; + +type Props = { + albumId: string; + initialRating: number | null; + initialText: string | null; + onSubmit: (rating: number, text: string) => Promise; + onDelete: () => Promise; +}; + +/** + * Review form. One review per user per album: submitting again replaces the + * previous one (the mutation is an upsert). A delete is offered when the user + * already has a review. + */ +export function ReviewForm({ + albumId, + initialRating, + initialText, + onSubmit, + onDelete, +}: Props) { + const [rating, setRating] = useState(initialRating); + const [text, setText] = useState(initialText ?? ''); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + const submit = async (e: React.FormEvent) => { + e.preventDefault(); + if (rating == null) { + setError('Choose a rating between 1 and 5.'); + return; + } + setBusy(true); + setError(null); + try { + await onSubmit(rating, text.trim()); + } catch { + setError('Could not save your review. Try again.'); + } finally { + setBusy(false); + } + }; + + return ( +
+

{initialRating == null ? 'Write a review' : 'Edit your review'}

+ + +