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:
parent
4e80e2b39b
commit
f0198f2e0a
19 changed files with 302 additions and 45 deletions
|
|
@ -209,13 +209,35 @@ export interface VersionInfo {
|
|||
class HttpError extends Error {
|
||||
status: number;
|
||||
detail?: string; // server-provided reason (FastAPI's `detail`), when present
|
||||
constructor(status: number, detail?: string) {
|
||||
detailData?: any; // structured detail ({ code, reason, ... }) when the server sent an object
|
||||
constructor(status: number, detail?: string, detailData?: any) {
|
||||
super(detail || `HTTP ${status}`);
|
||||
this.status = status;
|
||||
this.detail = detail;
|
||||
this.detailData = detailData;
|
||||
}
|
||||
}
|
||||
|
||||
// Localize a STRUCTURED server error ({ code, reason, ... }) into the user's language. FastAPI
|
||||
// usually returns a plain string `detail`, but some routes (e.g. download quota/edit) return a
|
||||
// structured object with an i18n reason key + numbers so HU/DE users don't get hardcoded English
|
||||
// with a dot-decimal separator. Falls back to a generic message for unknown shapes.
|
||||
function localizeDetail(d: any): string {
|
||||
const t = i18n.t.bind(i18n);
|
||||
if (d?.code === "quota") {
|
||||
if (d.reason === "max_bytes") {
|
||||
const gb = new Intl.NumberFormat(i18n.language, { maximumFractionDigits: 1 }).format(
|
||||
(d.limit || 0) / 1_073_741_824,
|
||||
);
|
||||
return t("downloads.errors.quota.max_bytes", { size: gb });
|
||||
}
|
||||
if (d.reason === "max_jobs") return t("downloads.errors.quota.max_jobs", { limit: d.limit });
|
||||
return t("downloads.errors.quota.generic");
|
||||
}
|
||||
if (d?.code === "edit") return t(`downloads.errors.edit.${d.reason}`, t("downloads.errors.edit.generic"));
|
||||
return t("errors.generic");
|
||||
}
|
||||
|
||||
// A single, self-resolving "connection lost" status: while the server is unreachable we
|
||||
// show one sticky, transient (non-persisted) notice; the moment any request reaches the
|
||||
// server again we remove it. This makes good on its "clears once it's back" copy and keeps
|
||||
|
|
@ -337,9 +359,14 @@ async function req(url: string, opts: RequestInit = {}, cfg: ReqConfig = {}): Pr
|
|||
if (!r.ok) {
|
||||
// Capture the server's reason (FastAPI returns `{ detail }`) so callers can react.
|
||||
let detail: string | undefined;
|
||||
let detailData: any;
|
||||
try {
|
||||
const body = await r.json();
|
||||
if (body && typeof body.detail === "string") detail = body.detail;
|
||||
else if (body && body.detail && typeof body.detail === "object") {
|
||||
detailData = body.detail;
|
||||
detail = localizeDetail(body.detail); // structured error → localized message
|
||||
}
|
||||
} catch {
|
||||
/* no JSON body */
|
||||
}
|
||||
|
|
@ -361,7 +388,7 @@ async function req(url: string, opts: RequestInit = {}, cfg: ReqConfig = {}): Pr
|
|||
} else if (r.status === 400 || r.status === 409 || r.status === 422) {
|
||||
reportError(detail);
|
||||
}
|
||||
throw new HttpError(r.status, detail);
|
||||
throw new HttpError(r.status, detail, detailData);
|
||||
}
|
||||
return r.status === 204 ? null : r.json();
|
||||
}
|
||||
|
|
@ -601,6 +628,7 @@ export interface ConfigItem {
|
|||
min: number | null;
|
||||
max: number | null;
|
||||
is_set: boolean; // a DB override row exists (else using the env/config default)
|
||||
allow_empty?: boolean; // if true, a blank field stores "" (explicit empty) rather than resetting
|
||||
value?: string | number | boolean; // non-secret: effective value
|
||||
default?: string | number | boolean; // non-secret: env/config default
|
||||
default_is_set?: boolean; // secret: whether an env default exists
|
||||
|
|
@ -1074,6 +1102,30 @@ export const api = {
|
|||
},
|
||||
{ idempotent: true }
|
||||
),
|
||||
// Fire-and-forget YT progress save that survives page unload (F5/close/navigate) — mirrors
|
||||
// plexProgressBeacon. A normal fetch is cancelled on unload and React effect cleanup doesn't run
|
||||
// on a full reload, so the last position (esp. right after a seek) would be lost; keepalive lets
|
||||
// it complete.
|
||||
saveProgressBeacon: (id: string, positionSeconds: number, durationSeconds: number): void => {
|
||||
const active = getActiveAccount();
|
||||
try {
|
||||
void fetch(`/api/videos/${encodeURIComponent(id)}/progress`, {
|
||||
method: "POST",
|
||||
keepalive: true,
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(active != null ? { [ACTIVE_ACCOUNT_HEADER]: String(active) } : {}),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
position_seconds: Math.floor(positionSeconds),
|
||||
duration_seconds: Math.floor(durationSeconds),
|
||||
}),
|
||||
}).catch(() => {});
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
},
|
||||
videoDetail: (id: string): Promise<VideoDetail> => req(`/api/videos/${id}`),
|
||||
pauseSync: () => req("/api/sync/pause", { method: "POST" }),
|
||||
resumeSync: () => req("/api/sync/resume", { method: "POST" }),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue