Skip to content
Merged
Show file tree
Hide file tree
Changes from 21 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
25 changes: 15 additions & 10 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,31 @@ 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} />
<figure className="transition-transform duration-200 group-hover:scale-[1.01]">
<BookCover book={book} />
</figure>

<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
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 };
44 changes: 44 additions & 0 deletions frontend/app/components/Breadcrumb/Breadcrumb.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { Fragment } from "react";
import { Link, type To } from "react-router";
import {
Breadcrumb as BreadcrumbRoot,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
} from "~/components/ui/Breadcrumb";

interface BreadcrumbProps {
items: {
label: string;
to?: To; // No "to" means that this is the current page, shown as plain text
}[];
}

function Breadcrumb({ items }: BreadcrumbProps) {
return (
<BreadcrumbRoot className="mx-auto w-full max-w-300 px-10 pt-6">
<BreadcrumbList>
{items.map((item, index) => (
<Fragment key={item.label}>
{index > 0 && <BreadcrumbSeparator />}
<BreadcrumbItem>
{item.to ? (
<BreadcrumbLink render={<Link to={item.to} />}>
{item.label}
</BreadcrumbLink>
) : (
<BreadcrumbPage className="max-w-60 truncate">
{item.label}
</BreadcrumbPage>
)}
</BreadcrumbItem>
</Fragment>
))}
</BreadcrumbList>
</BreadcrumbRoot>
);
}

export { Breadcrumb };
17 changes: 11 additions & 6 deletions frontend/app/components/FavoriteButton/FavoriteButton.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -27,21 +28,25 @@ function FavoriteButton({ book }: FavoriteButtonProps) {
setFavorites(savedFavorites);
};

const isFavorite = favorites.includes(book.id);

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 ${favorites.includes(book.id) ? "border-primary text-primary" : ""}`}
variant="outline"
size="icon"
className={`absolute right-2 top-2 z-10 bg-favorite-button text-xl ${isFavorite ? "border-primary text-primary" : ""}`}
onClick={saveFavorite}
aria-label={
// Toggle the accessibility label based on the current state for screen readers
favorites.includes(book.id)
isFavorite
? `Remove ${book.title} from favorites`
: `Add ${book.title} to favorites`
}
aria-pressed={favorites.includes(book.id)}
aria-pressed={isFavorite}
>
{favorites.includes(book.id) ? <CheckIcon /> : "+"}
</button>
{isFavorite ? <CheckIcon /> : "+"}
</Button>
);
}

Expand Down
134 changes: 103 additions & 31 deletions frontend/app/components/FilterBooksSection/FilterBooksSection.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,21 @@
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 { Button } from "~/components/ui/Button";
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";
Expand Down Expand Up @@ -38,6 +53,19 @@ const FILTER_INPUTS: {
},
];

const FILTER_KEYS = [...FILTER_INPUTS.map((input) => input.name), "language"];

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();
Expand Down Expand Up @@ -69,18 +97,46 @@ function FilterBooksSection() {
newFiltersFromInputRef.current = false;
}, [debouncedFilters, rawFilters, setSearchParams]);

const hasActiveFilters = Object.values(rawFilters).some(Boolean);

const clearFilters = () => {
// Cancel any filter change that are currently being typed but not yet debounced
newFiltersFromInputRef.current = false;

setSearchParams((searchParams) => {
FILTER_KEYS.forEach((key) => searchParams.delete(key));

// Reset the page to 1 when clearing filters. A missing "page" means page 1
searchParams.delete("page");

return searchParams;
});
};

return (
<section className="flex flex-col items-center mb-8">
<h2 className="my-4 text-2xl font-bold">Filter Books</h2>
<ul className="grid grid-cols-2 gap-4 max-[900px]:grid-cols-1">
<section className="flex flex-col gap-4">
<header className="flex items-center justify-between">
<h2 className="text-lg font-bold">Filter Books</h2>
<Button
type="button"
variant="link"
onClick={clearFilters}
disabled={!hasActiveFilters}
className="text-foreground hover:text-foreground/85"
>
Reset
</Button>
</header>

<ul className="flex flex-col gap-4">
{FILTER_INPUTS.map((input) => (
<li key={input.name} className="flex flex-col gap-2">
<label htmlFor={input.name} className="flex items-center gap-2">
<Label htmlFor={input.name}>
{input.name.charAt(0).toUpperCase() + input.name.slice(1)}
</label>
<input
</Label>
<Input
id={input.name}
type="text"
className="bg-white rounded-md border p-2 text-base h-full hover:border-primary"
name={input.name}
aria-label={input.ariaLabel}
placeholder={input.placeholder}
Expand All @@ -92,34 +148,50 @@ function FilterBooksSection() {
/>
</li>
))}

<li className="flex flex-col gap-2">
<label htmlFor="language" className="flex items-center gap-2">
Language
<span
className="flex items-center"
title="Only show books that have an edition in the selected language"
role="tooltip"
>
<CircleQuestionMark />
</span>
</label>
<select
className="bg-white rounded-md border p-2 text-base h-full hover:border-primary"
name="language"
aria-label="Search for language"
value={filters.language ?? ""}
onChange={(e) => {
<span className="flex items-center gap-2">
<Label htmlFor="language">Language</Label>
<Tooltip>
<TooltipTrigger
render={<button type="button" aria-label="More information" />}
>
<CircleQuestionMark aria-hidden="true" />
</TooltipTrigger>
<TooltipContent>
Only show books that have an edition in the selected language
</TooltipContent>
</Tooltip>
</span>

<Select
items={LANGUAGE_ITEMS}
value={
filters.language === "" ? "none" : (filters.language ?? "none")
}
onValueChange={(value) => {
newFiltersFromInputRef.current = true;
setFilters({ ...filters, language: e.target.value });
setFilters({
...filters,
language: value === "none" ? undefined : (value ?? undefined),
});
}}
>
<option value="">Select a language</option>
{languageCodes.map((lang) => (
<option key={lang.code} value={lang.code}>
{lang.name}
</option>
))}
</select>
<SelectTrigger
id="language"
aria-label="Search for language"
className="w-full"
>
<SelectValue placeholder="Select a language" />
</SelectTrigger>
<SelectContent>
{LANGUAGE_ITEMS.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
</li>
</ul>
</section>
Expand Down
4 changes: 2 additions & 2 deletions frontend/app/components/Footer/Footer.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
function Footer() {
return (
<footer className="flex items-center justify-between bg-zinc-800 text-white px-10 py-8 text-sm max-[800px]:flex-col max-[800px]:justify-center max-[800px]:gap-1">
<p>IT2810 Webutvikling - Gruppe 37 - Prosjekt 1</p>
<footer className="flex items-center justify-between bg-zinc-800 text-white px-10 py-16 text-sm max-[800px]:flex-col max-[800px]:justify-center max-[800px]:gap-1">
<p>IT2810 Webutvikling - Gruppe 37 - Prosjekt 2</p>
<p>2026</p>
</footer>
);
Expand Down
Loading