Files
silo-server/web/src/pages/SettingsLayout.tsx
T
2e0e145721 feat(settings): per-user date and time display format settings (#304)
* feat(settings): per-user date and time display format settings

Add ui.date_format (auto, DD/MM/YYYY, MM/DD/YYYY, YYYY-MM-DD) and
ui.time_format (auto, 12h, 24h) as validated user-scoped settings, a
shared preference-aware formatter module (web/src/lib/datetime.ts)
synced via DateTimeFormatProvider, a Date & time section in Appearance
settings, and convert all absolute date/time display call sites to the
shared formatters.

Closes #303

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(settings): make loaded API settings authoritative for date/time formats

Address adversarial review: once the authenticated settings request
resolves, a missing ui.date_format/ui.time_format key means "auto"
instead of falling back to device-wide localStorage (which could carry
another user's preference), and failed saves roll back through the
query cache. Layout and AdminLayout subscribe to the format store so
all routed pages re-render live when the preference changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(settings): reliable re-render and rollback for date/time format changes

ReactiveAppRoutes re-renders the routed page tree when the format
preference changes (a Layout-level subscription cannot re-render stable
children elements); memoized AdminLogs rows and the out-of-route
PlayingNextScreen subscribe directly. useSetSetting now rolls back only
the mutated key on error and invalidates the settings list on settle so
overlapping saves cannot resurrect stale values.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(settings): guard same-key rollback against newer optimistic saves

Roll back a failed setting save only while its optimistic value is
still current in the cache, invalidate the detail query on settle, and
add a regression test for overlapping same-key mutations.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(settings): owner-bind the datetime format warm start, pad 24h hours

Address PR review: the localStorage warm start is now tagged with the
user id that mirrored it and is ignored for a different authenticated
user, so a failed settings request can no longer leak another account's
format on a shared browser. The 24h branch of formatTime defaults to
2-digit hours ("09:04") since h23 alone does not guarantee padding in
every locale.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 16:45:06 -04:00

451 lines
14 KiB
TypeScript

import { useMemo, useState } from "react";
import { Link, Outlet, useLocation } from "react-router";
import {
Play,
Library,
Clock,
Cloud,
Subtitles,
LayoutDashboard,
Palette,
Eye,
Wand2,
Layers,
Users,
Server,
Sparkles,
Bell,
} from "lucide-react";
// Sparkles is used by the Personalization nav entry below.
import type { LucideIcon } from "lucide-react";
import PageBack from "@/components/PageBack";
import { SideNavItem, SideNavSection } from "@/components/SideNav";
import { SettingsSearchInput } from "@/components/settings/SettingsSearchInput";
import { useDocumentTitle } from "@/hooks/useDocumentTitle";
import { useCurrentProfile } from "@/hooks/useCurrentProfile";
import { useIsActingAdmin } from "@/hooks/useIsActingAdmin";
import { resolveSettingsDocumentTitle } from "@/lib/documentTitle";
import {
countSettingsSearchItems,
filterSettingsSearchGroups,
} from "@/components/settings/settingsSearch";
import { cn } from "@/lib/utils";
interface NavItem {
path: string;
label: string;
icon: LucideIcon;
description: string;
keywords?: readonly string[];
settings?: readonly { label: string; description?: string; keywords?: readonly string[] }[];
primaryOrAdmin?: boolean;
}
interface NavSection {
label: string;
items: NavItem[];
}
const settingIndex = (...labels: string[]) => labels.map((label) => ({ label }));
const NAV_SECTIONS: NavSection[] = [
{
label: "Playback",
items: [
{
path: "playback",
label: "Playback",
icon: Play,
description: "Quality, language, and skipping",
keywords: [
"video quality",
"spoken language",
"metadata language",
"auto skip",
"auto play",
"next up",
"preview",
],
settings: settingIndex(
"Video quality",
"Spoken language",
"Metadata language",
"Auto-skip intros",
"Auto-skip credits",
"Auto-skip recaps",
"Start next at preview",
"Auto-play next episode",
"Next up episodes",
),
},
{
path: "subtitle-appearance",
label: "Subtitles",
icon: Subtitles,
description: "Language, behavior, and style",
keywords: [
"subtitle language",
"forced subtitles",
"captions",
"font size",
"font color",
"background",
"position",
],
settings: settingIndex(
"Subtitle language",
"Subtitle behavior",
"Show forced subtitles",
"Preview",
"Font size",
"Font family",
"Font color",
"Text outline",
"Outline color",
"Background style",
"Background opacity",
"Background color",
"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: "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: "notifications",
label: "Notifications",
icon: Bell,
description: "New-episode alerts and webhooks",
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",
),
},
{
path: "profiles",
label: "Profiles",
icon: Users,
description: "Names, PINs, and access rules",
keywords: ["profile name", "pin", "access", "primary profile", "household"],
settings: settingIndex(
"Profile name",
"PIN",
"Library access",
"Create profile",
"Delete profile",
"Primary profile",
),
primaryOrAdmin: true,
},
],
},
];
export default function SettingsLayout() {
const location = useLocation();
const [settingsSearch, setSettingsSearch] = useState("");
const { profile } = useCurrentProfile();
const actingAdmin = useIsActingAdmin();
const segments = location.pathname.split("/");
const activeSegment = segments[2] || "playback";
const canManageProfiles = actingAdmin || profile?.is_primary === true;
const visibleSections = useMemo(
() =>
NAV_SECTIONS.map((section) => ({
...section,
items: section.items.filter((item) => !item.primaryOrAdmin || canManageProfiles),
})).filter((section) => section.items.length > 0),
[canManageProfiles],
);
const flatItems = useMemo(
() => visibleSections.flatMap((section) => section.items),
[visibleSections],
);
const filteredSections = useMemo(
() => filterSettingsSearchGroups(visibleSections, settingsSearch),
[settingsSearch, visibleSections],
);
const filteredFlatItems = useMemo(
() => filteredSections.flatMap((section) => section.items),
[filteredSections],
);
const filteredSettingsCount = countSettingsSearchItems(filteredSections);
useDocumentTitle(resolveSettingsDocumentTitle(location.pathname));
return (
<div className="min-h-[100dvh]">
<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>
<SettingsSearchInput
value={settingsSearch}
onChange={setSettingsSearch}
resultCount={filteredSettingsCount}
totalCount={flatItems.length}
className="w-full sm:max-w-sm"
/>
</div>
{/* Mobile: horizontal scrolling tab bar */}
<nav
aria-label="Settings sections"
className="surface-panel-subtle mt-6 overflow-x-auto rounded-[1.4rem] p-1 lg:hidden"
style={{
WebkitOverflowScrolling: "touch",
maskImage:
"linear-gradient(to right, transparent, black 40px, black calc(100% - 40px), transparent)",
WebkitMaskImage:
"linear-gradient(to right, transparent, black 40px, black calc(100% - 40px), transparent)",
}}
>
<div className="flex min-w-max items-stretch gap-1">
{filteredFlatItems.map((item) => {
const isActive = item.path === activeSegment;
const Icon = item.icon;
return (
<Link
key={item.path}
to={`/settings/${item.path}`}
aria-current={isActive ? "page" : undefined}
className={cn(
"inline-flex items-center gap-2 rounded-[1rem] px-3 py-2.5 text-sm font-medium whitespace-nowrap transition-colors",
isActive
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:bg-background/70 hover:text-foreground",
)}
>
<Icon className="h-4 w-4" />
{item.label}
</Link>
);
})}
{filteredFlatItems.length === 0 ? (
<p className="text-muted-foreground px-3 py-2.5 text-sm whitespace-nowrap">
No matching settings
</p>
) : null}
</div>
</nav>
{/* Desktop: two-column with inline vertical sidebar */}
<div className="mt-8 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">
{filteredSections.map((section) => (
<SideNavSection key={section.label} label={section.label} idPrefix="settings-nav">
{section.items.map((item) => (
<SideNavItem
key={item.path}
label={item.label}
icon={item.icon}
href={`/settings/${item.path}`}
active={item.path === activeSegment}
/>
))}
</SideNavSection>
))}
{filteredSections.length === 0 ? (
<p className="text-muted-foreground px-2 text-sm">No matching settings</p>
) : null}
</nav>
</aside>
<div className="min-w-0 flex-1 pt-8 lg:pt-0">
<div className="mx-auto w-full max-w-3xl">
<Outlet />
</div>
</div>
</div>
</main>
</div>
);
}