diff --git a/src/App.css b/src/App.css index 12675f8..570ace9 100644 --- a/src/App.css +++ b/src/App.css @@ -43,6 +43,39 @@ margin: 0; } +.view-navigation { + display: flex; + gap: 0.5rem; + max-width: 32rem; + margin: 0 auto 1.5rem; +} + +.view-navigation button { + flex: 1; + padding: 0.625rem 0.75rem; + border: 1px solid #cbd2d9; + border-radius: 0.375rem; + background-color: #ffffff; + color: #243b53; + font: inherit; + font-weight: 600; +} + +.view-navigation button:hover { + background-color: #edf2f7; +} + +.view-navigation button[aria-pressed="true"] { + border-color: #2c3e50; + background-color: #2c3e50; + color: #ffffff; +} + +.view-navigation button:focus-visible { + outline: 0.1875rem solid #2c3e50; + outline-offset: 0.125rem; +} + .book-viewer { max-width: 32rem; margin: 0 auto; diff --git a/src/App.test.tsx b/src/App.test.tsx index 4aa1baf..31b8555 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -49,6 +49,23 @@ describe("App", () => { expect(await screen.findByText(/No books found/)).toBeInTheDocument(); }); + it("switches between the library and favorites views", async () => { + const user = userEvent.setup(); + withProviders(); + await screen.findByRole("heading", { name: "A Wizard of Earthsea" }); + + await user.click(screen.getByRole("button", { name: "Favorites" })); + expect(screen.getByRole("button", { name: "Favorites" })).toHaveAttribute( + "aria-pressed", + "true", + ); + expect(screen.queryByRole("region", { name: "Book viewer" })).not.toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Library" })); + expect(screen.getByRole("button", { name: "Library" })).toHaveAttribute("aria-pressed", "true"); + expect(screen.getByRole("region", { name: "Book viewer" })).toBeInTheDocument(); + }); + it("navigates to the next and previous book with the controls", async () => { const user = userEvent.setup(); withProviders(); @@ -192,14 +209,24 @@ describe("App", () => { }); it("saves, restores, and removes a favorite", async () => { + const user = userEvent.setup(); const firstRender = withProviders(); await screen.findByRole("heading", { name: "A Wizard of Earthsea" }); fireEvent.click(screen.getByRole("button", { name: "Add A Wizard of Earthsea to favorites" })); - expect(localStorage.getItem("t19.favorites")).toBe(JSON.stringify(["/works/OL2"])); + expect(JSON.parse(localStorage.getItem("t19.favorites") ?? "[]")).toEqual([ + { + key: "/works/OL2", + title: "A Wizard of Earthsea", + authors: ["Ursula K. Le Guin"], + firstPublishYear: 1968, + coverId: null, + }, + ]); firstRender.unmount(); withProviders(); + await user.click(screen.getByRole("button", { name: "Favorites" })); await screen.findByRole("button", { name: "Remove A Wizard of Earthsea from favorites list" }); fireEvent.click( @@ -208,6 +235,58 @@ describe("App", () => { expect(localStorage.getItem("t19.favorites")).toBe(JSON.stringify([])); }); + it("keeps favorites from different subjects together after reload", async () => { + server.use( + http.get("https://openlibrary.org/search.json", ({ request }) => { + const subject = new URL(request.url).searchParams.get("subject"); + const docs = + subject === "mystery" + ? [ + { + key: "/works/OL4", + title: "The Moonstone", + author_name: ["Wilkie Collins"], + first_publish_year: 1868, + cover_i: 456, + }, + ] + : [ + { + key: "/works/OL2", + title: "A Wizard of Earthsea", + author_name: ["Ursula K. Le Guin"], + first_publish_year: 1968, + }, + ]; + return HttpResponse.json({ docs }); + }), + ); + const user = userEvent.setup(); + const firstRender = withProviders(); + + await screen.findByRole("heading", { name: "A Wizard of Earthsea" }); + await user.click(screen.getByRole("button", { name: "Add A Wizard of Earthsea to favorites" })); + + await user.selectOptions(screen.getByRole("combobox", { name: "Subject" }), "mystery"); + await screen.findByRole("heading", { name: "The Moonstone" }); + await user.click(screen.getByRole("button", { name: "Add The Moonstone to favorites" })); + await user.click(screen.getByRole("button", { name: "Favorites" })); + + expect( + screen.getByRole("heading", { name: "A Wizard of Earthsea", level: 3 }), + ).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "The Moonstone", level: 3 })).toBeInTheDocument(); + + firstRender.unmount(); + withProviders(); + await user.click(screen.getByRole("button", { name: "Favorites" })); + + expect( + await screen.findByRole("heading", { name: "A Wizard of Earthsea", level: 3 }), + ).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "The Moonstone", level: 3 })).toBeInTheDocument(); + }); + it("supports the complete library user flow without unnecessary requests", async () => { const user = userEvent.setup(); withProviders(); @@ -229,7 +308,15 @@ describe("App", () => { await user.click(screen.getByRole("button", { name: "Add A Wizard of Earthsea to favorites" })); - expect(localStorage.getItem("t19.favorites")).toBe(JSON.stringify(["/works/OL2"])); + expect(JSON.parse(localStorage.getItem("t19.favorites") ?? "[]")).toEqual([ + { + key: "/works/OL2", + title: "A Wizard of Earthsea", + authors: ["Ursula K. Le Guin"], + firstPublishYear: 1968, + coverId: null, + }, + ]); expect(getSearchRequestCount()).toBe(2); }); diff --git a/src/App.tsx b/src/App.tsx index ed9215b..e8b520d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import { BookCard } from "./components/BookCard"; import { BookJumpList } from "./components/BookJumpList"; import { NavigationControls } from "./components/NavigationControls"; @@ -12,6 +12,8 @@ import { SortSelect } from "./components/SortSelect"; import { sortBooks } from "./utils/sortBooks"; import "./App.css"; +type AppView = "library" | "favorites"; + function App() { const { subject, setSubject, sort, setSort } = usePreferences(); const { books, isLoading, isError } = useBooks(subject); @@ -19,10 +21,18 @@ function App() { const sortedBooks = sortBooks(books, sort); const [index, setIndex] = useState(0); + const [view, setView] = useState("library"); const safeIndex = sortedBooks.length > 0 ? Math.min(index, sortedBooks.length - 1) : 0; const current = sortedBooks[safeIndex]; - const { favoriteKeys, toggleFavorite, removeFavorite } = useFavorites(); + const { favoriteBooks, isFavorite, toggleFavorite, removeFavorite, updateFavoriteDetails } = + useFavorites(); + + useEffect(() => { + if (current !== undefined) { + updateFavoriteDetails(current); + } + }, [current, updateFavoriteDetails]); function handleSubjectChange(nextSubject: string) { setSubject(nextSubject); @@ -41,37 +51,56 @@ function App() {

Browse reading material from OpenLibrary, one book at a time.

-
- - -
- {isLoading &&

Loading books…

} - {isError &&

Could not load books. Please try refreshing the page.

} - {!isLoading && !isError && books.length === 0 && ( -

No books found for the subject “{subject}”.

- )} - {current && ( -
- toggleFavorite(current.key)} - /> - setIndex(Math.max(0, safeIndex - 1))} - onNext={() => setIndex(Math.min(sortedBooks.length - 1, safeIndex + 1))} - /> - book.title)} - currentIndex={safeIndex} - onSelect={setIndex} - /> -
- )} - {!isLoading && !isError && ( - + + {view === "library" ? ( + <> +
+ + +
+ {isLoading &&

Loading books…

} + {isError &&

Could not load books. Please try refreshing the page.

} + {!isLoading && !isError && books.length === 0 && ( +

No books found for the subject “{subject}”.

+ )} + {current && ( +
+ toggleFavorite(current)} + /> + setIndex(Math.max(0, safeIndex - 1))} + onNext={() => setIndex(Math.min(sortedBooks.length - 1, safeIndex + 1))} + /> + book.title)} + currentIndex={safeIndex} + onSelect={setIndex} + /> +
+ )} + + ) : ( + )}