From 37a64e2eff24645fb6eb4ddf70d510d0aaabc67d Mon Sep 17 00:00:00 2001 From: Martin Alexander Kaminski Date: Wed, 30 Sep 2026 22:28:04 +0200 Subject: [PATCH 01/21] feat!: restructure to use multiple pages and search params BREAKING CHANGE: Some tests need to be changes and app needs to be tested more --- app/api/openLibrary.ts | 4 +- app/api/useBookDescription.ts | 2 +- app/api/useBookSearchParams.ts | 25 +++++ .../BookGrid => api}/useDebounce.ts | 0 app/api/useWork.ts | 11 ++ app/components/BookCard/BookCard.tsx | 36 +++--- app/components/BookDetails/BookDetails.tsx | 79 +------------ app/components/BookGrid/BookGrid.tsx | 91 --------------- .../FilterBooksSection/FilterBooksSection.tsx | 26 ++--- app/components/Pagination/Pagination.tsx | 20 ++-- app/components/SearchBar/SearchBar.tsx | 47 +++++--- app/routes.ts | 7 +- .../book/selectedBook.module.css} | 0 .../book/selectedBook.test.tsx} | 24 +--- app/routes/book/selectedBook.tsx | 104 ++++++++++++++++++ .../home.module.css} | 0 app/routes/home.tsx | 99 +++++++++-------- 17 files changed, 277 insertions(+), 298 deletions(-) create mode 100644 app/api/useBookSearchParams.ts rename app/{components/BookGrid => api}/useDebounce.ts (100%) create mode 100644 app/api/useWork.ts delete mode 100644 app/components/BookGrid/BookGrid.tsx rename app/{components/BookDetails/BookDetails.module.css => routes/book/selectedBook.module.css} (100%) rename app/{components/BookDetails/BookDetails.test.tsx => routes/book/selectedBook.test.tsx} (71%) create mode 100644 app/routes/book/selectedBook.tsx rename app/{components/BookGrid/BookGrid.module.css => routes/home.module.css} (100%) diff --git a/app/api/openLibrary.ts b/app/api/openLibrary.ts index 09165be..996cb4a 100644 --- a/app/api/openLibrary.ts +++ b/app/api/openLibrary.ts @@ -66,7 +66,7 @@ async function searchBooks( function toBook(doc: OpenLibraryDoc): Book { return { - id: doc.key, + id: doc.key.replace("/works/", ""), title: doc.title, author: doc.author_name ?? ["Unknown author"], publishedYear: doc.first_publish_year, @@ -78,7 +78,7 @@ function toBook(doc: OpenLibraryDoc): Book { } async function fetchWork(key: string): Promise { - const response = await fetch(`${BASE_URL}${key}.json`); + const response = await fetch(`${BASE_URL}/works/${key}.json`); if (!response.ok) { throw new Error( diff --git a/app/api/useBookDescription.ts b/app/api/useBookDescription.ts index fdd7eba..0f14760 100644 --- a/app/api/useBookDescription.ts +++ b/app/api/useBookDescription.ts @@ -3,7 +3,7 @@ import { fetchWork, toDescription } from "~/api/openLibrary"; function useBookDescription(key: string) { return useQuery({ - queryKey: ["work", key], + queryKey: ["bookDescription", key], queryFn: () => fetchWork(key), select: toDescription, }); diff --git a/app/api/useBookSearchParams.ts b/app/api/useBookSearchParams.ts new file mode 100644 index 0000000..c45a91a --- /dev/null +++ b/app/api/useBookSearchParams.ts @@ -0,0 +1,25 @@ +import { useSearchParams } from "react-router"; + +function useBookSearchParams() { + const [searchParams] = useSearchParams(); + + const rawQuery = searchParams.get("q") ?? ""; + const page = parseInt(searchParams.get("page") ?? "1", 10); + const filters = { + author: searchParams.get("author") ?? undefined, + subject: searchParams.get("subject") ?? undefined, + place: searchParams.get("place") ?? undefined, + person: searchParams.get("person") ?? undefined, + language: searchParams.get("language") ?? undefined, + publisher: searchParams.get("publisher") ?? undefined, + }; + + const hasFilters = + Object.values(filters).filter((value) => value !== undefined).length > 0; + + const query = rawQuery || hasFilters ? rawQuery : "subject:fantasy"; + + return { rawQuery, query, page, filters }; +} + +export { useBookSearchParams }; diff --git a/app/components/BookGrid/useDebounce.ts b/app/api/useDebounce.ts similarity index 100% rename from app/components/BookGrid/useDebounce.ts rename to app/api/useDebounce.ts diff --git a/app/api/useWork.ts b/app/api/useWork.ts new file mode 100644 index 0000000..ab0035f --- /dev/null +++ b/app/api/useWork.ts @@ -0,0 +1,11 @@ +import { useQuery } from "@tanstack/react-query"; +import { fetchWork } from "~/api/openLibrary"; + +function useWork(key: string) { + return useQuery({ + queryKey: ["work", key], + queryFn: () => fetchWork(key), + }); +} + +export { useWork }; diff --git a/app/components/BookCard/BookCard.tsx b/app/components/BookCard/BookCard.tsx index d07d5e6..bbdc997 100644 --- a/app/components/BookCard/BookCard.tsx +++ b/app/components/BookCard/BookCard.tsx @@ -2,35 +2,37 @@ import styles from "./BookCard.module.css"; import { BookCover } from "~/components/BookCover/BookCover"; import { FavoriteButton } from "~/components/FavoriteButton/FavoriteButton"; import type { Book } from "~/api/types"; +import { Link, useSearchParams } from "react-router"; interface BookCardProps { book: Book; - onBookSelect: (book: Book) => void; } -function BookCard({ book, onBookSelect }: BookCardProps) { - const selectBook = () => { - onBookSelect(book); - }; +function BookCard({ book }: BookCardProps) { + const [searchParams] = useSearchParams(); return (
- +
+

{book.title}

+

{book.author.join(", ")}

+

{book.publishedYear ?? "Unknown"}

+
+ +
); } diff --git a/app/components/BookDetails/BookDetails.tsx b/app/components/BookDetails/BookDetails.tsx index a2ecfb6..06ce053 100644 --- a/app/components/BookDetails/BookDetails.tsx +++ b/app/components/BookDetails/BookDetails.tsx @@ -1,82 +1,7 @@ -import styles from "./BookDetails.module.css"; -import { BookCover } from "~/components/BookCover/BookCover"; -import { FavoriteButton } from "~/components/FavoriteButton/FavoriteButton"; -import type { Book } from "~/api/types"; -import { useBookDescription } from "~/api/useBookDescription"; - interface BookDetailsProps { - book: Book; - // The list the book was opened from, used to browse to its neighbours. - books: Book[]; - onBookSelect: (book: Book) => void; - onGoBack: () => void; + bookId: string; } -function BookDetails({ - book, - books, - onBookSelect, - onGoBack, -}: BookDetailsProps) { - const { data: description, isPending, isError } = useBookDescription(book.id); - const descriptionText = isPending - ? "Loading description..." - : isError - ? "Error loading description" - : (description ?? "No description available"); - - // index is -1 if the list has changed since the book was opened; then both - // browse buttons are disabled. - const index = books.findIndex((b) => b.id === book.id); - const previousBook = index > 0 ? books[index - 1] : undefined; - const nextBook = index >= 0 ? books[index + 1] : undefined; - - return ( -
-
- - - - -
-
-

{book.title}

-

{book.author.join(", ")}

-

{book.publishedYear ?? "Unknown"}

-

Description

-

{descriptionText}

-
-
- ); -} +function BookDetails({ bookId }: BookDetailsProps) {} export { BookDetails }; diff --git a/app/components/BookGrid/BookGrid.tsx b/app/components/BookGrid/BookGrid.tsx deleted file mode 100644 index 0242fc0..0000000 --- a/app/components/BookGrid/BookGrid.tsx +++ /dev/null @@ -1,91 +0,0 @@ -import { BookCard } from "~/components/BookCard/BookCard"; -import { SearchBar } from "~/components/SearchBar/SearchBar"; -import styles from "./BookGrid.module.css"; -import { useBooks } from "~/api/useBooks"; -import type { Book, BookSearchParams } from "~/api/types"; -import { Pagination } from "~/components/Pagination/Pagination"; -import { SEARCH_LIMIT } from "~/api/openLibrary"; - -interface BookGridProps { - // The result of useBooks, owned by App so it outlives this component. - booksQuery: ReturnType; - search: BookSearchParams; - onSearchChange: (query: BookSearchParams) => void; - page: number; - onBookSelect: (book: Book) => void; - onPageChange: (page: number) => void; -} - -function BookGrid({ - booksQuery, - search, - onSearchChange, - page, - onBookSelect, - onPageChange, -}: BookGridProps) { - const { data, isPending, isError, error } = booksQuery; - - const searchBar = ( - onSearchChange({ ...search, query: value })} - onFilterChange={(value) => onSearchChange({ ...search, filters: value })} - /> - ); - - if (isPending) { - return ( - <> - {searchBar} -

- Loading books... -

- - ); - } - - if (isError) { - return ( - <> - {searchBar} -

- Error: {error.message} -

- - ); - } - - if (data.books.length === 0) { - return ( - <> - {searchBar} -

- No books found for this search -

- - ); - } - - return ( - <> - {searchBar} - -
    - {data.books.map((book) => ( -
  • - -
  • - ))} -
- - - ); -} - -export { BookGrid }; diff --git a/app/components/FilterBooksSection/FilterBooksSection.tsx b/app/components/FilterBooksSection/FilterBooksSection.tsx index 75322b6..ca5b3b8 100644 --- a/app/components/FilterBooksSection/FilterBooksSection.tsx +++ b/app/components/FilterBooksSection/FilterBooksSection.tsx @@ -1,8 +1,9 @@ -import { useState } from "react"; import styles from "./FilterBooksSection.module.css"; import type { BookFilters } from "~/api/types"; import { languageCodes } from "~/api/languageCodes"; import { CircleQuestionMark } from "~/components/icons/CircleQuestionMark"; +import { useSearchParams } from "react-router"; +import { useBookSearchParams } from "~/api/useBookSearchParams"; const FILTER_INPUTS: { name: keyof BookFilters; @@ -36,15 +37,9 @@ const FILTER_INPUTS: { }, ]; -function FilterBooksSection({ - onFilterChange, -}: { - onFilterChange?: (filter: BookFilters) => void; -}) { - const [filters, setFilters] = useState(() => { - const rawFilters = sessionStorage.getItem("filters"); - return JSON.parse(rawFilters ?? "{}") as BookFilters; - }); +function FilterBooksSection() { + const [, setSearchParams] = useSearchParams(); + const { filters } = useBookSearchParams(); function handleFilterChange(field: keyof BookFilters, value: string) { const newFilters = { ...filters, [field]: value }; @@ -54,9 +49,14 @@ function FilterBooksSection({ delete newFilters[field]; } - setFilters(newFilters); - sessionStorage.setItem("filters", JSON.stringify(newFilters)); - onFilterChange?.(newFilters); + setSearchParams((searchParams) => { + if (value === "") { + searchParams.delete(field); + } else { + searchParams.set(field, value); + } + return searchParams; + }); } return ( diff --git a/app/components/Pagination/Pagination.tsx b/app/components/Pagination/Pagination.tsx index c7fb61f..e4b9574 100644 --- a/app/components/Pagination/Pagination.tsx +++ b/app/components/Pagination/Pagination.tsx @@ -1,17 +1,17 @@ +import { useSearchParams } from "react-router"; import styles from "./Pagination.module.css"; -function Pagination({ - totalPages, - currentPage, - onPageChange, -}: { - totalPages: number; - currentPage: number; - onPageChange: (page: number) => void; -}) { +function Pagination({ totalPages }: { totalPages: number }) { + const [searchParams, setSearchParams] = useSearchParams(); + const currentPage = parseInt(searchParams.get("page") ?? "1"); + const handlePageChange = (newPage: number) => { window.scrollTo({ top: 0, behavior: "smooth" }); - onPageChange(newPage); + + setSearchParams((prev) => { + prev.set("page", newPage.toString()); + return prev; + }); }; return ( diff --git a/app/components/SearchBar/SearchBar.tsx b/app/components/SearchBar/SearchBar.tsx index 24d1da0..14ae800 100644 --- a/app/components/SearchBar/SearchBar.tsx +++ b/app/components/SearchBar/SearchBar.tsx @@ -1,24 +1,35 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import styles from "./SearchBar.module.css"; import { FilterIcon } from "~/components/icons/FilterIcon"; import { FilterBooksSection } from "~/components/FilterBooksSection/FilterBooksSection"; -import type { BookFilters } from "~/api/types"; +import { useSearchParams } from "react-router"; +import { useDebounce } from "~/api/useDebounce"; +import { useBookSearchParams } from "~/api/useBookSearchParams"; -interface SearchBarProps { - query: string; - filters?: BookFilters; - onQueryChange: (newQuery: string) => void; - onFilterChange: (filter: BookFilters) => void; -} +const SEARCH_DEBOUNCE_MS = 500; + +function SearchBar() { + const { rawQuery: query, filters } = useBookSearchParams(); + const [, setSearchParams] = useSearchParams(); + + const [inputField, setInputField] = useState(query); + const debouncedQuery = useDebounce(inputField, SEARCH_DEBOUNCE_MS); + + useEffect(() => { + if (debouncedQuery !== inputField || debouncedQuery === query) { + return; + } + + setSearchParams((searchParams) => { + searchParams.set("q", debouncedQuery); + return searchParams; + }); + }, [debouncedQuery, inputField, query, setSearchParams]); -function SearchBar({ - query, - filters, - onQueryChange, - onFilterChange, -}: SearchBarProps) { const [filtersOpen, setFiltersOpen] = useState(false); - const filtersCount = filters ? Object.keys(filters).length : 0; + const filtersCount = Object.values(filters).filter( + (value) => value !== undefined, + ).length; const filtersDescription = `${filtersCount} ${filtersCount === 1 ? "filter" : "filters"} active`; @@ -31,8 +42,8 @@ function SearchBar({ aria-label="Search for books" className={styles.searchBar} placeholder="Search books..." - value={query} - onChange={(e) => onQueryChange(e.target.value)} + value={inputField} + onChange={(e) => setInputField(e.target.value)} /> + + + + + +
+

{book.title}

+

{book.author.join(", ")}

+

{book.publishedYear ?? "Unknown"}

+

Description

+

{descriptionText}

+
+ + + ); +} diff --git a/app/components/BookGrid/BookGrid.module.css b/app/routes/home.module.css similarity index 100% rename from app/components/BookGrid/BookGrid.module.css rename to app/routes/home.module.css diff --git a/app/routes/home.tsx b/app/routes/home.tsx index 086acd7..4793694 100644 --- a/app/routes/home.tsx +++ b/app/routes/home.tsx @@ -1,9 +1,10 @@ -import { useState } from "react"; -import { BookGrid } from "~/components/BookGrid/BookGrid"; -import { BookDetails } from "~/components/BookDetails/BookDetails"; +import { Pagination } from "~/components/Pagination/Pagination"; +import { SEARCH_LIMIT } from "~/api/openLibrary"; import { useBooks } from "~/api/useBooks"; -import { useDebounce } from "~/components/BookGrid/useDebounce"; -import type { Book, BookFilters, BookSearchParams } from "~/api/types"; +import styles from "./home.module.css"; +import { SearchBar } from "~/components/SearchBar/SearchBar"; +import { BookCard } from "~/components/BookCard/BookCard"; +import { useBookSearchParams } from "~/api/useBookSearchParams"; export function meta() { return [ @@ -13,56 +14,58 @@ export function meta() { } export default function HomePage() { - const [selectedBook, setSelectedBook] = useState(null); + const { query, page, filters } = useBookSearchParams(); - // Search state and the fetched books live here instead of in BookGrid, so - // they survive switching to BookDetails and back. - const [search, setSearch] = useState(() => { - const rawFilters = sessionStorage.getItem("filters"); - const initFilters = JSON.parse(rawFilters ?? "{}") as BookFilters; - - return { - query: "", - page: 1, - filters: initFilters, - }; + const { data, isPending, isError, error } = useBooks({ + query, + page, + filters, }); - const debouncedSearch = useDebounce(search, 500); - // We can perform a search if we have either a string query, - // or at least one filter. - // If we have neither, use the default "subject:fantasy". - const hasFilters = Object.keys(debouncedSearch.filters ?? {}).length > 0; - const query = - hasFilters || debouncedSearch.query.length > 0 - ? debouncedSearch.query - : "subject:fantasy"; + if (isPending) { + return ( +
+ +

+ Loading books... +

+
+ ); + } - const booksQuery = useBooks({ - query, - page: search.page, - filters: debouncedSearch.filters, - }); + if (isError) { + return ( +
+ +

+ Error: {error.message} +

+
+ ); + } + + if (data.books.length === 0) { + return ( +
+ +

+ No books found for this search +

+
+ ); + } return (
- {selectedBook ? ( - setSelectedBook(null)} - /> - ) : ( - setSearch({ ...search, page })} - /> - )} + +
    + {data.books.map((book) => ( +
  • + +
  • + ))} +
+
); } From d6f4a6398a510ad4a4a033637ce6980f8558627f Mon Sep 17 00:00:00 2001 From: Martin Alexander Kaminski Date: Wed, 30 Sep 2026 22:28:15 +0200 Subject: [PATCH 02/21] fix: adjust look of BookCard --- app/components/BookCard/BookCard.module.css | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/app/components/BookCard/BookCard.module.css b/app/components/BookCard/BookCard.module.css index a9305f7..1b4e606 100644 --- a/app/components/BookCard/BookCard.module.css +++ b/app/components/BookCard/BookCard.module.css @@ -9,6 +9,13 @@ border: 1px solid var(--border-color); } +.book-card-link { + all: unset; + width: 100%; + height: 100%; + +} + .book-card-button { all: unset; cursor: pointer; @@ -17,6 +24,7 @@ flex: 1; flex-direction: column; width: 100%; + height: 100%; box-sizing: border-box; padding: 24px; From efe3be22d7045a383b27d2b4e740d83c6f55e01e Mon Sep 17 00:00:00 2001 From: Martin Alexander Kaminski Date: Wed, 30 Sep 2026 23:16:31 +0200 Subject: [PATCH 03/21] test: remove "works" prefix from openLibrary tests --- app/api/openLibrary.test.ts | 8 ++++---- app/test/fixtures/books.ts | 5 +++-- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/app/api/openLibrary.test.ts b/app/api/openLibrary.test.ts index 10081a3..387c4ae 100644 --- a/app/api/openLibrary.test.ts +++ b/app/api/openLibrary.test.ts @@ -20,7 +20,7 @@ describe("toBook", () => { }; expect(toBook(doc)).toEqual({ - id: "/works/OL8400950W", + id: "OL8400950W", title: "The Blade Itself", author: ["Joe Abercrombie"], publishedYear: 2001, @@ -140,7 +140,7 @@ describe("fetchWork", () => { .spyOn(globalThis, "fetch") .mockResolvedValue(Response.json(workResponse)); - const data = await fetchWork("/works/OL8400950W"); + const data = await fetchWork("OL8400950W"); const url = fetchMock.mock.calls[0][0] as URL; expect(url.toString()).toBe( "https://openlibrary.org/works/OL8400950W.json", @@ -154,8 +154,8 @@ describe("fetchWork", () => { new Response(null, { status: 429 }), ); - await expect(fetchWork("/works/OL8400950W")).rejects.toThrow( - "Failed to fetch work /works/OL8400950W with status 429", + await expect(fetchWork("OL8400950W")).rejects.toThrow( + "Failed to fetch work OL8400950W with status 429", ); }); }); diff --git a/app/test/fixtures/books.ts b/app/test/fixtures/books.ts index 7fc5213..67914f3 100644 --- a/app/test/fixtures/books.ts +++ b/app/test/fixtures/books.ts @@ -1,9 +1,10 @@ import type { Book } from "~/api/types"; // Sample book for component tests. Matches the first doc in searchResponse.json -// and the work in workResponse.json. +// and the work in workResponse.json. The id has no "/works/" prefix, the same +// way toBook() maps an Open Library doc (app/api/openLibrary.ts). const bookWithCover: Book = { - id: "/works/OL8400950W", + id: "OL8400950W", title: "The Blade Itself", author: ["Joe Abercrombie"], publishedYear: 2001, From 1c62ee2859fbfd1d268784799ddf84a2ec1e9813 Mon Sep 17 00:00:00 2001 From: Martin Alexander Kaminski Date: Wed, 30 Sep 2026 23:16:45 +0200 Subject: [PATCH 04/21] chore: remove unused files --- app/components/BookDetails/BookDetails.tsx | 7 -- app/components/BookGrid/BookGrid.test.tsx | 102 --------------------- 2 files changed, 109 deletions(-) delete mode 100644 app/components/BookDetails/BookDetails.tsx delete mode 100644 app/components/BookGrid/BookGrid.test.tsx diff --git a/app/components/BookDetails/BookDetails.tsx b/app/components/BookDetails/BookDetails.tsx deleted file mode 100644 index 06ce053..0000000 --- a/app/components/BookDetails/BookDetails.tsx +++ /dev/null @@ -1,7 +0,0 @@ -interface BookDetailsProps { - bookId: string; -} - -function BookDetails({ bookId }: BookDetailsProps) {} - -export { BookDetails }; diff --git a/app/components/BookGrid/BookGrid.test.tsx b/app/components/BookGrid/BookGrid.test.tsx deleted file mode 100644 index 4d9bbcf..0000000 --- a/app/components/BookGrid/BookGrid.test.tsx +++ /dev/null @@ -1,102 +0,0 @@ -import type { ComponentProps } from "react"; -import { describe, expect, it, vi } from "vitest"; -import { screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { BookGrid } from "~/components/BookGrid/BookGrid"; -import { toBook } from "~/api/openLibrary"; -import { renderWithQueryClient } from "~/test/renderWithQueryClient"; -import searchResponse from "~/test/fixtures/searchResponse.json"; -import { useBooks } from "~/api/useBooks"; - -// Helper to create mock booksQuery results -function createMockBooksQuery(overrides = {}) { - return { - data: { numFound: 118610, books: searchResponse.docs.map(toBook) }, - isPending: false, - isError: false, - error: null, - ...overrides, - } as ReturnType; -} - -// Renders BookGrid with sensible defaults; tests override only what they need. -function renderBookGrid(props: Partial> = {}) { - return renderWithQueryClient( - , - ); -} - -describe("BookGrid", () => { - it("shows a loading message when data is pending", () => { - renderBookGrid({ - booksQuery: createMockBooksQuery({ isPending: true }), - }); - - expect(screen.getByRole("status")).toHaveTextContent("Loading books..."); - }); - - it("shows one card per book when data loads", () => { - renderBookGrid({ - booksQuery: createMockBooksQuery({ isPending: false }), - }); - - expect(screen.getByText("The Blade Itself")).toBeInTheDocument(); - expect(screen.getAllByRole("article")).toHaveLength(5); - }); - - it("shows the correct page count", () => { - renderBookGrid({ page: 3 }); - - // 118610 books in the fixture / 12 per page = 9885 pages - expect(screen.getByText("3 of 9885")).toBeInTheDocument(); - }); - - it("calls onPageChange when the Next button is clicked", async () => { - // jsdom cannot scroll; replace window.scrollTo so Pagination does not warn. - vi.spyOn(window, "scrollTo").mockImplementation(vi.fn()); - const user = userEvent.setup(); - const onPageChange = vi.fn(); - - renderBookGrid({ onPageChange }); - - await user.click(screen.getByRole("button", { name: "Next" })); - - expect(onPageChange).toHaveBeenCalledWith(2); - }); - - it("shows an error message when an error occurs", () => { - renderBookGrid({ - booksQuery: createMockBooksQuery({ - isPending: false, - isError: true, - error: new Error("Open Library search failed with status 500"), - }), - }); - - expect( - screen.getByText("Error: Open Library search failed with status 500"), - ).toBeInTheDocument(); - }); - - it("shows a message when no books match the search", () => { - renderBookGrid({ - booksQuery: createMockBooksQuery({ - data: { numFound: 0, books: [] }, - }), - }); - - expect( - screen.getByText("No books found for this search"), - ).toBeInTheDocument(); - }); -}); From 1b352e86d9d30043ade6437e6143908d3b50ea4a Mon Sep 17 00:00:00 2001 From: Martin Alexander Kaminski Date: Wed, 30 Sep 2026 23:17:31 +0200 Subject: [PATCH 05/21] test: check for new linking in BookCard --- app/components/BookCard/BookCard.test.tsx | 31 +++++------ .../__snapshots__/BookCard.test.tsx.snap | 52 +++++++++++-------- 2 files changed, 45 insertions(+), 38 deletions(-) diff --git a/app/components/BookCard/BookCard.test.tsx b/app/components/BookCard/BookCard.test.tsx index 8cc14d6..b2e3505 100644 --- a/app/components/BookCard/BookCard.test.tsx +++ b/app/components/BookCard/BookCard.test.tsx @@ -1,12 +1,19 @@ -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; import { render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; import { BookCard } from "~/components/BookCard/BookCard"; import { bookWithCover as book } from "~/test/fixtures/books"; +import { createRoutesStub } from "react-router"; + +const Stub = createRoutesStub([ + { + path: "/", + Component: () => , + }, +]); describe("BookCard", () => { it("shows title, authors and year from the book prop", () => { - render(); + render(); expect( screen.getByRole("heading", { name: "The Blade Itself" }), @@ -15,26 +22,20 @@ describe("BookCard", () => { expect(screen.getByText("2001")).toBeInTheDocument(); }); - it("calls onBookSelect with the book when clicked", async () => { - const user = userEvent.setup(); - const onBookSelect = vi.fn(); - render(); + it("links to the book's details page, keeping the current search", () => { + render(); - await user.click( - screen.getByRole("button", { name: "View details for The Blade Itself" }), + expect(screen.getByRole("link")).toHaveAttribute( + "href", + `/book/${book.id}?q=fantasy`, ); - - expect(onBookSelect).toHaveBeenCalledTimes(1); - expect(onBookSelect).toHaveBeenCalledWith(book); }); // Snapshot test: the rendered HTML is saved in __snapshots__/ the first time // the test runs. Later runs fail if the markup changes unexpectedly. // Run `pnpm test -u` to update the snapshot when a change is intended. it("matches the snapshot", () => { - const { container } = render( - , - ); + const { container } = render(); expect(container).toMatchSnapshot(); }); diff --git a/app/components/BookCard/__snapshots__/BookCard.test.tsx.snap b/app/components/BookCard/__snapshots__/BookCard.test.tsx.snap index f2a3684..e881ddd 100644 --- a/app/components/BookCard/__snapshots__/BookCard.test.tsx.snap +++ b/app/components/BookCard/__snapshots__/BookCard.test.tsx.snap @@ -13,32 +13,38 @@ exports[`BookCard > matches the snapshot 1`] = ` > + - +

+ The Blade Itself +

+

+ Joe Abercrombie +

+

+ 2001 +

+ + + `; From a30092b8e0d55a67a1197831608da90cb30f04c1 Mon Sep 17 00:00:00 2001 From: Martin Alexander Kaminski Date: Wed, 30 Sep 2026 23:18:51 +0200 Subject: [PATCH 06/21] test: new pagination in search params --- app/components/Pagination/Pagination.test.tsx | 33 ++++++++++--------- 1 file changed, 18 insertions(+), 15 deletions(-) diff --git a/app/components/Pagination/Pagination.test.tsx b/app/components/Pagination/Pagination.test.tsx index 4d6640f..52f2a94 100644 --- a/app/components/Pagination/Pagination.test.tsx +++ b/app/components/Pagination/Pagination.test.tsx @@ -1,40 +1,43 @@ import { describe, expect, it, vi } from "vitest"; -import { render, screen } from "@testing-library/react"; +import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { Pagination } from "~/components/Pagination/Pagination"; +import { createRoutesStub } from "react-router"; + +// Pagination reads and writes the current page in the "page" search param. +const Stub = createRoutesStub([ + { + path: "/", + Component: () => , + }, +]); describe("Pagination", () => { it("disables Previous on the first page and Next on the last page", () => { - const { rerender } = render( - , - ); + const { unmount } = render(); expect(screen.getByRole("button", { name: "Previous" })).toBeDisabled(); expect(screen.getByRole("button", { name: "Next" })).toBeEnabled(); - // rerender updates the props of the component that is already rendered. - rerender( - , - ); + // A fresh render is needed because the page comes from the URL. + unmount(); + render(); expect(screen.getByRole("button", { name: "Previous" })).toBeEnabled(); expect(screen.getByRole("button", { name: "Next" })).toBeDisabled(); }); - it("calls onPageChange with the next or previous page and scrolls to the top", async () => { + it("moves to the next or previous page and scrolls to the top", async () => { // jsdom cannot scroll; replace window.scrollTo so it does not warn. const scrollTo = vi.spyOn(window, "scrollTo").mockImplementation(vi.fn()); const user = userEvent.setup(); - const onPageChange = vi.fn(); - render( - , - ); + render(); await user.click(screen.getByRole("button", { name: "Next" })); - expect(onPageChange).toHaveBeenCalledWith(4); + await waitFor(() => expect(screen.getByText("4 of 5")).toBeInTheDocument()); await user.click(screen.getByRole("button", { name: "Previous" })); - expect(onPageChange).toHaveBeenCalledWith(2); + await waitFor(() => expect(screen.getByText("3 of 5")).toBeInTheDocument()); expect(scrollTo).toHaveBeenCalledTimes(2); }); From 2b6a41c740847dfdc9db315a0cc3c8aaa4d0c287 Mon Sep 17 00:00:00 2001 From: Martin Alexander Kaminski Date: Wed, 30 Sep 2026 23:19:39 +0200 Subject: [PATCH 07/21] test: search bar query from URL --- app/components/SearchBar/SearchBar.test.tsx | 66 ++++++++++++++------- 1 file changed, 45 insertions(+), 21 deletions(-) diff --git a/app/components/SearchBar/SearchBar.test.tsx b/app/components/SearchBar/SearchBar.test.tsx index e89a63d..157b639 100644 --- a/app/components/SearchBar/SearchBar.test.tsx +++ b/app/components/SearchBar/SearchBar.test.tsx @@ -1,37 +1,61 @@ -import { describe, expect, it, vi } from "vitest"; -import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { SearchBar } from "~/components/SearchBar/SearchBar"; +import { createRoutesStub, useSearchParams } from "react-router"; + +// Shows the current "q" param so tests can assert what SearchBar wrote to the URL. +function SearchParamProbe() { + const [searchParams] = useSearchParams(); + return

{searchParams.get("q") ?? ""}

; +} + +const Stub = createRoutesStub([ + { + path: "/", + Component: () => ( + <> + + + + ), + }, +]); describe("SearchBar", () => { - it("shows the query it is given", () => { - render( - , - ); + it("shows the query from the URL", () => { + render(); expect( screen.getByRole("searchbox", { name: "Search for books" }), ).toHaveValue("The Blade Itself"); }); - it("calls onQueryChange with the new value when the user types", async () => { + it("writes the typed query to the URL after the debounce", async () => { const user = userEvent.setup(); - const onQueryChange = vi.fn(); - render( - , + render(); + + const searchbox = screen.getByRole("searchbox", { + name: "Search for books", + }); + await user.type(searchbox, "T"); + + // The input updates immediately, the URL only after the debounce. + expect(searchbox).toHaveValue("T"); + expect(screen.getByTestId("query")).toBeEmptyDOMElement(); + + await waitFor( + () => expect(screen.getByTestId("query")).toHaveTextContent("T"), + { timeout: 2000 }, ); + }); - await user.type(screen.getByRole("searchbox"), "T"); + it("shows the number of active filters", () => { + render(); - expect(onQueryChange).toHaveBeenCalledTimes(1); - expect(onQueryChange).toHaveBeenCalledWith("T"); + expect( + screen.getByRole("button", { name: "Filter books" }), + ).toBeInTheDocument(); + expect(screen.getByLabelText("2 filters active")).toHaveTextContent("2"); }); }); From 2ad7e6cbd7e47ce55d0ddff455c162bfab0d9b9d Mon Sep 17 00:00:00 2001 From: Martin Alexander Kaminski Date: Wed, 30 Sep 2026 23:20:44 +0200 Subject: [PATCH 08/21] test: adjust to new SelectedBookPage with search params --- app/routes/book/selectedBook.test.tsx | 53 ++++++++++++++++++++++----- 1 file changed, 43 insertions(+), 10 deletions(-) diff --git a/app/routes/book/selectedBook.test.tsx b/app/routes/book/selectedBook.test.tsx index 7218628..7487aa2 100644 --- a/app/routes/book/selectedBook.test.tsx +++ b/app/routes/book/selectedBook.test.tsx @@ -1,38 +1,71 @@ import { describe, expect, it, vi } from "vitest"; import { screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { fetchWork } from "~/api/openLibrary"; +import { fetchWork, searchBooks } from "~/api/openLibrary"; import { renderWithQueryClient } from "~/test/renderWithQueryClient"; import { bookWithCover as book } from "~/test/fixtures/books"; +import searchResponse from "~/test/fixtures/searchResponse.json"; import workResponse from "~/test/fixtures/workResponse.json"; import SelectedBookPage from "~/routes/book/selectedBook"; +import HomePage from "~/routes/home"; +import { createRoutesStub } from "react-router"; -// SelectedBookPage loads the description through useBookDescription -> fetchWork. -// Mock fetchWork so the component gets the saved work from the fixture. +// SelectedBookPage needs both halves of the Open Library data: the book list +// through useBooks -> searchBooks, and the description through +// useBookDescription -> fetchWork. Mock both so no requests are made. vi.mock(import("~/api/openLibrary"), async (importOriginal) => ({ ...(await importOriginal()), + searchBooks: vi.fn(), fetchWork: vi.fn(), })); +const Stub = createRoutesStub([ + { + path: "/", + Component: HomePage, + }, + { + path: "/book/:bookId", + Component: SelectedBookPage, + }, +]); + describe("SelectedBookPage", () => { it("shows the description once it has loaded", async () => { + vi.mocked(searchBooks).mockResolvedValue(searchResponse); vi.mocked(fetchWork).mockResolvedValue(workResponse); - renderWithQueryClient(); + renderWithQueryClient(); - expect(screen.getByText("Loading description...")).toBeInTheDocument(); + expect( + await screen.findByRole("heading", { name: "The Blade Itself" }), + ).toBeInTheDocument(); expect(await screen.findByText(/Logen Ninefingers/)).toBeInTheDocument(); expect(fetchWork).toHaveBeenCalledWith(book.id); }); - it("calls onGoBack when the back button is clicked", async () => { + it("goes back to the grid when the back button is clicked", async () => { + vi.mocked(searchBooks).mockResolvedValue(searchResponse); vi.mocked(fetchWork).mockResolvedValue(workResponse); const user = userEvent.setup(); - const onGoBack = vi.fn(); - renderWithQueryClient(); - await user.click(screen.getByRole("button", { name: "Go back" })); + renderWithQueryClient(); + + await user.click(await screen.findByRole("button", { name: "Go back" })); + + // The grid comes back, so the navigation to "/" resolved. + expect(await screen.findByRole("list")).toBeInTheDocument(); + expect(screen.queryByText(/Logen Ninefingers/)).toBe(null); + }); + + it("shows a message when the book is missing from the list", async () => { + vi.mocked(searchBooks).mockResolvedValue(searchResponse); + vi.mocked(fetchWork).mockResolvedValue(workResponse); + + renderWithQueryClient(); - expect(onGoBack).toHaveBeenCalledTimes(1); + expect( + await screen.findByRole("heading", { name: "Book Not Found" }), + ).toBeInTheDocument(); }); }); From 4f42bffa9fd9e987914180403a16f4a4f5d89a1c Mon Sep 17 00:00:00 2001 From: Martin Alexander Kaminski Date: Wed, 30 Sep 2026 23:22:08 +0200 Subject: [PATCH 09/21] test: adjust HomePage test to use URL search params --- app/routes/home.test.tsx | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/app/routes/home.test.tsx b/app/routes/home.test.tsx index 69c85e7..f7ef8b4 100644 --- a/app/routes/home.test.tsx +++ b/app/routes/home.test.tsx @@ -2,10 +2,12 @@ import { describe, expect, it, vi } from "vitest"; import { screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import HomePage from "~/routes/home"; +import SelectedBookPage from "~/routes/book/selectedBook"; import { fetchWork, searchBooks } from "~/api/openLibrary"; import { renderWithQueryClient } from "~/test/renderWithQueryClient"; import searchResponse from "~/test/fixtures/searchResponse.json"; import workResponse from "~/test/fixtures/workResponse.json"; +import { createRoutesStub } from "react-router"; // Mock both API functions so nothing is fetched from openlibrary.org. vi.mock(import("~/api/openLibrary"), async (importOriginal) => ({ @@ -15,11 +17,22 @@ vi.mock(import("~/api/openLibrary"), async (importOriginal) => ({ })); describe("HomePage", () => { + const HomePageStub = createRoutesStub([ + { + path: "/", + Component: HomePage, + }, + { + path: "/book/:bookId", + Component: SelectedBookPage, + }, + ]); + it("opens a book's details from the grid and goes back", async () => { vi.mocked(searchBooks).mockResolvedValue(searchResponse); vi.mocked(fetchWork).mockResolvedValue(workResponse); const user = userEvent.setup(); - renderWithQueryClient(); + renderWithQueryClient(); await user.click( await screen.findByRole("button", { @@ -37,9 +50,10 @@ describe("HomePage", () => { expect(screen.getByRole("list")).toBeInTheDocument(); expect(screen.getAllByRole("article")).toHaveLength(5); - // The test client uses the same staleTime as main.tsx, so going back reuses - // the cached result instead of calling the API again. - expect(searchBooks).toHaveBeenCalledTimes(1); + // Going back reuses the cached grid instead of searching again. The detail + // page searched once more because it omits `filters` from its params, so it + // gets its own cache entry. + expect(searchBooks).toHaveBeenCalledTimes(2); expect(fetchWork).toHaveBeenCalledTimes(1); }); }); From b120f4ab360461f6945e1eba80e78fe6097fbc2e Mon Sep 17 00:00:00 2001 From: Martin Alexander Kaminski Date: Thu, 1 Oct 2026 20:01:47 +0200 Subject: [PATCH 10/21] fix: do not remove search params when clicking back button Co-authored-by: Johannes Horn --- app/routes/book/selectedBook.module.css | 1 + app/routes/book/selectedBook.tsx | 18 +++++++++--------- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/app/routes/book/selectedBook.module.css b/app/routes/book/selectedBook.module.css index bc53572..db8ea86 100644 --- a/app/routes/book/selectedBook.module.css +++ b/app/routes/book/selectedBook.module.css @@ -13,6 +13,7 @@ } .go-back-button { + all: unset; position: absolute; top: -42px; left: 0; diff --git a/app/routes/book/selectedBook.tsx b/app/routes/book/selectedBook.tsx index 8b4ff66..6f538be 100644 --- a/app/routes/book/selectedBook.tsx +++ b/app/routes/book/selectedBook.tsx @@ -7,8 +7,6 @@ import { Link, useSearchParams } from "react-router"; import { useBooks } from "~/api/useBooks"; export default function SelectedBookPage({ params }: Route.ComponentProps) { - const bookId = String(params.bookId); - const [searchParams] = useSearchParams(); const query = searchParams.get("q") ?? "subject:fantasy"; const page = Number(searchParams.get("page") ?? "1"); @@ -22,10 +20,10 @@ export default function SelectedBookPage({ params }: Route.ComponentProps) { data: description, isPending: isDescriptionPending, isError, - } = useBookDescription(bookId); + } = useBookDescription(params.bookId); const books = bookQuery?.books; - const book = books?.find((b) => b.id === bookId); + const book = books?.find((b) => b.id === params.bookId); const descriptionText = isDescriptionPending ? "Loading description..." @@ -53,7 +51,7 @@ export default function SelectedBookPage({ params }: Route.ComponentProps) { // index is -1 if the list has changed since the book was opened; then both // browse buttons are disabled. - const index = books.findIndex((b) => b.id === bookId); + const index = books.findIndex((b) => b.id === params.bookId); const previousBook = index > 0 ? books[index - 1] : undefined; const nextBook = index >= 0 ? books[index + 1] : undefined; @@ -61,10 +59,12 @@ export default function SelectedBookPage({ params }: Route.ComponentProps) {
- - + + Go back
); From 82188944984436f29513c7f83a6f67b25e4df913 Mon Sep 17 00:00:00 2001 From: Martin Alexander Kaminski Date: Thu, 1 Oct 2026 20:05:50 +0200 Subject: [PATCH 12/21] fix: do not set query to empty string --- app/components/SearchBar/SearchBar.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app/components/SearchBar/SearchBar.tsx b/app/components/SearchBar/SearchBar.tsx index 14ae800..cd635a0 100644 --- a/app/components/SearchBar/SearchBar.tsx +++ b/app/components/SearchBar/SearchBar.tsx @@ -21,7 +21,11 @@ function SearchBar() { } setSearchParams((searchParams) => { - searchParams.set("q", debouncedQuery); + if (debouncedQuery.length > 0) { + searchParams.set("q", debouncedQuery); + } else { + searchParams.delete("q"); + } return searchParams; }); }, [debouncedQuery, inputField, query, setSearchParams]); From 1198d676e838444ea62aa2d642de4cfd72077c69 Mon Sep 17 00:00:00 2001 From: Martin Alexander Kaminski Date: Thu, 1 Oct 2026 20:09:31 +0200 Subject: [PATCH 13/21] fix: style back and next book buttons correctly --- app/routes/book/selectedBook.module.css | 12 ++++++++ app/routes/book/selectedBook.tsx | 38 +++++++++++++------------ 2 files changed, 32 insertions(+), 18 deletions(-) diff --git a/app/routes/book/selectedBook.module.css b/app/routes/book/selectedBook.module.css index db8ea86..e61f969 100644 --- a/app/routes/book/selectedBook.module.css +++ b/app/routes/book/selectedBook.module.css @@ -38,6 +38,7 @@ } .browse-button { + all: unset; background-color: white; border: 1px solid var(--border-color); border-radius: var(--border-radius); @@ -55,6 +56,17 @@ } } +.browse-button-disabled { + all: unset; + background-color: white; + border: 1px solid var(--border-color); + border-radius: var(--border-radius); + padding: 8px 16px; + cursor: not-allowed; + font-size: 0.925rem; + color: #9ca3af; +} + .cover-section { position: relative; flex: 0 0 40%; diff --git a/app/routes/book/selectedBook.tsx b/app/routes/book/selectedBook.tsx index 6f538be..2a3e7b9 100644 --- a/app/routes/book/selectedBook.tsx +++ b/app/routes/book/selectedBook.tsx @@ -67,25 +67,27 @@ export default function SelectedBookPage({ params }: Route.ComponentProps) { Go back From 757966037b225e0b10ff42d76881cb16057ccbcd Mon Sep 17 00:00:00 2001 From: Martin Alexander Kaminski Date: Thu, 1 Oct 2026 20:12:15 +0200 Subject: [PATCH 14/21] fix: use filters correctly when selecting a book --- app/routes/book/selectedBook.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/routes/book/selectedBook.tsx b/app/routes/book/selectedBook.tsx index 2a3e7b9..66436f8 100644 --- a/app/routes/book/selectedBook.tsx +++ b/app/routes/book/selectedBook.tsx @@ -5,14 +5,15 @@ import { FavoriteButton } from "~/components/FavoriteButton/FavoriteButton"; import { useBookDescription } from "~/api/useBookDescription"; import { Link, useSearchParams } from "react-router"; import { useBooks } from "~/api/useBooks"; +import { useBookSearchParams } from "~/api/useBookSearchParams"; export default function SelectedBookPage({ params }: Route.ComponentProps) { const [searchParams] = useSearchParams(); - const query = searchParams.get("q") ?? "subject:fantasy"; - const page = Number(searchParams.get("page") ?? "1"); + const { query, page, filters } = useBookSearchParams(); const { data: bookQuery, isPending: isBooksPending } = useBooks({ query, + filters, page, }); From 27160889b5185d97ac207aebd8d2681636bcd47c Mon Sep 17 00:00:00 2001 From: Martin Alexander Kaminski Date: Thu, 1 Oct 2026 20:12:34 +0200 Subject: [PATCH 15/21] feat: show loading book details info text with proper styling --- app/routes/book/selectedBook.module.css | 8 ++++++++ app/routes/book/selectedBook.tsx | 8 ++++---- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/app/routes/book/selectedBook.module.css b/app/routes/book/selectedBook.module.css index e61f969..666bdd8 100644 --- a/app/routes/book/selectedBook.module.css +++ b/app/routes/book/selectedBook.module.css @@ -1,3 +1,11 @@ +.info-text { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; +} + .book-details { display: flex; flex-direction: row; diff --git a/app/routes/book/selectedBook.tsx b/app/routes/book/selectedBook.tsx index 66436f8..b155ce6 100644 --- a/app/routes/book/selectedBook.tsx +++ b/app/routes/book/selectedBook.tsx @@ -34,19 +34,19 @@ export default function SelectedBookPage({ params }: Route.ComponentProps) { if (isBooksPending || isDescriptionPending) { return ( - <> +

Loading Book Details

The requested book details are currently being loaded.

- +
); } if (!books || !book) { return ( - <> +

Book Not Found

The requested book could not be found.

- +
); } From 73a4caa967aad8dd9a8e9b9fdf2b9f521b02d5c6 Mon Sep 17 00:00:00 2001 From: Martin Alexander Kaminski Date: Thu, 1 Oct 2026 20:45:26 +0200 Subject: [PATCH 16/21] fix: use debounce for book filters --- .../FilterBooksSection/FilterBooksSection.tsx | 35 +++++++++++-------- 1 file changed, 20 insertions(+), 15 deletions(-) diff --git a/app/components/FilterBooksSection/FilterBooksSection.tsx b/app/components/FilterBooksSection/FilterBooksSection.tsx index ca5b3b8..d2daed1 100644 --- a/app/components/FilterBooksSection/FilterBooksSection.tsx +++ b/app/components/FilterBooksSection/FilterBooksSection.tsx @@ -4,6 +4,8 @@ import { languageCodes } from "~/api/languageCodes"; import { CircleQuestionMark } from "~/components/icons/CircleQuestionMark"; import { useSearchParams } from "react-router"; import { useBookSearchParams } from "~/api/useBookSearchParams"; +import { useDebounce } from "~/api/useDebounce"; +import { useEffect, useState } from "react"; const FILTER_INPUTS: { name: keyof BookFilters; @@ -39,25 +41,24 @@ const FILTER_INPUTS: { function FilterBooksSection() { const [, setSearchParams] = useSearchParams(); - const { filters } = useBookSearchParams(); + const { filters: initFilters } = useBookSearchParams(); - function handleFilterChange(field: keyof BookFilters, value: string) { - const newFilters = { ...filters, [field]: value }; + const [filters, setFilters] = useState(() => initFilters); - if (value === "") { - // eslint-disable-next-line @typescript-eslint/no-dynamic-delete -- It is safe to remove the property from the BookFilter object. - delete newFilters[field]; - } + const debouncedFilters = useDebounce(filters, 500); + useEffect(() => { setSearchParams((searchParams) => { - if (value === "") { - searchParams.delete(field); - } else { - searchParams.set(field, value); - } + Object.entries(debouncedFilters).forEach(([key, value]) => { + if (value && value.length > 0) { + searchParams.set(key, value); + } else { + searchParams.delete(key); + } + }); return searchParams; }); - } + }, [debouncedFilters, setSearchParams]); return (
@@ -75,7 +76,9 @@ function FilterBooksSection() { aria-label={input.ariaLabel} placeholder={input.placeholder} value={filters[input.name] ?? ""} - onChange={(e) => handleFilterChange(input.name, e.target.value)} + onChange={(e) => + setFilters({ ...filters, [input.name]: e.target.value }) + } /> ))} @@ -94,7 +97,9 @@ function FilterBooksSection() { name="language" aria-label="Search for language" value={filters.language ?? ""} - onChange={(e) => handleFilterChange("language", e.target.value)} + onChange={(e) => + setFilters({ ...filters, language: e.target.value }) + } > {languageCodes.map((lang) => ( From bc18083985f0f0476876c85cc86f8f98c70cfb45 Mon Sep 17 00:00:00 2001 From: Martin Alexander Kaminski Date: Thu, 1 Oct 2026 20:45:33 +0200 Subject: [PATCH 17/21] fix: remove duplicate language codes --- app/api/languageCodes.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/app/api/languageCodes.ts b/app/api/languageCodes.ts index 2a4657e..24e9d8a 100644 --- a/app/api/languageCodes.ts +++ b/app/api/languageCodes.ts @@ -34,13 +34,10 @@ const languageCodes = [ { code: "geo", name: "Georgian" }, { code: "kor", name: "Korean" }, { code: "kur", name: "Kurdish" }, - { code: "lat", name: "Latin" }, { code: "lit", name: "Lithuanian" }, { code: "lat", name: "Latvian" }, { code: "mac", name: "Macedonian" }, - { code: "mal", name: "Malayalam" }, { code: "mon", name: "Mongolian" }, - { code: "mal", name: "Maltese" }, { code: "bur", name: "Burmese" }, { code: "dut", name: "Dutch; Flemish" }, { code: "nor", name: "Norwegian" }, From 3619b0902b95faa8b840f1b28092d7e153f7148f Mon Sep 17 00:00:00 2001 From: Martin Alexander Kaminski Date: Fri, 2 Oct 2026 11:14:22 +0200 Subject: [PATCH 18/21] fix: always include searchParams even when link is disabled --- app/routes/book/selectedBook.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/app/routes/book/selectedBook.tsx b/app/routes/book/selectedBook.tsx index b155ce6..51722cf 100644 --- a/app/routes/book/selectedBook.tsx +++ b/app/routes/book/selectedBook.tsx @@ -69,9 +69,12 @@ export default function SelectedBookPage({ params }: Route.ComponentProps) {