Merge pull request #12 from Silo-Server/t3code/5155230b

feat(ui): shared PageBack component for consistent back navigation
This commit is contained in:
Quick
2026-05-26 21:29:27 -04:00
committed by GitHub
18 changed files with 282 additions and 183 deletions
+116
View File
@@ -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]");
});
});
+48
View File
@@ -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>
);
}
+20 -40
View File
@@ -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">
+3
View File
@@ -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>
);
+4 -2
View File
@@ -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]">
+4 -2
View File
@@ -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
+12 -19
View File
@@ -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 ? (
+8 -18
View File
@@ -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} />
+9 -24
View File
@@ -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}
+2 -3
View File
@@ -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", () => {
+4 -10
View File
@@ -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 */}
+16 -25
View File
@@ -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>
);
}