diff --git a/frontend/app/components/BookCard/BookCard.tsx b/frontend/app/components/BookCard/BookCard.tsx
index b8533a6..99283c3 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,27 +12,29 @@ function BookCard({ book }: BookCardProps) {
const [searchParams] = useSearchParams();
return (
-
-
-
+
-
- {book.title}
+
+ {book.title}
+
+
+
{book.author.join(", ")}
{book.publishedYear ?? "Unknown"}
-
+
-
+
+
+
);
}
diff --git a/frontend/app/components/BookCover/BookCover.tsx b/frontend/app/components/BookCover/BookCover.tsx
index 50559bc..63bb816 100644
--- a/frontend/app/components/BookCover/BookCover.tsx
+++ b/frontend/app/components/BookCover/BookCover.tsx
@@ -6,11 +6,15 @@ interface BookCoverProps {
function BookCover({ book }: BookCoverProps) {
return book.coverImage ? (
-
+
+
+
+
+
) : (
No cover available
diff --git a/frontend/app/components/BookDescription/BookDescription.tsx b/frontend/app/components/BookDescription/BookDescription.tsx
new file mode 100644
index 0000000..14a26bc
--- /dev/null
+++ b/frontend/app/components/BookDescription/BookDescription.tsx
@@ -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 (
+ <>
+
{text}
+ {isLong && (
+
+ )}
+ >
+ );
+}
+
+export { BookDescription };
diff --git a/frontend/app/components/Pagination/Pagination.test.tsx b/frontend/app/components/BookPagination/BookPagination.test.tsx
similarity index 90%
rename from frontend/app/components/Pagination/Pagination.test.tsx
rename to frontend/app/components/BookPagination/BookPagination.test.tsx
index 52f2a94..2995348 100644
--- a/frontend/app/components/Pagination/Pagination.test.tsx
+++ b/frontend/app/components/BookPagination/BookPagination.test.tsx
@@ -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: () => ,
+ Component: () => ,
},
]);
-describe("Pagination", () => {
+describe("BookPagination", () => {
it("disables Previous on the first page and Next on the last page", () => {
const { unmount } = render();
diff --git a/frontend/app/components/BookPagination/BookPagination.tsx b/frontend/app/components/BookPagination/BookPagination.tsx
new file mode 100644
index 0000000..569195a
--- /dev/null
+++ b/frontend/app/components/BookPagination/BookPagination.tsx
@@ -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) => (
+
+
+ {page}
+
+
+ );
+
+ return (
+
+
+
+
+
+
+ {renderPageLink(1)}
+
+ {showStartEllipsis && (
+
+
+
+ )}
+
+ {middlePages.map((page) => renderPageLink(page))}
+
+ {showEndEllipsis && (
+
+
+
+ )}
+
+ {showLastPage && renderPageLink(totalPages)}
+
+
+
+
+
+
+ );
+}
+
+export { BookPagination };
diff --git a/frontend/app/components/FavoriteButton/FavoriteButton.tsx b/frontend/app/components/FavoriteButton/FavoriteButton.tsx
index 26e9aee..20bc2f8 100644
--- a/frontend/app/components/FavoriteButton/FavoriteButton.tsx
+++ b/frontend/app/components/FavoriteButton/FavoriteButton.tsx
@@ -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,
@@ -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;
@@ -51,9 +53,16 @@ function FavoriteButton({ book }: FavoriteButtonProps) {
return (
<>
-
+
diff --git a/frontend/app/components/FilterBooksSection/FilterBooksSection.tsx b/frontend/app/components/FilterBooksSection/FilterBooksSection.tsx
index e3651fe..7f2fd0b 100644
--- a/frontend/app/components/FilterBooksSection/FilterBooksSection.tsx
+++ b/frontend/app/components/FilterBooksSection/FilterBooksSection.tsx
@@ -1,6 +1,21 @@
import type { BookFilters } from "~/api/types";
import { languageCodes } from "~/api/languageCodes";
-import { CircleQuestionMark } from "~/components/icons/CircleQuestionMark";
+import { CircleQuestionMark } from "lucide-react";
+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";
@@ -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();
@@ -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 (
-
);
}
diff --git a/frontend/app/components/icons/CheckIcon.tsx b/frontend/app/components/icons/CheckIcon.tsx
deleted file mode 100644
index 5a98bc1..0000000
--- a/frontend/app/components/icons/CheckIcon.tsx
+++ /dev/null
@@ -1,22 +0,0 @@
-// Found as a part of Lucide:
-// https://lucide.dev/icons/check
-
-function CheckIcon() {
- return (
-
- );
-}
-
-export { CheckIcon };
diff --git a/frontend/app/components/icons/CircleQuestionMark.tsx b/frontend/app/components/icons/CircleQuestionMark.tsx
deleted file mode 100644
index cc7f3e0..0000000
--- a/frontend/app/components/icons/CircleQuestionMark.tsx
+++ /dev/null
@@ -1,25 +0,0 @@
-// Found as a part of Lucide:
-// https://lucide.dev/icons/circle-question-mark
-
-function CircleQuestionMark() {
- return (
-
- );
-}
-
-export { CircleQuestionMark };
diff --git a/frontend/app/components/icons/FilterIcon.tsx b/frontend/app/components/icons/FilterIcon.tsx
deleted file mode 100644
index 5548a64..0000000
--- a/frontend/app/components/icons/FilterIcon.tsx
+++ /dev/null
@@ -1,22 +0,0 @@
-// Found as a part of Lucide:
-// https://lucide.dev/icons/funnel
-
-function FilterIcon() {
- return (
-
- );
-}
-
-export { FilterIcon };
diff --git a/frontend/app/components/ui/Button.tsx b/frontend/app/components/ui/Button.tsx
index 4212cbb..204c65c 100644
--- a/frontend/app/components/ui/Button.tsx
+++ b/frontend/app/components/ui/Button.tsx
@@ -3,7 +3,7 @@ import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "cn";
const buttonVariants = cva(
- "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
+ "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 not-disabled:cursor-pointer",
{
variants: {
variant: {
@@ -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/components/ui/Pagination.tsx b/frontend/app/components/ui/Pagination.tsx
index b2c7ab1..a3fcda4 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,16 @@ function PaginationLink({
...props
}: PaginationLinkProps) {
return (
-
- }
+
);
}
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,
+}
diff --git a/frontend/app/index.css b/frontend/app/index.css
index d17f6d2..78add90 100644
--- a/frontend/app/index.css
+++ b/frontend/app/index.css
@@ -26,7 +26,7 @@
}
main {
- @apply flex-1;
+ @apply flex-1 min-h-[calc(100svh-4rem)] pt-5;
}
footer {
@@ -37,6 +37,8 @@
@theme inline {
--font-heading: var(--font-sans);
--font-sans: "Inter Variable", sans-serif;
+ --color-hover-surface: var(--hover-surface);
+ --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);
@@ -75,6 +77,7 @@
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
+ --text-md: 0.9375rem;
}
:root {
@@ -110,6 +113,8 @@
--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);
+ --hover-surface: oklch(0.985 0 0);
}
.dark {
@@ -144,4 +149,6 @@
--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.22 0 0);
+ --hover-surface: oklch(0.22 0 0);
}
diff --git a/frontend/app/routes/book/selectedBook.tsx b/frontend/app/routes/book/selectedBook.tsx
index ddc7879..cf9c0a5 100644
--- a/frontend/app/routes/book/selectedBook.tsx
+++ b/frontend/app/routes/book/selectedBook.tsx
@@ -1,10 +1,25 @@
import type { Route } from "./+types/selectedBook";
+import { PageBreadcrumb } from "~/components/PageBreadcrumb/PageBreadcrumb";
import { BookCover } from "~/components/BookCover/BookCover";
import { FavoriteButton } from "~/components/FavoriteButton/FavoriteButton";
+import { BookDescription } from "~/components/BookDescription/BookDescription";
+import { Button } from "~/components/ui/Button";
+import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/Card";
+import { Separator } from "~/components/ui/Separator";
+import {
+ Pagination,
+ PaginationContent,
+ PaginationItem,
+ PaginationNext,
+ PaginationPrevious,
+} from "~/components/ui/Pagination";
import { Link, useSearchParams } from "react-router";
import { useBook } from "~/api/useBook";
import { SEARCH_LIMIT, useSearchBooks } from "~/api/useSearchBooks";
import { useBookSearchParams } from "~/api/useBookSearchParams";
+import { cn } from "~/lib/utils";
+
+const DISABLED_STYLE = "pointer-events-none opacity-50";
export default function SelectedBookPage({ params }: Route.ComponentProps) {
const [searchParams] = useSearchParams();
@@ -44,55 +59,104 @@ export default function SelectedBookPage({ params }: Route.ComponentProps) {
return (
-
-
-
- Go back
-
-
-
-
-
-
- {book.title}
- {book.author.join(", ")}
- {book.publishedYear ?? "Unknown"}
- Description
- {description ?? "No description available"}
+ />
+ }
+ >
+ Go back
+
+
+
+
+
+
+
+
+
+
+
+
+ {book.title}
+
+
+
+ {book.author.join(", ")}
+ {book.publishedYear ?? "Unknown"}
+
+
+ Description
+
+
+
+
+
+
+
+ More info
+
+
+ {/* Add extra details */}
+
+
+
+
+
+
+
+ span]:block",
+ !previousBook && DISABLED_STYLE,
+ )}
+ />
+
+
+ span]:block", !nextBook && DISABLED_STYLE)}
+ />
+
+
+
);
diff --git a/frontend/app/routes/home.tsx b/frontend/app/routes/home.tsx
index 76983a4..56ef996 100644
--- a/frontend/app/routes/home.tsx
+++ b/frontend/app/routes/home.tsx
@@ -1,4 +1,4 @@
-import { Pagination } from "~/components/Pagination/Pagination";
+import { BookPagination } from "~/components/BookPagination/BookPagination";
import { useSearchBooks, SEARCH_LIMIT } from "~/api/useSearchBooks";
import { SearchBar } from "~/components/SearchBar/SearchBar";
import { BookCard } from "~/components/BookCard/BookCard";
@@ -53,7 +53,7 @@ export default function HomePage() {
if (books.length === 0) {
return (
-
+
-
+
{books.map((book) => (
-
@@ -74,7 +74,7 @@ export default function HomePage() {
))}
-
+
);
}