Files
pinkudex/components/settings/SubtitleLibraryPaths.tsx
T
2026-05-26 22:46:00 +02:00

243 lines
9.3 KiB
TypeScript

"use client";
import { useEffect, useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { Captions, Folder, FolderOpen, Loader2, Plus, Save, Trash2 } from "lucide-react";
import { setSubtitleExtraPaths, setSubtitleCacheLimitMb } from "@/app/actions/settings";
import { useSettings } from "./SettingsProvider";
/**
* Persistent recursive-scan folders for subtitle sidecars. The player
* always finds same-folder sidecars; configure these only if subtitles
* live in a separate location from videos.
*/
export function SubtitleLibraryPaths() {
const { settings } = useSettings();
const router = useRouter();
const [, start] = useTransition();
const [draftSubExtras, setDraftSubExtras] = useState<string[]>(settings.subtitleExtraPaths ?? []);
const [newSubExtra, setNewSubExtra] = useState("");
const [saving, setSaving] = useState(false);
const [picking, setPicking] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
setDraftSubExtras(settings.subtitleExtraPaths ?? []);
}, [settings.subtitleExtraPaths]);
async function pickFolder(startPath: string): Promise<string | null> {
try {
const r = await fetch("/api/pick-folder", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ start: startPath }),
});
const j = await r.json();
if (!r.ok) throw new Error(j.error ?? `picker failed (${r.status})`);
return typeof j.path === "string" && j.path ? j.path : null;
} catch (e) {
setError((e as Error).message);
return null;
}
}
const dirty =
draftSubExtras.length !== (settings.subtitleExtraPaths ?? []).length ||
draftSubExtras.some((v, i) => v !== (settings.subtitleExtraPaths ?? [])[i]);
function addSubExtra() {
const v = newSubExtra.trim();
if (!v || draftSubExtras.includes(v)) return;
setDraftSubExtras((cur) => [...cur, v]);
setNewSubExtra("");
}
function removeSubExtra(idx: number) {
setDraftSubExtras((cur) => cur.filter((_, i) => i !== idx));
}
function updateSubExtra(idx: number, value: string) {
setDraftSubExtras((cur) => cur.map((v, i) => (i === idx ? value : v)));
}
async function browseExtra(idx: number) {
setPicking(`extra-${idx}`);
try {
const p = await pickFolder(draftSubExtras[idx] ?? "");
if (p) updateSubExtra(idx, p);
} finally {
setPicking(null);
}
}
async function browseNewExtra() {
setPicking("new");
try {
const p = await pickFolder(newSubExtra);
if (p) {
if (!draftSubExtras.includes(p)) {
setDraftSubExtras((cur) => [...cur, p]);
setNewSubExtra("");
} else {
setNewSubExtra(p);
}
}
} finally {
setPicking(null);
}
}
async function save() {
setSaving(true);
setError(null);
try {
await setSubtitleExtraPaths(draftSubExtras);
start(() => router.refresh());
} catch (e) {
setError((e as Error).message);
} finally {
setSaving(false);
}
}
return (
<div>
<div className="text-sm font-medium mb-1 flex items-center gap-1.5">
<Captions className="w-3.5 h-3.5 text-[var(--color-cyan)]" />
Subtitle Library Folders
</div>
<div className="text-xs text-[var(--color-fg-muted)] mb-2">
Recursively scanned for subtitle sidecars matching the playing video&apos;s stem or code (depth 3).
The player always finds same-folder sidecars; configure these only if you keep subtitles in a separate location.
</div>
<div className="space-y-1.5">
{draftSubExtras.length === 0 ? (
<div className="text-xs text-[var(--color-fg-muted)] italic px-3 py-2 rounded-lg border border-dashed border-[var(--color-glass-border)]">
No subtitle folders.
</div>
) : (
draftSubExtras.map((p, i) => (
<div key={i} className="flex items-center gap-2">
<div className="relative flex-1">
<Folder className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--color-fg-muted)] pointer-events-none" />
<input
type="text"
value={p}
onChange={(e) => updateSubExtra(i, e.target.value)}
className="w-full glass rounded-lg pl-9 pr-3 py-1.5 text-sm font-mono outline-none focus:border-[var(--color-cyan)]"
/>
</div>
<button
type="button"
onClick={() => browseExtra(i)}
disabled={picking !== null}
title="Browse for folder"
className="p-1.5 rounded-lg glass glass-hover disabled:opacity-40"
>
{picking === `extra-${i}` ? <Loader2 className="w-4 h-4 animate-spin" /> : <FolderOpen className="w-4 h-4" />}
</button>
<button
type="button"
onClick={() => removeSubExtra(i)}
title="Remove"
className="p-1.5 rounded-lg border border-red-500/30 text-red-300 hover:bg-red-500/10"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
))
)}
</div>
<div className="flex items-center gap-2 mt-2">
<div className="relative flex-1">
<Folder className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--color-fg-muted)] pointer-events-none" />
<input
type="text"
value={newSubExtra}
onChange={(e) => setNewSubExtra(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); addSubExtra(); } }}
placeholder="D:\\Subtitles"
className="w-full glass rounded-lg pl-9 pr-3 py-1.5 text-sm font-mono outline-none focus:border-[var(--color-cyan)]"
/>
</div>
<button
type="button"
onClick={browseNewExtra}
disabled={picking !== null}
title="Browse for folder"
className="inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg glass glass-hover disabled:opacity-40"
>
{picking === "new" ? <Loader2 className="w-4 h-4 animate-spin" /> : <FolderOpen className="w-4 h-4" />}
Browse
</button>
<button
type="button"
onClick={addSubExtra}
disabled={!newSubExtra.trim()}
className="inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg glass glass-hover disabled:opacity-40"
>
<Plus className="w-4 h-4" /> Add
</button>
<button
type="button"
onClick={save}
disabled={!dirty || saving}
className="inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg bg-[var(--color-cyan)] text-black font-medium disabled:opacity-40 disabled:cursor-not-allowed"
>
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
Save
</button>
</div>
{error && (
<div className="mt-2 text-xs text-red-300 bg-red-500/5 border border-red-500/25 rounded-lg px-3 py-2">
{error}
</div>
)}
<SubtitleCacheLimit />
</div>
);
}
function SubtitleCacheLimit() {
const { settings } = useSettings();
const router = useRouter();
const [, start] = useTransition();
const [draft, setDraft] = useState<number>(settings.subtitleCacheLimitMb ?? 100);
const [saving, setSaving] = useState(false);
useEffect(() => { setDraft(settings.subtitleCacheLimitMb ?? 100); }, [settings.subtitleCacheLimitMb]);
const dirty = draft !== (settings.subtitleCacheLimitMb ?? 100);
async function save() {
setSaving(true);
try {
await setSubtitleCacheLimitMb(Math.max(0, Math.floor(draft)));
start(() => router.refresh());
} finally {
setSaving(false);
}
}
return (
<div className="mt-section pt-section border-t border-[var(--color-glass-border)]">
<div className="text-sm font-medium mb-1">Subtitle Cache Size Limit</div>
<div className="text-xs text-[var(--color-fg-muted)] mb-2 max-w-2xl">
Soft cap on <span className="font-mono">data/subtitle-cache/</span> (converted WebVTT files).
When exceeded, oldest entries get evicted until size drops below 80% of the cap.{" "}
<span className="font-mono">0</span> = unlimited.
</div>
<div className="flex items-center gap-2">
<input
type="number"
min={0}
value={draft}
onChange={(e) => setDraft(Math.max(0, Number(e.target.value) || 0))}
className="w-28 glass rounded-lg px-3 py-1.5 text-sm font-mono outline-none focus:border-[var(--color-cyan)]"
/>
<span className="text-xs text-[var(--color-fg-dim)]">MB</span>
<button
type="button"
onClick={save}
disabled={!dirty || saving}
className="inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg bg-[var(--color-cyan)] text-black font-medium disabled:opacity-40 disabled:cursor-not-allowed"
>
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
Save
</button>
</div>
</div>
);
}