From c21db3aa2348c2815b1f370143c2879bf5411f78 Mon Sep 17 00:00:00 2001 From: ilsea Date: Thu, 15 Oct 2026 14:48:50 +0200 Subject: [PATCH 1/7] feat: Fetch books on BrowsePage via Apollo Add BOOKS_QUERY, replace mock books, search handled by server instead of via data/searchBooks.ts New file: - src/apollo/queries.tsx Modified files: - src/types/book.ts - src/pages/BrowsePage.tsx --- client/src/apollo/queries.ts | 13 ++++++++ client/src/pages/BrowsePage/BrowsePage.tsx | 37 +++++++++++++++------- client/src/types/book.ts | 5 ++- 3 files changed, 40 insertions(+), 15 deletions(-) create mode 100644 client/src/apollo/queries.ts diff --git a/client/src/apollo/queries.ts b/client/src/apollo/queries.ts new file mode 100644 index 0000000..21f12af --- /dev/null +++ b/client/src/apollo/queries.ts @@ -0,0 +1,13 @@ +import { gql } from '@apollo/client'; + +export const BOOKS_QUERY = gql` + query Books($search: String) { + books(search: $search) { + items { + id title subtitle authors coverURL description + genres language pageCount publishedYear + } + totalCount + } + } +`; \ No newline at end of file diff --git a/client/src/pages/BrowsePage/BrowsePage.tsx b/client/src/pages/BrowsePage/BrowsePage.tsx index 069c856..1f546d7 100644 --- a/client/src/pages/BrowsePage/BrowsePage.tsx +++ b/client/src/pages/BrowsePage/BrowsePage.tsx @@ -1,11 +1,13 @@ import { useState } from 'react'; import { useSearchParams } from 'react-router'; +import { useQuery } from '@apollo/client/react'; import type { Book } from '../../types/book'; import { BookGrid } from '../../components/BookGrid/BookGrid'; import { EmptyState } from '../../components/EmptyState/EmptyState'; +import { ErrorState } from '../../components/ErrorState/ErrorState'; import { LoadingState } from '../../components/LoadingState/LoadingState'; -import { mockBooks, mockShelfEntries, type ShelfEntry } from '../../data/mockBooks'; -import { searchBooks } from '../../data/searchBooks'; +import { mockShelfEntries, type ShelfEntry } from '../../data/mockBooks'; +import { BOOKS_QUERY } from '../../apollo/queries'; import styles from './BrowsePage.module.css'; export function BrowsePage() { @@ -20,8 +22,8 @@ export function BrowsePage() { return params; }); } - //shelf + //shelf (still mock data, replaced later by the myShelf query) const [shelf, setShelf] = useState>( () => new Map(mockShelfEntries.map((entry) => [entry.bookId, entry])), ); @@ -38,11 +40,15 @@ export function BrowsePage() { }); } - //temporary: search the mock data, replaced by the apollo query - const books = searchBooks(mockBooks, query); - // ===== TEMPORARY: fake loading state, replace with the query's `loading` ===== - const isLoading = false; - const noResults = !isLoading && query !== '' && books.length === 0; + //books come from the server, which does the searching + const { data, loading, error, refetch } = useQuery<{ + books: { items: Book[]; totalCount: number }; + }>(BOOKS_QUERY, { variables: { search: query || undefined } }); + + const books = data?.books.items ?? []; + const totalCount = data?.books.totalCount ?? 0; + const isLoading = loading; + const noResults = !isLoading && !error && query !== '' && books.length === 0; return (
@@ -51,13 +57,20 @@ export function BrowsePage() { Browse

- {resultLabel(books.length, query)} + {!isLoading && !error && resultLabel(totalCount, query)}

{isLoading && } - {noResults ? ( + {error ? ( + { + void refetch(); + }} + /> + ) : noResults ? ( Date: Fri, 16 Oct 2026 09:31:06 +0200 Subject: [PATCH 2/7] fix: Use global css for visually hidden elements LoadingState.module.css and LoadingState.tsx used it's own styling for visually hidden elements. Now uses global style. --- .../components/LoadingState/LoadingState.module.css | 10 ---------- client/src/components/LoadingState/LoadingState.tsx | 2 +- client/src/styles/base.css | 1 + 3 files changed, 2 insertions(+), 11 deletions(-) diff --git a/client/src/components/LoadingState/LoadingState.module.css b/client/src/components/LoadingState/LoadingState.module.css index 5b491ed..8102205 100644 --- a/client/src/components/LoadingState/LoadingState.module.css +++ b/client/src/components/LoadingState/LoadingState.module.css @@ -24,13 +24,3 @@ opacity: 0.3; } } - -/* ===== Hidden from sight, not from screen readers ===== */ -.visuallyHidden { - position: absolute; - width: 1px; - height: 1px; - overflow: hidden; - clip-path: inset(50%); - white-space: nowrap; -} diff --git a/client/src/components/LoadingState/LoadingState.tsx b/client/src/components/LoadingState/LoadingState.tsx index fd057a5..ff7c923 100644 --- a/client/src/components/LoadingState/LoadingState.tsx +++ b/client/src/components/LoadingState/LoadingState.tsx @@ -11,7 +11,7 @@ export function LoadingState({ label = 'Loading…', announceOnly = false }: Loa return (
{!announceOnly &&
); } diff --git a/client/src/styles/base.css b/client/src/styles/base.css index 3c272e9..7971217 100644 --- a/client/src/styles/base.css +++ b/client/src/styles/base.css @@ -22,6 +22,7 @@ a { outline-offset: 2px; } +/* ===== Hidden from sight, not from screen readers ===== */ .visually-hidden { position: absolute; width: 1px; From 05ae7f3391e9302b1301f2c35be19b39a0a4551f Mon Sep 17 00:00:00 2001 From: ilsea Date: Fri, 16 Oct 2026 09:35:37 +0200 Subject: [PATCH 3/7] feat: Add Load more to BrowsePage Paginate books with cursor (after/nextCursor). Merge pages in the Apollo cache (client.ts) Show ErrorState if loading more fails. Hidden h2 above the grid to maintain heading hierarchy for accessibility. --- client/src/apollo/client.ts | 26 +++++++-- client/src/apollo/queries.ts | 19 +++++-- .../pages/BrowsePage/BrowsePage.module.css | 7 +++ client/src/pages/BrowsePage/BrowsePage.tsx | 54 ++++++++++++++++--- 4 files changed, 90 insertions(+), 16 deletions(-) diff --git a/client/src/apollo/client.ts b/client/src/apollo/client.ts index 4bd2d72..a5875be 100644 --- a/client/src/apollo/client.ts +++ b/client/src/apollo/client.ts @@ -1,9 +1,29 @@ import { ApolloClient, HttpLink, InMemoryCache } from '@apollo/client'; -//GraphQL server address. Override with VITE_GRAPHQL_URL in .env (e.g. on the VM) +// GraphQL server address. Override with VITE_GRAPHQL_URL in .env (e.g. on the VM) const uri = import.meta.env.VITE_GRAPHQL_URL ?? 'http://localhost:3001/graphql'; export const client = new ApolloClient({ - link: new HttpLink({ uri }), //how requests reach the server - cache: new InMemoryCache(), //where query results are stored + link: new HttpLink({ uri }), // how requests reach the server + cache: new InMemoryCache({ + typePolicies: { + Query: { + fields: { + // ===== Cache rules for the books list ===== + books: { + // ===== keyArgs: which arguments make a result "different" (not `after`, pages of one list share a key) ===== + keyArgs: ['search', 'genres', 'yearFrom', 'yearTo', 'sort'], + + // ===== merge: combine cached books with the newly fetched page ===== + merge(existing, incoming, { args }) { + // no `after` = first page or refetch: replace + if (!args?.after) return incoming; + // "Load more": old books first, then the new ones + return { ...incoming, items: [...(existing?.items ?? []), ...incoming.items] }; + }, + }, + }, + }, + }, + }), }); diff --git a/client/src/apollo/queries.ts b/client/src/apollo/queries.ts index 21f12af..8af004e 100644 --- a/client/src/apollo/queries.ts +++ b/client/src/apollo/queries.ts @@ -1,13 +1,22 @@ import { gql } from '@apollo/client'; export const BOOKS_QUERY = gql` - query Books($search: String) { - books(search: $search) { + query Books($search: String, $after: String) { + books(search: $search, after: $after) { items { - id title subtitle authors coverURL description - genres language pageCount publishedYear + id + title + subtitle + authors + coverURL + description + genres + language + pageCount + publishedYear } totalCount + nextCursor } } -`; \ No newline at end of file +`; diff --git a/client/src/pages/BrowsePage/BrowsePage.module.css b/client/src/pages/BrowsePage/BrowsePage.module.css index 7c6a346..c8e83e5 100644 --- a/client/src/pages/BrowsePage/BrowsePage.module.css +++ b/client/src/pages/BrowsePage/BrowsePage.module.css @@ -25,6 +25,13 @@ opacity: 0.75; } +.loadMore { + display: flex; + flex-direction: column; /* button on top, error message below it */ + align-items: center; + margin-top: 2rem; /* space between the grid and the button */ +} + @media (min-width: 40rem) { /*tablets and bigger devices*/ .page { diff --git a/client/src/pages/BrowsePage/BrowsePage.tsx b/client/src/pages/BrowsePage/BrowsePage.tsx index 1f546d7..10a74c1 100644 --- a/client/src/pages/BrowsePage/BrowsePage.tsx +++ b/client/src/pages/BrowsePage/BrowsePage.tsx @@ -3,6 +3,7 @@ import { useSearchParams } from 'react-router'; import { useQuery } from '@apollo/client/react'; import type { Book } from '../../types/book'; import { BookGrid } from '../../components/BookGrid/BookGrid'; +import { Button } from '../../components/Button/Button'; import { EmptyState } from '../../components/EmptyState/EmptyState'; import { ErrorState } from '../../components/ErrorState/ErrorState'; import { LoadingState } from '../../components/LoadingState/LoadingState'; @@ -41,15 +42,34 @@ export function BrowsePage() { } //books come from the server, which does the searching - const { data, loading, error, refetch } = useQuery<{ - books: { items: Book[]; totalCount: number }; + const { data, loading, error, refetch, fetchMore } = useQuery<{ + books: { items: Book[]; totalCount: number; nextCursor: string | null }; }>(BOOKS_QUERY, { variables: { search: query || undefined } }); const books = data?.books.items ?? []; const totalCount = data?.books.totalCount ?? 0; - const isLoading = loading; + const nextCursor = data?.books.nextCursor; + const isLoading = loading && !data; const noResults = !isLoading && !error && query !== '' && books.length === 0; + //load more: ask the server for the page after the current last one + const [loadingMore, setLoadingMore] = useState(false); + const [loadMoreFailed, setLoadMoreFailed] = useState(false); + + async function handleLoadMore() { + if (!nextCursor) return; + setLoadingMore(true); + setLoadMoreFailed(false); + try { + // the original variables (search) are reused automatically + await fetchMore({ variables: { after: nextCursor } }); + } catch { + setLoadMoreFailed(true); + } finally { + setLoadingMore(false); + } + } + return (
@@ -77,7 +97,25 @@ export function BrowsePage() { action={{ label: 'Clear search', onClick: () => handleSearch('') }} /> ) : ( - + <> +

Books

+ + + )} + + {nextCursor && !error && !isLoading && ( +
+ {loadMoreFailed ? ( + void handleLoadMore()} + /> + ) : ( + + )} +
)}
); @@ -85,7 +123,7 @@ export function BrowsePage() { //resultLabel: the count next to the heading function resultLabel(count: number, query: string) { - if (!query) return count === 1 ? '1 book' : `${count} books`; - const results = count === 1 ? 'result' : 'results'; - return `Searchword: "${query}" gave ${count} ${results}`; -} \ No newline at end of file + const books = count === 1 ? '1 book' : `${count} books`; + if (!query) return books; + return count === 0 ? `No results for "${query}"` : `${books} found for "${query}"`; +} From a7896112f7602e5390a157cd319d21ee6cee8996 Mon Sep 17 00:00:00 2001 From: ilsea Date: Fri, 16 Oct 2026 09:44:07 +0200 Subject: [PATCH 4/7] feat: Fetch book in BookDetailPage with Apollo Add query for a single book using its id Swap mock books for real query and error/load states in BookDetailPage.tsx --- client/src/apollo/queries.ts | 9 ++++++ .../pages/BookDetailPage/BookDetailPage.tsx | 30 ++++++++++++------- 2 files changed, 28 insertions(+), 11 deletions(-) diff --git a/client/src/apollo/queries.ts b/client/src/apollo/queries.ts index 8af004e..d7ddb11 100644 --- a/client/src/apollo/queries.ts +++ b/client/src/apollo/queries.ts @@ -20,3 +20,12 @@ export const BOOKS_QUERY = gql` } } `; + +export const BOOK_QUERY = gql` + query Book($id: ID!) { + book(id: $id) { + id title subtitle authors coverURL description + genres language pageCount publishedYear + } + } +`; \ No newline at end of file diff --git a/client/src/pages/BookDetailPage/BookDetailPage.tsx b/client/src/pages/BookDetailPage/BookDetailPage.tsx index bc1dbe8..dc36dae 100644 --- a/client/src/pages/BookDetailPage/BookDetailPage.tsx +++ b/client/src/pages/BookDetailPage/BookDetailPage.tsx @@ -1,11 +1,14 @@ import { useState } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; +import { useQuery } from '@apollo/client/react'; import { BookCover } from '../../components/BookCover/BookCover'; import { ConfirmDialog } from '../../components/ConfirmDialog/ConfirmDialog'; import { Eyebrow } from '../../components/Eyebrow/Eyebrow'; import { MyNotesPanel } from '../../components/MyNotesPanel/MyNotesPanel'; import { ShelfRibbon } from '../../components/ShelfRibbon/ShelfRibbon'; -import { type ShelfEntry, mockBooks, mockShelfEntries } from '../../data/mockBooks'; +import { type ShelfEntry, mockShelfEntries } from '../../data/mockBooks'; +import type { Book } from '../../types/book'; +import { BOOK_QUERY } from '../../apollo/queries'; import styles from './BookDetailPage.module.css'; import { LoadingState } from '../../components/LoadingState/LoadingState'; import { ErrorState } from '../../components/ErrorState/ErrorState'; @@ -18,10 +21,16 @@ export function BookDetailPage() { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); - // ===== TEMPORARY! TODO: replace with a query by id once the backend is ready ===== - const book = mockBooks.find((b) => b.id === id); + // ===== The book, from the server ===== + // `data.book` is null when no book has this id (not an error), undefined while loading. + const { data, loading, error, refetch } = useQuery<{ book: Book | null }>(BOOK_QUERY, { + variables: { id: id! }, + skip: !id, + }); + const book = data?.book; // ===== TEMPORARY: the user's entry for this book (undefined = not on shelf) ===== + // TODO: replace with `myEntry` from the query once the user ID header is set up const [entry, setEntry] = useState( mockShelfEntries.find((e) => e.bookId === id), ); @@ -29,20 +38,19 @@ export function BookDetailPage() { const [pendingConfirm, setPendingConfirm] = useState(null); - // ===== TEMPORARY: fake loading and error states, replace with the query's `loading` and `error` ===== - const isLoading = false; - const isError = false; - // ===== Page-level states ===== // Order matters: while loading there is no `book` yet, and a failed query has none either. // Neither is "not found", so those two are checked first. - if (isLoading) return ; + if (loading) return ; - if (isError) { + if (error) { return ( console.log('TEMPORARY: replace with the query refetch')} + headingLevel={1} + message="Couldn't load this book. Please try again in a moment." + onRetry={() => { + void refetch(); + }} /> ); } From 963e5c81b1d9a9856b3a9cc101e0a5bc46548524 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ilse=20=C3=85sbakk=20Resell?= Date: Fri, 16 Oct 2026 09:48:42 +0200 Subject: [PATCH 5/7] style: run format (Prettier) on updated files --- client/src/apollo/queries.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/client/src/apollo/queries.ts b/client/src/apollo/queries.ts index d7ddb11..e85dc87 100644 --- a/client/src/apollo/queries.ts +++ b/client/src/apollo/queries.ts @@ -24,8 +24,16 @@ export const BOOKS_QUERY = gql` export const BOOK_QUERY = gql` query Book($id: ID!) { book(id: $id) { - id title subtitle authors coverURL description - genres language pageCount publishedYear + id + title + subtitle + authors + coverURL + description + genres + language + pageCount + publishedYear } } -`; \ No newline at end of file +`; From 51e5718a9539bc2999d6dc37a85c7099be691295 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ilse=20=C3=85sbakk=20Resell?= Date: Fri, 16 Oct 2026 09:54:41 +0200 Subject: [PATCH 6/7] chore: Remove mock books from client/ Note: mockShelfEntries and ShelfEntry are still in use. --- client/src/data/mockBooks.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/client/src/data/mockBooks.ts b/client/src/data/mockBooks.ts index 18f7f1e..7f4664e 100644 --- a/client/src/data/mockBooks.ts +++ b/client/src/data/mockBooks.ts @@ -1,5 +1,4 @@ import type { Book } from '../types/book'; -import booksJson from '../../../server/test-data/books.json'; export type ShelfEntry = { bookId: Book['id']; @@ -8,9 +7,4 @@ export type ShelfEntry = { updatedAt?: string; // ISO date, set whenever the note is saved }; -export const mockBooks: Book[] = booksJson.map(({ googleBooksId, ...rest }) => ({ - id: googleBooksId, - ...rest, -})); - export const mockShelfEntries: ShelfEntry[] = []; From 6e53bef79acb36ccb0030ee08f1d9f56dcc72a47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ilse=20=C3=85sbakk=20Resell?= Date: Fri, 16 Oct 2026 10:44:18 +0200 Subject: [PATCH 7/7] style: Clean up book sizes, padding, grid, clickable book covers --- .../src/components/BookCard/BookCard.module.css | 1 + .../src/components/BookGrid/BookGrid.module.css | 10 +++++----- client/src/pages/BrowsePage/BrowsePage.module.css | 15 +++++++-------- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/client/src/components/BookCard/BookCard.module.css b/client/src/components/BookCard/BookCard.module.css index 752b6fe..db47f13 100644 --- a/client/src/components/BookCard/BookCard.module.css +++ b/client/src/components/BookCard/BookCard.module.css @@ -21,6 +21,7 @@ .coverWrap { position: relative; z-index: 1; + pointer-events: none; } /* ===== Shelf plank (only rendered when showShelf is true) ===== */ diff --git a/client/src/components/BookGrid/BookGrid.module.css b/client/src/components/BookGrid/BookGrid.module.css index d0a30b9..0917d1f 100644 --- a/client/src/components/BookGrid/BookGrid.module.css +++ b/client/src/components/BookGrid/BookGrid.module.css @@ -1,13 +1,13 @@ /* ===== 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. */ +/* auto-fill: as many columns as fit, each at least 8rem wide. + No media queries; gives 2 columns on a phone and more on wider screens. */ .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 */ + grid-template-columns: repeat(auto-fill, minmax(8rem, 1fr)); + gap: 1.5rem 1rem; /* row gap, column gap */ } /* ===== Only when the shelf plank is shown ===== */ @@ -16,4 +16,4 @@ .withShelfPlank { 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/pages/BrowsePage/BrowsePage.module.css b/client/src/pages/BrowsePage/BrowsePage.module.css index c8e83e5..34586fd 100644 --- a/client/src/pages/BrowsePage/BrowsePage.module.css +++ b/client/src/pages/BrowsePage/BrowsePage.module.css @@ -1,10 +1,8 @@ /*mobile first */ -/* TODO: replace the raw values with the shared design tokens in src/styles/. */ - .page { max-width: 1200px; margin: 0 auto; - padding: 1.5rem 1rem; + padding: var(--space-6) var(--space-4); } .header { @@ -12,7 +10,7 @@ flex-wrap: wrap; align-items: baseline; justify-content: space-between; - gap: 0.25rem 1rem; + gap: var(--space-1) var(--space-4); margin-bottom: 1.25rem; } @@ -27,14 +25,15 @@ .loadMore { display: flex; - flex-direction: column; /* button on top, error message below it */ + flex-direction: column; align-items: center; - margin-top: 2rem; /* space between the grid and the button */ + margin-top: var(--space-8); } @media (min-width: 40rem) { + /*tablets and bigger devices*/ .page { - padding: 2rem 1.5rem; + padding: var(--space-8) var(--space-6); } -} +} \ No newline at end of file