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 new file mode 100644 index 0000000..e85dc87 --- /dev/null +++ b/client/src/apollo/queries.ts @@ -0,0 +1,39 @@ +import { gql } from '@apollo/client'; + +export const BOOKS_QUERY = gql` + query Books($search: String, $after: String) { + books(search: $search, after: $after) { + items { + id + title + subtitle + authors + coverURL + description + genres + language + pageCount + publishedYear + } + totalCount + nextCursor + } + } +`; + +export const BOOK_QUERY = gql` + query Book($id: ID!) { + book(id: $id) { + id + title + subtitle + authors + coverURL + description + genres + language + pageCount + publishedYear + } + } +`; 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/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/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[] = []; 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(); + }} /> ); } diff --git a/client/src/pages/BrowsePage/BrowsePage.module.css b/client/src/pages/BrowsePage/BrowsePage.module.css index 7c6a346..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; } @@ -25,9 +23,17 @@ opacity: 0.75; } +.loadMore { + display: flex; + flex-direction: column; + align-items: center; + 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 diff --git a/client/src/pages/BrowsePage/BrowsePage.tsx b/client/src/pages/BrowsePage/BrowsePage.tsx index 069c856..10a74c1 100644 --- a/client/src/pages/BrowsePage/BrowsePage.tsx +++ b/client/src/pages/BrowsePage/BrowsePage.tsx @@ -1,11 +1,14 @@ 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 { Button } from '../../components/Button/Button'; 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 +23,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 +41,34 @@ 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, 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 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 (
@@ -51,20 +77,45 @@ export function BrowsePage() { Browse

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

{isLoading && } - {noResults ? ( + {error ? ( + { + void refetch(); + }} + /> + ) : noResults ? ( handleSearch('') }} /> ) : ( - + <> +

Books

+ + + )} + + {nextCursor && !error && !isLoading && ( +
+ {loadMoreFailed ? ( + void handleLoadMore()} + /> + ) : ( + + )} +
)}
); @@ -72,7 +123,7 @@ export function BrowsePage() { //resultLabel: the count next to the heading function resultLabel(count: number, query: string) { - if (!query) return count === 1 ? '1book' : `${count} books`; - const results = count === 1 ? 'result' : 'results'; - return `Searchword: "${query}" gave ${count} ${results}`; + const books = count === 1 ? '1 book' : `${count} books`; + if (!query) return books; + return count === 0 ? `No results for "${query}"` : `${books} found for "${query}"`; } 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; diff --git a/client/src/types/book.ts b/client/src/types/book.ts index 9f7069e..45f37b3 100644 --- a/client/src/types/book.ts +++ b/client/src/types/book.ts @@ -1,7 +1,6 @@ -/* ---------- Book: mirrors the GraphQL schema / Mongoose model ---------- */ -// TODO: check field names against the backend's typeDefs once they exist +/* ---------- Book: mirrors the GraphQL `Book` type (server schema) ---------- */ export type Book = { - id: string; // TODO: agree with backend: googleBooksId or Mongo _id + id: string; // Mongo _id, exposed by the server as `id` title: string; subtitle: string; // "" when none authors: string[]; // [] when none