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 && }
- {label}
+ {label}
);
}
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