From 3c735505224b7562566afcc45b7cdf0df66cbc6d Mon Sep 17 00:00:00 2001 From: Robert Andreas Kyllo Date: Thu, 10 Sep 2026 11:46:46 +0200 Subject: [PATCH 1/2] feat: add persistent favorites and overview (#21) --- src/App.tsx | 13 ++++++- src/components/BookCard.tsx | 12 +++++- src/components/FavoriteButton.css | 18 +++++++++ src/components/FavoriteButton.tsx | 23 +++++++++++ src/components/FavoritesView.css | 63 +++++++++++++++++++++++++++++++ src/components/FavoritesView.tsx | 39 +++++++++++++++++++ src/hooks/useFavorites.ts | 53 ++++++++++++++++++++++++++ 7 files changed, 218 insertions(+), 3 deletions(-) create mode 100644 src/components/FavoriteButton.css create mode 100644 src/components/FavoriteButton.tsx create mode 100644 src/components/FavoritesView.css create mode 100644 src/components/FavoritesView.tsx create mode 100644 src/hooks/useFavorites.ts diff --git a/src/App.tsx b/src/App.tsx index a7c2db0..2198822 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,18 +2,20 @@ import { useState } from "react"; import { BookCard } from "./components/BookCard"; import { BookJumpList } from "./components/BookJumpList"; import { NavigationControls } from "./components/NavigationControls"; +import { FavoritesView } from "./components/FavoritesView"; import { SubjectFilter } from "./components/SubjectFilter"; import { useBooks } from "./hooks/useBooks"; +import { useFavorites } from "./hooks/useFavorites"; import { usePreferences } from "./hooks/usePreferences"; import "./App.css"; function App() { const { subject, setSubject } = usePreferences(); const { books, isLoading, isError } = useBooks(subject); - const [index, setIndex] = useState(0); const safeIndex = books.length > 0 ? Math.min(index, books.length - 1) : 0; const current = books[safeIndex]; + const { favoriteKeys, toggleFavorite, removeFavorite } = useFavorites(); return (
@@ -30,7 +32,11 @@ function App() { )} {current && (
- + toggleFavorite(current.key)} + />
)} + {!isLoading && !isError && ( + + )}

Data from the OpenLibrary API.

diff --git a/src/components/BookCard.tsx b/src/components/BookCard.tsx index 6c66ecc..72d95fd 100644 --- a/src/components/BookCard.tsx +++ b/src/components/BookCard.tsx @@ -1,12 +1,15 @@ import { coverUrl } from "../api/openLibrary"; import type { Book } from "../types"; +import { FavoriteButton } from "./FavoriteButton"; import "./BookCard.css"; interface BookCardProps { book: Book; + isFavorite?: boolean; + onFavoriteToggle?: () => void; } -export function BookCard({ book }: BookCardProps) { +export function BookCard({ book, isFavorite = false, onFavoriteToggle }: BookCardProps) { const imageUrl = coverUrl(book.coverId); const authors = book.authors.length > 0 ? book.authors.join(", ") : "Unknown author"; @@ -30,6 +33,13 @@ export function BookCard({ book }: BookCardProps) { ))} )} + {onFavoriteToggle && ( + + )}
); diff --git a/src/components/FavoriteButton.css b/src/components/FavoriteButton.css new file mode 100644 index 0000000..5007bc2 --- /dev/null +++ b/src/components/FavoriteButton.css @@ -0,0 +1,18 @@ +.favorite-button { + align-self: flex-start; + padding: 0.5rem 0.75rem; + border: 1px solid #cbd2d9; + border-radius: 0.375rem; + background-color: #ffffff; + color: #1f2933; + font: inherit; +} + +.favorite-button:hover { + background-color: #f5f7fa; +} + +.favorite-button:focus-visible { + outline: 0.1875rem solid #2c3e50; + outline-offset: 0.125rem; +} diff --git a/src/components/FavoriteButton.tsx b/src/components/FavoriteButton.tsx new file mode 100644 index 0000000..03a6cad --- /dev/null +++ b/src/components/FavoriteButton.tsx @@ -0,0 +1,23 @@ +import "./FavoriteButton.css"; + +interface FavoriteButtonProps { + bookTitle: string; + isFavorite: boolean; + onToggle: () => void; +} + +export function FavoriteButton({ bookTitle, isFavorite, onToggle }: FavoriteButtonProps) { + const action = isFavorite ? "Remove" : "Add"; + + return ( + + ); +} diff --git a/src/components/FavoritesView.css b/src/components/FavoritesView.css new file mode 100644 index 0000000..577feaf --- /dev/null +++ b/src/components/FavoritesView.css @@ -0,0 +1,63 @@ +.favorites-view { + max-width: 32rem; + margin: 2rem auto 0; + padding-top: 1.5rem; + border-top: 1px solid #d5dde5; +} + +.favorites-view h2 { + margin: 0; + color: #243b53; +} + +.favorites-view ul { + display: grid; + gap: 0.75rem; + margin: 1rem 0 0; + padding: 0; + list-style: none; +} + +.favorites-view li { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 0.75rem; + border: 1px solid #d5dde5; + border-radius: 0.5rem; +} + +.favorites-view h3, +.favorites-view p { + margin: 0; +} + +.favorites-view p { + color: #616e7c; +} + +.favorites-view button { + padding: 0.5rem 0.75rem; + border: 1px solid #cbd2d9; + border-radius: 0.375rem; + background-color: #ffffff; + color: #1f2933; + font: inherit; +} + +.favorites-view button:hover { + background-color: #f5f7fa; +} + +.favorites-view button:focus-visible { + outline: 0.1875rem solid #2c3e50; + outline-offset: 0.125rem; +} + +@media (max-width: 30rem) { + .favorites-view li { + align-items: flex-start; + flex-direction: column; + } +} diff --git a/src/components/FavoritesView.tsx b/src/components/FavoritesView.tsx new file mode 100644 index 0000000..64e4e5f --- /dev/null +++ b/src/components/FavoritesView.tsx @@ -0,0 +1,39 @@ +import type { Book } from "../types"; +import "./FavoritesView.css"; + +interface FavoritesViewProps { + books: Book[]; + favoriteKeys: string[]; + onRemove: (bookKey: string) => void; +} + +export function FavoritesView({ books, favoriteKeys, onRemove }: FavoritesViewProps) { + const favorites = books.filter((book) => favoriteKeys.includes(book.key)); + + return ( +
+

Favorites

+ {favorites.length === 0 ? ( +

No favorite books yet.

+ ) : ( + + )} +
+ ); +} diff --git a/src/hooks/useFavorites.ts b/src/hooks/useFavorites.ts new file mode 100644 index 0000000..7a0d9bf --- /dev/null +++ b/src/hooks/useFavorites.ts @@ -0,0 +1,53 @@ +import { useState } from "react"; + +const FAVORITES_KEY = "t19.favorites"; + +function readFavorites(): string[] { + try { + const storedFavorites = localStorage.getItem(FAVORITES_KEY); + if (storedFavorites === null) { + return []; + } + + const favorites: unknown = JSON.parse(storedFavorites); + if (!Array.isArray(favorites) || favorites.some((favorite) => typeof favorite !== "string")) { + return []; + } + + return [...new Set(favorites)]; + } catch { + return []; + } +} + +function saveFavorites(favoriteKeys: string[]) { + try { + localStorage.setItem(FAVORITES_KEY, JSON.stringify(favoriteKeys)); + } catch { + return; + } +} + +export function useFavorites() { + const [favoriteKeys, setFavoriteKeys] = useState(readFavorites); + + function toggleFavorite(bookKey: string) { + setFavoriteKeys((currentKeys) => { + const nextKeys = currentKeys.includes(bookKey) + ? currentKeys.filter((key) => key !== bookKey) + : [...currentKeys, bookKey]; + saveFavorites(nextKeys); + return nextKeys; + }); + } + + function removeFavorite(bookKey: string) { + setFavoriteKeys((currentKeys) => { + const nextKeys = currentKeys.filter((key) => key !== bookKey); + saveFavorites(nextKeys); + return nextKeys; + }); + } + + return { favoriteKeys, toggleFavorite, removeFavorite }; +} From 8a0f2456f50e7440b653f5b1c331d8d812bf7b1c Mon Sep 17 00:00:00 2001 From: Robert Andreas Kyllo Date: Thu, 10 Sep 2026 11:46:46 +0200 Subject: [PATCH 2/2] test: cover favorites persistence and interactions (#21) --- src/App.test.tsx | 18 +++++++- src/__snapshots__/App.test.tsx.snap | 21 +++++++++ src/components/FavoriteButton.test.tsx | 25 +++++++++++ src/components/FavoritesView.test.tsx | 43 +++++++++++++++++++ .../FavoriteButton.test.tsx.snap | 14 ++++++ .../__snapshots__/FavoritesView.test.tsx.snap | 34 +++++++++++++++ src/hooks/useFavorites.test.ts | 35 +++++++++++++++ 7 files changed, 189 insertions(+), 1 deletion(-) create mode 100644 src/components/FavoriteButton.test.tsx create mode 100644 src/components/FavoritesView.test.tsx create mode 100644 src/components/__snapshots__/FavoriteButton.test.tsx.snap create mode 100644 src/components/__snapshots__/FavoritesView.test.tsx.snap create mode 100644 src/hooks/useFavorites.test.ts diff --git a/src/App.test.tsx b/src/App.test.tsx index 9821139..7b7911c 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -15,6 +15,7 @@ import { describe("App", () => { beforeEach(() => { sessionStorage.clear(); + localStorage.clear(); resetSearchRequestCount(); }); @@ -121,9 +122,24 @@ describe("App", () => { expect(screen.getByRole("combobox", { name: "Subject" })).toHaveValue("fantasy"); }); + it("saves, restores, and removes a favorite", async () => { + const firstRender = withProviders(); + await screen.findByRole("heading", { name: "The Hobbit" }); + + fireEvent.click(screen.getByRole("button", { name: "Add The Hobbit to favorites" })); + expect(localStorage.getItem("t19.favorites")).toBe(JSON.stringify(["/works/OL1"])); + + firstRender.unmount(); + withProviders(); + await screen.findByRole("button", { name: "Remove The Hobbit from favorites list" }); + + fireEvent.click(screen.getByRole("button", { name: "Remove The Hobbit from favorites list" })); + expect(localStorage.getItem("t19.favorites")).toBe(JSON.stringify([])); + }); + it("matches the snapshot", async () => { const { container } = withProviders(); - await screen.findByRole("heading", { level: 2 }); + await screen.findByRole("heading", { level: 2, name: "The Hobbit" }); expect(container).toMatchSnapshot(); }); }); diff --git a/src/__snapshots__/App.test.tsx.snap b/src/__snapshots__/App.test.tsx.snap index 10bad78..77d621e 100644 --- a/src/__snapshots__/App.test.tsx.snap +++ b/src/__snapshots__/App.test.tsx.snap @@ -99,6 +99,14 @@ exports[`App > matches the snapshot 1`] = ` dragons +