diff --git a/web/src/components/PageBack.test.tsx b/web/src/components/PageBack.test.tsx new file mode 100644 index 00000000..c6c8108c --- /dev/null +++ b/web/src/components/PageBack.test.tsx @@ -0,0 +1,116 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { MemoryRouter } from "react-router"; +import { afterEach, 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", () => { + afterEach(() => { + mocks.navigate.mockClear(); + window.history.replaceState(null, ""); + }); + + 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("falls back to the default route when there is no router history", async () => { + render( + + + , + ); + + await userEvent.click(screen.getByRole("button", { name: "Go back" })); + + expect(mocks.navigate).toHaveBeenCalledTimes(1); + expect(mocks.navigate).toHaveBeenCalledWith("/"); + }); + + it("uses browser history when a router history entry is available", async () => { + window.history.replaceState({ idx: 1 }, ""); + render( + + + , + ); + + await userEvent.click(screen.getByRole("button", { name: "Go back" })); + + expect(mocks.navigate).toHaveBeenCalledTimes(1); + expect(mocks.navigate).toHaveBeenCalledWith(-1); + }); + + it("uses the explicit target when history preference is disabled", async () => { + window.history.replaceState({ idx: 1 }, ""); + render( + + + , + ); + + await userEvent.click(screen.getByRole("button", { name: "Go back" })); + + expect(mocks.navigate).toHaveBeenCalledTimes(1); + expect(mocks.navigate).toHaveBeenCalledWith("/collections"); + }); + + it("applies the documented positioning and glass styling", () => { + render( + + + , + ); + + const button = screen.getByRole("button", { name: "Go back" }); + expect(button).toHaveClass( + "glass", + "absolute", + "top-4", + "left-2", + "z-20", + "rounded-full", + "p-1.5", + ); + }); + + it("pins to the viewport on lg+ when floating is set", () => { + render( + + + , + ); + + const button = screen.getByRole("button", { name: "Go back" }); + expect(button).toHaveClass("lg:fixed", "lg:left-[268px]"); + }); +}); diff --git a/web/src/components/PageBack.tsx b/web/src/components/PageBack.tsx new file mode 100644 index 00000000..a2814fd3 --- /dev/null +++ b/web/src/components/PageBack.tsx @@ -0,0 +1,48 @@ +import { ChevronLeft } from "lucide-react"; +import { type To, useNavigate } from "react-router"; + +interface PageBackProps { + label?: string; + to?: To; + preferHistory?: boolean; + /** + * When true, pins the button to the viewport on lg+ so it stays visible + * while scrolling. The offset matches the app sidebar (260px) so the + * button sits just inside the page content area. + */ + floating?: boolean; +} + +export default function PageBack({ + label = "Go back", + to = "/", + preferHistory = true, + floating = false, +}: PageBackProps) { + const navigate = useNavigate(); + const position = floating + ? "absolute top-4 left-2 sm:top-6 lg:fixed lg:left-[268px]" + : "absolute top-4 left-2 sm:top-6"; + + function goBack() { + const historyIndex = window.history.state?.idx; + + if (preferHistory && typeof historyIndex === "number" && historyIndex > 0) { + navigate(-1); + return; + } + + navigate(to); + } + + return ( + + ); +} diff --git a/web/src/pages/CollectionEditor.tsx b/web/src/pages/CollectionEditor.tsx index df6343f4..b35194fc 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,14 +31,9 @@ export default function CollectionEditor() { if (id && !collection && !isLoading) { return ( -
- - +
+ + Collection not found The selected collection could not be loaded. @@ -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 a408bb64..b4aeed55 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"; @@ -223,6 +224,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 f8a63430..b7494b41 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"; @@ -181,6 +182,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 e8830845..f53892a2 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"; @@ -84,6 +85,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 638acc45..bfd52db5 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"; @@ -118,6 +119,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..b0d583a8 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,8 +77,9 @@ export default function PersonDetail() { return (
{/* Person Header */} -
-
+
+ +
{/* Photo */}
diff --git a/web/src/pages/ProfileCustomizeHome.tsx b/web/src/pages/ProfileCustomizeHome.tsx index 460d01af..cd39182b 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,8 +208,9 @@ export default function ProfileCustomizeHome() { } return ( -
-
+
+ +

Customize home

-
); } @@ -63,6 +60,7 @@ export default function RequestDetail() {
} context={} studioLabel={studioLabel} backdropUrl={backdropUrl} @@ -79,7 +77,6 @@ export default function RequestDetail() { createRequest.isPending && createRequest.variables?.tmdb_id === item.tmdb_id } onRequest={() => createRequest.mutate(requestInputFromMediaResult(item))} - onBack={() => navigate(-1)} /> } /> @@ -198,12 +195,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 +208,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."} +

+
); }