Files
2026-05-26 22:46:00 +02:00

314 lines
11 KiB
TypeScript

"use client";
import { useEffect, useRef, useState } from "react";
import {
Settings as SettingsIcon, X, Palette, Trash2, Wrench, Film, FolderTree, Captions,
} from "lucide-react";
import { useSettingsPanel } from "./SettingsPanelProvider";
import { DefaultSortSelect } from "./DefaultSortSelect";
import { AccentColorPickers } from "./AccentColorPickers";
import { DisplayGroup, TrashGroup, MaintenanceGroup, BackupGroup } from "./SettingsToggles";
import { VideoLibrarySettings } from "./VideoLibrarySettings";
import { WhisperJavSettings } from "./WhisperJavSettings";
import { SubtitleLibraryPaths } from "./SubtitleLibraryPaths";
import { useClickOutside } from "@/lib/hooks/useClickOutside";
import { cn } from "@/lib/utils";
import type { SortKey } from "@/lib/sort";
import type { LibraryStats } from "@/lib/db/queries";
interface PanelData {
defaultSort: SortKey;
stats: LibraryStats;
libraryRoot: string;
dbPath: string;
}
function fmtBytes(n: number): string {
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
if (n < 1024 * 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} MB`;
return `${(n / 1024 / 1024 / 1024).toFixed(2)} GB`;
}
function fmtDate(ms: number | null): string {
if (!ms) return "—";
const d = new Date(ms);
return d.toISOString().slice(0, 10);
}
export function SettingsPanel({ data }: { data: PanelData }) {
const { open, close } = useSettingsPanel();
const panelRef = useRef<HTMLDivElement>(null);
useClickOutside(panelRef, close, open);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") close(); };
window.addEventListener("keydown", onKey);
document.body.style.overflow = "hidden";
return () => {
window.removeEventListener("keydown", onKey);
document.body.style.overflow = "";
};
}, [open, close]);
if (!open) return null;
return (
<div
className="fixed inset-0 z-50 backdrop-blur-sm grid place-items-center p-4 sm:p-8"
style={{ background: "color-mix(in oklch, var(--color-bg-0) 65%, transparent)" }}
>
<div
ref={panelRef}
className="w-full max-w-[1400px] h-[min(900px,calc(100vh-4rem))] flex flex-col rounded-2xl border border-[var(--color-glass-border-strong)] backdrop-blur-2xl overflow-hidden shadow-2xl"
style={{ background: "color-mix(in oklch, var(--color-bg-0) 96%, transparent)" }}
>
<header className="flex items-center justify-between px-6 py-4 border-b border-[var(--color-glass-border)] shrink-0">
<div className="flex items-center gap-2">
<SettingsIcon className="w-5 h-5 text-[var(--color-cyan)]" />
<h2 className="text-xl font-semibold tracking-tight">Settings</h2>
</div>
<button
onClick={close}
aria-label="Close settings"
className="w-8 h-8 grid place-items-center rounded-lg text-[var(--color-fg-dim)] hover:text-[var(--color-fg)] hover:bg-[var(--color-glass)]"
>
<X className="w-4 h-4" />
</button>
</header>
<SidebarLayout data={data} />
</div>
</div>
);
}
/* =====================================================================
Section content blocks. Five top-level sections:
Appearance · Library · Video · Tools · Info
===================================================================== */
function AppearanceSection({ data }: { data: PanelData }) {
return (
<Card title="Appearance">
<SubGroup label="Colors">
<AccentColorPickers />
</SubGroup>
<Divider />
<SubGroup label="Display">
<DisplayGroup />
</SubGroup>
<Divider />
<SubGroup label="Defaults">
<DefaultSortSelect initial={data.defaultSort} />
</SubGroup>
</Card>
);
}
function LibrarySection() {
return (
<Card title="Library · file handling">
<TrashGroup />
</Card>
);
}
function VideoSection() {
return <Card title="Video"><VideoLibrarySettings /></Card>;
}
function SubtitlesSection() {
return (
<Card title="Subtitles">
<SubtitleLibraryPaths />
<Divider />
<WhisperJavSettings />
</Card>
);
}
function ToolsSection() {
return (
<Card title="Tools">
<SubGroup label="Maintenance">
<MaintenanceGroup />
</SubGroup>
<Divider />
<SubGroup label="Backup">
<BackupGroup />
</SubGroup>
</Card>
);
}
function InfoSection({ data }: { data: PanelData }) {
const s = data.stats;
const watchedPct = s.images > 0 ? Math.round((s.watched / s.images) * 100) : 0;
return (
<Card title="Info">
<div className="grid grid-cols-1 md:grid-cols-2 gap-section">
<StatGroup label="Covers">
<Row label="Top-level" value={s.images.toLocaleString()} mono />
<Row label="Attached (back / stills)" value={s.attached.toLocaleString()} mono />
{s.trashed > 0 && (
<Row label="In trash" value={s.trashed.toLocaleString()} mono />
)}
</StatGroup>
<StatGroup label="Entities">
<Row label="Actresses" value={s.actresses.toLocaleString()} mono />
<Row label="Studios" value={s.studios.toLocaleString()} mono />
<Row label="Series" value={s.series.toLocaleString()} mono />
{s.labels > 0 && <Row label="Labels" value={s.labels.toLocaleString()} mono />}
<Row label="Genres" value={s.genres.toLocaleString()} mono />
</StatGroup>
<StatGroup label="Tagging">
<Row label="Tags" value={s.tags.toLocaleString()} mono />
<Row label="Tag categories" value={s.tagCategories.toLocaleString()} mono />
<Row label="Collections" value={s.collections.toLocaleString()} mono />
</StatGroup>
<StatGroup label="State">
<Row label="Watched" value={`${s.watched.toLocaleString()} (${watchedPct}%)`} mono />
<Row label="VIP" value={s.vip.toLocaleString()} mono />
<Row label="Favorite" value={s.favorite.toLocaleString()} mono />
<Row label="Owned" value={s.owned.toLocaleString()} mono />
<Row label="Rated" value={s.rated.toLocaleString()} mono />
</StatGroup>
</div>
<Divider />
<SubGroup label="Disk">
<dl className="space-y-1.5 text-sm">
<Row label="Total cover bytes" value={fmtBytes(s.totalBytes)} mono />
<Row
label="Imports"
value={
s.earliestImportedAt && s.latestImportedAt
? `${fmtDate(s.earliestImportedAt)}${fmtDate(s.latestImportedAt)}`
: "—"
}
mono
/>
</dl>
</SubGroup>
<Divider />
<SubGroup label="Paths">
<dl className="space-y-1.5 text-sm">
<Row label="Library folder" value={data.libraryRoot} mono />
<Row label="Database" value={data.dbPath} mono />
</dl>
</SubGroup>
</Card>
);
}
/* =====================================================================
Sidebar layout — single layout, no toggle.
===================================================================== */
const SIDEBAR_NAV = [
{ id: "appearance", label: "Appearance", Icon: Palette },
{ id: "library", label: "Library", Icon: Trash2 },
{ id: "video", label: "Video", Icon: Film },
{ id: "subtitles", label: "Subtitles", Icon: Captions },
{ id: "tools", label: "Tools", Icon: Wrench },
{ id: "info", label: "Info", Icon: FolderTree },
] as const;
type SidebarSection = typeof SIDEBAR_NAV[number]["id"];
function SidebarLayout({ data }: { data: PanelData }) {
const [active, setActive] = useState<SidebarSection>("appearance");
const content: Record<SidebarSection, React.ReactNode> = {
appearance: <AppearanceSection data={data} />,
library: <LibrarySection />,
video: <VideoSection />,
subtitles: <SubtitlesSection />,
tools: <ToolsSection />,
info: <InfoSection data={data} />,
};
return (
<div className="flex-1 grid grid-cols-[220px_1fr] min-h-0">
<nav className="border-r border-[var(--color-glass-border)] p-3 overflow-y-auto"
style={{ background: "color-mix(in oklch, var(--color-bg-0) 50%, transparent)" }}>
{SIDEBAR_NAV.map(({ id, label, Icon }) => {
const isActive = id === active;
return (
<button
key={id}
type="button"
onClick={() => setActive(id)}
className={cn(
"w-full flex items-center gap-2.5 px-3 py-2 rounded-lg text-sm text-left transition-colors mb-0.5",
isActive
? "bg-[var(--color-cyan)]/15 text-[var(--color-cyan)]"
: "text-[var(--color-fg-dim)] hover:text-[var(--color-fg)] hover:bg-[var(--color-glass)]",
)}
>
<Icon className="w-4 h-4 shrink-0" />
<span className="truncate">{label}</span>
</button>
);
})}
</nav>
<div className="overflow-y-auto p-card">
{content[active]}
</div>
</div>
);
}
/* =====================================================================
Tiny primitives.
===================================================================== */
function Card({ title, children }: { title: string; children: React.ReactNode }) {
return (
<section className="glass rounded-2xl p-card">
<h3 className="text-sm uppercase tracking-wider font-mono text-[var(--color-fg-muted)] mb-label">
{title}
</h3>
{children}
</section>
);
}
/** Sub-group label inside a Card — small cyan caps header. */
function SubGroup({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div>
<div className="text-[10px] uppercase tracking-wider font-mono text-[var(--color-cyan)] mb-label">{label}</div>
{children}
</div>
);
}
/** Horizontal rule between sub-groups inside a Card. */
function Divider() {
return <hr className="my-section border-0 border-t border-[var(--color-glass-border)]" />;
}
function StatGroup({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div>
<div className="text-[10px] uppercase tracking-wider font-mono text-[var(--color-cyan)] mb-label">{label}</div>
<dl className="space-y-1.5 text-sm border-t border-[var(--color-glass-border)] pt-1.5">
{children}
</dl>
</div>
);
}
function Row({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
return (
<div className="flex items-start justify-between gap-4">
<dt className="text-[var(--color-fg-dim)]">{label}</dt>
<dd className={`text-right break-all ${mono ? "font-mono text-xs text-[var(--color-fg)]" : ""}`}>{value}</dd>
</div>
);
}