Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 23 additions & 3 deletions client/src/apollo/client.ts
Original file line number Diff line number Diff line change
@@ -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] };
},
},
},
},
},
}),
});
39 changes: 39 additions & 0 deletions client/src/apollo/queries.ts
Original file line number Diff line number Diff line change
@@ -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
}
}
`;
1 change: 1 addition & 0 deletions client/src/components/BookCard/BookCard.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
.coverWrap {
position: relative;
z-index: 1;
pointer-events: none;
}

/* ===== Shelf plank (only rendered when showShelf is true) ===== */
Expand Down
10 changes: 5 additions & 5 deletions client/src/components/BookGrid/BookGrid.module.css
Original file line number Diff line number Diff line change
@@ -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 ===== */
Expand All @@ -16,4 +16,4 @@
.withShelfPlank {
overflow-x: clip;
padding-inline: var(--space-2); /* room so focus rings and shadows aren't clipped */
}
}
10 changes: 0 additions & 10 deletions client/src/components/LoadingState/LoadingState.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
2 changes: 1 addition & 1 deletion client/src/components/LoadingState/LoadingState.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export function LoadingState({ label = 'Loading…', announceOnly = false }: Loa
return (
<div role="status" className={announceOnly ? undefined : styles.wrapper}>
{!announceOnly && <span className={styles.dot} aria-hidden="true" />}
<span className={styles.visuallyHidden}>{label}</span>
<span className="visually-hidden">{label}</span>
</div>
);
}
6 changes: 0 additions & 6 deletions client/src/data/mockBooks.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import type { Book } from '../types/book';
import booksJson from '../../../server/test-data/books.json';

export type ShelfEntry = {
bookId: Book['id'];
Expand All @@ -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[] = [];
30 changes: 19 additions & 11 deletions client/src/pages/BookDetailPage/BookDetailPage.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -18,31 +21,36 @@ 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<ShelfEntry | undefined>(
mockShelfEntries.find((e) => e.bookId === id),
);
const isSaved = entry !== undefined;

const [pendingConfirm, setPendingConfirm] = useState<PendingConfirm>(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 <LoadingState label="Loading book…" />;
if (loading) return <LoadingState label="Loading book…" />;

if (isError) {
if (error) {
return (
<ErrorState
message="We couldn't load this book. Please try again in a moment."
onRetry={() => 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();
}}
/>
);
}
Expand Down
18 changes: 12 additions & 6 deletions client/src/pages/BrowsePage/BrowsePage.module.css
Original file line number Diff line number Diff line change
@@ -1,18 +1,16 @@
/*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 {
display: flex;
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;
}

Expand All @@ -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);
}
}
}
79 changes: 65 additions & 14 deletions client/src/pages/BrowsePage/BrowsePage.tsx
Original file line number Diff line number Diff line change
@@ -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() {
Expand All @@ -20,8 +23,8 @@ export function BrowsePage() {
return params;
});
}
//shelf

//shelf (still mock data, replaced later by the myShelf query)
const [shelf, setShelf] = useState<Map<Book['id'], ShelfEntry>>(
() => new Map(mockShelfEntries.map((entry) => [entry.bookId, entry])),
);
Expand All @@ -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 (
<section className={styles.page} aria-labelledby="browse-heading">
Expand All @@ -51,28 +77,53 @@ export function BrowsePage() {
Browse
</h1>
<p className={styles.count} aria-live="polite">
{resultLabel(books.length, query)}
{!isLoading && !error && resultLabel(totalCount, query)}
</p>
</header>

{isLoading && <LoadingState announceOnly />}

{noResults ? (
{error ? (
<ErrorState
message="Couldn't load the books. Check connection and try again in a moment."
onRetry={() => {
void refetch();
}}
/>
) : noResults ? (
<EmptyState
title="No books found"
message={`Nothing matches "${query}". Try another title or author.`}
action={{ label: 'Clear search', onClick: () => handleSearch('') }}
/>
) : (
<BookGrid books={books} shelf={shelf} onToggleSaved={toggleSaved} isLoading={isLoading} />
<>
<h2 className="visually-hidden">Books</h2>
<BookGrid books={books} shelf={shelf} onToggleSaved={toggleSaved} isLoading={isLoading} />
</>
)}

{nextCursor && !error && !isLoading && (
<div className={styles.loadMore}>
{loadMoreFailed ? (
<ErrorState
message="Couldn't load more books. Check connection and try again in a moment."
onRetry={() => void handleLoadMore()}
/>
) : (
<Button disabled={loadingMore} onClick={() => void handleLoadMore()}>
{loadingMore ? 'Loading…' : 'Load more'}
</Button>
)}
</div>
)}
</section>
);
}

//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}"`;
}
1 change: 1 addition & 0 deletions client/src/styles/base.css
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ a {
outline-offset: 2px;
}

/* ===== Hidden from sight, not from screen readers ===== */
.visually-hidden {
position: absolute;
width: 1px;
Expand Down
5 changes: 2 additions & 3 deletions client/src/types/book.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down