Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
d3476a1
refactor: use shadcn Button for FavoriteButton
hestro Oct 20, 2026
6b4ee4f
refactor: use shadcn Card in BookCard
hestro Oct 20, 2026
aae070b
feat: add hover animations to BookCard
hestro Oct 20, 2026
f5fd7a4
feat: add shadcn pagination with page links
hestro Oct 21, 2026
12a8b09
fix: Keep favorite button above book cover
hestro Oct 21, 2026
90a77eb
feat: add hover style to pagination links
hestro Oct 21, 2026
46f72f5
feat: add keyboard focus ring to BookCard
hestro Oct 21, 2026
88d2696
feat: use shadcn input group in SearchBar
hestro Oct 21, 2026
371da70
feat: add shadcn Popover component
hestro Oct 21, 2026
f7d3489
feat: add shadcn components for filters
hestro Oct 21, 2026
ce48cb2
fix: improve accessibility of language filter tooltip
hestro Oct 21, 2026
46103d9
feat: add clear all filters button
hestro Oct 21, 2026
d8f5998
refactor: move pagination hover style to shared PaginationLink
hestro Oct 22, 2026
78c285b
fix: reset to first page when search change
hestro Oct 22, 2026
8b745d8
feat: add text-md font size to theme
hestro Oct 22, 2026
6a801a5
feat: make main fill the screen
hestro Oct 22, 2026
979e616
feat: add read more to book description
hestro Oct 22, 2026
1e2142f
feat: update selected book page with shadcn components and new features
hestro Oct 22, 2026
5ebbc59
fix: update project number in footer to 2
hestro Oct 22, 2026
27acaa4
feat: add shadcn breadcrumb component
hestro Oct 22, 2026
e1a4fd4
style: add newline
hestro Oct 22, 2026
7511bd6
feat: zoom book covers into frame on hover instead of scaling up
hestro Oct 24, 2026
5e28ffb
refactor: use cn utility for favorite button class
hestro Oct 24, 2026
b5258a2
refactor: replace manually imported icons with lucide-react
hestro Oct 24, 2026
c82538d
refactor: move pointer cursor to buttonVariants base classes
hestro Oct 24, 2026
24f85cb
refactor: rename pagination component to BookPagination
hestro Oct 24, 2026
65d2590
refactor: rename breadcrumb component to PageBreadcrumb
hestro Oct 24, 2026
6ee33b3
Merge branch 'main' into refactor/shadcn-ui
martakam Oct 24, 2026
f10bd83
fix: adjust FavoriteButton size and variant after merge with main
martakam Oct 24, 2026
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
21 changes: 12 additions & 9 deletions frontend/app/components/BookCard/BookCard.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -11,27 +12,29 @@ function BookCard({ book }: BookCardProps) {
const [searchParams] = useSearchParams();

return (
<article className="relative flex flex-col h-full box-border bg-card rounded-md border-2 border-transparent hover:border-border">
<FavoriteButton book={book} />

<Card className="group relative h-full bg-card p-0 transition-all duration-200 hover:shadow-xl has-[a:focus-visible]:border-ring has-[a:focus-visible]:ring-[3px] has-[a:focus-visible]:ring-ring/50">
<Link
to={{
pathname: `/book/${book.id}`,
search: searchParams.toString(),
}}
className="flex flex-1 flex-col h-full w-full cursor-pointer box-border rounded-md p-6"

className="flex h-full flex-col cursor-pointer p-6 rounded-xl"
aria-label={`View details for ${book.title}`}
>
<BookCover book={book} />

<section className="mt-4 text-[0.9rem]">
<h3 className="mb-2 text-base">{book.title}</h3>
<CardHeader className="mt-4">
<CardTitle className="font-bold">{book.title}</CardTitle>
</CardHeader>

<CardContent className="mt-2">
<p>{book.author.join(", ")}</p>
<p>{book.publishedYear ?? "Unknown"}</p>
</section>
</CardContent>
</Link>
</article>

<FavoriteButton book={book} />
</Card>
);
}

Expand Down
14 changes: 9 additions & 5 deletions frontend/app/components/BookCover/BookCover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,15 @@ interface BookCoverProps {

function BookCover({ book }: BookCoverProps) {
return book.coverImage ? (
<img
src={book.coverImage}
alt={`Book cover of ${book.title}`}
className="w-full object-contain px-6 py-3"
/>
<figure className="px-6 py-3 contain-content">
<picture className="block overflow-hidden">
<img
src={book.coverImage}
alt={`Book cover of ${book.title}`}
className="w-full object-contain transition-transform duration-200 group-hover:scale-103"
/>
</picture>
</figure>
) : (
<p className="flex items-center justify-center mx-6 my-3 aspect-2/3 rounded-md bg-background text-center text-sm">
No cover available
Expand Down
35 changes: 35 additions & 0 deletions frontend/app/components/BookDescription/BookDescription.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { useState } from "react";
import { Button } from "~/components/ui/Button";
import { cn } from "~/lib/utils";

const DESCRIPTION_PREVIEW_LENGTH = 650;

interface BookDescriptionProps {
description?: string | null;
}

function BookDescription({ description }: BookDescriptionProps) {
const [expanded, setExpanded] = useState(false);

const text = description ?? "No description available";
const isLong = text.length > DESCRIPTION_PREVIEW_LENGTH;

return (
<>
<p className={cn("leading-relaxed", isLong && !expanded && "line-clamp-8")}>{text}</p>
{isLong && (
<Button
type="button"
variant="link"
className="px-0 text-foreground/80 hover:text-foreground"
aria-expanded={expanded}
onClick={() => setExpanded(!expanded)}
>
{expanded ? "Read less" : "Read more"}
</Button>
)}
</>
);
}

export { BookDescription };
Original file line number Diff line number Diff line change
@@ -1,18 +1,18 @@
import { describe, expect, it, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Pagination } from "~/components/Pagination/Pagination";
import { BookPagination } from "~/components/BookPagination/BookPagination";
import { createRoutesStub } from "react-router";

// Pagination reads and writes the current page in the "page" search param.
const Stub = createRoutesStub([
{
path: "/",
Component: () => <Pagination totalPages={5} />,
Component: () => <BookPagination totalPages={5} />,
},
]);

describe("Pagination", () => {
describe("BookPagination", () => {
it("disables Previous on the first page and Next on the last page", () => {
const { unmount } = render(<Stub />);

Expand Down
109 changes: 109 additions & 0 deletions frontend/app/components/BookPagination/BookPagination.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { useSearchParams } from "react-router";
import {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from "~/components/ui/Pagination";

const DISABLED_STYLE = "pointer-events-none opacity-50";

function BookPagination({ totalPages }: { totalPages: number }) {
const [searchParams] = useSearchParams();
const currentPage = parseInt(searchParams.get("page") ?? "1");

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;

const getValidPage = (page: number) => {
if (page < 1) return 1;
if (page > totalPages) return totalPages;
return page;
};

// Copy the current search params and update only the page parameter
const getPageLink = (page: number) => {
const params = new URLSearchParams(searchParams);
params.set("page", getValidPage(page).toString());
return { search: params.toString() };
};

const handlePageChange = () => {
window.scrollTo({ top: 0, behavior: "smooth" });
};

const renderPageLink = (page: number) => (
<PaginationItem key={page}>
<PaginationLink
to={getPageLink(page)}
onClick={handlePageChange}
isActive={page === currentPage}
className="min-w-8 w-auto px-3"
>
{page}
</PaginationLink>
</PaginationItem>
);

return (
<Pagination className="py-3 mb-10">
<PaginationContent>
<PaginationItem className="mr-2">
<PaginationPrevious
to={getPageLink(currentPage - 1)}
onClick={handlePageChange}
aria-disabled={isFirstPage}
tabIndex={isFirstPage ? -1 : undefined} // -1 means the link is skipped when tabbing
className={isFirstPage ? DISABLED_STYLE : undefined}
/>
</PaginationItem>

{renderPageLink(1)}

{showStartEllipsis && (
<PaginationItem>
<PaginationEllipsis />
</PaginationItem>
)}

{middlePages.map((page) => renderPageLink(page))}

{showEndEllipsis && (
<PaginationItem>
<PaginationEllipsis />
</PaginationItem>
)}

{showLastPage && renderPageLink(totalPages)}

<PaginationItem className="ml-2">
<PaginationNext
to={getPageLink(currentPage + 1)}
onClick={handlePageChange}
aria-disabled={isLastPage}
tabIndex={isLastPage ? -1 : undefined} // -1 means the link is skipped when tabbing
className={isLastPage ? DISABLED_STYLE : undefined}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
);
}

export { BookPagination };
17 changes: 13 additions & 4 deletions frontend/app/components/FavoriteButton/FavoriteButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ import { useState } from "react";
import { Link } from "react-router";
import { authClient } from "~/api/auth";
import { useFavoriteStatus } from "~/api/useFavoriteBooks";
import { CheckIcon } from "~/components/icons/CheckIcon";
import { Button } from "~/components/ui/Button";
import { CheckIcon } from "lucide-react";
import {
AlertDialog,
AlertDialogCancel,
Expand All @@ -15,6 +16,7 @@ import {
import { buttonVariants } from "~/components/ui/Button";
import { toast } from "~/components/ui/Toast";
import type { Book } from "~/api/types";
import { cn } from "~/lib/utils";

interface FavoriteButtonProps {
book: Book;
Expand Down Expand Up @@ -51,9 +53,16 @@ function FavoriteButton({ book }: FavoriteButtonProps) {

return (
<>
<button
<Button
type="button"
className={`absolute right-2 top-2 flex items-center justify-center h-8 w-8 cursor-pointer text-2xl rounded-md border ${favorite ? "border-primary text-primary" : ""}`}
variant="outline"
size="icon"
className={cn(
"absolute right-2 top-2 z-10 bg-favorite-button text-xl",
{
isFavorite: "border-primary text-primary",
},
)}
onClick={() => void toggleFavorite()}
disabled={saving}
aria-label={
Expand All @@ -65,7 +74,7 @@ function FavoriteButton({ book }: FavoriteButtonProps) {
aria-pressed={favorite}
>
{favorite ? <CheckIcon /> : "+"}
</button>
</Button>
<AlertDialog open={loginDialogOpen} onOpenChange={setLoginDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
Expand Down
Loading