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:
npeter83 2026-07-12 05:59:25 +02:00
parent 4e80e2b39b
commit f0198f2e0a
19 changed files with 302 additions and 45 deletions

View file

@ -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<number | null>(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 }) {
>
<button
onClick={() => onToggle(u)}
disabled={u.is_demo || self || setRole.isPending}
disabled={u.is_demo || self || pendingId === u.id}
className="shrink-0 glass-card glass-hover inline-flex items-center gap-1 px-2.5 py-1.5 rounded-lg text-xs disabled:opacity-40 transition"
>
<Shield className="w-3.5 h-3.5" />
@ -182,7 +191,7 @@ function UsersRoles({ me }: { me: Me }) {
>
<button
onClick={() => onSuspend(u)}
disabled={u.is_demo || self || suspend.isPending}
disabled={u.is_demo || self || pendingId === u.id}
aria-label={u.is_suspended ? t("users.suspend.undoAction") : t("users.suspend.action")}
className={`shrink-0 p-1.5 rounded-lg border disabled:opacity-40 transition ${
u.is_suspended
@ -196,7 +205,7 @@ function UsersRoles({ me }: { me: Me }) {
<Tooltip hint={u.is_demo || self ? t("users.delete.locked") : t("users.delete.action")}>
<button
onClick={() => onDelete(u)}
disabled={u.is_demo || self || del.isPending}
disabled={u.is_demo || self || pendingId === u.id}
aria-label={t("users.delete.action")}
className="shrink-0 p-1.5 rounded-lg border border-border text-muted hover:text-red-400 disabled:opacity-40 transition"
>
@ -217,6 +226,7 @@ function UsersRoles({ me }: { me: Me }) {
function AdminInvites() {
const { t } = useTranslation();
const qc = useQueryClient();
const confirm = useConfirm();
const invites = useQuery({ queryKey: ["admin-invites"], queryFn: () => api.adminInvites() });
const [newEmail, setNewEmail] = useState("");
const refresh = () => {
@ -233,9 +243,21 @@ function AdminInvites() {
});
const deny = useMutation({
mutationFn: (id: number) => api.denyInvite(id),
onSuccess: refresh,
onSuccess: (_d, _id) => {
refresh();
notify({ level: "success", message: t("settings.invites.denied") });
},
onError: () => notify({ level: "error", message: t("settings.invites.denyFailed") }),
});
const onDeny = async (i: { id: number; email: string }) => {
const ok = await confirm({
title: t("settings.invites.denyTitle"),
message: t("settings.invites.denyConfirm", { email: i.email }),
confirmLabel: t("settings.invites.deny"),
danger: true,
});
if (ok) deny.mutate(i.id);
};
const add = useMutation({
mutationFn: (email: string) => api.addInvite(email),
onSuccess: (_d, email) => {
@ -261,7 +283,7 @@ function AdminInvites() {
key={i.id}
inv={i}
onApprove={() => approve.mutate({ id: i.id, email: i.email })}
onDeny={() => deny.mutate(i.id)}
onDeny={() => onDeny(i)}
busy={approve.isPending || deny.isPending}
/>
))}
@ -329,9 +351,21 @@ function AdminDemo() {
});
const remove = useMutation({
mutationFn: (id: number) => api.removeDemoWhitelist(id),
onSuccess: () => qc.invalidateQueries({ queryKey: ["demo-whitelist"] }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["demo-whitelist"] });
notify({ level: "success", message: t("settings.demo.removed") });
},
onError: () => notify({ level: "error", message: t("settings.demo.removeFailed") }),
});
const onRemove = async (r: { id: number; email: string }) => {
const ok = await confirm({
title: t("settings.demo.removeTitle"),
message: t("settings.demo.removeConfirm", { email: r.email }),
confirmLabel: t("settings.demo.remove"),
danger: true,
});
if (ok) remove.mutate(r.id);
};
const reset = useMutation({
mutationFn: () => api.resetDemo(),
onSuccess: (r: { playlists_seeded: number }) =>
@ -362,7 +396,7 @@ function AdminDemo() {
</div>
<Tooltip hint={t("settings.demo.removeHint")}>
<button
onClick={() => remove.mutate(r.id)}
onClick={() => onRemove(r)}
disabled={remove.isPending}
className="shrink-0 p-1.5 rounded-lg border border-border text-muted hover:text-red-400 disabled:opacity-50 transition"
aria-label={t("settings.demo.remove")}