All checks were successful
Build and Push Docker Image / build (push) Successful in 2m48s
ProcessingColumn now anchors a local deadline when a 'sleeping' queue status arrives and ticks a 1s timer. "Sleeping 60s between jobs" becomes "Next job in 59s, 58s, …". Settings: API key inputs now span the card's width (matching the URL field), and the reveal affordance is a GNOME-style eye glyph sitting inside the input's right edge. Uses an inline SVG so it inherits currentColor and doesn't fight emoji rendering across OSes. When the field is env-locked, the lock glyph takes the slot (eye hidden — no edit possible anyway). v2026.04.15.5 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
629 lines
19 KiB
TypeScript
629 lines
19 KiB
TypeScript
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<string, string>;
|
|
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<HTMLInputElement>) {
|
|
return (
|
|
<div className="relative">
|
|
<Input {...props} disabled={locked || props.disabled} className={locked ? "pr-9" : ""} />
|
|
{locked && (
|
|
<span
|
|
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-[0.9rem] opacity-40 pointer-events-none select-none"
|
|
title="Set via environment variable — edit your .env file to change this value"
|
|
>
|
|
🔒
|
|
</span>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 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 (
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
width="16"
|
|
height="16"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
aria-hidden="true"
|
|
>
|
|
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94" />
|
|
<path d="M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19" />
|
|
<path d="M14.12 14.12a3 3 0 1 1-4.24-4.24" />
|
|
<line x1="1" y1="1" x2="23" y2="23" />
|
|
</svg>
|
|
);
|
|
}
|
|
return (
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
width="16"
|
|
height="16"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
aria-hidden="true"
|
|
>
|
|
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
|
|
<circle cx="12" cy="12" r="3" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 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 (
|
|
<div className={`relative ${className ?? ""}`}>
|
|
<Input
|
|
type={revealed || !isMasked ? "text" : "password"}
|
|
value={value}
|
|
disabled={locked}
|
|
onChange={(e) => onChange(e.target.value)}
|
|
placeholder={placeholder}
|
|
className="pr-9"
|
|
/>
|
|
{locked ? (
|
|
<span
|
|
className="absolute inset-y-0 right-0 flex items-center pr-2.5 text-[0.9rem] opacity-40 pointer-events-none select-none"
|
|
title="Set via environment variable — edit your .env file to change this value"
|
|
>
|
|
🔒
|
|
</span>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
onClick={toggle}
|
|
tabIndex={-1}
|
|
className="absolute inset-y-0 right-0 flex items-center px-2.5 text-gray-400 hover:text-gray-700 focus:outline-none focus-visible:text-gray-700"
|
|
title={revealed ? "Hide" : "Reveal"}
|
|
aria-label={revealed ? "Hide secret" : "Reveal secret"}
|
|
>
|
|
<EyeIcon open={revealed} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Section card ──────────────────────────────────────────────────────────────
|
|
|
|
function SectionCard({
|
|
title,
|
|
subtitle,
|
|
children,
|
|
}: {
|
|
title: React.ReactNode;
|
|
subtitle?: React.ReactNode;
|
|
children: React.ReactNode;
|
|
}) {
|
|
return (
|
|
<div className="border border-gray-200 rounded-lg p-4 mb-4">
|
|
<div className="font-semibold text-sm mb-1">{title}</div>
|
|
{subtitle && <p className="text-gray-500 text-sm mb-3 mt-0">{subtitle}</p>}
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── 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 (
|
|
<div>
|
|
{langs.length > 0 && (
|
|
<div className="space-y-1 mb-2">
|
|
{langs.map((code, i) => {
|
|
const label = LANG_NAMES[code] ?? code.toUpperCase();
|
|
return (
|
|
<div key={code} className="flex items-center gap-1.5 text-sm">
|
|
<button
|
|
type="button"
|
|
disabled={disabled || i === 0}
|
|
onClick={() => move(i, -1)}
|
|
aria-label={`Move ${label} up`}
|
|
className="w-6 h-6 flex items-center justify-center rounded border border-gray-200 bg-white text-gray-500 hover:bg-gray-50 disabled:opacity-30 disabled:cursor-not-allowed cursor-pointer text-xs"
|
|
>
|
|
↑
|
|
</button>
|
|
<button
|
|
type="button"
|
|
disabled={disabled || i === langs.length - 1}
|
|
onClick={() => move(i, 1)}
|
|
aria-label={`Move ${label} down`}
|
|
className="w-6 h-6 flex items-center justify-center rounded border border-gray-200 bg-white text-gray-500 hover:bg-gray-50 disabled:opacity-30 disabled:cursor-not-allowed cursor-pointer text-xs"
|
|
>
|
|
↓
|
|
</button>
|
|
<span className="min-w-[8rem]">
|
|
{label} <span className="text-gray-400 text-xs font-mono">({code})</span>
|
|
</span>
|
|
<button
|
|
type="button"
|
|
disabled={disabled}
|
|
onClick={() => remove(i)}
|
|
aria-label={`Remove ${label}`}
|
|
className="text-red-400 hover:text-red-600 text-sm border-0 bg-transparent cursor-pointer disabled:opacity-30 disabled:cursor-not-allowed"
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
{!disabled && available.length > 0 && (
|
|
<Select
|
|
value=""
|
|
onChange={(e) => {
|
|
add(e.target.value);
|
|
e.target.value = "";
|
|
}}
|
|
className="text-sm max-w-[14rem]"
|
|
>
|
|
<option value="">+ Add language…</option>
|
|
{available.map(({ code, label }) => (
|
|
<option key={code} value={code}>
|
|
{label} ({code})
|
|
</option>
|
|
))}
|
|
</Select>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Connection section ────────────────────────────────────────────────────────
|
|
|
|
function ConnSection({
|
|
title,
|
|
subtitle,
|
|
cfg,
|
|
locked,
|
|
urlKey,
|
|
apiKey: apiKeyProp,
|
|
urlPlaceholder,
|
|
onSave,
|
|
children,
|
|
}: {
|
|
title: React.ReactNode;
|
|
subtitle?: React.ReactNode;
|
|
cfg: Record<string, string>;
|
|
locked: Set<string>;
|
|
urlKey: string;
|
|
apiKey: string;
|
|
urlPlaceholder: string;
|
|
onSave: (url: string, apiKey: string) => Promise<SaveResult>;
|
|
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 (
|
|
<SectionCard title={title} subtitle={subtitle}>
|
|
<label className="block text-sm text-gray-700 mb-1">
|
|
URL
|
|
<LockedInput
|
|
locked={locked.has(urlKey)}
|
|
type="url"
|
|
value={url}
|
|
onChange={(e) => setUrl(e.target.value)}
|
|
placeholder={urlPlaceholder}
|
|
className="mt-0.5"
|
|
/>
|
|
</label>
|
|
<label className="block text-sm text-gray-700 mb-1 mt-3">
|
|
API Key
|
|
<SecretInput
|
|
configKey={apiKeyProp}
|
|
locked={locked.has(apiKeyProp)}
|
|
value={key}
|
|
onChange={setKey}
|
|
placeholder="your-api-key"
|
|
className="mt-0.5"
|
|
/>
|
|
</label>
|
|
<div className="flex items-center gap-2 mt-3">
|
|
<Button onClick={save} disabled={saving || (locked.has(urlKey) && locked.has(apiKeyProp))}>
|
|
{saving ? "Saving…" : "Test & Save"}
|
|
</Button>
|
|
{status?.kind === "saved" && <span className="text-sm text-green-700">✓ Saved & connected</span>}
|
|
{status?.kind === "saved-test-failed" && (
|
|
<span className="text-sm text-amber-700" title={status.error}>
|
|
⚠ Saved, but connection test failed: {status.error}
|
|
</span>
|
|
)}
|
|
{status?.kind === "validation-error" && <span className="text-sm text-red-600">✗ {status.error}</span>}
|
|
{status?.kind === "request-error" && <span className="text-sm text-red-600">✗ {status.error}</span>}
|
|
</div>
|
|
{children && <div className="mt-5 pt-4 border-t border-gray-200">{children}</div>}
|
|
</SectionCard>
|
|
);
|
|
}
|
|
|
|
// ─── Schedule section ─────────────────────────────────────────────────────────
|
|
|
|
function WindowEditor({
|
|
label,
|
|
window,
|
|
onChange,
|
|
}: {
|
|
label: string;
|
|
window: ScheduleWindow;
|
|
onChange: (w: ScheduleWindow) => void;
|
|
}) {
|
|
return (
|
|
<div className="mb-3">
|
|
<label className="flex items-center gap-2 text-sm text-gray-700 mb-1.5">
|
|
<input
|
|
type="checkbox"
|
|
checked={window.enabled}
|
|
onChange={(e) => onChange({ ...window, enabled: e.target.checked })}
|
|
/>
|
|
{label}
|
|
</label>
|
|
{window.enabled && (
|
|
<div className="flex items-center gap-2 pl-5">
|
|
<TimeInput value={window.start} onChange={(next) => onChange({ ...window, start: next })} />
|
|
<span className="text-xs text-gray-500">to</span>
|
|
<TimeInput value={window.end} onChange={(next) => onChange({ ...window, end: next })} />
|
|
<span className="text-xs text-gray-500">(24h, overnight ranges wrap midnight)</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ScheduleSection() {
|
|
const [cfg, setCfg] = useState<ScheduleConfig | null>(null);
|
|
const [saved, setSaved] = useState("");
|
|
|
|
useEffect(() => {
|
|
api.get<ScheduleConfig>("/api/settings/schedule").then(setCfg);
|
|
}, []);
|
|
|
|
const save = async () => {
|
|
if (!cfg) return;
|
|
const next = await api.patch<ScheduleConfig>("/api/settings/schedule", cfg);
|
|
setCfg(next);
|
|
setSaved("Saved.");
|
|
setTimeout(() => setSaved(""), 2000);
|
|
};
|
|
|
|
if (!cfg) return null;
|
|
|
|
return (
|
|
<SectionCard
|
|
title="Schedule"
|
|
subtitle="Restrict when the app is allowed to scan Jellyfin and process files. Useful when this container runs as an always-on service but you only want it to do real work at night."
|
|
>
|
|
<WindowEditor label="Scan window" window={cfg.scan} onChange={(scan) => setCfg({ ...cfg, scan })} />
|
|
<WindowEditor label="Processing window" window={cfg.process} onChange={(process) => setCfg({ ...cfg, process })} />
|
|
<label className="block text-sm text-gray-700 mt-4">
|
|
Sleep between jobs (seconds)
|
|
<Input
|
|
type="number"
|
|
min={0}
|
|
value={cfg.job_sleep_seconds}
|
|
onChange={(e) => setCfg({ ...cfg, job_sleep_seconds: Number.parseInt(e.target.value, 10) || 0 })}
|
|
className="mt-0.5 w-28"
|
|
/>
|
|
</label>
|
|
<div className="flex items-center gap-2 mt-3">
|
|
<Button onClick={save}>Save</Button>
|
|
{saved && <span className="text-green-700 text-sm">{saved}</span>}
|
|
</div>
|
|
</SectionCard>
|
|
);
|
|
}
|
|
|
|
// ─── Setup page ───────────────────────────────────────────────────────────────
|
|
|
|
export function SettingsPage() {
|
|
const [data, setData] = useState<SettingsData | null>(settingsCache);
|
|
const [loading, setLoading] = useState(settingsCache === null);
|
|
const [clearStatus, setClearStatus] = useState("");
|
|
const [audLangs, setAudLangs] = useState<string[]>([]);
|
|
const [audSaved, setAudSaved] = useState("");
|
|
const langsLoadedRef = useRef(false);
|
|
|
|
const load = useCallback(() => {
|
|
if (!settingsCache) setLoading(true);
|
|
api
|
|
.get<SettingsData>("/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 <div className="text-gray-400 py-8 text-center">Loading…</div>;
|
|
if (!data) return <div className="text-red-600">Failed to load settings.</div>;
|
|
|
|
const { config: cfg, envLocked: envLockedArr } = data;
|
|
const locked = new Set(envLockedArr);
|
|
const saveJellyfin = (url: string, apiKey: string) =>
|
|
api.post<SaveResult>("/api/settings/jellyfin", { url, api_key: apiKey });
|
|
const saveRadarr = (url: string, apiKey: string) =>
|
|
api.post<SaveResult>("/api/settings/radarr", { url, api_key: apiKey });
|
|
const saveSonarr = (url: string, apiKey: string) =>
|
|
api.post<SaveResult>("/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 (
|
|
<div>
|
|
<div className="flex items-center gap-3 mb-4">
|
|
<h1 className="text-xl font-bold m-0">Settings</h1>
|
|
</div>
|
|
|
|
{/* Jellyfin (with nested MQTT webhook config) */}
|
|
<ConnSection
|
|
title="Jellyfin"
|
|
urlKey="jellyfin_url"
|
|
apiKey="jellyfin_api_key"
|
|
urlPlaceholder="http://192.168.1.100:8096"
|
|
cfg={cfg}
|
|
locked={locked}
|
|
onSave={saveJellyfin}
|
|
>
|
|
<MqttSection cfg={cfg} locked={locked} />
|
|
</ConnSection>
|
|
|
|
{/* Radarr */}
|
|
<ConnSection
|
|
title={
|
|
<>
|
|
Radarr <span className="text-gray-400 font-normal">(optional)</span>
|
|
</>
|
|
}
|
|
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 */}
|
|
<ConnSection
|
|
title={
|
|
<>
|
|
Sonarr <span className="text-gray-400 font-normal">(optional)</span>
|
|
</>
|
|
}
|
|
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 */}
|
|
<ScheduleSection />
|
|
|
|
{/* Audio languages */}
|
|
<SectionCard
|
|
title="Audio Languages"
|
|
subtitle="Additional audio languages to keep alongside the original language. Order determines stream priority in the output file. The original language is always kept first."
|
|
>
|
|
<SortableLanguageList langs={audLangs} onChange={setAudLangs} disabled={locked.has("audio_languages")} />
|
|
<div className="flex items-center gap-2 mt-3">
|
|
<Button onClick={saveAudioLangs} disabled={locked.has("audio_languages")}>
|
|
Save
|
|
</Button>
|
|
{audSaved && <span className="text-green-700 text-sm">{audSaved}</span>}
|
|
</div>
|
|
</SectionCard>
|
|
|
|
{/* Danger zone */}
|
|
<div className="border border-red-400 rounded-lg p-4 mb-4">
|
|
<div className="font-semibold text-sm text-red-700 mb-1">Danger Zone</div>
|
|
<p className="text-gray-500 text-sm mb-3">
|
|
These actions are irreversible. Scan data can be regenerated by running a new scan.
|
|
</p>
|
|
<div className="flex items-center gap-4 mb-3">
|
|
<Button variant="danger" onClick={clearScan}>
|
|
Clear all scan data
|
|
</Button>
|
|
<span className="text-gray-400 text-sm">Removes all scanned items, review plans, and jobs. Keeps settings.</span>
|
|
</div>
|
|
<div className="flex items-center gap-4">
|
|
<Button variant="danger" onClick={factoryReset}>
|
|
Reset to first-run state
|
|
</Button>
|
|
<span className="text-gray-400 text-sm">
|
|
Wipes everything — scan data and settings. Sends you back to the setup wizard.
|
|
</span>
|
|
</div>
|
|
{clearStatus && <p className="text-green-700 text-sm mt-2">{clearStatus}</p>}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
import type React from "react";
|