fix(deferred): close frontend UI/UX tails from the hygiene sweep

- player YB2: keepalive pagehide beacon (api.saveProgressBeacon) so the resume
  position isn't lost on F5/close within 5s of the last checkpoint (mirrors Plex).
- player YB4: bound consecutive unplayable items in auto-advance so an
  auto-advancing/loop=all queue can't spin over a run of dead videos.
- downloads B6 (client): localise structured quota/edit errors centrally in
  api.req() via localizeDetail() + Intl.NumberFormat (fixes HU/DE + decimal sep);
  + 3-locale download error strings.
- channels FB1: focusChannelToken bump so re-clicking the same channel re-seeds
  the search box; FB2: syncSubs invalidates feed/feed-count (set can change now).
- config CB2: reseed the ConfigPanel draft on a key-set dataVersion + explicit
  post-save token, so a mid-edit refetch can't clobber an in-progress edit.
- config AB3 (UI): a blank allow_empty field stores "" instead of resetting.
- admin SB2/SC2: confirm + success toast on demo-whitelist remove and deny-invite
  (+ trilingual strings); SC1: disable only the acted-on row (pendingId), not all;
  SB3: pause the 1s scheduler countdown ticker while the tab is hidden.
- messages CT4: only invalidate conversations/unread when the incoming-message
  count actually changed, not on every 20s poll; MB3 (client): react to the live
  unread ping (onUnread → invalidate); MC3: extract e2ee installKey (setup/unlock);
  CC2: hoist POLL_MS to messaging.ts; MB4: document the reload-scoped socket.
This commit is contained in:
2026-07-12 05:59:25 +02:00
parent 229502a61e
commit 5ac30abe1f
19 changed files with 302 additions and 45 deletions
+15 -3
View File
@@ -38,15 +38,23 @@ export default function ConfigPanel() {
// clearing the field, since an empty secret field means "leave unchanged").
const [secretReset, setSecretReset] = useState<Record<string, boolean>>({});
const [saveState, setSaveState] = useState<SaveState>("idle");
// Bumped after our own save so the draft re-seeds from fresh server values; a plain mid-edit
// refetch does NOT (see the effect below).
const [reseedToken, setReseedToken] = useState(0);
// 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.
const [tab, setTab] = usePersistedTab(LS.configTab);
// Re-seed the draft whenever the server data changes (initial load + after a save/reset).
// A stable signature of the config SHAPE — changes only when keys are added/removed, not when an
// unchanged refetch produces a new baseline object. Re-seed the draft on a shape change (initial
// load, registry change) or an explicit post-save bump — so a refetch that resolves mid-edit
// (e.g. an invalidation) can no longer clobber the admin's in-progress draft (CB2).
const dataVersion = useMemo(() => items.map((i) => i.key).join("|"), [items]);
useEffect(() => {
setDraft(baseline);
setSecretReset({});
}, [baseline]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dataVersion, reseedToken]);
const isDirty = (it: ConfigItem): boolean => {
if (it.secret) return (draft[it.key] ?? "").trim() !== "" || !!secretReset[it.key];
@@ -67,12 +75,16 @@ export default function ConfigPanel() {
} else if (it.type === "bool") {
await api.setConfig(key, raw === "true");
} else if (raw === "") {
if (it.is_set) await api.resetConfig(key); // cleared → fall back to default
// allow_empty keys treat a blank as an explicit empty value (e.g. disable smtp_user /
// youtube_api_proxy) rather than resetting to the env default (AB3).
if (it.allow_empty) await api.setConfig(key, "");
else if (it.is_set) await api.resetConfig(key); // cleared → fall back to default
} else {
await api.setConfig(key, it.type === "int" ? Number(raw) : raw);
}
}
await qc.invalidateQueries({ queryKey: ["admin-config"] });
setReseedToken((n) => n + 1); // re-seed the draft from the saved server state
setSaveState("saved");
setTimeout(() => setSaveState((s) => (s === "saved" ? "idle" : s)), 2500);
} catch {