314 lines
11 KiB
TypeScript
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>
|
|
);
|
|
}
|