diff --git a/web/src/components/PageBack.test.tsx b/web/src/components/PageBack.test.tsx new file mode 100644 index 00000000..27077150 --- /dev/null +++ b/web/src/components/PageBack.test.tsx @@ -0,0 +1,73 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { MemoryRouter } from "react-router"; +import { describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + navigate: vi.fn(), +})); + +vi.mock("react-router", async () => { + const actual = await vi.importActual("react-router"); + return { + ...actual, + useNavigate: () => mocks.navigate, + }; +}); + +import PageBack from "./PageBack"; + +describe("PageBack", () => { + it("renders a button with the default 'Go back' aria-label", () => { + render( + + + , + ); + + expect(screen.getByRole("button", { name: "Go back" })).toBeInTheDocument(); + }); + + it("uses a custom label when provided", () => { + render( + + + , + ); + + expect(screen.getByRole("button", { name: "Return to library" })).toBeInTheDocument(); + }); + + it("calls navigate(-1) on click", async () => { + mocks.navigate.mockClear(); + render( + + + , + ); + + await userEvent.click(screen.getByRole("button", { name: "Go back" })); + + expect(mocks.navigate).toHaveBeenCalledTimes(1); + expect(mocks.navigate).toHaveBeenCalledWith(-1); + }); + + it("applies the documented positioning and glass-subtle styling", () => { + render( + + + , + ); + + const button = screen.getByRole("button", { name: "Go back" }); + expect(button).toHaveClass( + "glass-subtle", + "absolute", + "top-4", + "left-4", + "z-20", + "rounded-full", + "p-1.5", + ); + }); +}); diff --git a/web/src/components/PageBack.tsx b/web/src/components/PageBack.tsx new file mode 100644 index 00000000..d6b87565 --- /dev/null +++ b/web/src/components/PageBack.tsx @@ -0,0 +1,20 @@ +import { ChevronLeft } from "lucide-react"; +import { useNavigate } from "react-router"; + +interface PageBackProps { + label?: string; +} + +export default function PageBack({ label = "Go back" }: PageBackProps) { + const navigate = useNavigate(); + return ( + + ); +} diff --git a/web/src/pages/CollectionEditor.tsx b/web/src/pages/CollectionEditor.tsx index df6343f4..6b0b8769 100644 --- a/web/src/pages/CollectionEditor.tsx +++ b/web/src/pages/CollectionEditor.tsx @@ -1,8 +1,7 @@ -import { Link, useNavigate, useParams } from "react-router"; -import { ArrowLeft } from "lucide-react"; +import { useNavigate, useParams } from "react-router"; import type { Collection, UserCollectionType } from "@/api/types"; -import { Button } from "@/components/ui/button"; +import PageBack from "@/components/PageBack"; import { Card, CardHeader, CardDescription, CardTitle } from "@/components/ui/card"; import { useCollections } from "@/hooks/queries/collections"; @@ -32,13 +31,8 @@ export default function CollectionEditor() { if (id && !collection && !isLoading) { return ( -
- +
+ Collection not found @@ -51,21 +45,14 @@ export default function CollectionEditor() { if (collection && isImportedCollection(collection)) { return ( -
-
- -
-

{collection.name}

-

- Edit what's local — name, libraries, sharing. Source-managed details (URL, schedule, - item ordering) are locked. -

-
+
+ +
+

{collection.name}

+

+ Edit what's local — name, libraries, sharing. Source-managed details (URL, schedule, + item ordering) are locked. +

-
- -
-

Edit {collection.name}

-

- Manual collections are curated by adding titles directly. -

-
+
+ +
+

Edit {collection.name}

+

+ Manual collections are curated by adding titles directly. +

navigate("/collections")} />
diff --git a/web/src/pages/ItemDetail/DetailHero.tsx b/web/src/pages/ItemDetail/DetailHero.tsx index 01cc0934..52169f41 100644 --- a/web/src/pages/ItemDetail/DetailHero.tsx +++ b/web/src/pages/ItemDetail/DetailHero.tsx @@ -22,6 +22,7 @@ interface DetailHeroProps { scoreRow?: ReactNode; crewLine?: ReactNode; variant?: "full" | "compact"; + topNav?: ReactNode; } export default function DetailHero({ @@ -45,6 +46,7 @@ export default function DetailHero({ scoreRow, crewLine, variant = "full", + topNav, }: DetailHeroProps) { const [backdropLoaded, setBackdropLoaded] = useState(false); const [posterLoaded, setPosterLoaded] = useState(false); @@ -65,6 +67,7 @@ export default function DetailHero({ return (
+ {topNav} {(backdropUrl || backdropPlaceholder) && (
{ mocks.useRating.mockReturnValue({ data: { rating: 3, rated_at: "2026-03-22T00:00:00Z" } }); }); - it("links the season breadcrumb and back button to the resolved season page", () => { + it("links the season breadcrumb segment to the resolved season page", () => { const markup = renderToStaticMarkup( @@ -233,7 +233,7 @@ describe("EpisodeContent", () => { ); expect(countOccurrences(markup, 'href="/item/series-1"')).toBe(1); - expect(countOccurrences(markup, 'href="/item/season-1"')).toBe(2); + expect(countOccurrences(markup, 'href="/item/season-1"')).toBe(1); expect(markup).toContain(">Season 1<"); }); @@ -258,7 +258,7 @@ describe("EpisodeContent", () => { , ); - expect(countOccurrences(markup, 'href="/item/season-99"')).toBe(2); + expect(countOccurrences(markup, 'href="/item/season-99"')).toBe(1); expect(markup).toContain(">Season 99<"); }); diff --git a/web/src/pages/ItemDetail/EpisodeContent.tsx b/web/src/pages/ItemDetail/EpisodeContent.tsx index aaa380f1..1aa799a9 100644 --- a/web/src/pages/ItemDetail/EpisodeContent.tsx +++ b/web/src/pages/ItemDetail/EpisodeContent.tsx @@ -16,6 +16,7 @@ import CrewList from "@/components/CrewList"; import DownloadVersionPicker from "@/components/DownloadVersionPicker"; import EditMetadataDialog from "@/components/EditMetadataDialog"; import MediaLocations from "@/components/MediaLocations"; +import PageBack from "@/components/PageBack"; import EpisodeCarousel from "./components/EpisodeCarousel"; import DetailHero from "./DetailHero"; import MetadataBadges from "./components/MetadataBadges"; @@ -221,6 +222,7 @@ export default function EpisodeContent({ item }: { item: ItemDetail & { type: "e
} context={
diff --git a/web/src/pages/ItemDetail/MovieContent.tsx b/web/src/pages/ItemDetail/MovieContent.tsx index e78ea0d5..e83acc4d 100644 --- a/web/src/pages/ItemDetail/MovieContent.tsx +++ b/web/src/pages/ItemDetail/MovieContent.tsx @@ -16,6 +16,7 @@ import DownloadVersionPicker from "@/components/DownloadVersionPicker"; import EditMetadataDialog from "@/components/EditMetadataDialog"; import MediaLocations from "@/components/MediaLocations"; import MatchItemDialog from "@/components/MatchItemDialog"; +import PageBack from "@/components/PageBack"; import RecommendationGrid from "@/components/RecommendationGrid"; import DetailHero from "./DetailHero"; import MetadataBadges from "./components/MetadataBadges"; @@ -179,6 +180,7 @@ export default function MovieContent({ item }: { item: ItemDetail & { type: "mov
} context="Movie" studioLabel={firstStudio} backdropUrl={item.backdrop_url} diff --git a/web/src/pages/ItemDetail/SeasonContent.tsx b/web/src/pages/ItemDetail/SeasonContent.tsx index 06910754..6a5c2347 100644 --- a/web/src/pages/ItemDetail/SeasonContent.tsx +++ b/web/src/pages/ItemDetail/SeasonContent.tsx @@ -8,6 +8,7 @@ import { useAuth } from "@/hooks/useAuth"; import CastCarousel from "@/components/CastCarousel"; import CrewList from "@/components/CrewList"; import EditMetadataDialog from "@/components/EditMetadataDialog"; +import PageBack from "@/components/PageBack"; import DetailHero from "./DetailHero"; import MetadataBadges from "./components/MetadataBadges"; import ActionBar from "./components/ActionBar"; @@ -82,6 +83,7 @@ export default function SeasonContent({ item }: { item: ItemDetail & { type: "se } context={breadcrumb} backdropUrl={item.backdrop_url} backdropThumbhash={item.backdrop_thumbhash} diff --git a/web/src/pages/ItemDetail/SeriesContent.tsx b/web/src/pages/ItemDetail/SeriesContent.tsx index de46882a..dbe0683f 100644 --- a/web/src/pages/ItemDetail/SeriesContent.tsx +++ b/web/src/pages/ItemDetail/SeriesContent.tsx @@ -14,6 +14,7 @@ import CastCarousel from "@/components/CastCarousel"; import CrewList from "@/components/CrewList"; import EditMetadataDialog from "@/components/EditMetadataDialog"; import MatchItemDialog from "@/components/MatchItemDialog"; +import PageBack from "@/components/PageBack"; import RecommendationGrid from "@/components/RecommendationGrid"; import DetailHero from "./DetailHero"; import SeasonCarousel from "./SeasonCarousel"; @@ -116,6 +117,7 @@ export default function SeriesContent({ item }: { item: ItemDetail & { type: "se
} context="Series" studioLabel={firstNetwork} backdropUrl={item.backdrop_url} diff --git a/web/src/pages/ItemDetail/components/DetailBreadcrumb.tsx b/web/src/pages/ItemDetail/components/DetailBreadcrumb.tsx index 259c9fc5..95eb7627 100644 --- a/web/src/pages/ItemDetail/components/DetailBreadcrumb.tsx +++ b/web/src/pages/ItemDetail/components/DetailBreadcrumb.tsx @@ -1,5 +1,5 @@ import { Link } from "react-router"; -import { ChevronLeft, ChevronRight } from "lucide-react"; +import { ChevronRight } from "lucide-react"; interface BreadcrumbSegment { label: string; @@ -13,44 +13,32 @@ interface DetailBreadcrumbProps { export default function DetailBreadcrumb({ segments }: DetailBreadcrumbProps) { if (segments.length === 0) return null; - const backSegment = [...segments].reverse().find((segment) => segment.href); - return ( ); diff --git a/web/src/pages/PersonDetail.tsx b/web/src/pages/PersonDetail.tsx index 44905f94..60719075 100644 --- a/web/src/pages/PersonDetail.tsx +++ b/web/src/pages/PersonDetail.tsx @@ -8,6 +8,7 @@ import { createEmptyQueryDefinition } from "@/api/types"; import type { CatalogSearchState } from "@/pages/catalogSearchParams"; import EditPersonDialog from "@/components/EditPersonDialog"; import ItemGrid from "@/components/ItemGrid"; +import PageBack from "@/components/PageBack"; import { Button } from "@/components/ui/button"; import { useCatalogWindow } from "@/hooks/queries/catalog"; import { personKeys } from "@/hooks/queries/keys"; @@ -76,7 +77,8 @@ export default function PersonDetail() { return (
{/* Person Header */} -
+
+
{/* Photo */}
diff --git a/web/src/pages/ProfileCustomizeHome.tsx b/web/src/pages/ProfileCustomizeHome.tsx index 460d01af..2f47694e 100644 --- a/web/src/pages/ProfileCustomizeHome.tsx +++ b/web/src/pages/ProfileCustomizeHome.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from "react"; +import PageBack from "@/components/PageBack"; import ProfileSectionRow from "@/components/ProfileSectionRow"; import RecipeGalleryModal from "@/components/RecipeGallery/RecipeGalleryModal"; import RecipeConfigDrawer from "@/components/RecipeGallery/RecipeConfigDrawer"; @@ -207,7 +208,8 @@ export default function ProfileCustomizeHome() { } return ( -
+
+

Customize home

diff --git a/web/src/pages/RecommendationsSection.tsx b/web/src/pages/RecommendationsSection.tsx index 1f26d3a4..f0fa428d 100644 --- a/web/src/pages/RecommendationsSection.tsx +++ b/web/src/pages/RecommendationsSection.tsx @@ -1,6 +1,7 @@ -import { Link, useParams } from "react-router"; -import { ArrowLeft, RefreshCw, Sparkles } from "lucide-react"; +import { useParams } from "react-router"; +import { RefreshCw, Sparkles } from "lucide-react"; +import PageBack from "@/components/PageBack"; import SectionItemCard from "@/components/SectionItemCard"; import { Skeleton } from "@/components/ui/skeleton"; import { useRecommendationSection } from "@/hooks/queries/recommendations"; @@ -75,23 +76,15 @@ export default function RecommendationsSection() { useDocumentTitle(title); return ( -
-
- - - Recommendations - -
-

{title}

- {data && data.items.length > 0 && ( -

- {data.items.length} {data.items.length === 1 ? "title" : "titles"} -

- )} -
+
+ +
+

{title}

+ {data && data.items.length > 0 && ( +

+ {data.items.length} {data.items.length === 1 ? "title" : "titles"} +

+ )}
{isLoading ? ( diff --git a/web/src/pages/RequestBrowse.tsx b/web/src/pages/RequestBrowse.tsx index a85b967f..448447d8 100644 --- a/web/src/pages/RequestBrowse.tsx +++ b/web/src/pages/RequestBrowse.tsx @@ -1,5 +1,5 @@ -import { Link, useParams, useSearchParams } from "react-router"; -import { ArrowLeft } from "lucide-react"; +import { useParams, useSearchParams } from "react-router"; +import PageBack from "@/components/PageBack"; import RequestPosterCard from "@/components/RequestPosterCard"; import { Button } from "@/components/ui/button"; import { @@ -83,29 +83,19 @@ export default function RequestBrowse({ kind }: RequestBrowseProps) { if (browse.isError && (browse.error as { status?: number }).status === 404) { return ( -
+
+

{kind === "studio" ? "Studio" : kind === "network" ? "Network" : "Genre"} not found.

- - Back to Requests -
); } return ( -
+
+
- - Back to Requests -
diff --git a/web/src/pages/RequestDetail.tsx b/web/src/pages/RequestDetail.tsx index 304dee77..2585cc2a 100644 --- a/web/src/pages/RequestDetail.tsx +++ b/web/src/pages/RequestDetail.tsx @@ -1,7 +1,8 @@ -import { useNavigate, useParams } from "react-router"; -import { ArrowLeft, Check, Clock, Loader2, Plus, Star } from "lucide-react"; +import { useParams } from "react-router"; +import { Check, Clock, Loader2, Plus, Star } from "lucide-react"; import CastCarousel from "@/components/CastCarousel"; import MediaCarousel from "@/components/MediaCarousel"; +import PageBack from "@/components/PageBack"; import RequestPosterCard from "@/components/RequestPosterCard"; import DetailHero from "@/pages/ItemDetail/DetailHero"; import { Button } from "@/components/ui/button"; @@ -23,7 +24,6 @@ import { } from "@/lib/mediaRequests"; export default function RequestDetail() { - const navigate = useNavigate(); const params = useParams<{ mediaType: string; tmdbId: string }>(); const mediaType = (params.mediaType === "series" ? "series" : "movie") as "movie" | "series"; const tmdbID = Number(params.tmdbId) || 0; @@ -39,17 +39,12 @@ export default function RequestDetail() { if (detail.isError || !detail.data) { return ( -
+
+

Couldn't load this title.

The TMDB record may be temporarily unavailable.

-
- -
); } @@ -63,6 +58,7 @@ export default function RequestDetail() {
} context={} studioLabel={studioLabel} backdropUrl={backdropUrl} @@ -79,7 +75,6 @@ export default function RequestDetail() { createRequest.isPending && createRequest.variables?.tmdb_id === item.tmdb_id } onRequest={() => createRequest.mutate(requestInputFromMediaResult(item))} - onBack={() => navigate(-1)} /> } /> @@ -198,12 +193,10 @@ function RequestActions({ item, isSubmitting, onRequest, - onBack, }: { item: RequestMediaDetail; isSubmitting: boolean; onRequest: () => void; - onBack: () => void; }) { const requestable = item.request.requestable; const statusLabel = item.request.status ? formatRequestStatus(item.request.status) : null; @@ -213,16 +206,6 @@ function RequestActions({ return (
- - {requestable ? ( -
-
-

{title}

-

- {step === 1 - ? "Tune the filters until the cards below show the collection you want." - : isEdit - ? "Update naming, artwork, and sharing for this collection." - : "Give your new collection a name, artwork, and sharing rules."} -

-
- +
+
+

{title}

+

+ {step === 1 + ? "Tune the filters until the cards below show the collection you want." + : isEdit + ? "Update naming, artwork, and sharing for this collection." + : "Give your new collection a name, artwork, and sharing rules."} +

+
); }