feat(ui): add shared PageBack component for consistent back navigation
Replace the eight inconsistent back affordances across user-facing pages with a single absolute-positioned chevron pill, so the control lives in the same screen position regardless of title length or hero content. DetailBreadcrumb keeps its textual hierarchy path but no longer owns the back chevron; PageBack does. DetailHero gains a topNav slot consumed by Movie/Series/Season/Episode/Request detail pages. Non-hero pages drop their bespoke back buttons and add PageBack inside a relative wrapper. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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<typeof import("react-router")>("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(
|
||||
<MemoryRouter>
|
||||
<PageBack />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("button", { name: "Go back" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("uses a custom label when provided", () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<PageBack label="Return to library" />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("button", { name: "Return to library" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls navigate(-1) on click", async () => {
|
||||
mocks.navigate.mockClear();
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<PageBack />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
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(
|
||||
<MemoryRouter>
|
||||
<PageBack />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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 (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={label}
|
||||
onClick={() => navigate(-1)}
|
||||
className="glass-subtle text-muted-foreground hover:text-foreground absolute top-4 left-4 z-20 flex items-center justify-center rounded-full p-1.5 transition-colors sm:top-6 sm:left-6"
|
||||
>
|
||||
<ChevronLeft className="size-5" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="page-shell space-y-4 py-4 sm:py-6">
|
||||
<Button asChild variant="ghost" className="w-fit px-0">
|
||||
<Link to="/collections">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Collections
|
||||
</Link>
|
||||
</Button>
|
||||
<div className="page-shell relative space-y-4 py-4 sm:py-6">
|
||||
<PageBack />
|
||||
<Card className="surface-panel rounded-[1.7rem] border-0 shadow-none">
|
||||
<CardHeader>
|
||||
<CardTitle>Collection not found</CardTitle>
|
||||
@@ -51,21 +45,14 @@ export default function CollectionEditor() {
|
||||
|
||||
if (collection && isImportedCollection(collection)) {
|
||||
return (
|
||||
<div className="page-shell space-y-6 py-4 sm:py-6">
|
||||
<div className="space-y-3">
|
||||
<Button asChild variant="ghost" className="w-fit px-0">
|
||||
<Link to="/collections">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Collections
|
||||
</Link>
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="page-title text-[clamp(2rem,4vw,3rem)]">{collection.name}</h1>
|
||||
<p className="page-subtitle mt-1 text-sm sm:text-base">
|
||||
Edit what's local — name, libraries, sharing. Source-managed details (URL, schedule,
|
||||
item ordering) are locked.
|
||||
</p>
|
||||
</div>
|
||||
<div className="page-shell relative space-y-6 py-4 sm:py-6">
|
||||
<PageBack />
|
||||
<div>
|
||||
<h1 className="page-title text-[clamp(2rem,4vw,3rem)]">{collection.name}</h1>
|
||||
<p className="page-subtitle mt-1 text-sm sm:text-base">
|
||||
Edit what's local — name, libraries, sharing. Source-managed details (URL, schedule,
|
||||
item ordering) are locked.
|
||||
</p>
|
||||
</div>
|
||||
<ImportedCollectionEditor
|
||||
key={collection.id}
|
||||
@@ -80,20 +67,13 @@ export default function CollectionEditor() {
|
||||
// the wizard so users can see the live card preview while tuning filters.
|
||||
if (collection && collection.collection_type === "manual") {
|
||||
return (
|
||||
<div className="page-shell space-y-6 py-4 sm:py-6">
|
||||
<div className="space-y-3">
|
||||
<Button asChild variant="ghost" className="w-fit px-0">
|
||||
<Link to="/collections">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Collections
|
||||
</Link>
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="page-title text-[clamp(2rem,4vw,3rem)]">Edit {collection.name}</h1>
|
||||
<p className="page-subtitle mt-1 text-sm sm:text-base">
|
||||
Manual collections are curated by adding titles directly.
|
||||
</p>
|
||||
</div>
|
||||
<div className="page-shell relative space-y-6 py-4 sm:py-6">
|
||||
<PageBack />
|
||||
<div>
|
||||
<h1 className="page-title text-[clamp(2rem,4vw,3rem)]">Edit {collection.name}</h1>
|
||||
<p className="page-subtitle mt-1 text-sm sm:text-base">
|
||||
Manual collections are curated by adding titles directly.
|
||||
</p>
|
||||
</div>
|
||||
<UserCollectionForm collection={collection} onClose={() => navigate("/collections")} />
|
||||
<section className="space-y-3">
|
||||
|
||||
@@ -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 (
|
||||
<section className="item-detail-hero border-border/10 relative isolate overflow-hidden border-b">
|
||||
{topNav}
|
||||
{(backdropUrl || backdropPlaceholder) && (
|
||||
<div
|
||||
className="absolute inset-0 h-full w-full"
|
||||
|
||||
@@ -225,7 +225,7 @@ describe("EpisodeContent", () => {
|
||||
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(
|
||||
<MemoryRouter initialEntries={["/item/episode-1"]}>
|
||||
<EpisodeContent item={makeEpisodeItem()} />
|
||||
@@ -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", () => {
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(countOccurrences(markup, 'href="/item/season-99"')).toBe(2);
|
||||
expect(countOccurrences(markup, 'href="/item/season-99"')).toBe(1);
|
||||
expect(markup).toContain(">Season 99<");
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
<div>
|
||||
<DetailHero
|
||||
title={title}
|
||||
topNav={<PageBack />}
|
||||
context={
|
||||
<div className="space-y-3">
|
||||
<DetailBreadcrumb segments={breadcrumbSegments} />
|
||||
|
||||
@@ -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
|
||||
<div>
|
||||
<DetailHero
|
||||
title={title}
|
||||
topNav={<PageBack />}
|
||||
context="Movie"
|
||||
studioLabel={firstStudio}
|
||||
backdropUrl={item.backdrop_url}
|
||||
|
||||
@@ -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
|
||||
<DetailHero
|
||||
variant="compact"
|
||||
title={displayTitle}
|
||||
topNav={<PageBack />}
|
||||
context={breadcrumb}
|
||||
backdropUrl={item.backdrop_url}
|
||||
backdropThumbhash={item.backdrop_thumbhash}
|
||||
|
||||
@@ -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
|
||||
<div>
|
||||
<DetailHero
|
||||
title={title}
|
||||
topNav={<PageBack />}
|
||||
context="Series"
|
||||
studioLabel={firstNetwork}
|
||||
backdropUrl={item.backdrop_url}
|
||||
|
||||
@@ -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 (
|
||||
<nav aria-label="Breadcrumb">
|
||||
<div className="flex items-center gap-2.5">
|
||||
{backSegment?.href && (
|
||||
<Link
|
||||
to={backSegment.href}
|
||||
className="glass-subtle text-muted-foreground hover:text-foreground flex items-center justify-center rounded-full p-1.5 transition-colors"
|
||||
>
|
||||
<ChevronLeft className="size-5" />
|
||||
</Link>
|
||||
)}
|
||||
<div className="flex items-center gap-1.5">
|
||||
{segments.map((segment, i) => {
|
||||
const isLast = i === segments.length - 1;
|
||||
return (
|
||||
<span key={i} className="flex items-center gap-1.5">
|
||||
{i > 0 && <ChevronRight className="text-muted-foreground/40 size-4" />}
|
||||
{segment.href && !isLast ? (
|
||||
<Link
|
||||
to={segment.href}
|
||||
className="text-muted-foreground hover:text-foreground text-[13px] transition-colors"
|
||||
>
|
||||
{segment.label}
|
||||
</Link>
|
||||
) : (
|
||||
<span
|
||||
className="text-muted-foreground/60 text-[13px]"
|
||||
{...(isLast ? { "aria-current": "page" as const } : {})}
|
||||
>
|
||||
{segment.label}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{segments.map((segment, i) => {
|
||||
const isLast = i === segments.length - 1;
|
||||
return (
|
||||
<span key={i} className="flex items-center gap-1.5">
|
||||
{i > 0 && <ChevronRight className="text-muted-foreground/40 size-4" />}
|
||||
{segment.href && !isLast ? (
|
||||
<Link
|
||||
to={segment.href}
|
||||
className="text-muted-foreground hover:text-foreground text-[13px] transition-colors"
|
||||
>
|
||||
{segment.label}
|
||||
</Link>
|
||||
) : (
|
||||
<span
|
||||
className="text-muted-foreground/60 text-[13px]"
|
||||
{...(isLast ? { "aria-current": "page" as const } : {})}
|
||||
>
|
||||
{segment.label}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
<div>
|
||||
{/* Person Header */}
|
||||
<section className="page-shell pt-8 pb-6 sm:pt-10 sm:pb-8">
|
||||
<section className="page-shell relative pt-8 pb-6 sm:pt-10 sm:pb-8">
|
||||
<PageBack />
|
||||
<div className="flex flex-col gap-6 lg:flex-row lg:gap-8">
|
||||
{/* Photo */}
|
||||
<div className="shrink-0 self-start">
|
||||
|
||||
@@ -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 (
|
||||
<div className="mx-auto max-w-3xl p-6">
|
||||
<div className="relative mx-auto max-w-3xl p-6">
|
||||
<PageBack />
|
||||
<div className="flex items-center justify-between border-b border-white/10 pb-3">
|
||||
<h1 className="text-base font-semibold">Customize home</h1>
|
||||
<div className="flex gap-2">
|
||||
|
||||
@@ -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 (
|
||||
<div className="space-y-6 px-4 pt-6 pb-12 sm:px-6 lg:px-10 xl:px-12">
|
||||
<div className="space-y-3">
|
||||
<Link
|
||||
to="/recommendations"
|
||||
className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1.5 text-xs font-medium tracking-[0.16em] uppercase transition-colors"
|
||||
>
|
||||
<ArrowLeft className="h-3.5 w-3.5" />
|
||||
Recommendations
|
||||
</Link>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<h1 className="text-foreground text-2xl font-bold tracking-tight sm:text-3xl">{title}</h1>
|
||||
{data && data.items.length > 0 && (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{data.items.length} {data.items.length === 1 ? "title" : "titles"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="relative space-y-6 px-4 pt-6 pb-12 sm:px-6 lg:px-10 xl:px-12">
|
||||
<PageBack />
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<h1 className="text-foreground text-2xl font-bold tracking-tight sm:text-3xl">{title}</h1>
|
||||
{data && data.items.length > 0 && (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{data.items.length} {data.items.length === 1 ? "title" : "titles"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
|
||||
@@ -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 (
|
||||
<div className="space-y-4 py-10 text-center">
|
||||
<div className="relative space-y-4 py-10 text-center">
|
||||
<PageBack />
|
||||
<p className="text-foreground text-lg font-semibold">
|
||||
{kind === "studio" ? "Studio" : kind === "network" ? "Network" : "Genre"} not found.
|
||||
</p>
|
||||
<Link
|
||||
to="/requests"
|
||||
className="text-muted-foreground hover:text-foreground text-sm underline"
|
||||
>
|
||||
Back to Requests
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 py-6 sm:py-8">
|
||||
<div className="relative space-y-6 py-6 sm:py-8">
|
||||
<PageBack />
|
||||
<div className="space-y-4 px-4 sm:px-6 lg:px-10 xl:px-12">
|
||||
<Link
|
||||
to="/requests"
|
||||
className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1 text-sm"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" /> Back to Requests
|
||||
</Link>
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div className="flex min-w-0 items-center gap-4">
|
||||
<BrowseHeaderTile browse={browse.data} kind={kind} fallback={title} />
|
||||
|
||||
@@ -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 (
|
||||
<div className="page-shell space-y-3 py-12 text-center">
|
||||
<div className="page-shell relative space-y-3 py-12 text-center">
|
||||
<PageBack />
|
||||
<p className="text-foreground text-base font-semibold">Couldn't load this title.</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
The TMDB record may be temporarily unavailable.
|
||||
</p>
|
||||
<div className="pt-4">
|
||||
<Button variant="outline" size="sm" onClick={() => navigate(-1)}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -63,6 +58,7 @@ export default function RequestDetail() {
|
||||
<div>
|
||||
<DetailHero
|
||||
title={item.title}
|
||||
topNav={<PageBack />}
|
||||
context={<RequestContext mediaType={mediaType} />}
|
||||
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 (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onBack}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back
|
||||
</Button>
|
||||
|
||||
{requestable ? (
|
||||
<Button
|
||||
onClick={onRequest}
|
||||
|
||||
@@ -20,15 +20,14 @@ describe("SettingsLayout", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("includes a back link to the main app shell", () => {
|
||||
it("includes a PageBack control at the top of the page", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<MemoryRouter initialEntries={["/settings/playback"]}>
|
||||
<SettingsLayout />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(markup).toContain('href="/"');
|
||||
expect(markup).toContain(">Back<");
|
||||
expect(markup).toContain('aria-label="Go back"');
|
||||
});
|
||||
|
||||
it("does not include a plugins section in personal settings", () => {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Link, Outlet, useLocation } from "react-router";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Play,
|
||||
Library,
|
||||
Clock,
|
||||
@@ -16,6 +15,7 @@ import {
|
||||
} from "lucide-react";
|
||||
// Sparkles is used by the Personalization nav entry below.
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import PageBack from "@/components/PageBack";
|
||||
import { useDocumentTitle } from "@/hooks/useDocumentTitle";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { resolveSettingsDocumentTitle } from "@/lib/documentTitle";
|
||||
@@ -155,7 +155,8 @@ export default function SettingsLayout() {
|
||||
|
||||
return (
|
||||
<div className="min-h-[100dvh]">
|
||||
<main className="page-shell-wide flex min-h-[100dvh] flex-col py-4 sm:py-6">
|
||||
<main className="page-shell-wide relative flex min-h-[100dvh] flex-col py-4 sm:py-6">
|
||||
<PageBack />
|
||||
<div className="page-header gap-5">
|
||||
<div className="min-w-0 space-y-3">
|
||||
<h1 className="page-title text-[clamp(2rem,4vw,3rem)]">Settings</h1>
|
||||
@@ -163,13 +164,6 @@ export default function SettingsLayout() {
|
||||
Manage your playback preferences, libraries, and display options.
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
to="/"
|
||||
className="surface-panel-subtle text-muted-foreground hover:text-foreground inline-flex shrink-0 items-center gap-2 rounded-[1.1rem] px-3 py-2 text-sm font-medium transition-colors"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Back</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Mobile: horizontal scrolling tab bar */}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { ArrowLeft, ArrowRight, Check } from "lucide-react";
|
||||
|
||||
import type {
|
||||
@@ -15,6 +14,7 @@ import { normalizeQueryDefinition } from "@/api/types";
|
||||
import CatalogFiltersPanel from "@/components/catalog/CatalogFiltersPanel";
|
||||
import { ImageUploadField } from "@/components/ImageUploadField";
|
||||
import ItemGrid from "@/components/ItemGrid";
|
||||
import PageBack from "@/components/PageBack";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -118,12 +118,12 @@ export default function SmartCollectionWizard(wizard: SmartCollectionWizardProps
|
||||
}, []);
|
||||
|
||||
const headerTitle = isEdit ? draft.title || "Edit Collection" : "New Collection";
|
||||
const backHref = wizard.mode === "user" ? "/collections" : adminBackHref(wizard);
|
||||
const adminLibraries = wizard.mode === "admin" ? wizard.libraries : [];
|
||||
|
||||
return (
|
||||
<div className="page-shell-wide space-y-6 py-4 sm:py-6">
|
||||
<WizardHeader backHref={backHref} title={headerTitle} step={step} isEdit={isEdit} />
|
||||
<div className="page-shell-wide relative space-y-6 py-4 sm:py-6">
|
||||
<PageBack />
|
||||
<WizardHeader title={headerTitle} step={step} isEdit={isEdit} />
|
||||
|
||||
{step === 1 ? (
|
||||
<Step1FiltersAndPreview
|
||||
@@ -171,44 +171,28 @@ function smartUserDraft(collection: Collection | null): CollectionBuilderValue {
|
||||
: createCollectionBuilderValue({ ...value, collection_type: "smart" });
|
||||
}
|
||||
|
||||
function adminBackHref(props: AdminModeProps): string {
|
||||
return props.initialLibraryId
|
||||
? `/admin/collections?libraryId=${props.initialLibraryId}`
|
||||
: "/admin/collections";
|
||||
}
|
||||
|
||||
function WizardHeader({
|
||||
backHref,
|
||||
title,
|
||||
step,
|
||||
isEdit,
|
||||
}: {
|
||||
backHref: string;
|
||||
title: string;
|
||||
step: WizardStep;
|
||||
isEdit: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<Button asChild variant="ghost" className="w-fit px-0">
|
||||
<Link to={backHref}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Collections
|
||||
</Link>
|
||||
</Button>
|
||||
<div className="flex flex-wrap items-end justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="page-title text-[clamp(1.75rem,3vw,2.5rem)]">{title}</h1>
|
||||
<p className="page-subtitle mt-1 text-sm">
|
||||
{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."}
|
||||
</p>
|
||||
</div>
|
||||
<StepIndicator step={step} />
|
||||
<div className="flex flex-wrap items-end justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="page-title text-[clamp(1.75rem,3vw,2.5rem)]">{title}</h1>
|
||||
<p className="page-subtitle mt-1 text-sm">
|
||||
{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."}
|
||||
</p>
|
||||
</div>
|
||||
<StepIndicator step={step} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user