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 ? ( - {`Book +
+ + {`Book + +
) : (

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 ( -
-

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/Footer/Footer.tsx b/frontend/app/components/Footer/Footer.tsx index d71c254..576b1e7 100644 --- a/frontend/app/components/Footer/Footer.tsx +++ b/frontend/app/components/Footer/Footer.tsx @@ -1,7 +1,7 @@ function Footer() { return ( -
    -

    IT2810 Webutvikling - Gruppe 37 - Prosjekt 1

    +
    +

    IT2810 Webutvikling - Gruppe 37 - Prosjekt 2

    2026

    ); diff --git a/frontend/app/components/PageBreadcrumb/PageBreadcrumb.tsx b/frontend/app/components/PageBreadcrumb/PageBreadcrumb.tsx new file mode 100644 index 0000000..82ebe92 --- /dev/null +++ b/frontend/app/components/PageBreadcrumb/PageBreadcrumb.tsx @@ -0,0 +1,44 @@ +import { Fragment } from "react"; +import { Link, type To } from "react-router"; +import { + Breadcrumb, + 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 PageBreadcrumb({ items }: BreadcrumbProps) { + return ( + + + {items.map((item, index) => ( + + {index > 0 && } + + {item.to ? ( + }> + {item.label} + + ) : ( + + {item.label} + + )} + + + ))} + + + ); +} + +export { PageBreadcrumb }; diff --git a/frontend/app/components/Pagination/Pagination.tsx b/frontend/app/components/Pagination/Pagination.tsx deleted file mode 100644 index 814376c..0000000 --- a/frontend/app/components/Pagination/Pagination.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { useSearchParams } from "react-router"; - -function Pagination({ totalPages }: { totalPages: number }) { - const [searchParams, setSearchParams] = useSearchParams(); - const currentPage = parseInt(searchParams.get("page") ?? "1"); - - const handlePageChange = (newPage: number) => { - window.scrollTo({ top: 0, behavior: "smooth" }); - - setSearchParams((prev) => { - prev.set("page", newPage.toString()); - return prev; - }); - }; - - return ( -
    - - - {currentPage} of {totalPages} - - -
    - ); -} - -export { Pagination }; diff --git a/frontend/app/components/SearchBar/SearchBar.tsx b/frontend/app/components/SearchBar/SearchBar.tsx index a4345d9..e1c44b5 100644 --- a/frontend/app/components/SearchBar/SearchBar.tsx +++ b/frontend/app/components/SearchBar/SearchBar.tsx @@ -1,13 +1,31 @@ import { useEffect, useRef, useState } from "react"; -import { FilterIcon } from "~/components/icons/FilterIcon"; +import { FilterIcon } from "lucide-react"; +import { SearchIcon } from "lucide-react"; import { FilterBooksSection } from "~/components/FilterBooksSection/FilterBooksSection"; +import { + InputGroup, + InputGroupAddon, + InputGroupInput, + InputGroupText, +} 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"; const SEARCH_DEBOUNCE_MS = 500; -function SearchBar() { +interface SearchBarProps { + resultCount?: number; +} + +function SearchBar({ resultCount }: SearchBarProps) { const { rawQuery: query, filters } = useBookSearchParams(); const [, setSearchParams] = useSearchParams(); @@ -33,12 +51,14 @@ function SearchBar() { } else { searchParams.delete("q"); } + // A new search starts on page 1 + searchParams.delete("page"); + return searchParams; }); newQueryFromInputRef.current = false; }, [debouncedQuery, inputField, query, setSearchParams]); - const [filtersOpen, setFiltersOpen] = useState(false); const filtersCount = Object.values(filters).filter( (value) => value !== undefined, ).length; @@ -46,13 +66,16 @@ function SearchBar() { const filtersDescription = `${filtersCount} ${filtersCount === 1 ? "filter" : "filters"} active`; return ( - <> -
    - + + + + + + { @@ -60,25 +83,43 @@ function SearchBar() { setInputField(e.target.value); }} /> - -
    - {filtersOpen && } - + + + + + +
); } 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 ( - + +
+ + + + + +
+ + + + {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() {
  • ))}
- +
); }