Every locale formatter takes the language as an argument. Under the React
Compiler a formatter that read the i18n singleton was memoized on its other
arguments, so a card kept "2 hr 4 min" after a switch to Hungarian; plexLang()
had the same flaw and left Plex queries in the old language. Components now
take the language from useTranslation, and the signature makes forgetting it
a type error.
- ratings, file sizes, stream stats: the language's decimal mark ("8,3")
- view and subscriber counts compact in the language ("1,2 E")
- seconds as the language's unit ("6 mp") in the sliders and the stats
- a person's birth and death dates as dates, not ISO strings
- A-Z lists by the shown name in the language's alphabet (compareText),
the tag catalog re-sorted by its translated names
- lint local/locale-format (was date-format) also refuses Intl.NumberFormat
outside lib/format.ts, localeCompare, and a component reading the language
off the i18n singleton
201 lines
8.1 KiB
TypeScript
201 lines
8.1 KiB
TypeScript
import { useEffect, useRef, useState } from "react";
|
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
import { useTranslation } from "react-i18next";
|
|
|
|
import { api, type DiagnosticsLevel } from "../lib/api";
|
|
import { levelInForce } from "../lib/diagnostics";
|
|
import { focusIsUnclaimed } from "../lib/useFocusKeeper";
|
|
import { updateMeCache, useMe } from "../lib/useMe";
|
|
import { Button } from "./ui/Button";
|
|
import InlineNotice from "./ui/InlineNotice";
|
|
import SegmentedToggle from "./ui/SegmentedToggle";
|
|
import { Section, SettingRow } from "./ui/form";
|
|
import MyLogViewer from "./MyLogViewer";
|
|
import { RecorderSettings } from "./DebugRecorder";
|
|
import { formatUnit } from "../lib/format";
|
|
|
|
const LEVELS: readonly DiagnosticsLevel[] = ["INFO", "DEBUG", "TRACE"];
|
|
|
|
/** Hours and minutes until `until`, refreshed every 30 s — the status line counts down. */
|
|
function useRemaining(until: string | null): { h: number; m: number } | null {
|
|
// The clock is read at render, and the interval only re-renders: a `now` held in state was the
|
|
// moment the page MOUNTED, so a level raised two minutes after opening Settings read "24 h 2 m"
|
|
// until the first tick (prod UAT 2026-09-19).
|
|
const [, tick] = useState(0);
|
|
useEffect(() => {
|
|
if (!until) return;
|
|
const id = window.setInterval(() => tick((n) => n + 1), 30_000);
|
|
return () => window.clearInterval(id);
|
|
}, [until]);
|
|
if (!until) return null;
|
|
// eslint-disable-next-line react-hooks/purity -- same as DebugRecorder's elapsed clock, and the comment above records the prod UAT finding that a state-held `now` produced: a level raised two minutes after opening Settings read "24 h 2 m" until the first tick.
|
|
const minutes = Math.max(0, Math.ceil((Date.parse(until) - Date.now()) / 60_000));
|
|
return { h: Math.floor(minutes / 60), m: minutes % 60 };
|
|
}
|
|
|
|
// Settings → Diagnosztika (E22 S5): the consented diagnostics level and the tab's own log.
|
|
//
|
|
// There is no separate on/off switch: Alap IS off. Choosing Részletes or Teljes does not change
|
|
// anything yet — it opens the consent box under the picker, which says what will be recorded, that
|
|
// the server keeps it and for how long, and that it lapses; only its button raises the level.
|
|
// Returning to Alap needs no consent and happens at once.
|
|
export default function DiagnosticsSettings() {
|
|
const { t, i18n } = useTranslation();
|
|
const lang = i18n.language;
|
|
const me = useMe();
|
|
const qc = useQueryClient();
|
|
const state = me.diagnostics;
|
|
const current = levelInForce(state);
|
|
const [pending, setPending] = useState<DiagnosticsLevel | null>(null);
|
|
const remaining = useRemaining(current === "INFO" ? null : (state?.until ?? null));
|
|
|
|
// The consent box and the status line each go away with the button that closed them — the one
|
|
// holding the focus. Put it back on the level picker (its chosen option) rather than let it fall
|
|
// to <body>, where the next Tab starts over from the top of the page. Only when it did fall: a
|
|
// user who moved on while the request was out keeps their place.
|
|
//
|
|
// Watched until that button is actually GONE, not for one render: a saved level arrives as two
|
|
// updates (the `me` cache, then the closed box), and the first render still has the button in the
|
|
// page — measured in Chrome, where a one-shot check spent itself there and the focus then fell.
|
|
const pickerRef = useRef<HTMLDivElement>(null);
|
|
const closedFrom = useRef<Element | null>(null);
|
|
useEffect(() => {
|
|
const from = closedFrom.current;
|
|
if (!from || from.isConnected) return;
|
|
closedFrom.current = null;
|
|
if (!focusIsUnclaimed()) return;
|
|
pickerRef.current?.querySelector<HTMLElement>('[aria-pressed="true"]')?.focus();
|
|
});
|
|
const watchFocus = () => {
|
|
closedFrom.current = document.activeElement;
|
|
};
|
|
|
|
const change = useMutation({
|
|
mutationFn: api.setDiagnosticsLevel,
|
|
meta: { failTitle: "settings.diagnostics.failed" },
|
|
onSuccess: (result) => {
|
|
watchFocus();
|
|
updateMeCache(qc, (prev) => ({ ...prev, diagnostics: result.diagnostics }));
|
|
setPending(null);
|
|
},
|
|
});
|
|
|
|
const cancel = () => {
|
|
watchFocus();
|
|
setPending(null);
|
|
};
|
|
|
|
const choose = (level: DiagnosticsLevel) => {
|
|
if (level === "INFO") {
|
|
setPending(null);
|
|
if (current !== "INFO") change.mutate("INFO");
|
|
} else {
|
|
setPending(level === current ? null : level);
|
|
}
|
|
};
|
|
|
|
const levelName = (level: DiagnosticsLevel) => t(`settings.diagnostics.levels.${level}`);
|
|
|
|
return (
|
|
<>
|
|
<Section title={t("settings.diagnostics.title")}>
|
|
{me.is_demo ? (
|
|
<InlineNotice>{t("settings.diagnostics.demo")}</InlineNotice>
|
|
) : (
|
|
<SettingRow
|
|
label={t("settings.diagnostics.level")}
|
|
hint={t("settings.diagnostics.levelHint")}
|
|
>
|
|
<div ref={pickerRef}>
|
|
<SegmentedToggle
|
|
size="md"
|
|
ariaLabel={t("settings.diagnostics.level")}
|
|
value={pending ?? current}
|
|
onChange={choose}
|
|
options={LEVELS.map((level) => ({ value: level, label: levelName(level) }))}
|
|
/>
|
|
</div>
|
|
</SettingRow>
|
|
)}
|
|
|
|
{pending && (
|
|
<div
|
|
data-testid="diagnostics-consent"
|
|
className="mt-2 rounded-xl border border-border bg-accent/5 p-3 text-sm"
|
|
>
|
|
<p className="font-semibold">{t(`settings.diagnostics.consent.title.${pending}`)}</p>
|
|
<ul className="mt-1.5 mb-3 list-disc space-y-1 pl-5 text-muted">
|
|
<li>{t(`settings.diagnostics.consent.what.${pending}`)}</li>
|
|
<li>
|
|
{t("settings.diagnostics.consent.server", {
|
|
count: state?.retention_days ?? 7,
|
|
})}
|
|
</li>
|
|
<li>{t("settings.diagnostics.consent.never")}</li>
|
|
<li>
|
|
{t("settings.diagnostics.consent.lapse")}{" "}
|
|
<a
|
|
href="/privacy#diagnostics"
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className="text-accent hover:underline"
|
|
>
|
|
{t("settings.diagnostics.consent.policy")}
|
|
</a>
|
|
</li>
|
|
</ul>
|
|
<div className="flex flex-wrap justify-end gap-2">
|
|
<Button variant="ghost" size="sm" onClick={cancel}>
|
|
{t("settings.diagnostics.consent.cancel")}
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
data-testid="diagnostics-consent-confirm"
|
|
loading={change.isPending}
|
|
onClick={() => change.mutate(pending)}
|
|
>
|
|
{t("settings.diagnostics.consent.confirm")}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{current !== "INFO" && !pending && remaining && (
|
|
// Not a live region: the countdown changes every minute, and a screen reader would read
|
|
// the whole line out again each time for as long as the page is open.
|
|
<div
|
|
data-testid="diagnostics-status"
|
|
className="mt-2 flex flex-wrap items-center justify-between gap-2 rounded-xl border border-warn/40 bg-warn/10 px-3 py-2 text-sm"
|
|
>
|
|
<span className="inline-flex items-center gap-2">
|
|
<span className="h-2 w-2 shrink-0 rounded-full bg-warn" aria-hidden />
|
|
<span>
|
|
{t("settings.diagnostics.status", {
|
|
level: levelName(current),
|
|
time:
|
|
remaining.h > 0
|
|
? `${formatUnit(remaining.h, "hour", lang)} ${formatUnit(remaining.m, "minute", lang)}`
|
|
: formatUnit(remaining.m, "minute", lang),
|
|
})}
|
|
</span>
|
|
</span>
|
|
<Button variant="link" loading={change.isPending} onClick={() => change.mutate("INFO")}>
|
|
{t("settings.diagnostics.revert")}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</Section>
|
|
|
|
{!me.is_demo && (
|
|
<Section title={t("recorder.settings.title")}>
|
|
<RecorderSettings />
|
|
</Section>
|
|
)}
|
|
|
|
<Section title={t("settings.myLog.title")}>
|
|
<MyLogViewer />
|
|
</Section>
|
|
</>
|
|
);
|
|
}
|