Initial commit
This commit is contained in:
@@ -0,0 +1,313 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user