From d3476a1d8a618702e4623d90f8848dea695f73e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Str=C3=B8m-Andersen?= Date: Tue, 20 Oct 2026 21:26:09 +0200 Subject: [PATCH 01/28] refactor: use shadcn Button for FavoriteButton --- .../FavoriteButton/FavoriteButton.tsx | 17 +++++++++++------ frontend/app/index.css | 8 ++++++++ 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/frontend/app/components/FavoriteButton/FavoriteButton.tsx b/frontend/app/components/FavoriteButton/FavoriteButton.tsx index 26c74e3..5b5014a 100644 --- a/frontend/app/components/FavoriteButton/FavoriteButton.tsx +++ b/frontend/app/components/FavoriteButton/FavoriteButton.tsx @@ -1,4 +1,5 @@ import { useState } from "react"; +import { Button } from "~/components/ui/Button"; import { CheckIcon } from "~/components/icons/CheckIcon"; import type { Book } from "~/api/types"; @@ -27,21 +28,25 @@ function FavoriteButton({ book }: FavoriteButtonProps) { setFavorites(savedFavorites); }; + const isFavorite = favorites.includes(book.id); + return ( - + {isFavorite ? : "+"} + ); } diff --git a/frontend/app/index.css b/frontend/app/index.css index d17f6d2..665339d 100644 --- a/frontend/app/index.css +++ b/frontend/app/index.css @@ -32,11 +32,17 @@ footer { @apply mt-auto; } + + button:not(:disabled), + [role="button"]:not(:disabled) { + cursor: pointer; + } } @theme inline { --font-heading: var(--font-sans); --font-sans: "Inter Variable", sans-serif; + --color-favorite-button: var(--favorite-button); --color-sidebar-ring: var(--sidebar-ring); --color-sidebar-border: var(--sidebar-border); --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); @@ -110,6 +116,7 @@ --sidebar-accent-foreground: oklch(0.205 0 0); --sidebar-border: oklch(0.922 0 0); --sidebar-ring: oklch(0.708 0 0); + --favorite-button: oklch(0.985 0 0); } .dark { @@ -144,4 +151,5 @@ --sidebar-accent-foreground: oklch(0.985 0 0); --sidebar-border: oklch(1 0 0 / 10%); --sidebar-ring: oklch(0.556 0 0); + --favorite-button: oklch(0.220 0 0); } From 6b4ee4f47d75e3650d2e6d91d633b9e0d8b66c20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Str=C3=B8m-Andersen?= Date: Tue, 20 Oct 2026 22:54:49 +0200 Subject: [PATCH 02/28] refactor: use shadcn Card in BookCard --- frontend/app/components/BookCard/BookCard.tsx | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/frontend/app/components/BookCard/BookCard.tsx b/frontend/app/components/BookCard/BookCard.tsx index b8533a6..4ef9db4 100644 --- a/frontend/app/components/BookCard/BookCard.tsx +++ b/frontend/app/components/BookCard/BookCard.tsx @@ -1,5 +1,6 @@ import { BookCover } from "~/components/BookCover/BookCover"; import { FavoriteButton } from "~/components/FavoriteButton/FavoriteButton"; +import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/Card"; import type { Book } from "~/api/types"; import { Link, useSearchParams } from "react-router"; @@ -11,7 +12,7 @@ function BookCard({ book }: BookCardProps) { const [searchParams] = useSearchParams(); return ( -
+ - + + + + + + {book.title} + -
-

{book.title}

+

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

{book.publishedYear ?? "Unknown"}

-
+ -
+ ); } From aae070b13ef6dfd8c75b5d29413cdb6fcf0277e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Str=C3=B8m-Andersen?= Date: Tue, 20 Oct 2026 22:55:38 +0200 Subject: [PATCH 03/28] feat: add hover animations to BookCard --- frontend/app/components/BookCard/BookCard.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/app/components/BookCard/BookCard.tsx b/frontend/app/components/BookCard/BookCard.tsx index 4ef9db4..d75b1bf 100644 --- a/frontend/app/components/BookCard/BookCard.tsx +++ b/frontend/app/components/BookCard/BookCard.tsx @@ -12,7 +12,7 @@ function BookCard({ book }: BookCardProps) { const [searchParams] = useSearchParams(); return ( - + - +
- +
{book.title} From f5fd7a4ce04e98b3933c69128c56644ed4ada3c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Str=C3=B8m-Andersen?= Date: Wed, 21 Oct 2026 20:16:19 +0200 Subject: [PATCH 04/28] feat: add shadcn pagination with page links --- .../app/components/Pagination/Pagination.tsx | 124 ++++++++++++++---- frontend/app/components/ui/Pagination.tsx | 27 ++-- 2 files changed, 108 insertions(+), 43 deletions(-) diff --git a/frontend/app/components/Pagination/Pagination.tsx b/frontend/app/components/Pagination/Pagination.tsx index 814376c..1d65527 100644 --- a/frontend/app/components/Pagination/Pagination.tsx +++ b/frontend/app/components/Pagination/Pagination.tsx @@ -1,40 +1,108 @@ import { useSearchParams } from "react-router"; +import { + Pagination as PaginationRoot, + PaginationContent, + PaginationEllipsis, + PaginationItem, + PaginationLink, + PaginationNext, + PaginationPrevious, +} from "~/components/ui/Pagination"; + +const DISABLED_STYLE = "pointer-events-none opacity-50"; function Pagination({ totalPages }: { totalPages: number }) { - const [searchParams, setSearchParams] = useSearchParams(); + const [searchParams] = useSearchParams(); const currentPage = parseInt(searchParams.get("page") ?? "1"); - const handlePageChange = (newPage: number) => { - window.scrollTo({ top: 0, behavior: "smooth" }); + const isFirstPage = currentPage === 1; + const isLastPage = currentPage === totalPages; + + // Pages around the current page (one before, current, one after) + // For each page in the list, keep only page numbers larger than 1 and less than the last page + const middlePages = [currentPage - 1, currentPage, currentPage + 1].filter( + (page) => page > 1 && page < totalPages, + ); + + // Show ellipsis when there is a gap between the first/last page and the middle pages + const showStartEllipsis = currentPage > 3; + const showEndEllipsis = currentPage < totalPages - 2; + + // Only show the last page separately if there is more than 1 page, so page 1 is not rendered twice + const showLastPage = totalPages > 1; - setSearchParams((prev) => { - prev.set("page", newPage.toString()); - return prev; - }); + const getValidPage = (page: number) => { + if (page < 1) return 1; + if (page > totalPages) return totalPages; + return page; }; - return ( -
- - - {currentPage} of {totalPages} - - -
+ {page} + + + ); + + return ( + + + + + + + {renderPageLink(1)} + + {showStartEllipsis && ( + + + + )} + + {middlePages.map((page) => renderPageLink(page))} + + {showEndEllipsis && ( + + + + )} + + {showLastPage && renderPageLink(totalPages)} + + + + + + ); } diff --git a/frontend/app/components/ui/Pagination.tsx b/frontend/app/components/ui/Pagination.tsx index b2c7ab1..54a2fd8 100644 --- a/frontend/app/components/ui/Pagination.tsx +++ b/frontend/app/components/ui/Pagination.tsx @@ -1,7 +1,8 @@ import * as React from "react"; import { cn } from "cn"; +import { Link } from "react-router"; -import { Button } from "~/components/ui/Button"; +import { Button, buttonVariants } from "~/components/ui/Button"; import { ChevronLeftIcon, ChevronRightIcon, @@ -40,7 +41,7 @@ function PaginationItem({ ...props }: React.ComponentProps<"li">) { type PaginationLinkProps = { isActive?: boolean; } & Pick, "size"> & - React.ComponentProps<"a">; + React.ComponentProps; function PaginationLink({ className, @@ -49,19 +50,15 @@ function PaginationLink({ ...props }: PaginationLinkProps) { return ( - + {filtersOpen && } diff --git a/frontend/app/components/icons/SearchIcon.tsx b/frontend/app/components/icons/SearchIcon.tsx new file mode 100644 index 0000000..d8cf75a --- /dev/null +++ b/frontend/app/components/icons/SearchIcon.tsx @@ -0,0 +1,23 @@ +// Found as a part of Lucide: +// https://lucide.dev/icons/search + +function SearchIcon() { + return ( + + + + + ); +} + +export { SearchIcon }; diff --git a/frontend/app/components/ui/Button.tsx b/frontend/app/components/ui/Button.tsx index 4212cbb..f70fd70 100644 --- a/frontend/app/components/ui/Button.tsx +++ b/frontend/app/components/ui/Button.tsx @@ -30,6 +30,7 @@ const buttonVariants = cva( "icon-sm": "size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg", "icon-lg": "size-9", + "icon-xl": "size-10", }, }, defaultVariants: { diff --git a/frontend/app/routes/home.tsx b/frontend/app/routes/home.tsx index 76983a4..9d0871e 100644 --- a/frontend/app/routes/home.tsx +++ b/frontend/app/routes/home.tsx @@ -53,7 +53,7 @@ export default function HomePage() { if (books.length === 0) { return (
- +

- +

    {books.map((book) => (
  • From 371da7005d979c5efe05c1ecd9a92d2293e0d685 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Str=C3=B8m-Andersen?= Date: Thu, 22 Oct 2026 00:29:39 +0200 Subject: [PATCH 09/28] feat: add shadcn Popover component --- frontend/app/components/ui/Popover.tsx | 87 ++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 frontend/app/components/ui/Popover.tsx diff --git a/frontend/app/components/ui/Popover.tsx b/frontend/app/components/ui/Popover.tsx new file mode 100644 index 0000000..904781c --- /dev/null +++ b/frontend/app/components/ui/Popover.tsx @@ -0,0 +1,87 @@ +import * as React from "react" +import { Popover as PopoverPrimitive } from "@base-ui/react/popover" +import { cn } from "cn" + +function Popover({ ...props }: PopoverPrimitive.Root.Props) { + return +} + +function PopoverTrigger({ ...props }: PopoverPrimitive.Trigger.Props) { + return +} + +function PopoverContent({ + className, + align = "center", + alignOffset = 0, + side = "bottom", + sideOffset = 4, + ...props +}: PopoverPrimitive.Popup.Props & + Pick< + PopoverPrimitive.Positioner.Props, + "align" | "alignOffset" | "side" | "sideOffset" + >) { + return ( + + + + + + ) +} + +function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
    + ) +} + +function PopoverTitle({ className, ...props }: PopoverPrimitive.Title.Props) { + return ( + + ) +} + +function PopoverDescription({ + className, + ...props +}: PopoverPrimitive.Description.Props) { + return ( + + ) +} + +export { + Popover, + PopoverContent, + PopoverDescription, + PopoverHeader, + PopoverTitle, + PopoverTrigger, +} From f7d34898bf0a126fad773177aade9d0460d2d998 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Str=C3=B8m-Andersen?= Date: Thu, 22 Oct 2026 00:31:19 +0200 Subject: [PATCH 10/28] feat: add shadcn components for filters --- .../FilterBooksSection/FilterBooksSection.tsx | 105 ++++++++++++------ .../app/components/SearchBar/SearchBar.tsx | 83 ++++++++------ 2 files changed, 120 insertions(+), 68 deletions(-) diff --git a/frontend/app/components/FilterBooksSection/FilterBooksSection.tsx b/frontend/app/components/FilterBooksSection/FilterBooksSection.tsx index e3651fe..9dd9e0c 100644 --- a/frontend/app/components/FilterBooksSection/FilterBooksSection.tsx +++ b/frontend/app/components/FilterBooksSection/FilterBooksSection.tsx @@ -1,6 +1,20 @@ import type { BookFilters } from "~/api/types"; import { languageCodes } from "~/api/languageCodes"; import { CircleQuestionMark } from "~/components/icons/CircleQuestionMark"; +import { Input } from "~/components/ui/Input"; +import { Label } from "~/components/ui/Label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "~/components/ui/Select"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "~/components/ui/Tooltip"; import { useSearchParams } from "react-router"; import { useBookSearchParams } from "~/api/useBookSearchParams"; import { useDebounce } from "~/api/useDebounce"; @@ -38,6 +52,17 @@ const FILTER_INPUTS: { }, ]; +const LANGUAGE_ITEMS = [ + { + value: "none", + label: "Select a language", + }, + ...languageCodes.map((lang) => ({ + value: lang.code, + label: lang.name, + })), +]; + function FilterBooksSection() { const [, setSearchParams] = useSearchParams(); const { filters: rawFilters } = useBookSearchParams(); @@ -70,17 +95,17 @@ function FilterBooksSection() { }, [debouncedFilters, rawFilters, setSearchParams]); return ( -
    -

    Filter Books

    -
      +
      +

      Filter Books

      +
        {FILTER_INPUTS.map((input) => (
      • -
      • ))} +
      • - - { newFiltersFromInputRef.current = true; - setFilters({ ...filters, language: e.target.value }); + setFilters({ + ...filters, + language: value === "none" ? undefined : (value ?? undefined), + }); }} > - - {languageCodes.map((lang) => ( - - ))} - + + + + + {LANGUAGE_ITEMS.map((item) => ( + + {item.label} + + ))} + +
      diff --git a/frontend/app/components/SearchBar/SearchBar.tsx b/frontend/app/components/SearchBar/SearchBar.tsx index 466abbf..5f7c639 100644 --- a/frontend/app/components/SearchBar/SearchBar.tsx +++ b/frontend/app/components/SearchBar/SearchBar.tsx @@ -10,6 +10,11 @@ import { } from "~/components/ui/InputGroup"; import { Button } from "~/components/ui/Button"; import { Badge } from "~/components/ui/Badge"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "~/components/ui/Popover"; import { useSearchParams } from "react-router"; import { useDebounce } from "~/api/useDebounce"; import { useBookSearchParams } from "~/api/useBookSearchParams"; @@ -51,7 +56,6 @@ function SearchBar({ resultCount }: SearchBarProps) { newQueryFromInputRef.current = false; }, [debouncedQuery, inputField, query, setSearchParams]); - const [filtersOpen, setFiltersOpen] = useState(false); const filtersCount = Object.values(filters).filter( (value) => value !== undefined, ).length; @@ -59,41 +63,44 @@ function SearchBar({ resultCount }: SearchBarProps) { const filtersDescription = `${filtersCount} ${filtersCount === 1 ? "filter" : "filters"} active`; return ( - <> -
      - - - - +
      + + + + - { - newQueryFromInputRef.current = true; - setInputField(e.target.value); - }} - /> + { + newQueryFromInputRef.current = true; + setInputField(e.target.value); + }} + /> - {resultCount !== undefined && ( - - - {resultCount} {resultCount === 1 ? "result" : "results"} - - - )} - + {resultCount !== undefined && ( + + + {resultCount} {resultCount === 1 ? "result" : "results"} + + + )} + - -
      - {filtersOpen && } - + + + + + +
      ); } From ce48cb2ec84e670eae55983f986d40a053638a21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Str=C3=B8m-Andersen?= Date: Thu, 22 Oct 2026 01:08:13 +0200 Subject: [PATCH 11/28] fix: improve accessibility of language filter tooltip --- .../FilterBooksSection/FilterBooksSection.tsx | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/frontend/app/components/FilterBooksSection/FilterBooksSection.tsx b/frontend/app/components/FilterBooksSection/FilterBooksSection.tsx index 9dd9e0c..e8b5078 100644 --- a/frontend/app/components/FilterBooksSection/FilterBooksSection.tsx +++ b/frontend/app/components/FilterBooksSection/FilterBooksSection.tsx @@ -119,21 +119,19 @@ function FilterBooksSection() { ))}
    • -
      + } > - + -

      - Only show books that have an edition in the selected language -

      + Only show books that have an edition in the selected language
      -
      +