fix(admin-ui): Plex library toggle inversion + Purge-discovery confirm (+ cleanups)

Bugs:
- ConfigPanel (CB1): unchecking a Plex library from the "all" (empty) state made
  that library the ONLY selected one instead of all-except-it, because the toggle
  ADDED the key to an empty list. Expand "all" to the explicit section set before
  toggling. E2E-verified: uncheck from all → all-except-one.
- Scheduler (SB1): "Purge discovery" bulk-deleted search videos/videos/channels
  immediately with no confirm (every AdminUsers destructive action uses useConfirm).
  Wrapped in a danger useConfirm dialog (+ trilingual purgeConfirmTitle key).
  E2E-verified: dialog appears, Cancel aborts.

Cleanups (behavior-neutral):
- Register LS.configTab; ConfigPanel uses it instead of the bare "siftlode.configTab".
- ConfigPanel + SettingsPanel import SaveState from ui/DraftSaveBar instead of
  redeclaring the union (PrefsSaveState is now an alias).

tsc green, re-review clean, localdev boots.
This commit is contained in:
npeter83 2026-07-11 19:44:53 +02:00
parent a6ffcf9577
commit 3d00d75863
7 changed files with 28 additions and 9 deletions

View file

@ -7,7 +7,8 @@ import { notify } from "../lib/notifications";
import Tooltip from "./Tooltip"; import Tooltip from "./Tooltip";
import Tabs, { usePersistedTab } from "./Tabs"; import Tabs, { usePersistedTab } from "./Tabs";
import { Switch } from "./ui/form"; import { Switch } from "./ui/form";
import { DraftSaveBar } from "./ui/DraftSaveBar"; import { DraftSaveBar, type SaveState } from "./ui/DraftSaveBar";
import { LS } from "../lib/storage";
// Admin Configuration page: edit DB-overridable operational settings (registry-driven from the // Admin Configuration page: edit DB-overridable operational settings (registry-driven from the
// backend — adding a key there makes it appear here automatically). Edits are drafted and // backend — adding a key there makes it appear here automatically). Edits are drafted and
@ -15,7 +16,6 @@ import { DraftSaveBar } from "./ui/DraftSaveBar";
// the server until Save. Group order is fixed where known so logically related settings (e.g. // the server until Save. Group order is fixed where known so logically related settings (e.g.
// Email/SMTP) stay together. // Email/SMTP) stay together.
const GROUP_ORDER = ["access", "email", "youtube", "google", "quota", "backfill", "shorts", "batch", "downloads", "plex"]; const GROUP_ORDER = ["access", "email", "youtube", "google", "quota", "backfill", "shorts", "batch", "downloads", "plex"];
type SaveState = "idle" | "saving" | "saved" | "error";
export default function ConfigPanel() { export default function ConfigPanel() {
const { t } = useTranslation(); const { t } = useTranslation();
@ -40,7 +40,7 @@ export default function ConfigPanel() {
const [saveState, setSaveState] = useState<SaveState>("idle"); const [saveState, setSaveState] = useState<SaveState>("idle");
// One tab per config group (persisted). Called before the loading guard so hook order is // One tab per config group (persisted). Called before the loading guard so hook order is
// stable; the active id is clamped to a real group at render time once data has loaded. // stable; the active id is clamped to a real group at render time once data has loaded.
const [tab, setTab] = usePersistedTab("siftlode.configTab"); const [tab, setTab] = usePersistedTab(LS.configTab);
// Re-seed the draft whenever the server data changes (initial load + after a save/reset). // Re-seed the draft whenever the server data changes (initial load + after a save/reset).
useEffect(() => { useEffect(() => {
@ -110,9 +110,14 @@ export default function ConfigPanel() {
}); });
const plexLibs = (draft["plex_libraries"] ?? "").split(",").map((s) => s.trim()).filter(Boolean); const plexLibs = (draft["plex_libraries"] ?? "").split(",").map((s) => s.trim()).filter(Boolean);
const togglePlexLib = (key: string) => { const togglePlexLib = (key: string) => {
const next = plexLibs.includes(key) // An empty list means "all libraries" — every box renders checked. Unchecking one from that
? plexLibs.filter((k) => k !== key) // state must select all-except-this, so expand "all" to the explicit section set first;
: [...plexLibs, key]; // otherwise the toggle would ADD the key and leave it as the ONLY selected library.
const allKeys = plexResult?.sections?.map((s) => s.key) ?? [];
const current = plexLibs.length === 0 ? allKeys : plexLibs;
const next = current.includes(key)
? current.filter((k) => k !== key)
: [...current, key];
setDraft((d) => ({ ...d, plex_libraries: next.join(",") })); setDraft((d) => ({ ...d, plex_libraries: next.join(",") }));
}; };

View file

@ -18,6 +18,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import { api, type SchedulerJob, type SchedulerStatus } from "../lib/api"; import { api, type SchedulerJob, type SchedulerStatus } from "../lib/api";
import { useLiveQuery } from "../lib/useLiveQuery"; import { useLiveQuery } from "../lib/useLiveQuery";
import { useConfirm } from "./ConfirmProvider";
import { relativeTime } from "../lib/format"; import { relativeTime } from "../lib/format";
import { notify } from "../lib/notifications"; import { notify } from "../lib/notifications";
import Tooltip from "./Tooltip"; import Tooltip from "./Tooltip";
@ -309,6 +310,7 @@ function Stat({
export default function Scheduler() { export default function Scheduler() {
const { t } = useTranslation(); const { t } = useTranslation();
const qc = useQueryClient(); const qc = useQueryClient();
const confirm = useConfirm();
// Poll faster while any job is running so live progress is smooth, then ease back when // Poll faster while any job is running so live progress is smooth, then ease back when
// idle. Derived from the freshest data by react-query itself (see useLiveQuery). // idle. Derived from the freshest data by react-query itself (see useLiveQuery).
const q = useLiveQuery<SchedulerStatus>(["scheduler"], api.schedulerStatus, { const q = useLiveQuery<SchedulerStatus>(["scheduler"], api.schedulerStatus, {
@ -411,7 +413,15 @@ export default function Scheduler() {
</Tooltip> </Tooltip>
<Tooltip hint={t("scheduler.purgeDiscoveryHint")}> <Tooltip hint={t("scheduler.purgeDiscoveryHint")}>
<button <button
onClick={() => purgeMut.mutate()} onClick={async () => {
const ok = await confirm({
title: t("scheduler.purgeConfirmTitle"),
message: t("scheduler.purgeDiscoveryHint"),
confirmLabel: t("scheduler.purgeDiscovery"),
danger: true,
});
if (ok) purgeMut.mutate();
}}
disabled={purgeMut.isPending} disabled={purgeMut.isPending}
className="glass-card glass-hover flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition" className="glass-card glass-hover flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition"
> >

View file

@ -9,14 +9,14 @@ import Avatar from "./Avatar";
import { notify, type NotifSettings } from "../lib/notifications"; import { notify, type NotifSettings } from "../lib/notifications";
import Tooltip from "./Tooltip"; import Tooltip from "./Tooltip";
import { Section, SettingRow, Switch } from "./ui/form"; import { Section, SettingRow, Switch } from "./ui/form";
import { DraftSaveBar } from "./ui/DraftSaveBar"; import { DraftSaveBar, type SaveState } from "./ui/DraftSaveBar";
import { useConfirm } from "./ConfirmProvider"; import { useConfirm } from "./ConfirmProvider";
// The Settings page edits server-persisted preferences as a draft: changes apply locally // The Settings page edits server-persisted preferences as a draft: changes apply locally
// for instant preview but reach the server only on an explicit Save (or revert on Discard). // for instant preview but reach the server only on an explicit Save (or revert on Discard).
// App owns the draft + baseline (so the leave-the-page guard can see it); this controller is // App owns the draft + baseline (so the leave-the-page guard can see it); this controller is
// how the panel reads/writes it. See App.tsx. // how the panel reads/writes it. See App.tsx.
export type PrefsSaveState = "idle" | "saving" | "saved" | "error"; export type PrefsSaveState = SaveState;
export interface PrefsController { export interface PrefsController {
theme: ThemePrefs; theme: ThemePrefs;
setTheme: (t: ThemePrefs) => void; setTheme: (t: ThemePrefs) => void;

View file

@ -103,5 +103,6 @@
}, },
"purgeDiscovery": "Entdeckung leeren", "purgeDiscovery": "Entdeckung leeren",
"purgeDiscoveryHint": "Alle nicht behaltenen Entdeckungsinhalte jetzt entfernen (von niemandem angesehene/gespeicherte Suchergebnisse und erkundete, nicht abonnierte Kanäle) — ohne Karenzzeit.", "purgeDiscoveryHint": "Alle nicht behaltenen Entdeckungsinhalte jetzt entfernen (von niemandem angesehene/gespeicherte Suchergebnisse und erkundete, nicht abonnierte Kanäle) — ohne Karenzzeit.",
"purgeConfirmTitle": "Entdeckungsinhalte löschen?",
"purgedDiscovery": "{{count}} Entdeckungselemente entfernt" "purgedDiscovery": "{{count}} Entdeckungselemente entfernt"
} }

View file

@ -103,5 +103,6 @@
}, },
"purgeDiscovery": "Purge discovery", "purgeDiscovery": "Purge discovery",
"purgeDiscoveryHint": "Remove all un-kept discovery content now (search results nobody watched/saved, and explored-but-unsubscribed channels) — ignoring the grace period.", "purgeDiscoveryHint": "Remove all un-kept discovery content now (search results nobody watched/saved, and explored-but-unsubscribed channels) — ignoring the grace period.",
"purgeConfirmTitle": "Purge discovery content?",
"purgedDiscovery": "Removed {{count}} discovery items" "purgedDiscovery": "Removed {{count}} discovery items"
} }

View file

@ -103,5 +103,6 @@
}, },
"purgeDiscovery": "Felfedezés ürítése", "purgeDiscovery": "Felfedezés ürítése",
"purgeDiscoveryHint": "Most eltávolítja az összes meg nem tartott felfedezés-tartalmat (senki által meg nem nézett/mentett keresési találatok és felfedezett, de nem követett csatornák) — a türelmi időt figyelmen kívül hagyva.", "purgeDiscoveryHint": "Most eltávolítja az összes meg nem tartott felfedezés-tartalmat (senki által meg nem nézett/mentett keresési találatok és felfedezett, de nem követett csatornák) — a türelmi időt figyelmen kívül hagyva.",
"purgeConfirmTitle": "Felfedezés-tartalom törlése?",
"purgedDiscovery": "{{count}} felfedezés-elem eltávolítva" "purgedDiscovery": "{{count}} felfedezés-elem eltávolítva"
} }

View file

@ -19,6 +19,7 @@ export const LS = {
channelsTable: "siftlode.channelsTable", channelsTable: "siftlode.channelsTable",
channelDiscoveryTable: "siftlode.channelDiscoveryTable", channelDiscoveryTable: "siftlode.channelDiscoveryTable",
settingsTab: "siftlode.settingsTab", settingsTab: "siftlode.settingsTab",
configTab: "siftlode.configTab",
statsTab: "siftlode.statsTab", statsTab: "siftlode.statsTab",
adminUsersTab: "siftlode.adminUsersTab", adminUsersTab: "siftlode.adminUsersTab",
navCollapsed: "siftlode.navCollapsed", navCollapsed: "siftlode.navCollapsed",