Merge pull request #12 from Silo-Server/t3code/5155230b
feat(ui): shared PageBack component for consistent back navigation
This commit is contained in:
@@ -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<typeof import("react-router")>("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(
|
||||
<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("falls back to the default route when there is no router history", async () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<PageBack />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
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(
|
||||
<MemoryRouter>
|
||||
<PageBack />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
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(
|
||||
<MemoryRouter>
|
||||
<PageBack to="/collections" preferHistory={false} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
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(
|
||||
<MemoryRouter>
|
||||
<PageBack />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
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(
|
||||
<MemoryRouter>
|
||||
<PageBack floating />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
const button = screen.getByRole("button", { name: "Go back" });
|
||||
expect(button).toHaveClass("lg:fixed", "lg:left-[268px]");
|
||||
});
|
||||
});
|
||||
@@ -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 (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={label}
|
||||
onClick={goBack}
|
||||
className={`glass text-foreground hover:bg-accent ${position} z-20 flex items-center justify-center rounded-full p-1.5 shadow-md transition-colors`}
|
||||
>
|
||||
<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,14 +31,9 @@ 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>
|
||||
<Card className="surface-panel rounded-[1.7rem] border-0 shadow-none">
|
||||
<div className="page-shell relative space-y-4 py-4 sm:py-6">
|
||||
<PageBack to="/collections" preferHistory={false} />
|
||||
<Card className="surface-panel mt-10 rounded-[1.7rem] border-0 shadow-none sm:mt-12">
|
||||
<CardHeader>
|
||||
<CardTitle>Collection not found</CardTitle>
|
||||
<CardDescription>The selected collection could not be loaded.</CardDescription>
|
||||
@@ -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 to="/collections" preferHistory={false} />
|
||||
<div className="mt-10 sm:mt-12">
|
||||
<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 to="/collections" preferHistory={false} />
|
||||
<div className="mt-10 sm:mt-12">
|
||||
<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";
|
||||
@@ -223,6 +224,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";
|
||||
@@ -181,6 +182,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";
|
||||
@@ -84,6 +85,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";
|
||||
@@ -118,6 +119,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,8 +77,9 @@ export default function PersonDetail() {
|
||||
return (
|
||||
<div>
|
||||
{/* Person Header */}
|
||||
<section className="page-shell pt-8 pb-6 sm:pt-10 sm:pb-8">
|
||||
<div className="flex flex-col gap-6 lg:flex-row lg:gap-8">
|
||||
<section className="page-shell relative pt-8 pb-6 sm:pt-10 sm:pb-8">
|
||||
<PageBack />
|
||||
<div className="mt-10 flex flex-col gap-6 sm:mt-12 lg:flex-row lg:gap-8">
|
||||
{/* Photo */}
|
||||
<div className="shrink-0 self-start">
|
||||
<div className="media-card-image aspect-[2/3] w-[140px] overflow-hidden rounded-lg sm:w-[180px]">
|
||||
|
||||
@@ -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 (
|
||||
<div className="mx-auto max-w-3xl p-6">
|
||||
<div className="flex items-center justify-between border-b border-white/10 pb-3">
|
||||
<div className="relative mx-auto max-w-3xl p-6">
|
||||
<PageBack />
|
||||
<div className="mt-10 flex items-center justify-between border-b border-white/10 pb-3 sm:mt-12">
|
||||
<h1 className="text-base font-semibold">Customize home</h1>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
|
||||
@@ -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 to="/recommendations" preferHistory={false} />
|
||||
<div className="mt-10 flex flex-col gap-1.5 sm:mt-12">
|
||||
<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">
|
||||
<p className="text-foreground text-lg font-semibold">
|
||||
<div className="relative space-y-4 py-10 text-center">
|
||||
<PageBack to="/requests" preferHistory={false} />
|
||||
<p className="text-foreground mt-10 text-lg font-semibold sm:mt-12">
|
||||
{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="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="relative space-y-6 py-6 sm:py-8">
|
||||
<PageBack to="/requests" preferHistory={false} />
|
||||
<div className="mt-10 space-y-4 px-4 sm:mt-12 sm:px-6 lg:px-10 xl:px-12">
|
||||
<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,14 @@ export default function RequestDetail() {
|
||||
|
||||
if (detail.isError || !detail.data) {
|
||||
return (
|
||||
<div className="page-shell space-y-3 py-12 text-center">
|
||||
<p className="text-foreground text-base font-semibold">Couldn't load this title.</p>
|
||||
<div className="page-shell relative space-y-3 py-12 text-center">
|
||||
<PageBack to="/requests" />
|
||||
<p className="text-foreground mt-10 text-base font-semibold sm:mt-12">
|
||||
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 +60,7 @@ export default function RequestDetail() {
|
||||
<div>
|
||||
<DetailHero
|
||||
title={item.title}
|
||||
topNav={<PageBack to="/requests" />}
|
||||
context={<RequestContext mediaType={mediaType} />}
|
||||
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 (
|
||||
<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,21 +155,15 @@ 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">
|
||||
<div className="page-header gap-5">
|
||||
<main className="page-shell-wide relative flex min-h-[100dvh] flex-col py-4 sm:py-6">
|
||||
<PageBack to="/" preferHistory={false} floating />
|
||||
<div className="page-header mt-10 gap-5 sm:mt-12">
|
||||
<div className="min-w-0 space-y-3">
|
||||
<h1 className="page-title text-[clamp(2rem,4vw,3rem)]">Settings</h1>
|
||||
<p className="page-subtitle text-sm sm:text-base">
|
||||
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,13 @@ export default function SmartCollectionWizard(wizard: SmartCollectionWizardProps
|
||||
}, []);
|
||||
|
||||
const headerTitle = isEdit ? draft.title || "Edit Collection" : "New Collection";
|
||||
const backHref = wizard.mode === "user" ? "/collections" : adminBackHref(wizard);
|
||||
const backTarget = 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 to={backTarget} preferHistory={false} />
|
||||
<WizardHeader title={headerTitle} step={step} isEdit={isEdit} />
|
||||
|
||||
{step === 1 ? (
|
||||
<Step1FiltersAndPreview
|
||||
@@ -178,37 +179,27 @@ function adminBackHref(props: AdminModeProps): string {
|
||||
}
|
||||
|
||||
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="mt-10 flex flex-wrap items-end justify-between gap-4 sm:mt-12">
|
||||
<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