feat(web): improve desktop user settings layout (#615)
- Reorganize settings into task-focused groups with uniform cards - Move search into the page header and refine desktop detail navigation - Remove redundant category jump links
This commit is contained in:
@@ -1714,6 +1714,19 @@
|
||||
box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.04);
|
||||
}
|
||||
|
||||
/* Panel chrome only once there is a sidebar to enclose. Below this the
|
||||
settings detail view is already a stack of surface-panel groups, and a
|
||||
panel around them would nest a card in a card. */
|
||||
@media (min-width: 1024px) {
|
||||
.surface-panel-lg {
|
||||
background: color-mix(in srgb, var(--surface) 90%, transparent);
|
||||
border-radius: calc(var(--radius-2xl) + 0.35rem);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgb(255 255 255 / 0.04),
|
||||
0 24px 50px -34px rgb(0 0 0 / 0.65);
|
||||
}
|
||||
}
|
||||
|
||||
.caption-empty-state span {
|
||||
display: block;
|
||||
height: 0.35rem;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useState } from "react";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { Link } from "react-router";
|
||||
@@ -35,14 +34,7 @@ export function SettingsOverviewNav({
|
||||
idPrefix,
|
||||
variant = "grouped-list",
|
||||
}: SettingsOverviewNavProps) {
|
||||
const firstGroupId = groups[0] ? groupSectionId(idPrefix, groups[0].label) : "";
|
||||
const [activeGroupId, setActiveGroupId] = useState(firstGroupId);
|
||||
const isDirectory = variant === "directory";
|
||||
const resolvedActiveGroupId = groups.some(
|
||||
(group) => groupSectionId(idPrefix, group.label) === activeGroupId,
|
||||
)
|
||||
? activeGroupId
|
||||
: firstGroupId;
|
||||
|
||||
if (groups.length === 0) {
|
||||
return (
|
||||
@@ -55,136 +47,96 @@ export function SettingsOverviewNav({
|
||||
);
|
||||
}
|
||||
|
||||
// No category jump bar: it listed the same group names, with the same
|
||||
// counts, directly above the headings that repeat them — and on an index
|
||||
// this short it saved about one scroll flick.
|
||||
return (
|
||||
<div className={cn(isDirectory && "lg:space-y-8")}>
|
||||
{isDirectory ? (
|
||||
<nav
|
||||
aria-label={`${ariaLabel} categories`}
|
||||
className="surface-panel-subtle hidden max-w-2xl overflow-hidden rounded-xl border lg:flex"
|
||||
>
|
||||
{groups.map((group) => {
|
||||
const sectionId = groupSectionId(idPrefix, group.label);
|
||||
const isActive = sectionId === resolvedActiveGroupId;
|
||||
<nav
|
||||
aria-label={ariaLabel}
|
||||
className={cn(
|
||||
"grid items-start gap-6 lg:grid-cols-2",
|
||||
isDirectory && "block space-y-6 lg:space-y-10",
|
||||
)}
|
||||
>
|
||||
{groups.map((group) => {
|
||||
const sectionId = groupSectionId(idPrefix, group.label);
|
||||
|
||||
return (
|
||||
<a
|
||||
key={group.label}
|
||||
href={`#${sectionId}`}
|
||||
aria-label={`${group.label}, ${group.items.length} settings sections`}
|
||||
aria-current={isActive ? "location" : undefined}
|
||||
onClick={() => setActiveGroupId(sectionId)}
|
||||
className={cn(
|
||||
"text-muted-foreground hover:bg-surface-hover/60 hover:text-foreground focus-visible:ring-ring/60 relative flex min-h-11 flex-1 items-center justify-center gap-2 px-4 text-sm font-medium transition-colors focus-visible:z-10 focus-visible:ring-[3px] focus-visible:outline-none focus-visible:ring-inset",
|
||||
isActive &&
|
||||
"text-foreground after:bg-primary after:absolute after:inset-x-4 after:bottom-0 after:h-0.5 after:rounded-full",
|
||||
)}
|
||||
>
|
||||
<span>{group.label}</span>
|
||||
<span className="text-muted-foreground text-xs tabular-nums">
|
||||
{group.items.length}
|
||||
</span>
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
) : null}
|
||||
|
||||
<nav
|
||||
aria-label={ariaLabel}
|
||||
className={cn(
|
||||
"grid items-start gap-6 lg:grid-cols-2",
|
||||
isDirectory && "block space-y-6 lg:space-y-10",
|
||||
)}
|
||||
>
|
||||
{groups.map((group) => {
|
||||
const sectionId = groupSectionId(idPrefix, group.label);
|
||||
|
||||
return (
|
||||
<section
|
||||
key={group.label}
|
||||
aria-labelledby={sectionId}
|
||||
className={cn(isDirectory && "scroll-mt-6")}
|
||||
return (
|
||||
<section key={group.label} aria-labelledby={sectionId}>
|
||||
<h2
|
||||
id={sectionId}
|
||||
className={cn(
|
||||
"text-muted-foreground mb-2 px-1 text-[11px] font-semibold tracking-[0.16em] uppercase",
|
||||
isDirectory &&
|
||||
"lg:text-foreground lg:mb-3 lg:px-0 lg:text-base lg:font-semibold lg:tracking-tight lg:normal-case",
|
||||
)}
|
||||
>
|
||||
<h2
|
||||
id={sectionId}
|
||||
className={cn(
|
||||
"text-muted-foreground mb-2 px-1 text-[11px] font-semibold tracking-[0.16em] uppercase",
|
||||
isDirectory &&
|
||||
"lg:text-foreground lg:mb-3 lg:px-0 lg:text-base lg:font-semibold lg:tracking-tight lg:normal-case",
|
||||
)}
|
||||
>
|
||||
{group.label}
|
||||
{isDirectory ? (
|
||||
<span className="text-muted-foreground ml-1.5 hidden font-medium lg:inline">
|
||||
({group.items.length})
|
||||
</span>
|
||||
) : null}
|
||||
</h2>
|
||||
<ul
|
||||
className={cn(
|
||||
"surface-panel overflow-hidden rounded-2xl border-0 shadow-none",
|
||||
isDirectory &&
|
||||
"lg:grid lg:grid-cols-2 lg:gap-3 lg:overflow-visible lg:rounded-none lg:bg-transparent 2xl:grid-cols-4",
|
||||
)}
|
||||
>
|
||||
{group.items.map((item) => {
|
||||
const Icon = item.icon;
|
||||
{group.label}
|
||||
</h2>
|
||||
<ul
|
||||
className={cn(
|
||||
"surface-panel overflow-hidden rounded-2xl border-0 shadow-none",
|
||||
isDirectory &&
|
||||
"lg:grid lg:grid-cols-2 lg:gap-3 lg:overflow-visible lg:rounded-none lg:bg-transparent 2xl:grid-cols-4",
|
||||
)}
|
||||
>
|
||||
{group.items.map((item) => {
|
||||
const Icon = item.icon;
|
||||
|
||||
return (
|
||||
<li
|
||||
key={item.id}
|
||||
return (
|
||||
<li
|
||||
key={item.id}
|
||||
className={cn(
|
||||
"border-border/60 border-b last:border-b-0",
|
||||
isDirectory && "lg:border-0",
|
||||
)}
|
||||
>
|
||||
<Link
|
||||
to={item.href}
|
||||
className={cn(
|
||||
"border-border/60 border-b last:border-b-0",
|
||||
isDirectory && "lg:border-0",
|
||||
"hover:bg-surface-hover/70 focus-visible:bg-surface-hover/70 focus-visible:ring-ring/70 flex min-h-[4.5rem] items-center gap-3 px-4 py-3 transition-colors focus-visible:ring-2 focus-visible:outline-none focus-visible:ring-inset",
|
||||
isDirectory &&
|
||||
"lg:bg-card/45 lg:border-border/70 lg:hover:border-ring/40 lg:h-28 lg:rounded-xl lg:border lg:px-4 lg:py-4 lg:shadow-sm lg:hover:-translate-y-0.5 lg:hover:shadow-md lg:focus-visible:-translate-y-0.5",
|
||||
)}
|
||||
>
|
||||
<Link
|
||||
to={item.href}
|
||||
<span
|
||||
className={cn(
|
||||
"hover:bg-surface-hover/70 focus-visible:bg-surface-hover/70 focus-visible:ring-ring/70 flex min-h-[4.5rem] items-center gap-3 px-4 py-3 transition-colors focus-visible:ring-2 focus-visible:outline-none focus-visible:ring-inset",
|
||||
isDirectory &&
|
||||
"lg:bg-card/45 lg:border-border/70 lg:hover:border-ring/40 lg:h-28 lg:rounded-xl lg:border lg:px-4 lg:py-4 lg:shadow-sm lg:hover:-translate-y-0.5 lg:hover:shadow-md lg:focus-visible:-translate-y-0.5",
|
||||
"bg-accent text-foreground flex h-9 w-9 shrink-0 items-center justify-center rounded-xl",
|
||||
isDirectory && "lg:h-10 lg:w-10",
|
||||
)}
|
||||
>
|
||||
<Icon className="h-[18px] w-[18px]" aria-hidden="true" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span
|
||||
className={cn(
|
||||
"bg-accent text-foreground flex h-9 w-9 shrink-0 items-center justify-center rounded-xl",
|
||||
isDirectory && "lg:h-10 lg:w-10",
|
||||
"block text-sm font-semibold",
|
||||
isDirectory && "lg:text-[15px]",
|
||||
)}
|
||||
>
|
||||
<Icon className="h-[18px] w-[18px]" aria-hidden="true" />
|
||||
{item.label}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span
|
||||
className={cn(
|
||||
"block text-sm font-semibold",
|
||||
isDirectory && "lg:text-[15px]",
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"text-muted-foreground mt-0.5 block text-xs leading-snug",
|
||||
isDirectory && "lg:line-clamp-3",
|
||||
)}
|
||||
>
|
||||
{item.description}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"text-muted-foreground mt-0.5 block text-xs leading-snug",
|
||||
isDirectory && "lg:line-clamp-3",
|
||||
)}
|
||||
>
|
||||
{item.description}
|
||||
</span>
|
||||
<ChevronRight
|
||||
className="text-muted-foreground h-4 w-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
</span>
|
||||
<ChevronRight
|
||||
className="text-muted-foreground h-4 w-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -49,16 +49,61 @@ describe("SettingsLayout", () => {
|
||||
);
|
||||
|
||||
expect(screen.getByRole("heading", { name: "Settings" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("heading", { name: "Playback" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("heading", { name: "Appearance" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("heading", { name: "Library & Data" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("heading", { name: "Account" })).toBeInTheDocument();
|
||||
for (const group of ["Playback", "Appearance", "Home & Discovery", "Connections", "Account"]) {
|
||||
expect(screen.getByRole("heading", { name: group })).toBeInTheDocument();
|
||||
}
|
||||
expect(
|
||||
screen.getByRole("link", { name: /Playback.*Quality, language, and skipping/ }),
|
||||
screen.getByRole("link", { name: /Playback.*Quality, languages, skipping/ }),
|
||||
).toHaveAttribute("href", "/settings/playback");
|
||||
expect(screen.getByRole("link", { name: /Connect Apps.*Sign-in details/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("names each settings group exactly once", () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/settings"]}>
|
||||
<SettingsLayout />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
// The category jump bar used to repeat every group name and count directly
|
||||
// above the headings that already carry them.
|
||||
expect(
|
||||
screen.queryByRole("navigation", { name: "Settings sections categories" }),
|
||||
).not.toBeInTheDocument();
|
||||
for (const group of ["Playback", "Appearance", "Home & Discovery", "Connections", "Account"]) {
|
||||
expect(screen.getAllByRole("heading", { name: group })).toHaveLength(1);
|
||||
expect(
|
||||
screen.queryByRole("link", { name: new RegExp(`^${group}, \\d+ settings`) }),
|
||||
).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("uses one desktop grid and card geometry for every settings group", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<MemoryRouter initialEntries={["/settings"]}>
|
||||
<SettingsLayout />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
// Five groups, seventeen sections — every card the same height so no group
|
||||
// is visually ranked above another.
|
||||
expect(markup.match(/2xl:grid-cols-4/g)).toHaveLength(5);
|
||||
expect(markup.match(/lg:h-28/g)).toHaveLength(17);
|
||||
expect(markup).not.toContain("max-w-5xl");
|
||||
});
|
||||
|
||||
it("keeps each settings section in exactly one group", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<MemoryRouter initialEntries={["/settings"]}>
|
||||
<SettingsLayout />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
for (const path of ["devices", "libraries", "watch-providers", "profiles"]) {
|
||||
expect(markup.match(new RegExp(`href="/settings/${path}"`, "g"))).toHaveLength(1);
|
||||
}
|
||||
});
|
||||
|
||||
it("offers a clear return to the settings index from detail pages", () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/settings/playback"]}>
|
||||
|
||||
+272
-257
@@ -61,6 +61,13 @@ const settingIndex = (...labels: string[]) => labels.map((label) => ({ label }))
|
||||
*/
|
||||
const WIDE_SETTINGS_PAGES = new Set(["devices"]);
|
||||
|
||||
/**
|
||||
* Grouped by the question a person arrives with, not by which service stores
|
||||
* the value: "how does it play", "how does it look", "what do I see", "what is
|
||||
* it wired to", "who am I". Devices sits under Playback because every setting
|
||||
* on that screen is a playback override, and Personalize sits with Home Screen
|
||||
* because both shape what the app puts in front of you.
|
||||
*/
|
||||
const NAV_SECTIONS: NavSection[] = [
|
||||
{
|
||||
label: "Playback",
|
||||
@@ -69,7 +76,7 @@ const NAV_SECTIONS: NavSection[] = [
|
||||
path: "playback",
|
||||
label: "Playback",
|
||||
icon: Play,
|
||||
description: "Quality, language, and skipping",
|
||||
description: "Quality, languages, skipping, and what plays next.",
|
||||
keywords: [
|
||||
"video quality",
|
||||
"bitrate",
|
||||
@@ -98,7 +105,7 @@ const NAV_SECTIONS: NavSection[] = [
|
||||
path: "subtitle-appearance",
|
||||
label: "Subtitles",
|
||||
icon: Subtitles,
|
||||
description: "Language, behavior, and style",
|
||||
description: "Subtitle language, when they appear, and how they look.",
|
||||
keywords: [
|
||||
"subtitle language",
|
||||
"forced subtitles",
|
||||
@@ -124,215 +131,11 @@ const NAV_SECTIONS: NavSection[] = [
|
||||
"Subtitle position",
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Appearance",
|
||||
items: [
|
||||
{
|
||||
path: "appearance",
|
||||
label: "Appearance",
|
||||
icon: Palette,
|
||||
description: "Theme and interface tone",
|
||||
keywords: [
|
||||
"theme",
|
||||
"profile theme",
|
||||
"dark",
|
||||
"light",
|
||||
"custom theme",
|
||||
"date format",
|
||||
"time format",
|
||||
"clock",
|
||||
"24-hour",
|
||||
"12-hour",
|
||||
],
|
||||
settings: settingIndex(
|
||||
"Theme",
|
||||
"Date & time",
|
||||
"Date format",
|
||||
"Time format",
|
||||
"Current selection",
|
||||
"Reset to Cinema Dark",
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "interface",
|
||||
label: "Navigation & Cards",
|
||||
icon: PanelTop,
|
||||
description: "Menus, poster size, and captions",
|
||||
keywords: [
|
||||
"navigation",
|
||||
"menu",
|
||||
"pin library",
|
||||
"poster size",
|
||||
"card size",
|
||||
"hide title",
|
||||
"hide year",
|
||||
"artwork only",
|
||||
"preset",
|
||||
],
|
||||
settings: settingIndex(
|
||||
"Card preset",
|
||||
"Poster size",
|
||||
"Caption",
|
||||
"Title & metadata",
|
||||
"Title only",
|
||||
"Artwork only",
|
||||
"Primary menu",
|
||||
"Choose destination or shortcut",
|
||||
"Add to menu",
|
||||
"Reset to default",
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "theme-editor",
|
||||
label: "Theme Editor",
|
||||
icon: Wand2,
|
||||
description: "Customize colors and CSS",
|
||||
keywords: ["design tokens", "token overrides", "custom css", "community themes"],
|
||||
settings: settingIndex("Preview", "Token Overrides", "Custom CSS", "Community Themes"),
|
||||
},
|
||||
{
|
||||
path: "accessibility",
|
||||
label: "Accessibility",
|
||||
icon: Eye,
|
||||
description: "Readability and contrast",
|
||||
keywords: ["contrast", "readability", "motion", "transparency", "text"],
|
||||
settings: settingIndex("Text size", "Text weight", "Contrast", "High Contrast", "Preview"),
|
||||
},
|
||||
{
|
||||
path: "home-screen",
|
||||
label: "Home Screen",
|
||||
icon: LayoutDashboard,
|
||||
description: "Sections and layout",
|
||||
keywords: ["sections", "rows", "continue watching", "next up", "library order"],
|
||||
settings: settingIndex(
|
||||
"Scope",
|
||||
"Sections",
|
||||
"Reset section customizations",
|
||||
"Continue Watching",
|
||||
"Next Up",
|
||||
"Recently Added",
|
||||
"Library order",
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "card-overlays",
|
||||
label: "Card Overlays",
|
||||
icon: Layers,
|
||||
description: "Badges on poster cards",
|
||||
keywords: ["poster", "badges", "overlay", "accent color", "preset"],
|
||||
settings: settingIndex(
|
||||
"Preview",
|
||||
"Preset",
|
||||
"Accent color",
|
||||
"Show icon",
|
||||
"Position",
|
||||
"How styling works",
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "personalize",
|
||||
label: "Personalize",
|
||||
icon: Sparkles,
|
||||
description: "Re-tune your taste profile",
|
||||
keywords: ["taste profile", "recommendations", "ratings", "likes", "dislikes"],
|
||||
settings: settingIndex("Refine your taste profile", "Taste profile", "Recommendations"),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Library & Data",
|
||||
items: [
|
||||
{
|
||||
path: "libraries",
|
||||
label: "Libraries",
|
||||
icon: Library,
|
||||
description: "Visibility and access",
|
||||
keywords: [
|
||||
"library visibility",
|
||||
"access",
|
||||
"disabled libraries",
|
||||
"library order",
|
||||
"playback preferences",
|
||||
],
|
||||
settings: settingIndex(
|
||||
"Remember library pages",
|
||||
"Library visibility",
|
||||
"Library order",
|
||||
"Spoken language",
|
||||
"Subtitle language",
|
||||
"Subtitle behavior",
|
||||
"Forced subtitles",
|
||||
"Playback preferences",
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "history-import",
|
||||
label: "History Import",
|
||||
icon: Clock,
|
||||
description: "Emby watch history",
|
||||
keywords: ["emby", "watched history", "import", "mapping", "sync"],
|
||||
settings: settingIndex(
|
||||
"New import",
|
||||
"Import history",
|
||||
"Fetched",
|
||||
"Matched",
|
||||
"Unmatched",
|
||||
"Progress",
|
||||
"History",
|
||||
"Skipped",
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "webhook-sync",
|
||||
label: "Webhook Sync",
|
||||
icon: Server,
|
||||
description: "Plex, Emby, and Jellyfin webhook intake",
|
||||
keywords: ["plex", "emby", "jellyfin", "webhook", "progress", "watched"],
|
||||
settings: settingIndex(
|
||||
"Add a connection",
|
||||
"Connected servers",
|
||||
"Recent deliveries",
|
||||
"Plex",
|
||||
"Emby",
|
||||
"Jellyfin",
|
||||
"Server URL",
|
||||
"Token",
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "watch-providers",
|
||||
label: "Watch Providers",
|
||||
icon: Cloud,
|
||||
description: "Trakt watch history and scrobbling",
|
||||
keywords: ["trakt", "import", "export", "scrobble", "favorites", "watch history"],
|
||||
settings: settingIndex(
|
||||
"Last imported",
|
||||
"Last exported",
|
||||
"Watched",
|
||||
"Progress",
|
||||
"Favorites",
|
||||
"Exported",
|
||||
"Import watched history",
|
||||
"Import paused progress",
|
||||
"Send watched changes",
|
||||
"Send unwatched changes",
|
||||
"Sync favorites",
|
||||
"Sync favorite removals",
|
||||
"Scrobble playback",
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Account",
|
||||
items: [
|
||||
{
|
||||
path: "devices",
|
||||
label: "Your Devices",
|
||||
icon: MonitorSmartphone,
|
||||
description: "Settings for each device you watch on",
|
||||
description: "Per-device quality, HDR, and audio or subtitle sync.",
|
||||
keywords: [
|
||||
"devices",
|
||||
"tv",
|
||||
@@ -359,28 +162,159 @@ const NAV_SECTIONS: NavSection[] = [
|
||||
"Forget this device",
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Appearance",
|
||||
items: [
|
||||
{
|
||||
path: "notifications",
|
||||
label: "Notifications",
|
||||
icon: Bell,
|
||||
description: "New-episode alerts and webhooks",
|
||||
keywords: ["new episodes", "email", "discord", "browser push", "webhooks"],
|
||||
path: "appearance",
|
||||
label: "Appearance",
|
||||
icon: Palette,
|
||||
description: "Theme, interface tone, and date and time formats.",
|
||||
keywords: [
|
||||
"theme",
|
||||
"profile theme",
|
||||
"dark",
|
||||
"light",
|
||||
"custom theme",
|
||||
"date format",
|
||||
"time format",
|
||||
"clock",
|
||||
"24-hour",
|
||||
"12-hour",
|
||||
],
|
||||
settings: settingIndex(
|
||||
"New Episode Notifications",
|
||||
"Email Notifications",
|
||||
"Discord Notifications",
|
||||
"Browser Notifications",
|
||||
"Webhooks",
|
||||
"Per-episode alerts",
|
||||
"Digest",
|
||||
"Webhook URL",
|
||||
"Theme",
|
||||
"Date & time",
|
||||
"Date format",
|
||||
"Time format",
|
||||
"Current selection",
|
||||
"Reset to Cinema Dark",
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "interface",
|
||||
label: "Navigation & Cards",
|
||||
icon: PanelTop,
|
||||
description: "Your primary menu, poster size, and card captions.",
|
||||
keywords: [
|
||||
"navigation",
|
||||
"menu",
|
||||
"pin library",
|
||||
"poster size",
|
||||
"card size",
|
||||
"hide title",
|
||||
"hide year",
|
||||
"artwork only",
|
||||
"preset",
|
||||
],
|
||||
settings: settingIndex(
|
||||
"Card preset",
|
||||
"Poster size",
|
||||
"Caption",
|
||||
"Title & metadata",
|
||||
"Title only",
|
||||
"Artwork only",
|
||||
"Primary menu",
|
||||
"Choose destination or shortcut",
|
||||
"Add to menu",
|
||||
"Reset to default",
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "card-overlays",
|
||||
label: "Card Overlays",
|
||||
icon: Layers,
|
||||
description: "Badges drawn on poster cards, and where they sit.",
|
||||
keywords: ["poster", "badges", "overlay", "accent color", "preset"],
|
||||
settings: settingIndex(
|
||||
"Preview",
|
||||
"Preset",
|
||||
"Accent color",
|
||||
"Show icon",
|
||||
"Position",
|
||||
"How styling works",
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "accessibility",
|
||||
label: "Accessibility",
|
||||
icon: Eye,
|
||||
description: "Text size, weight, and contrast for easier reading.",
|
||||
keywords: ["contrast", "readability", "motion", "transparency", "text"],
|
||||
settings: settingIndex("Text size", "Text weight", "Contrast", "High Contrast", "Preview"),
|
||||
},
|
||||
{
|
||||
path: "theme-editor",
|
||||
label: "Theme Editor",
|
||||
icon: Wand2,
|
||||
description: "Fine-tune theme colors and add your own CSS.",
|
||||
keywords: ["design tokens", "token overrides", "custom css", "community themes"],
|
||||
settings: settingIndex("Preview", "Token Overrides", "Custom CSS", "Community Themes"),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Home & Discovery",
|
||||
items: [
|
||||
{
|
||||
path: "home-screen",
|
||||
label: "Home Screen",
|
||||
icon: LayoutDashboard,
|
||||
description: "Which rows appear on Home, and in what order.",
|
||||
keywords: ["sections", "rows", "continue watching", "next up", "library order"],
|
||||
settings: settingIndex(
|
||||
"Scope",
|
||||
"Sections",
|
||||
"Reset section customizations",
|
||||
"Continue Watching",
|
||||
"Next Up",
|
||||
"Recently Added",
|
||||
"Library order",
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "personalize",
|
||||
label: "Personalize",
|
||||
icon: Sparkles,
|
||||
description: "Re-tune the taste profile behind your recommendations.",
|
||||
keywords: ["taste profile", "recommendations", "ratings", "likes", "dislikes"],
|
||||
settings: settingIndex("Refine your taste profile", "Taste profile", "Recommendations"),
|
||||
},
|
||||
{
|
||||
path: "libraries",
|
||||
label: "Libraries",
|
||||
icon: Library,
|
||||
description: "Which libraries you see, their order, and per-library audio.",
|
||||
keywords: [
|
||||
"library visibility",
|
||||
"access",
|
||||
"disabled libraries",
|
||||
"library order",
|
||||
"playback preferences",
|
||||
],
|
||||
settings: settingIndex(
|
||||
"Remember library pages",
|
||||
"Library visibility",
|
||||
"Library order",
|
||||
"Spoken language",
|
||||
"Subtitle language",
|
||||
"Subtitle behavior",
|
||||
"Forced subtitles",
|
||||
"Playback preferences",
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Connections",
|
||||
items: [
|
||||
{
|
||||
path: "connect-apps",
|
||||
label: "Connect Apps",
|
||||
icon: Cast,
|
||||
description: "Sign-in details for other apps",
|
||||
description: "Sign-in details for Silo and Jellyfin-compatible apps.",
|
||||
keywords: [
|
||||
"jellyfin",
|
||||
"infuse",
|
||||
@@ -405,11 +339,72 @@ const NAV_SECTIONS: NavSection[] = [
|
||||
"Every profile at a glance",
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "watch-providers",
|
||||
label: "Watch Providers",
|
||||
icon: Cloud,
|
||||
description: "Trakt watch history, favorites, and scrobbling.",
|
||||
keywords: ["trakt", "import", "export", "scrobble", "favorites", "watch history"],
|
||||
settings: settingIndex(
|
||||
"Last imported",
|
||||
"Last exported",
|
||||
"Watched",
|
||||
"Progress",
|
||||
"Favorites",
|
||||
"Exported",
|
||||
"Import watched history",
|
||||
"Import paused progress",
|
||||
"Send watched changes",
|
||||
"Send unwatched changes",
|
||||
"Sync favorites",
|
||||
"Sync favorite removals",
|
||||
"Scrobble playback",
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "webhook-sync",
|
||||
label: "Webhook Sync",
|
||||
icon: Server,
|
||||
description: "Take progress from Plex, Emby, and Jellyfin webhooks.",
|
||||
keywords: ["plex", "emby", "jellyfin", "webhook", "progress", "watched"],
|
||||
settings: settingIndex(
|
||||
"Add a connection",
|
||||
"Connected servers",
|
||||
"Recent deliveries",
|
||||
"Plex",
|
||||
"Emby",
|
||||
"Jellyfin",
|
||||
"Server URL",
|
||||
"Token",
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "history-import",
|
||||
label: "History Import",
|
||||
icon: Clock,
|
||||
description: "Bring an existing Emby watch history into Silo.",
|
||||
keywords: ["emby", "watched history", "import", "mapping", "sync"],
|
||||
settings: settingIndex(
|
||||
"New import",
|
||||
"Import history",
|
||||
"Fetched",
|
||||
"Matched",
|
||||
"Unmatched",
|
||||
"Progress",
|
||||
"History",
|
||||
"Skipped",
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Account",
|
||||
items: [
|
||||
{
|
||||
path: "profiles",
|
||||
label: "Profiles",
|
||||
icon: Users,
|
||||
description: "Names, PINs, and access rules",
|
||||
description: "Household profile names, PINs, and library access.",
|
||||
keywords: ["profile name", "pin", "access", "primary profile", "household"],
|
||||
settings: settingIndex(
|
||||
"Profile name",
|
||||
@@ -421,6 +416,23 @@ const NAV_SECTIONS: NavSection[] = [
|
||||
),
|
||||
primaryOrAdmin: true,
|
||||
},
|
||||
{
|
||||
path: "notifications",
|
||||
label: "Notifications",
|
||||
icon: Bell,
|
||||
description: "New-episode alerts by email, Discord, push, or webhook.",
|
||||
keywords: ["new episodes", "email", "discord", "browser push", "webhooks"],
|
||||
settings: settingIndex(
|
||||
"New Episode Notifications",
|
||||
"Email Notifications",
|
||||
"Discord Notifications",
|
||||
"Browser Notifications",
|
||||
"Webhooks",
|
||||
"Per-episode alerts",
|
||||
"Digest",
|
||||
"Webhook URL",
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -428,24 +440,21 @@ const NAV_SECTIONS: NavSection[] = [
|
||||
interface SettingsOverviewProps {
|
||||
sections: readonly { label: string; items: readonly NavItem[] }[];
|
||||
profile: { name: string; avatar_url?: string } | null;
|
||||
search: string;
|
||||
onSearchChange: (value: string) => void;
|
||||
resultCount: number;
|
||||
totalCount: number;
|
||||
}
|
||||
|
||||
function SettingsOverview({
|
||||
sections,
|
||||
profile,
|
||||
search,
|
||||
onSearchChange,
|
||||
resultCount,
|
||||
totalCount,
|
||||
}: SettingsOverviewProps) {
|
||||
/**
|
||||
* The index is the same directory the admin settings index uses: category jump
|
||||
* links, then one uniform card grid per group across the full page width.
|
||||
*
|
||||
* The two-column list it replaced paired groups side by side, so a short group
|
||||
* next to a long one left a column of dead space taller than the short group
|
||||
* itself — the exact shape of the desktop complaint.
|
||||
*/
|
||||
function SettingsOverview({ sections, profile }: SettingsOverviewProps) {
|
||||
const profileName = profile?.name ?? "Your profile";
|
||||
|
||||
return (
|
||||
<div className="mx-auto mt-6 w-full max-w-5xl space-y-6 sm:mt-8">
|
||||
<div className="w-full space-y-6">
|
||||
<Link
|
||||
to="/profiles"
|
||||
aria-label={`Current profile: ${profileName}`}
|
||||
@@ -464,14 +473,6 @@ function SettingsOverview({
|
||||
<ChevronRight className="text-muted-foreground h-4 w-4 shrink-0" aria-hidden="true" />
|
||||
</Link>
|
||||
|
||||
<SettingsSearchInput
|
||||
value={search}
|
||||
onChange={onSearchChange}
|
||||
resultCount={resultCount}
|
||||
totalCount={totalCount}
|
||||
className="w-full sm:max-w-md"
|
||||
/>
|
||||
|
||||
<SettingsOverviewNav
|
||||
groups={sections.map((section) => ({
|
||||
...section,
|
||||
@@ -485,6 +486,7 @@ function SettingsOverview({
|
||||
}))}
|
||||
ariaLabel="Settings sections"
|
||||
idPrefix="settings-index"
|
||||
variant="directory"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -555,9 +557,21 @@ export default function SettingsLayout() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 min-w-0 flex-1 lg:mt-10 lg:flex lg:gap-10">
|
||||
<aside className="hidden lg:block lg:w-[220px] lg:shrink-0">
|
||||
<nav aria-label="Settings sections" className="sticky top-6 space-y-5 pl-3">
|
||||
{/* From lg up, the rail and the active page share one panel, the
|
||||
same shell the admin settings use. Below lg the rail is hidden
|
||||
and the page is already a stack of its own panels, so the extra
|
||||
chrome would only nest a card inside a card — mobile keeps the
|
||||
bare layout.
|
||||
|
||||
Admin scrolls its detail pane inside the panel; here the page
|
||||
scrolls and the rail sticks, because Your Devices owns a
|
||||
viewport-height scroller that a nested one would strand. */}
|
||||
<div className="surface-panel-lg mt-5 flex min-w-0 flex-1 flex-col lg:mt-10 lg:min-h-[500px] lg:flex-row lg:overflow-hidden">
|
||||
<aside className="border-border hidden lg:block lg:w-60 lg:flex-shrink-0 lg:border-r">
|
||||
<nav
|
||||
aria-label="Settings sections"
|
||||
className="sticky top-6 space-y-5 py-5 pr-3 pl-5"
|
||||
>
|
||||
{filteredSections.map((section) => (
|
||||
<SideNavSection
|
||||
key={section.label}
|
||||
@@ -581,8 +595,8 @@ export default function SettingsLayout() {
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<div className="min-w-0 flex-1 pt-8 lg:pt-0">
|
||||
<div className={cn("mx-auto w-full", wideSetting ? "max-w-6xl" : "max-w-3xl")}>
|
||||
<div className="min-w-0 flex-1 p-4 sm:p-6">
|
||||
<div className={cn("w-full", wideSetting ? "max-w-none" : "max-w-3xl")}>
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
@@ -591,22 +605,23 @@ export default function SettingsLayout() {
|
||||
) : (
|
||||
<>
|
||||
<PageBack to="/" preferHistory={false} floating />
|
||||
<div className="page-header mt-10 gap-5 sm:mt-12">
|
||||
<div className="page-header mt-10 mb-6 gap-5 sm:mt-12 sm:mb-8">
|
||||
<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">
|
||||
Make Silo work the way you like.
|
||||
</p>
|
||||
</div>
|
||||
<SettingsSearchInput
|
||||
value={settingsSearch}
|
||||
onChange={setSettingsSearch}
|
||||
resultCount={filteredSettingsCount}
|
||||
totalCount={flatItems.length}
|
||||
className="w-full sm:max-w-sm lg:w-[26rem] lg:max-w-none"
|
||||
showShortcutHint
|
||||
/>
|
||||
</div>
|
||||
<SettingsOverview
|
||||
sections={filteredSections}
|
||||
profile={profile}
|
||||
search={settingsSearch}
|
||||
onSearchChange={setSettingsSearch}
|
||||
resultCount={filteredSettingsCount}
|
||||
totalCount={flatItems.length}
|
||||
/>
|
||||
<SettingsOverview sections={filteredSections} profile={profile} />
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
|
||||
@@ -69,28 +69,20 @@ describe("AdminSettingsLayout", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("renders desktop category jump links with section counts", () => {
|
||||
it("names each settings group exactly once", () => {
|
||||
renderInteractiveLayout();
|
||||
|
||||
const categoryNavigation = screen.getByRole("navigation", {
|
||||
name: "Admin settings sections categories",
|
||||
});
|
||||
|
||||
expect(categoryNavigation).toContainElement(
|
||||
screen.getByRole("link", { name: "Server, 4 settings sections" }),
|
||||
);
|
||||
expect(screen.getByRole("link", { name: "Media, 7 settings sections" })).toHaveAttribute(
|
||||
"href",
|
||||
"#admin-settings-index-media",
|
||||
);
|
||||
expect(screen.getByRole("link", { name: "Connections, 6 settings sections" })).toHaveAttribute(
|
||||
"href",
|
||||
"#admin-settings-index-connections",
|
||||
);
|
||||
expect(screen.getByRole("link", { name: "Data, 3 settings sections" })).toHaveAttribute(
|
||||
"href",
|
||||
"#admin-settings-index-data",
|
||||
);
|
||||
// The category jump bar used to repeat every group name and count directly
|
||||
// above the headings that already carry them.
|
||||
expect(
|
||||
screen.queryByRole("navigation", { name: "Admin settings sections categories" }),
|
||||
).not.toBeInTheDocument();
|
||||
for (const group of ["Server", "Media", "Connections", "Data"]) {
|
||||
expect(screen.getAllByRole("heading", { name: group })).toHaveLength(1);
|
||||
expect(
|
||||
screen.queryByRole("link", { name: new RegExp(`^${group}, \\d+ settings`) }),
|
||||
).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("uses one desktop grid and card geometry for every settings group", () => {
|
||||
@@ -102,19 +94,6 @@ describe("AdminSettingsLayout", () => {
|
||||
expect(markup.match(/lg:line-clamp-3/g)).toHaveLength(20);
|
||||
});
|
||||
|
||||
it("marks a selected category jump link as the current location", async () => {
|
||||
renderInteractiveLayout();
|
||||
|
||||
const server = screen.getByRole("link", { name: "Server, 4 settings sections" });
|
||||
const data = screen.getByRole("link", { name: "Data, 3 settings sections" });
|
||||
|
||||
expect(server).toHaveAttribute("aria-current", "location");
|
||||
await userEvent.click(data);
|
||||
|
||||
expect(server).not.toHaveAttribute("aria-current");
|
||||
expect(data).toHaveAttribute("aria-current", "location");
|
||||
});
|
||||
|
||||
it("renders every settings tab", () => {
|
||||
const markup = renderLayout();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user