import { useCallback, useEffect, useRef, useState } from "react"; import { Button } from "~/shared/components/ui/button"; import { Input } from "~/shared/components/ui/input"; import { Select } from "~/shared/components/ui/select"; import { TimeInput } from "~/shared/components/ui/time-input"; import { api } from "~/shared/lib/api"; import { LANG_NAMES } from "~/shared/lib/lang"; import { MqttSection } from "./MqttSection"; interface ScheduleWindow { enabled: boolean; start: string; end: string; } interface ScheduleConfig { job_sleep_seconds: number; scan: ScheduleWindow; process: ScheduleWindow; } interface SettingsData { config: Record; envLocked: string[]; } /** Server response from /api/settings/{jellyfin,radarr,sonarr}. */ interface SaveResult { ok: boolean; // connection test passed saved?: boolean; // values were persisted (true even when test failed) testError?: string; error?: string; // validation error (URL/API key required) } let settingsCache: SettingsData | null = null; const LANGUAGE_OPTIONS = Object.entries(LANG_NAMES).map(([code, label]) => ({ code, label })); // ─── Locked input ───────────────────────────────────────────────────────────── function LockedInput({ locked, ...props }: { locked: boolean } & React.InputHTMLAttributes) { return (
{locked && ( 🔒 )}
); } // Note: the section-header EnvBadge was removed — the per-input lock icon // (LockedInput) already signals when a value is env-controlled, the badge // was duplicate noise. // ─── Secret input (password-masked with eye-icon reveal) ────────────────────── function EyeIcon({ open }: { open: boolean }) { // GNOME-style eye / crossed-eye glyphs as inline SVG so they inherit // currentColor instead of fighting emoji rendering across OSes. if (open) { return ( ); } return ( ); } /** * Input for API keys / passwords. Shows "***" masked when the server returns * a secret value (the raw key never reaches this component by default). Eye * icon fetches the real value via /api/settings/reveal and shows it. Users * can also type a new value directly — any edit clears the masked state. */ function SecretInput({ configKey, locked, value, onChange, placeholder, className, }: { configKey: string; locked: boolean; value: string; onChange: (next: string) => void; placeholder?: string; className?: string; }) { const [revealed, setRevealed] = useState(false); const isMasked = value === "***"; const toggle = async () => { if (revealed) { setRevealed(false); return; } if (isMasked) { try { const res = await api.get<{ value: string }>(`/api/settings/reveal?key=${encodeURIComponent(configKey)}`); onChange(res.value); } catch { /* ignore — keep masked */ } } setRevealed(true); }; return (
onChange(e.target.value)} placeholder={placeholder} className="pr-9" /> {locked ? ( 🔒 ) : ( )}
); } // ─── Section card ────────────────────────────────────────────────────────────── function SectionCard({ title, subtitle, children, }: { title: React.ReactNode; subtitle?: React.ReactNode; children: React.ReactNode; }) { return (
{title}
{subtitle &&

{subtitle}

} {children}
); } // ─── Sortable language list ───────────────────────────────────────────────────── function SortableLanguageList({ langs, onChange, disabled, }: { langs: string[]; onChange: (langs: string[]) => void; disabled: boolean; }) { const available = LANGUAGE_OPTIONS.filter((o) => !langs.includes(o.code)); const move = (idx: number, dir: -1 | 1) => { const next = [...langs]; const target = idx + dir; if (target < 0 || target >= next.length) return; [next[idx], next[target]] = [next[target], next[idx]]; onChange(next); }; const remove = (idx: number) => onChange(langs.filter((_, i) => i !== idx)); const add = (code: string) => { if (code && !langs.includes(code)) onChange([...langs, code]); }; return (
{langs.length > 0 && (
{langs.map((code, i) => { const label = LANG_NAMES[code] ?? code.toUpperCase(); return (
{label} ({code})
); })}
)} {!disabled && available.length > 0 && ( )}
); } // ─── Connection section ──────────────────────────────────────────────────────── function ConnSection({ title, subtitle, cfg, locked, urlKey, apiKey: apiKeyProp, urlPlaceholder, onSave, children, }: { title: React.ReactNode; subtitle?: React.ReactNode; cfg: Record; locked: Set; urlKey: string; apiKey: string; urlPlaceholder: string; onSave: (url: string, apiKey: string) => Promise; children?: React.ReactNode; }) { const [url, setUrl] = useState(cfg[urlKey] ?? ""); const [key, setKey] = useState(cfg[apiKeyProp] ?? ""); const [status, setStatus] = useState< | { kind: "saved" } | { kind: "saved-test-failed"; error: string } | { kind: "validation-error"; error: string } | { kind: "request-error"; error: string } | null >(null); const [saving, setSaving] = useState(false); const save = async () => { setSaving(true); setStatus(null); try { const result = await onSave(url, key); if (result.saved && result.ok) setStatus({ kind: "saved" }); else if (result.saved) setStatus({ kind: "saved-test-failed", error: result.testError ?? "Connection test failed" }); else setStatus({ kind: "validation-error", error: result.error ?? "Save failed" }); } catch (e) { setStatus({ kind: "request-error", error: String(e) }); } setSaving(false); }; return (
{status?.kind === "saved" && ✓ Saved & connected} {status?.kind === "saved-test-failed" && ( ⚠ Saved, but connection test failed: {status.error} )} {status?.kind === "validation-error" && ✗ {status.error}} {status?.kind === "request-error" && ✗ {status.error}}
{children &&
{children}
}
); } // ─── Schedule section ───────────────────────────────────────────────────────── function WindowEditor({ label, window, onChange, }: { label: string; window: ScheduleWindow; onChange: (w: ScheduleWindow) => void; }) { return (
{window.enabled && (
onChange({ ...window, start: next })} /> to onChange({ ...window, end: next })} /> (24h, overnight ranges wrap midnight)
)}
); } function ScheduleSection() { const [cfg, setCfg] = useState(null); const [saved, setSaved] = useState(""); useEffect(() => { api.get("/api/settings/schedule").then(setCfg); }, []); const save = async () => { if (!cfg) return; const next = await api.patch("/api/settings/schedule", cfg); setCfg(next); setSaved("Saved."); setTimeout(() => setSaved(""), 2000); }; if (!cfg) return null; return ( setCfg({ ...cfg, scan })} /> setCfg({ ...cfg, process })} />
{saved && {saved}}
); } // ─── Setup page ─────────────────────────────────────────────────────────────── export function SettingsPage() { const [data, setData] = useState(settingsCache); const [loading, setLoading] = useState(settingsCache === null); const [clearStatus, setClearStatus] = useState(""); const [audLangs, setAudLangs] = useState([]); const [audSaved, setAudSaved] = useState(""); const langsLoadedRef = useRef(false); const load = useCallback(() => { if (!settingsCache) setLoading(true); api .get("/api/settings") .then((d) => { settingsCache = d; setData(d); if (!langsLoadedRef.current) { let parsed: string[] = []; try { const raw = JSON.parse(d.config.audio_languages ?? "[]"); if (Array.isArray(raw)) parsed = raw.filter((x): x is string => typeof x === "string"); } catch (e) { console.warn("audio_languages config is not valid JSON, defaulting to []", e); } setAudLangs(parsed); langsLoadedRef.current = true; } }) .finally(() => setLoading(false)); }, []); useEffect(() => { load(); }, [load]); if (loading && !data) return
Loading…
; if (!data) return
Failed to load settings.
; const { config: cfg, envLocked: envLockedArr } = data; const locked = new Set(envLockedArr); const saveJellyfin = (url: string, apiKey: string) => api.post("/api/settings/jellyfin", { url, api_key: apiKey }); const saveRadarr = (url: string, apiKey: string) => api.post("/api/settings/radarr", { url, api_key: apiKey }); const saveSonarr = (url: string, apiKey: string) => api.post("/api/settings/sonarr", { url, api_key: apiKey }); const saveAudioLangs = async () => { await api.post("/api/settings/audio-languages", { langs: audLangs }); setAudSaved("Saved."); setTimeout(() => setAudSaved(""), 2000); }; const clearScan = async () => { if ( !confirm( "Delete all scanned data? This will remove all media items, stream decisions, review plans, and jobs. This cannot be undone.", ) ) return; await api.post("/api/settings/clear-scan"); setClearStatus("Cleared."); }; const factoryReset = async () => { if ( !confirm( "Reset to first-run state? This wipes EVERYTHING — scan data, settings, languages, schedule, Jellyfin/Radarr/Sonarr credentials. You'll land back on the setup wizard. This cannot be undone.", ) ) return; if (!confirm("Really reset? Type-nothing-just-click to confirm.")) return; await api.post("/api/settings/reset"); // Invalidate client-side caches and reload so the setup gate re-evaluates. settingsCache = null; window.location.href = "/"; }; return (

Settings

{/* Jellyfin (with nested MQTT webhook config) */} {/* Radarr */} Radarr (optional) } subtitle="Provides accurate original-language data for movies." urlKey="radarr_url" apiKey="radarr_api_key" urlPlaceholder="http://192.168.1.100:7878" cfg={cfg} locked={locked} onSave={saveRadarr} /> {/* Sonarr */} Sonarr (optional) } subtitle="Provides original-language data for TV series." urlKey="sonarr_url" apiKey="sonarr_api_key" urlPlaceholder="http://192.168.1.100:8989" cfg={cfg} locked={locked} onSave={saveSonarr} /> {/* Schedule */} {/* Audio languages */}
{audSaved && {audSaved}}
{/* Danger zone */}
Danger Zone

These actions are irreversible. Scan data can be regenerated by running a new scan.

Removes all scanned items, review plans, and jobs. Keeps settings.
Wipes everything — scan data and settings. Sends you back to the setup wizard.
{clearStatus &&

{clearStatus}

}
); } import type React from "react";