From f0198f2e0a9fde8937e200fc43c8617f15b223a2 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 12 Jul 2026 05:59:25 +0200 Subject: [PATCH] fix(deferred): close frontend UI/UX tails from the hygiene sweep MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- frontend/src/App.tsx | 3 ++ frontend/src/components/AdminUsers.tsx | 48 +++++++++++++++--- frontend/src/components/Channels.tsx | 11 +++- frontend/src/components/ChatDock.tsx | 12 ++++- frontend/src/components/ChatThread.tsx | 17 ++++--- frontend/src/components/ConfigPanel.tsx | 18 +++++-- frontend/src/components/Messages.tsx | 4 +- frontend/src/components/PlayerModal.tsx | 37 +++++++++++++- frontend/src/components/Scheduler.tsx | 28 +++++++++-- frontend/src/i18n/locales/de/downloads.json | 14 +++++- frontend/src/i18n/locales/de/settings.json | 10 +++- frontend/src/i18n/locales/en/downloads.json | 14 +++++- frontend/src/i18n/locales/en/settings.json | 10 +++- frontend/src/i18n/locales/hu/downloads.json | 14 +++++- frontend/src/i18n/locales/hu/settings.json | 10 +++- frontend/src/lib/api.ts | 56 ++++++++++++++++++++- frontend/src/lib/e2ee.ts | 19 ++++--- frontend/src/lib/messagesSocket.ts | 19 ++++++- frontend/src/lib/messaging.ts | 3 ++ 19 files changed, 302 insertions(+), 45 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 68d1339..a498575 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -275,8 +275,10 @@ export default function App() { // "Focus this channel in the manager": jump to the Channels page and seed its name filter // so the channel is isolated. Cleared when leaving the page so it doesn't re-apply later. const [focusChannelName, setFocusChannelName] = useState(null); + const [focusChannelToken, setFocusChannelToken] = useState(0); const focusChannel = (name: string) => { setFocusChannelName(name); + setFocusChannelToken((n) => n + 1); // re-seed even when the same channel is re-focused setChannelsView("subscribed"); // the name filter applies to the subscriptions table setPage("channels"); }; @@ -803,6 +805,7 @@ export default function App() { canWrite={meQuery.data!.can_write} isAdmin={meQuery.data!.role === "admin"} focusChannelName={focusChannelName} + focusChannelToken={focusChannelToken} onFocusChannel={focusChannel} statusFilter={channelFilter} setStatusFilter={setChannelFilter} diff --git a/frontend/src/components/AdminUsers.tsx b/frontend/src/components/AdminUsers.tsx index 20b69af..d112173 100644 --- a/frontend/src/components/AdminUsers.tsx +++ b/frontend/src/components/AdminUsers.tsx @@ -52,19 +52,25 @@ function UsersRoles({ me }: { me: Me }) { const qc = useQueryClient(); const confirm = useConfirm(); const q = useQuery({ queryKey: ["admin-users"], queryFn: api.adminUsers }); + // Which row currently has an action in flight, so we disable ONLY that row's button (mutations + // are single-flight, so one id suffices) rather than greying every row (SC1). + const [pendingId, setPendingId] = useState(null); const setRole = useMutation({ mutationFn: ({ id, role }: { id: number; role: "user" | "admin"; email: string }) => api.setUserRole(id, role), + onMutate: (vars) => setPendingId(vars.id), onSuccess: (_d, vars) => { qc.invalidateQueries({ queryKey: ["admin-users"] }); notify({ level: "success", message: t("users.roles.updated", { email: vars.email }) }); }, + onSettled: () => setPendingId(null), // Errors (e.g. last-admin / self) surface via the global error dialog with the server's detail. }); const suspend = useMutation({ mutationFn: ({ id, suspended }: { id: number; suspended: boolean; email: string }) => api.setUserSuspended(id, suspended), + onMutate: (vars) => setPendingId(vars.id), onSuccess: (_d, vars) => { qc.invalidateQueries({ queryKey: ["admin-users"] }); notify({ @@ -74,14 +80,17 @@ function UsersRoles({ me }: { me: Me }) { }), }); }, + onSettled: () => setPendingId(null), // Guards (demo / self / last admin) surface via the global error dialog. }); const del = useMutation({ mutationFn: ({ id }: { id: number; email: string }) => api.adminDeleteUser(id), + onMutate: (vars) => setPendingId(vars.id), onSuccess: (_d, vars) => { qc.invalidateQueries({ queryKey: ["admin-users"] }); notify({ level: "success", message: t("users.delete.done", { email: vars.email }) }); }, + onSettled: () => setPendingId(null), }); const onToggle = async (u: AdminUserRow) => { @@ -162,7 +171,7 @@ function UsersRoles({ me }: { me: Me }) { >