fix(i18n): numbers, units, dates and order in the UI language, and after a switch (E30 S3)
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
This commit is contained in:
+8
-1
@@ -531,7 +531,7 @@ pick a density step; it never changes the anatomy or loses an action. [Nielsen #
|
||||
- **Messages.** A toast or error of one sentence has no full stop and no "✓": "Couldn't sync
|
||||
subscriptions" / "Nem sikerült szinkronizálni". Two sentences keep their stops. Help text is prose.
|
||||
- **Quotes** around a name are curly: “abc” in English, „abc” in Hungarian.
|
||||
- **Dates**, in the UI language, only from `lib/format.ts` (lint: `local/date-format`): recency in a list
|
||||
- **Dates**, in the UI language, only from `lib/format.ts` (lint: `local/locale-format`): recency in a list
|
||||
is relative ("3d ago" / "3 napja") through `ui/RelativeTime`, which carries the exact minute on hover;
|
||||
a date is "Sep 27, 2026" / "2026. szept. 27."; a moment "Sep 27, 2026, 08:30 AM" /
|
||||
"2026. szept. 27. 08:30"; seconds only in the server log, the audit and the recorder.
|
||||
@@ -540,6 +540,12 @@ pick a density step; it never changes the anatomy or loses an action. [Nielsen #
|
||||
label holding one is written in that same form ("Under 90 min"). Never a bare letter ("90m", "24h",
|
||||
"7d"; test-held). In a sentence, and in a list of time spans named in words ("24 hours", "1 week"), the
|
||||
unit is a word. A clock-style length on a picture stays "2:34:11".
|
||||
- **Numbers** in the UI language, from `lib/format.ts` (same lint): the decimal mark and grouping are the
|
||||
language's ("8.3" / "8,3", "1,234" / "1 234"); a big count is compact ("1.2K" / "1,2 E"); a size keeps
|
||||
its symbol ("5.0 GB" / "5,0 GB"), as do a stream's (Mbps) and "%" / "px". Never `toFixed` on screen.
|
||||
- **Order.** An A→Z list is in the UI language's alphabet ("Ö" after "O", "Cs" after "Cu" in Hungarian),
|
||||
by the name the reader SEES: `compareText` (`lib/collate.ts`) on the client, the ICU collation of
|
||||
`app/collation.py` on the server, so both sort alike.
|
||||
- A label says what happens ("Delete playlist"), not "OK"; a destructive confirm names the object.
|
||||
|
||||
## Anti-patterns
|
||||
@@ -624,6 +630,7 @@ pick a density step; it never changes the anatomy or loses an action. [Nielsen #
|
||||
| 2026-09-27 | C4 as built | `initialFocus` required on every `Modal`; the commit row in `ModalFooter`, portalled under the body from any depth; the fades driven by the body's scroll timeline; `interactive-widget=resizes-content` | An optional prop defaulting to the ✕; a `footer` prop (the issue composer's buttons live three components down); always-on fades | A default is how 21 dialogs came to open on the ✕; lifting the composer's state to reach a prop is the larger change; a resting fade dimmed the first line |
|
||||
| 2026-09-27 | C3 + C5 as built | A tap toggles the bar through `pointerType` from `pointerdown`; a double-tap takes back its first tap's toggle; the card's pair is the video card's `MediaResumeButtons` (Continue / Restart), a sibling over the picture; the YT description is an ⓘ click popover | A delayed single tap (wait out the double-tap window); new "Resume / From the start" words for Plex; the pair inside the picture's button | A delayed tap feels dead; one media card says one pair of words; a button inside a button is invalid and the hover row already sits beside the picture |
|
||||
| 2026-09-27 | C2 as built | The side panel's drawer is `ui/Modal` `drawer-start`; drawer = a coarse pointer or below 768; the pin offered in its header ≥ 1024 | A drawer of its own inside `SidePanel` | Esc, Back, the scrim, the trap and the scroll lock are the dialog's; a second implementation is how two shells drift |
|
||||
| 2026-09-29 | Numbers and order in the UI language (E30 S3) | Every number through `lib/format.ts` in the UI language (decimal mark, grouping, compact, units); A→Z by the shown name in the UI language's alphabet, client and server alike; the language comes off `useTranslation`, never the singleton | `toFixed`, hand-built "1.2K", `localeCompare` in the browser's locale, byte-order server sorts | A Hungarian reader saw "8.3", "1.2K", "Életrajzi" after "Z"; the singleton read also left values in the old language after a switch (React Compiler) |
|
||||
| 2026-09-27 | Narrow rows by container | The download row, the playlist table and the Channels tag row stack by a container query; a fact line never wraps | Breakpoints on the window | The iPad zoomed in, split screen and a column beside a rail are narrow while the window is not; the zoomed download row broke "18.9 / MB" apart |
|
||||
| 2026-09-27 | Phones deferred | A phone-only layout (the hamburger nav, a folded feed band) waits for its own epic; E28 stays desktop + tablet | Fix the phone views inside E28 | Principle 2; the first phone attempts made the feed worse, and the epic's review grows with every sprint |
|
||||
| 2026-09-27 | Conformance check (S3g) | A four-step check before a UI change is done: primitive at a named rung, the chapter re-read, the rendered page measured by kind and look, every state seen | A reviewer's eye per sprint | The S1 probe found 69 button looks; after S3 every control kind is drawn by its primitive, and the check keeps a new look from arriving unnoticed |
|
||||
|
||||
+48
-15
@@ -82,33 +82,66 @@ const focusSafeDisabled = {
|
||||
};
|
||||
},
|
||||
};
|
||||
// E28 S3g (docs/DESIGN.md "Copy"): the app shows a date in three forms plus the relative one, all
|
||||
// built in lib/format.ts in the UI language. A bare `toLocaleString()` formats in the BROWSER's locale
|
||||
// and as numbers with seconds; seven call sites had drifted into five shapes. Outside lib/format.ts
|
||||
// a `toLocale*String` call or an `Intl.DateTimeFormat` / `Intl.RelativeTimeFormat` is refused (a
|
||||
// number goes through formatNumber, which the same rule covers).
|
||||
const dateFormat = {
|
||||
// E28 S3g + E30 S3 (docs/DESIGN.md "Copy"): a date, a number and an alphabetical order are all in the
|
||||
// UI language — dates and numbers built in lib/format.ts, text compared in lib/collate.ts. A bare
|
||||
// `toLocaleString()` formats in the BROWSER's locale (seven date sites had drifted into five shapes), a
|
||||
// bare `toFixed` writes the English decimal point, a bare `localeCompare` sorts in the browser's
|
||||
// alphabet. So outside those files a `toLocale*String` call or an Intl date/number formatter is refused,
|
||||
// an `Intl.Collator` outside collate.ts, and a `localeCompare` anywhere.
|
||||
//
|
||||
// And in a component, the language itself must come off `useTranslation()`, never the i18n singleton
|
||||
// (`import i18n from "../i18n"`): the React Compiler memoizes on what it can see, and a singleton read
|
||||
// is invisible to it — a card kept "2 hr 4 min" and a Plex query its English after a switch to
|
||||
// Hungarian. The singleton is resolved by SCOPE, so `const { i18n } = useTranslation()` passes.
|
||||
const localeFormat = {
|
||||
meta: {
|
||||
type: "problem",
|
||||
messages: {
|
||||
date: "Format dates and numbers through lib/format.ts (formatDate, formatDateTime, formatDateTimeSec, relativeTime, formatNumber), in the UI language.",
|
||||
date: "Format dates and numbers through lib/format.ts (formatDate, formatDateTime, formatNumber, formatDecimal, …), in the UI language.",
|
||||
order:
|
||||
"Compare text with compareText (lib/collate.ts), in the UI language — a bare localeCompare sorts in the browser's.",
|
||||
singleton:
|
||||
"Take the language from useTranslation() (`const { i18n } = useTranslation()`), not the i18n singleton — the React Compiler cannot see a singleton read, so the value stays in the old language after a switch.",
|
||||
},
|
||||
},
|
||||
create(context) {
|
||||
const path = context.filename.split("\\").join("/");
|
||||
// lib/format.ts is where the forms live; a test computes its expectations; the Plex player's
|
||||
// on-screen clock (lib/plexPlayerPrefs.ts) is a clock face with its own 12/24h switch, not a date.
|
||||
if (/\/src\/lib\/(format|plexPlayerPrefs)\.ts$|\.test\.tsx?$/.test(path)) return {};
|
||||
if (/\.test\.tsx?$/.test(path)) return {};
|
||||
// lib/format.ts is where the forms live; the Plex player's on-screen clock (lib/plexPlayerPrefs.ts)
|
||||
// is a clock face with its own 12/24h switch, not a date.
|
||||
const formats = /\/src\/lib\/(format|plexPlayerPrefs)\.ts$/.test(path);
|
||||
const collates = /\/src\/lib\/collate\.ts$/.test(path);
|
||||
const component = /\/src\/components\//.test(path);
|
||||
const importedFromI18n = (node) => {
|
||||
for (let scope = context.sourceCode.getScope(node); scope; scope = scope.upper) {
|
||||
const v = scope.set.get(node.name);
|
||||
if (v)
|
||||
return v.defs.some(
|
||||
(d) => d.type === "ImportBinding" && /\/i18n$|^i18next$/.test(d.parent.source.value),
|
||||
);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
return {
|
||||
"CallExpression > MemberExpression.callee[property.name=/^toLocale(Date|Time)?String$/]"(
|
||||
node,
|
||||
) {
|
||||
context.report({ node, messageId: "date" });
|
||||
if (!formats) context.report({ node, messageId: "date" });
|
||||
},
|
||||
"MemberExpression[object.name='Intl'][property.name=/^(DateTimeFormat|RelativeTimeFormat)$/]"(
|
||||
"MemberExpression[object.name='Intl'][property.name=/^(DateTimeFormat|RelativeTimeFormat|NumberFormat)$/]"(
|
||||
node,
|
||||
) {
|
||||
context.report({ node, messageId: "date" });
|
||||
if (!formats) context.report({ node, messageId: "date" });
|
||||
},
|
||||
"MemberExpression[object.name='Intl'][property.name='Collator']"(node) {
|
||||
if (!collates) context.report({ node, messageId: "order" });
|
||||
},
|
||||
"CallExpression > MemberExpression.callee[property.name='localeCompare']"(node) {
|
||||
context.report({ node, messageId: "order" });
|
||||
},
|
||||
"MemberExpression[property.name='language'][object.type='Identifier']"(node) {
|
||||
if (component && importedFromI18n(node.object))
|
||||
context.report({ node, messageId: "singleton" });
|
||||
},
|
||||
};
|
||||
},
|
||||
@@ -382,7 +415,7 @@ const localRules = {
|
||||
"jsx-comment-text": jsxCommentText,
|
||||
"focus-safe-disabled": focusSafeDisabled,
|
||||
"button-primitive": buttonPrimitive,
|
||||
"date-format": dateFormat,
|
||||
"locale-format": localeFormat,
|
||||
"input-primitive": inputPrimitive,
|
||||
"surface-role": surfaceRole,
|
||||
"literal-a11y-text": literalA11yText,
|
||||
@@ -477,7 +510,7 @@ export default tseslint.config(
|
||||
"local/jsx-comment-text": "error",
|
||||
"local/focus-safe-disabled": "error",
|
||||
"local/button-primitive": "error",
|
||||
"local/date-format": "error",
|
||||
"local/locale-format": "error",
|
||||
"local/input-primitive": "error",
|
||||
"local/surface-role": "error",
|
||||
"local/literal-a11y-text": "error",
|
||||
|
||||
@@ -8,10 +8,10 @@ import { formatDateTime } from "../lib/format";
|
||||
import { Button } from "./ui/Button";
|
||||
import Modal from "./ui/Modal";
|
||||
|
||||
function fmtDate(d?: string | null): string {
|
||||
function fmtDate(d: string | null | undefined, lang: string): string {
|
||||
if (!d) return "—";
|
||||
const t = new Date(d);
|
||||
return isNaN(t.getTime()) ? d : formatDateTime(d);
|
||||
return isNaN(t.getTime()) ? d : formatDateTime(d, lang);
|
||||
}
|
||||
|
||||
// About dialog: app + build + schema versions (frontend/backend/database), plus a
|
||||
@@ -23,7 +23,8 @@ export default function About({
|
||||
onClose: () => void;
|
||||
onOpenReleaseNotes: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
const { data } = useQuery({
|
||||
queryKey: qk.version(),
|
||||
queryFn: api.version,
|
||||
@@ -40,7 +41,7 @@ export default function About({
|
||||
data ? data.app_version + (data.git_sha !== "unknown" ? ` · ${data.git_sha}` : "") : "…",
|
||||
],
|
||||
[t("about.database"), data?.db_revision ?? "…"],
|
||||
[t("about.buildDate"), fmtDate(data?.build_date ?? FRONTEND_BUILD_DATE)],
|
||||
[t("about.buildDate"), fmtDate(data?.build_date ?? FRONTEND_BUILD_DATE, lang)],
|
||||
];
|
||||
|
||||
return (
|
||||
|
||||
@@ -55,7 +55,8 @@ export default function AccountPanel({
|
||||
user: AdminUserRow;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
const qc = useQueryClient();
|
||||
const [link, setLink] = useState<{ url: string; expires_at: string | null } | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
@@ -182,7 +183,7 @@ export default function AccountPanel({
|
||||
label={t("users.account.lastLogin")}
|
||||
value={
|
||||
a.last_login_at
|
||||
? formatDateTime(a.last_login_at)
|
||||
? formatDateTime(a.last_login_at, lang)
|
||||
: a.is_demo
|
||||
? t("users.account.lastLoginDemo")
|
||||
: t("users.account.lastLoginNone")
|
||||
|
||||
@@ -74,7 +74,8 @@ function toQuery(state: LogsUrlState, q: string): LogQuery {
|
||||
}
|
||||
|
||||
export default function AdminLogs() {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
const qc = useQueryClient();
|
||||
const [state, setState] = useState<LogsUrlState>(() =>
|
||||
logsParamsToState(new URLSearchParams(window.location.search)),
|
||||
@@ -406,7 +407,7 @@ export default function AdminLogs() {
|
||||
level={row.level}
|
||||
event={row.event ?? "—"}
|
||||
msg={row.msg}
|
||||
title={formatDateTimeSec(row.ts)}
|
||||
title={formatDateTimeSec(row.ts, lang)}
|
||||
open={open === row.id}
|
||||
onToggle={() => setOpen(open === row.id ? null : row.id)}
|
||||
>
|
||||
@@ -468,14 +469,15 @@ function RowDetail({
|
||||
onRequest: (id: string) => void;
|
||||
onJob: (id: number) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
const pivotCls =
|
||||
"font-mono underline decoration-dotted underline-offset-2 hover:decoration-solid";
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<dl className="grid grid-cols-[max-content_minmax(0,1fr)] gap-x-3 gap-y-0.5 font-sans text-xs">
|
||||
<dt>{t("adminLogs.fields.when")}</dt>
|
||||
<dd className="tabular-nums">{formatDateTimeSec(row.ts)}</dd>
|
||||
<dd className="tabular-nums">{formatDateTimeSec(row.ts, lang)}</dd>
|
||||
<dt>{t("adminLogs.fields.source")}</dt>
|
||||
<dd className="font-mono">
|
||||
{row.source} · {row.logger}
|
||||
|
||||
@@ -93,7 +93,7 @@ describe("the last sign-in column (E22 S1)", () => {
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
expect(await screen.findByText(relativeTime(at))).toBeTruthy();
|
||||
expect(await screen.findByText(relativeTime(at, "en"))).toBeTruthy();
|
||||
expect(screen.getAllByText("—")).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -103,7 +103,8 @@ export default function AdminUsers() {
|
||||
}
|
||||
|
||||
function UsersRoles({ me }: { me: Me }) {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
const qc = useQueryClient();
|
||||
const confirm = useConfirm();
|
||||
const q = useQuery({ queryKey: qk.adminUsers(), queryFn: api.adminUsers });
|
||||
@@ -336,14 +337,14 @@ function UsersRoles({ me }: { me: Me }) {
|
||||
<Tooltip
|
||||
hint={
|
||||
u.last_login_at
|
||||
? t("users.roles.lastLogin", { time: formatDateTime(u.last_login_at) })
|
||||
? t("users.roles.lastLogin", { time: formatDateTime(u.last_login_at, lang) })
|
||||
: u.is_demo
|
||||
? t("users.roles.lastLoginDemo")
|
||||
: t("users.roles.lastLoginNone")
|
||||
}
|
||||
>
|
||||
<span className="relative hidden sm:inline-block w-20 shrink-0 text-right text-2xs text-muted tabular-nums">
|
||||
{u.last_login_at ? relativeTime(u.last_login_at) : "—"}
|
||||
{u.last_login_at ? relativeTime(u.last_login_at, lang) : "—"}
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
@@ -440,7 +441,8 @@ function PendingAccessRowView({
|
||||
onActivate: () => void;
|
||||
onResend: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
return (
|
||||
<div className={`${SURFACE.card} flex items-center gap-2 p-2.5`}>
|
||||
<div className="min-w-0 flex-1">
|
||||
@@ -449,7 +451,7 @@ function PendingAccessRowView({
|
||||
{row.created_at && (
|
||||
<span>
|
||||
{t("users.pendingAccess.signedUp", {
|
||||
time: formatDateTime(row.created_at),
|
||||
time: formatDateTime(row.created_at, lang),
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
@@ -536,6 +538,7 @@ function InviteLangPicker({
|
||||
|
||||
function AdminInvites() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
const qc = useQueryClient();
|
||||
const confirm = useConfirm();
|
||||
const me = useMe();
|
||||
@@ -640,7 +643,7 @@ function AdminInvites() {
|
||||
: "settings.invites.inviteConfirm",
|
||||
{
|
||||
email: inv.email,
|
||||
time: formatDateTime(inv.invited_at ?? new Date(0).toISOString()),
|
||||
time: formatDateTime(inv.invited_at ?? new Date(0).toISOString(), lang),
|
||||
},
|
||||
)}
|
||||
</p>
|
||||
@@ -744,7 +747,7 @@ function AdminInvites() {
|
||||
hint={
|
||||
i.invited_at
|
||||
? t("settings.invites.invitedAt", {
|
||||
time: formatDateTime(i.invited_at),
|
||||
time: formatDateTime(i.invited_at, lang),
|
||||
})
|
||||
: t("settings.invites.inviteHint")
|
||||
}
|
||||
@@ -931,7 +934,8 @@ function InviteRow({
|
||||
onDelete: () => void;
|
||||
busy: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
return (
|
||||
<div className={`${SURFACE.card} flex items-center gap-2 p-2.5`}>
|
||||
<div className="min-w-0 flex-1">
|
||||
@@ -939,7 +943,7 @@ function InviteRow({
|
||||
{inv.requested_at && (
|
||||
<div className="text-2xs text-muted">
|
||||
{t("settings.invites.requested", {
|
||||
time: formatDateTime(inv.requested_at),
|
||||
time: formatDateTime(inv.requested_at, lang),
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -13,12 +13,14 @@ import DataTable from "./ui/DataTable";
|
||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||
import { auditColumns } from "./auditColumns";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
import { compareText } from "../lib/collate";
|
||||
|
||||
// Admin-only audit trail: an append-only who/what/when log of admin actions + scheduler run
|
||||
// summaries (never per-row). Read-only apart from Clear. Client-side sort/filter/paginate via
|
||||
// DataTable; the API returns the most recent window (capped) — the log stays small by design.
|
||||
export default function AuditLog() {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
const qc = useQueryClient();
|
||||
const confirm = useConfirm();
|
||||
const q = useQuery({ queryKey: qk.adminAudit(), queryFn: () => api.adminAudit() });
|
||||
@@ -26,9 +28,11 @@ export default function AuditLog() {
|
||||
const rows = useMemo(() => data?.items ?? [], [data]);
|
||||
|
||||
const actionOptions = useMemo(() => {
|
||||
const present = Array.from(new Set(rows.map((r) => r.action))).sort();
|
||||
return present.map((a) => ({ value: a, label: t(`auditActions.${a}`, a) }));
|
||||
}, [rows, t]);
|
||||
// By what the reader sees — the named action, in their alphabet — not by the code.
|
||||
return Array.from(new Set(rows.map((r) => r.action)))
|
||||
.map((a) => ({ value: a, label: t(`auditActions.${a}`, a) }))
|
||||
.sort((a, b) => compareText(a.label, b.label, lang));
|
||||
}, [rows, t, lang]);
|
||||
const columns = useMemo(() => auditColumns(t, actionOptions), [t, actionOptions]);
|
||||
|
||||
const clear = useMutation({
|
||||
|
||||
@@ -104,6 +104,7 @@ export default function BootGate({ children }: { children: ReactNode }) {
|
||||
const account = meRef.current;
|
||||
if (account && getActiveAccount() !== account.id) setActiveAccount(account.id);
|
||||
const lang = account?.preferences?.language;
|
||||
// eslint-disable-next-line local/locale-format -- a one-shot read in an effect, compared before adopting the account's language; nothing renders it, so there is no memoized value to go stale.
|
||||
if (typeof lang === "string" && isSupported(lang) && lang !== i18n.language) setLanguage(lang);
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- the adoption latch. It cannot be derived: the whole point is that the tree mounts only AFTER the pin and the language have been applied, and both are writes to things outside React (sessionStorage, i18n). A layout effect so the frame that first shows the app already has them.
|
||||
setReadyFor(identity);
|
||||
|
||||
@@ -41,7 +41,8 @@ export default function ChannelDiscovery({
|
||||
onOpenWizard: () => void;
|
||||
onViewChannel: (id: string, name: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
const qc = useQueryClient();
|
||||
const confirm = useConfirm();
|
||||
// The table/cards density is shared with the Subscriptions tab (ChannelsProvider). The sort is
|
||||
@@ -148,7 +149,7 @@ export default function ChannelDiscovery({
|
||||
hideInCard: true,
|
||||
render: (c) => <AboutCell text={c.description} channelId={c.id} />,
|
||||
},
|
||||
subsColumn<DiscoveredChannel>(t),
|
||||
subsColumn<DiscoveredChannel>(t, lang),
|
||||
{
|
||||
key: "videos",
|
||||
header: t("channels.discovery.cols.totalVideos"),
|
||||
@@ -157,7 +158,7 @@ export default function ChannelDiscovery({
|
||||
sortable: true,
|
||||
sortValue: (c) => c.video_count ?? -1,
|
||||
render: (c) => (
|
||||
<span className="text-muted tabular-nums">{formatCountOrDash(c.video_count)}</span>
|
||||
<span className="text-muted tabular-nums">{formatCountOrDash(c.video_count, lang)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -186,7 +187,7 @@ export default function ChannelDiscovery({
|
||||
// computed for cards — DataTable sorts and slices internally, so doing it in table mode too would
|
||||
// copy+sort the whole list every render for a result nothing reads.
|
||||
const isCards = layout === "cards";
|
||||
const sortedRows = isCards ? sortRows(rows, columns, sort) : rows;
|
||||
const sortedRows = isCards ? sortRows(rows, columns, sort, lang) : rows;
|
||||
const {
|
||||
paged: pagedRows,
|
||||
pagerProps,
|
||||
|
||||
@@ -35,7 +35,7 @@ import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
import { api, DEFAULT_TAG_MODES, type FeedFilters, type Me, type ChannelDetail } from "../lib/api";
|
||||
import type { FeedView } from "../lib/feedView";
|
||||
import { channelYouTubeUrl, formatDate, formatViews } from "../lib/format";
|
||||
import { channelYouTubeUrl, formatDate, formatCompact } from "../lib/format";
|
||||
import { ChannelTagsControl } from "./ChannelTags";
|
||||
import { useChannelTagActions } from "../lib/useChannelTagActions";
|
||||
import { Button } from "./ui/Button";
|
||||
@@ -70,6 +70,7 @@ export default function ChannelPage({
|
||||
onShowInFeed: () => void;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
const { closeChannel } = useNavigationActions();
|
||||
const qc = useQueryClient();
|
||||
const confirm = useConfirm();
|
||||
@@ -281,7 +282,7 @@ export default function ChannelPage({
|
||||
const metaParts = [
|
||||
ch?.handle ? `@${ch.handle.replace(/^@/, "")}` : null,
|
||||
ch?.subscriber_count != null
|
||||
? t("channel.subscribers", { formatted: formatViews(ch.subscriber_count) })
|
||||
? t("channel.subscribers", { formatted: formatCompact(ch.subscriber_count, lang) })
|
||||
: null,
|
||||
// Show "stored / total" when we only hold a subset of a subscribed channel's uploads, so the
|
||||
// gap between the browsable videos and YouTube's raw count is honest rather than looking like
|
||||
@@ -292,7 +293,7 @@ export default function ChannelPage({
|
||||
: t("channel.videoCount", { count: ch.video_count })
|
||||
: null,
|
||||
ch?.total_view_count != null
|
||||
? t("channel.totalViews", { formatted: formatViews(ch.total_view_count) })
|
||||
? t("channel.totalViews", { formatted: formatCompact(ch.total_view_count, lang) })
|
||||
: null,
|
||||
// Per-type breakdown (R14 moved this off the manager table's N/S/L column). Only meaningful
|
||||
// once we actually hold some of the channel's videos.
|
||||
|
||||
@@ -14,12 +14,16 @@ import { FloatingTree } from "@floating-ui/react";
|
||||
// (i18n/index.ts calls i18n.use(initReactI18next) at import time).
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }),
|
||||
};
|
||||
});
|
||||
|
||||
import ChannelStatsPopover from "./ChannelStatsPopover";
|
||||
import { type MyStatus } from "../lib/api";
|
||||
import { formatEta, formatNumber } from "../lib/format";
|
||||
import type { TFunction } from "i18next";
|
||||
|
||||
beforeAll(() => {
|
||||
class Stub {
|
||||
@@ -74,7 +78,7 @@ describe("ChannelStatsPopover", () => {
|
||||
expect(screen.getByText("channels.stats.recentSynced")).toBeTruthy();
|
||||
expect(screen.getByText("3/10")).toBeTruthy(); // recent-synced ratio
|
||||
expect(screen.getByText("1/4")).toBeTruthy(); // full-history ratio
|
||||
expect(screen.getByText(formatNumber(1234))).toBeTruthy(); // my_videos, locale-agnostic
|
||||
expect(screen.getByText(formatNumber(1234, "en"))).toBeTruthy(); // my_videos, locale-agnostic
|
||||
});
|
||||
|
||||
it("shows the 'left' ETA row only while a backfill is pending, with the formatted ETA value", () => {
|
||||
@@ -86,7 +90,9 @@ describe("ChannelStatsPopover", () => {
|
||||
// The value is the FORMATTED ETA (via formatEta), not raw seconds — asserting it guards the
|
||||
// formatEta wiring, which is untested anywhere else. Using the same formatEta keeps this
|
||||
// locale/impl-agnostic (expected === what the component renders).
|
||||
expect(screen.getByText(formatEta(120))).toBeTruthy();
|
||||
expect(
|
||||
screen.getByText(formatEta(((k: string) => k) as unknown as TFunction, 120)),
|
||||
).toBeTruthy();
|
||||
expect(screen.queryByText("120")).toBeNull(); // never the raw seconds
|
||||
});
|
||||
|
||||
|
||||
@@ -22,7 +22,8 @@ import { SURFACE } from "./ui/surfaces";
|
||||
// with a leading icon. Kept in the manager's own toolbar (the header's SyncStatus is global; these
|
||||
// are channel-catalog figures).
|
||||
export default function ChannelStatsPopover({ status }: { status: MyStatus }) {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
const [open, setOpen] = useState(false);
|
||||
const pop = useAnchoredPopover({
|
||||
open,
|
||||
@@ -58,7 +59,7 @@ export default function ChannelStatsPopover({ status }: { status: MyStatus }) {
|
||||
{
|
||||
icon: Clock,
|
||||
label: t("channels.stats.left"),
|
||||
value: formatEta(status.deep_eta_seconds),
|
||||
value: formatEta(t, status.deep_eta_seconds),
|
||||
hint: t("channels.stats.leftHint", { count: status.deep_pending_count }),
|
||||
},
|
||||
]
|
||||
@@ -66,13 +67,13 @@ export default function ChannelStatsPopover({ status }: { status: MyStatus }) {
|
||||
{
|
||||
icon: Film,
|
||||
label: t("channels.stats.myVideos"),
|
||||
value: formatNumber(status.my_videos),
|
||||
value: formatNumber(status.my_videos, lang),
|
||||
hint: t("channels.stats.myVideosHint"),
|
||||
},
|
||||
{
|
||||
icon: Zap,
|
||||
label: t("channels.stats.quotaLeft"),
|
||||
value: formatNumber(status.quota_remaining_today),
|
||||
value: formatNumber(status.quota_remaining_today, lang),
|
||||
hint: t("channels.stats.quotaLeftHint"),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -107,7 +107,8 @@ export default function Channels() {
|
||||
const { openChannel: onViewChannel, setPage } = useNavigationActions();
|
||||
const feedFilters = useFeedFilters();
|
||||
const { setFilters: setFeedFilters } = useFeedFiltersActions();
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
const qc = useQueryClient();
|
||||
|
||||
// Clicking a tag chip filters the feed by that tag and jumps there. Internalised (was an App
|
||||
@@ -364,6 +365,7 @@ export default function Channels() {
|
||||
|
||||
const columns = buildChannelColumns({
|
||||
t,
|
||||
lang: i18n.language,
|
||||
prioOptions,
|
||||
userTags,
|
||||
bumpPriority,
|
||||
@@ -471,7 +473,7 @@ export default function Channels() {
|
||||
// Cards have no column headers, so apply the shared column filters (sync + any funnel set in table
|
||||
// mode) here via the exported predicate — the same filterRows DataTable runs internally — then sort.
|
||||
const sortedChannels = isCards
|
||||
? sortRows(filterRows(syncedChannels, columns, filters), columns, sort)
|
||||
? sortRows(filterRows(syncedChannels, columns, filters), columns, sort, lang)
|
||||
: syncedChannels;
|
||||
const {
|
||||
paged: pagedCards,
|
||||
|
||||
@@ -372,7 +372,8 @@ const chipTone = (s: DebugSession): string => {
|
||||
|
||||
/** The last sessions, newest first — reopening one opens its composer, with what was written. */
|
||||
function SessionHistory() {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
const q = useQuery({ queryKey: qk.debugSessions(), queryFn: api.debugSessions });
|
||||
const items = q.data?.items.filter((s) => s.ended_at) ?? [];
|
||||
if (q.isError && !q.data)
|
||||
@@ -394,7 +395,7 @@ function SessionHistory() {
|
||||
key={s.id}
|
||||
className="flex flex-wrap items-center gap-x-3 gap-y-1 px-3 py-2 text-sm"
|
||||
>
|
||||
<span className="tabular-nums">{formatDateTimeSec(s.started_at)}</span>
|
||||
<span className="tabular-nums">{formatDateTimeSec(s.started_at, lang)}</span>
|
||||
<span className="min-w-0 flex-1 truncate text-muted">
|
||||
{s.draft?.title || t(`settings.diagnostics.levels.${s.level}`)}
|
||||
</span>
|
||||
|
||||
@@ -40,7 +40,8 @@ function useRemaining(until: string | null): { h: number; m: number } | null {
|
||||
// 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 } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
const me = useMe();
|
||||
const qc = useQueryClient();
|
||||
const state = me.diagnostics;
|
||||
@@ -173,8 +174,8 @@ export default function DiagnosticsSettings() {
|
||||
level: levelName(current),
|
||||
time:
|
||||
remaining.h > 0
|
||||
? `${formatUnit(remaining.h, "hour")} ${formatUnit(remaining.m, "minute")}`
|
||||
: formatUnit(remaining.m, "minute"),
|
||||
? `${formatUnit(remaining.h, "hour", lang)} ${formatUnit(remaining.m, "minute", lang)}`
|
||||
: formatUnit(remaining.m, "minute", lang),
|
||||
})}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
@@ -15,7 +15,10 @@ const mocks = vi.hoisted(() => ({
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }),
|
||||
};
|
||||
});
|
||||
vi.mock("./confirmContext", () => ({ useConfirm: () => mocks.confirm }));
|
||||
vi.mock("../lib/useLiveQuery", () => ({
|
||||
|
||||
@@ -18,7 +18,10 @@ const mocks = vi.hoisted(() => ({
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }),
|
||||
};
|
||||
});
|
||||
vi.mock("./confirmContext", () => ({ useConfirm: () => mocks.confirm }));
|
||||
vi.mock("../lib/useLiveQuery", () => ({
|
||||
|
||||
@@ -136,7 +136,8 @@ export default function Feed({
|
||||
// that are constant within one channel (subscribers, priority) — they'd be meaningless.
|
||||
channelScoped?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n: ui } = useTranslation();
|
||||
const lang = ui.language;
|
||||
// "Connect YouTube" onboarding lives in WizardProvider (the empty-state CTA when not connected).
|
||||
const { openWizard: onOpenWizard } = useWizard();
|
||||
// Same list the panel's badge counts — see lib/useActiveFilters.
|
||||
@@ -439,6 +440,7 @@ export default function Feed({
|
||||
withOverrides(ytLoaded),
|
||||
ytSort.key as SortKey,
|
||||
ytSort.dir,
|
||||
lang,
|
||||
);
|
||||
const ytPlayerQueue: Video[] = ytItems.filter((v) => v.status !== "hidden");
|
||||
const ytError =
|
||||
|
||||
@@ -31,12 +31,14 @@ beforeAll(() => {
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
// the real instance: these tests switch the language with setLanguage
|
||||
const real = (await import("i18next")).default;
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (key: string, opts?: Record<string, unknown>) =>
|
||||
opts?.time ? `${key}:${opts.time}` : key,
|
||||
i18n: { language: "en" },
|
||||
i18n: real,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -30,7 +30,13 @@ import {
|
||||
} from "../lib/api";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
import { takeSurprise, useSurpriseRequest } from "../lib/homeIntent";
|
||||
import { formatDuration, formatRuntime, formatViews, relativeTime } from "../lib/format";
|
||||
import {
|
||||
formatCompact,
|
||||
formatDecimal,
|
||||
formatDuration,
|
||||
formatRuntime,
|
||||
relativeTime,
|
||||
} from "../lib/format";
|
||||
import { toggleWatchState } from "../lib/plexWatch";
|
||||
import { useComfortPin, pinTargetForCard, shelfMoveTarget, type ShelfMove } from "./comfortPin";
|
||||
import { type ActionMenuItem } from "./ui/ActionMenu";
|
||||
@@ -502,7 +508,8 @@ function Hero({
|
||||
onReroll: () => void;
|
||||
rerolling: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
// The hero is a card like any other (the owner, 2026-09-27): the same hover row on its picture and
|
||||
// the same whole action list on its "…", right-click and long-press — it was a pin row alone.
|
||||
const { menu, items, panels, plexAdd } = useHomeCardMenu({
|
||||
@@ -533,12 +540,12 @@ function Hero({
|
||||
const plex = card.plex;
|
||||
const video = card.video;
|
||||
const facts = plex
|
||||
? [plex.year, formatRuntime(card.duration_seconds)]
|
||||
? [plex.year, formatRuntime(card.duration_seconds, lang)]
|
||||
: video
|
||||
? // The channel is the subtitle already.
|
||||
[
|
||||
video.view_count != null && `${formatViews(video.view_count)} ${t("card.views")}`,
|
||||
relativeTime(video.published_at),
|
||||
video.view_count != null && `${formatCompact(video.view_count, lang)} ${t("card.views")}`,
|
||||
relativeTime(video.published_at, lang),
|
||||
]
|
||||
: [];
|
||||
const factLine = facts.filter(Boolean).join(" · ");
|
||||
@@ -631,7 +638,7 @@ function Hero({
|
||||
<span className="inline-flex items-center gap-0.5">
|
||||
{factLine && <span aria-hidden>·</span>}
|
||||
<Star className="h-3 w-3 fill-current text-rating" aria-hidden />
|
||||
{plex.rating.toFixed(1)}
|
||||
{formatDecimal(plex.rating, lang)}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
@@ -692,7 +699,7 @@ function readSavedQuestion(): SavedQuestion | null {
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const { setPage } = useNavigationActions();
|
||||
const { setJumpTo } = usePlexActions();
|
||||
@@ -722,7 +729,7 @@ export default function Home() {
|
||||
const [player, setPlayer] = useState<{ video: Video; startAt: number | null } | null>(null);
|
||||
|
||||
// A Plex card's title is in the reader's language (E30), so the answer is cached per language.
|
||||
const lang = plexLang();
|
||||
const lang = plexLang(i18n.language);
|
||||
const q = useQuery({
|
||||
queryKey: qk.discover(budget, lang),
|
||||
queryFn: () => api.discover(budget, 0, lang),
|
||||
|
||||
@@ -114,6 +114,7 @@ function ComposerBody({
|
||||
total: number;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
const qc = useQueryClient();
|
||||
const pageName = (page: string) => {
|
||||
const def = moduleDef(page as Page);
|
||||
@@ -304,8 +305,8 @@ function ComposerBody({
|
||||
<div ref={root} className="flex flex-col gap-4">
|
||||
<p className="text-sm text-muted">
|
||||
{t("recorder.composer.meta", {
|
||||
start: formatDateTimeSec(started.toISOString()),
|
||||
minutes: formatUnit(minutes, "minute"),
|
||||
start: formatDateTimeSec(started.toISOString(), lang),
|
||||
minutes: formatUnit(minutes, "minute", lang),
|
||||
level: t(`settings.diagnostics.levels.${session.level}`),
|
||||
})}
|
||||
</p>
|
||||
@@ -607,7 +608,8 @@ function Attachments({
|
||||
onReview: (source: Source) => void;
|
||||
reviewed: MutableRefObject<Source | null>;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
const qc = useQueryClient();
|
||||
const shots = shotsQ.data?.items ?? [];
|
||||
// A removed screenshot's ✕ goes with it: the focus moves to the file picker, which stays.
|
||||
@@ -685,7 +687,7 @@ function Attachments({
|
||||
/>
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<span className="text-sm">{t("recorder.composer.screenshot", { n: i + 1 })}</span>
|
||||
<span className="text-xs text-muted tabular-nums">{formatBytes(shot.size)}</span>
|
||||
<span className="text-xs text-muted tabular-nums">{formatBytes(shot.size, lang)}</span>
|
||||
</div>
|
||||
{!readOnly && (
|
||||
<IconButton
|
||||
|
||||
@@ -8,7 +8,10 @@ import { act, cleanup, render, screen } from "@testing-library/react";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }),
|
||||
};
|
||||
});
|
||||
|
||||
const conversation = (id: number, name: string) => ({
|
||||
|
||||
@@ -23,7 +23,7 @@ const h = vi.hoisted(() => ({
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("react-i18next")>()),
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }),
|
||||
}));
|
||||
vi.mock("./confirmContext", () => ({ useConfirm: () => h.confirm }));
|
||||
vi.mock("../lib/useLiveQuery", () => ({
|
||||
|
||||
@@ -507,6 +507,7 @@ function NotificationRow({
|
||||
onOpenScheduler: () => void;
|
||||
onOpenDownloads: () => void;
|
||||
}) {
|
||||
const lang = useTranslation().i18n.language;
|
||||
// The maintenance producer ships the affected videos in `data.videos`; list a few titles.
|
||||
const videos: { id: string; title: string | null }[] = Array.isArray(n.data?.videos)
|
||||
? n.data!.videos
|
||||
@@ -535,7 +536,7 @@ function NotificationRow({
|
||||
job,
|
||||
});
|
||||
const result = d.result ?? parseSummary(d.summary);
|
||||
body = result ? jobResultText(t, result) : d.summary || n.body;
|
||||
body = result ? jobResultText(t, result, lang) : d.summary || n.body;
|
||||
} else if (isEmailUndelivered) {
|
||||
// Email is fail-soft: a drop leaves no error anywhere else, so this notice IS the trace. Name
|
||||
// the message that was lost, since that is what tells the operator what their users didn't get.
|
||||
|
||||
@@ -22,6 +22,7 @@ vi.mock("react-i18next", async (importOriginal) => {
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
i18n: { language: "en" },
|
||||
t: (key: string, o?: { name?: string }) =>
|
||||
key === "common.order.dragNamed" ? `${key}:${o?.name}` : key,
|
||||
}),
|
||||
|
||||
@@ -44,7 +44,7 @@ import {
|
||||
channelYouTubeUrl,
|
||||
formatDate,
|
||||
formatDuration,
|
||||
formatViews,
|
||||
formatCompact,
|
||||
relativeTime,
|
||||
} from "../lib/format";
|
||||
import { renderDescription } from "./descriptionLinks";
|
||||
@@ -119,6 +119,7 @@ export default function PlayerModal({
|
||||
// icon next to the name keeps the open-on-YouTube behaviour.
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
const { openChannel } = useNavigationActions();
|
||||
const qc = useQueryClient();
|
||||
const descFade = useScrollFade();
|
||||
@@ -899,12 +900,12 @@ export default function PlayerModal({
|
||||
·{" "}
|
||||
{t("player.views", {
|
||||
count: active.view_count,
|
||||
formattedCount: formatViews(active.view_count),
|
||||
formattedCount: formatCompact(active.view_count, lang),
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
<span>
|
||||
· {relativeTime(active.published_at)}
|
||||
· {relativeTime(active.published_at, lang)}
|
||||
{active.published_at && ` · ${fullDate(active.published_at)}`}
|
||||
</span>
|
||||
{active.duration_seconds != null && (
|
||||
@@ -924,13 +925,13 @@ export default function PlayerModal({
|
||||
·{" "}
|
||||
{t("player.views", {
|
||||
count: detail.data.view_count,
|
||||
formattedCount: formatViews(detail.data.view_count),
|
||||
formattedCount: formatCompact(detail.data.view_count, lang),
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
{detail.data?.published_at && (
|
||||
<span>
|
||||
· {relativeTime(detail.data.published_at)} ·{" "}
|
||||
· {relativeTime(detail.data.published_at, lang)} ·{" "}
|
||||
{fullDate(detail.data.published_at)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -160,7 +160,8 @@ export default function Playlists() {
|
||||
// The selected playlist lives in PlaylistsProvider, shared with the PlaylistsRail (which owns the
|
||||
// list + its rail controls). This detail pane reacts to the selection.
|
||||
const { selectedId } = usePlaylists();
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
const qc = useQueryClient();
|
||||
const plActions = usePlaylistActions();
|
||||
const [renaming, setRenaming] = useState(false);
|
||||
@@ -391,8 +392,8 @@ export default function Playlists() {
|
||||
|
||||
const filterActive = Object.values(filters).some(isActive);
|
||||
const shown = useMemo(
|
||||
() => applyView(filterRows(items, columns, filters), columns, viewSort, groupBy),
|
||||
[items, columns, filters, viewSort, groupBy],
|
||||
() => applyView(filterRows(items, columns, filters), columns, viewSort, groupBy, lang),
|
||||
[items, columns, filters, viewSort, groupBy, lang],
|
||||
);
|
||||
// Read by the drag announcements, which must report the CURRENT positions without making the
|
||||
// announcement closures a render dependency of DndContext.
|
||||
@@ -909,7 +910,7 @@ export default function Playlists() {
|
||||
<div className="flex items-center gap-2 mt-1 flex-wrap">
|
||||
<span className="text-xs text-muted">
|
||||
{t("playlists.itemCount", { count: items.length })}
|
||||
{totalSeconds > 0 && ` · ${formatRuntime(totalSeconds)}`}
|
||||
{totalSeconds > 0 && ` · ${formatRuntime(totalSeconds, lang)}`}
|
||||
{filterActive && ` · ${t("playlists.filteredCount", { count: shown.length })}`}
|
||||
</span>
|
||||
{(items.length > 1 || viewActive) && (
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { ListVideo, Pin, Plus, RefreshCw, Youtube } from "lucide-react";
|
||||
import { api, type Playlist } from "../lib/api";
|
||||
import { compareText } from "../lib/collate";
|
||||
import { formatRuntime } from "../lib/format";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
||||
@@ -59,7 +60,8 @@ export default function PlaylistsRail({
|
||||
// box and the Playlists detail pane); this rail owns the list query + its own controls.
|
||||
const { search, selectedId } = usePlaylists();
|
||||
const { setSelectedId } = usePlaylistsActions();
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
const qc = useQueryClient();
|
||||
const [plSort, setPlSort] = useState<PlSort>(() => readAccountMerged(LS.plSort, PL_SORT_DEFAULT));
|
||||
const plSortOptions: SortOption[] = [
|
||||
@@ -123,7 +125,7 @@ export default function PlaylistsRail({
|
||||
if (plSort.dirtyFirst && a.dirty !== b.dirty) return a.dirty ? -1 : 1;
|
||||
switch (plSort.key) {
|
||||
case "name":
|
||||
return sign * plName(a).localeCompare(plName(b));
|
||||
return sign * compareText(plName(a), plName(b), lang);
|
||||
case "count":
|
||||
return sign * (a.item_count - b.item_count);
|
||||
case "duration":
|
||||
@@ -134,7 +136,7 @@ export default function PlaylistsRail({
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
}, [playlists, plSort, plName]);
|
||||
}, [playlists, plSort, plName, lang]);
|
||||
|
||||
const q = search.trim().toLowerCase();
|
||||
const visiblePlaylists = useMemo(
|
||||
@@ -366,7 +368,7 @@ export default function PlaylistsRail({
|
||||
<div className="text-2xs text-muted">
|
||||
{t("playlists.itemCount", { count: pl.item_count })}
|
||||
{pl.total_duration_seconds > 0 &&
|
||||
` · ${formatRuntime(pl.total_duration_seconds)}`}
|
||||
` · ${formatRuntime(pl.total_duration_seconds, lang)}`}
|
||||
</div>
|
||||
</div>
|
||||
</Pressable>
|
||||
|
||||
@@ -93,7 +93,7 @@ const BACKABLE_VIEWS: readonly string[] = [
|
||||
];
|
||||
|
||||
export default function PlexBrowse() {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
// Scope / watch-state / sort / filters live in PlexProvider (edited mainly from PlexSidebar); the
|
||||
// search term (q) comes from the shared header box; the rail hands off a playlist to open via the
|
||||
@@ -103,7 +103,7 @@ export default function PlexBrowse() {
|
||||
const dq = useDebounced(q.trim(), 350);
|
||||
// Active language for the HU overlay: "hu" serves Hungarian metadata, else EN. In the query keys so
|
||||
// a switch refetches the grid/facets/detail; useTranslation re-renders this component on switch.
|
||||
const lang = plexLang();
|
||||
const lang = plexLang(i18n.language);
|
||||
// The language the grid query is allowed to run at — lags `lang` by one render on a switch so the
|
||||
// genre-clear effect (below) can settle first (see the effect for why).
|
||||
const [committedLang, setCommittedLang] = useState(lang);
|
||||
@@ -116,7 +116,7 @@ export default function PlexBrowse() {
|
||||
// The provider holds sort scope-agnostically; heal it against THIS scope's options so a movie-only
|
||||
// key (e.g. duration) left over from before a switch to shows doesn't ask for an unavailable sort.
|
||||
const sortState = healPlexSort(sort, scope);
|
||||
const sortOptions = plexSortOptions(scope, (k) => t(`plex.sort.${k}`));
|
||||
const sortOptions = plexSortOptions(scope, (k) => t(`plex.sort.${k}`), lang);
|
||||
// Same idiom for the search scope-pill: a movie-only scope can't hold the Episode field.
|
||||
const fieldState = healPlexField(field, scope);
|
||||
// Discover is the landing (E5 S4); the flat grid is the "Browse all" secondary view. A header search
|
||||
|
||||
@@ -27,7 +27,12 @@ beforeAll(() => {
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
|
||||
// the real instance: these tests switch the language with setLanguage
|
||||
const real = (await import("i18next")).default;
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({ i18n: real, t: (key: string) => key }),
|
||||
};
|
||||
});
|
||||
vi.mock("./confirmContext", () => ({ useConfirm: () => async () => true }));
|
||||
|
||||
|
||||
@@ -67,7 +67,7 @@ export default function PlexCollectionView({
|
||||
onRestartCard: (card: PlexCard) => void;
|
||||
onInfoCard: (card: PlexCard) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const confirm = useConfirm();
|
||||
// One menu for the member grid (see ui/ContextMenu).
|
||||
@@ -84,7 +84,7 @@ export default function PlexCollectionView({
|
||||
// ONE key for the read and every write into it: when the sort joined the key, the watched toggle's
|
||||
// optimistic write kept the old spelling and landed on an entry nothing reads (setQueryData matches
|
||||
// exactly), so the click did nothing visible until a reload.
|
||||
const lang = plexLang();
|
||||
const lang = plexLang(i18n.language);
|
||||
const detailKey = qk.plexCollections("detail", collectionId, sortPref.key, sortPref.dir, lang);
|
||||
const q = useQuery({
|
||||
queryKey: detailKey,
|
||||
@@ -118,7 +118,7 @@ export default function PlexCollectionView({
|
||||
// what the add-panel searches and which sort keys are on offer (only a movie has a runtime).
|
||||
const scope = items.some((c) => c.type === "show") ? "show" : "movie";
|
||||
const sortState = healPlexSort(sortPref, scope);
|
||||
const sortOptions = plexSortOptions(scope, (k) => t(`plex.sort.${k}`));
|
||||
const sortOptions = plexSortOptions(scope, (k) => t(`plex.sort.${k}`), lang);
|
||||
// Movies/episodes are playable leaves that queue as themselves; a show can't queue directly, so
|
||||
// Play-all expands it into its episodes (buildQueue). Either kind means there's something to play —
|
||||
// this is what a show-collection needs: before, `playable` held only leaves, so a show-collection's
|
||||
@@ -264,7 +264,7 @@ export default function PlexCollectionView({
|
||||
(d.year_max && d.year_max !== d.year_min ? `${d.year_min} — ${d.year_max}` : `${d.year_min}`),
|
||||
d?.content_rating,
|
||||
d && t("plex.browse.items", { count: d.child_count ?? items.length }),
|
||||
formatRuntime(d?.total_runtime_s),
|
||||
formatRuntime(d?.total_runtime_s, lang),
|
||||
].filter(Boolean) as string[];
|
||||
|
||||
return (
|
||||
|
||||
@@ -27,6 +27,7 @@ vi.mock("react-i18next", async (importOriginal) => {
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
i18n: { language: "en" },
|
||||
t: (key: string, opts?: Record<string, unknown>) =>
|
||||
opts?.title ? `${key}:${opts.title}` : key,
|
||||
}),
|
||||
|
||||
@@ -108,9 +108,9 @@ export default function PlexDiscover({
|
||||
onGenre: (genre: string) => void;
|
||||
onSeeAllRecent: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const lang = plexLang();
|
||||
const lang = plexLang(i18n.language);
|
||||
// The active sidebar filters narrow every rail, so Discover matches the Browse grid. They're in the
|
||||
// query keys so a filter change refetches (and the watched-toggle setQueryData below keys the same).
|
||||
const { filters } = usePlex();
|
||||
|
||||
@@ -14,6 +14,7 @@ vi.mock("react-i18next", async (importOriginal) => {
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
i18n: { language: "en" },
|
||||
t: (key: string, opts?: Record<string, unknown>) =>
|
||||
opts?.name ? `${key}:${opts.name}` : key,
|
||||
}),
|
||||
|
||||
@@ -4,12 +4,11 @@ import { useTranslation } from "react-i18next";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Check, ExternalLink, FolderPlus, ListPlus, Play, RotateCcw, Star, X } from "lucide-react";
|
||||
import { api, type PlexCard, type PlexFilters, type PlexItemDetail } from "../lib/api";
|
||||
import { formatRuntime } from "../lib/format";
|
||||
import { formatCompact, formatDecimal, formatMoney, formatRuntime } from "../lib/format";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
import { DetailCustomizeMenu, Filterable, PrefToggle } from "./plexDetailUi";
|
||||
import { tmdbBackdropUrl, useCrossfadeBackdrop, useDetailPrefs } from "../lib/plexDetail";
|
||||
import { usePref } from "../lib/usePref";
|
||||
import i18n from "../i18n";
|
||||
import { Carousel } from "./ui/Carousel";
|
||||
import PlexCollectionEditor from "./PlexCollectionEditor";
|
||||
import PlexPlaylistAdd from "./PlexPlaylistAdd";
|
||||
@@ -38,24 +37,6 @@ import { regionName, vocab } from "../lib/vocab";
|
||||
// The TMDB-enriched pieces (multi-source ratings row, facts, keyword chips, full crew — E5 S5) render
|
||||
// on the page variant only; the overlay stays lean.
|
||||
|
||||
// Localize the grouping/compact notation to the active language (the app convention, lib/api/core.ts);
|
||||
// `i18n.language` is read at call time, and the component re-renders on a language switch. Currency stays
|
||||
// USD — TMDB budget/revenue figures are USD regardless of the viewer's language.
|
||||
const compact = (n: number) =>
|
||||
new Intl.NumberFormat(i18n.language, { notation: "compact" }).format(n);
|
||||
const money = (n: number) =>
|
||||
new Intl.NumberFormat(i18n.language, {
|
||||
notation: "compact",
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
// The minimum is load-bearing, not a default written out: given only a maximum, a compact
|
||||
// format is free to pad to it. The very same options render "$160.0M" on Node 22 and "$160M"
|
||||
// on Node 24 (measured 2026-09-20 — the server gate was red on exactly this while the local one
|
||||
// was green), and browsers split along the same line. Pin it rather than inherit the engine's.
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 1,
|
||||
}).format(n);
|
||||
|
||||
// One rating chip's number: a filter button when this is the score the backend's ratingMin column
|
||||
// tracks, plain text otherwise. It lives at MODULE level, not inside RatingsRow, because a component
|
||||
// declared during render is a NEW component type on every render — React unmounts and remounts the
|
||||
@@ -70,7 +51,8 @@ function Score({
|
||||
filterable: boolean;
|
||||
onFilterRating?: (score: number) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const shown = formatDecimal(score, i18n.language);
|
||||
return filterable && onFilterRating ? (
|
||||
<Pressable
|
||||
onClick={() => onFilterRating(Math.floor(score))}
|
||||
@@ -78,12 +60,12 @@ function Score({
|
||||
title={t("plex.info.filterRating", { n: Math.floor(score) })}
|
||||
>
|
||||
<Star className="h-3.5 w-3.5 fill-current" />
|
||||
{score.toFixed(1)}
|
||||
{shown}
|
||||
</Pressable>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Star className="h-3.5 w-3.5 fill-current" />
|
||||
{score.toFixed(1)}
|
||||
{shown}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -110,7 +92,7 @@ function RatingsRow({
|
||||
* theme's dark gold would sit on the player's black (2.6:1, measured). */
|
||||
onDark?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const primary = imdbRating ?? audience ?? null; // the filterable score
|
||||
// The source label is the chip's own colour at full strength: dimmed to 70% it fell under 4.5:1.
|
||||
const chipCls = `inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 font-semibold ${
|
||||
@@ -147,7 +129,7 @@ function RatingsRow({
|
||||
<span className={chipCls}>
|
||||
<Score score={tmdbRating} filterable={false} />
|
||||
<span className="text-2xs font-normal">
|
||||
TMDb{has(tmdbVotes) ? ` · ${compact(tmdbVotes)}` : ""}
|
||||
TMDb{has(tmdbVotes) ? ` · ${formatCompact(tmdbVotes, i18n.language)}` : ""}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
@@ -171,7 +153,8 @@ function FactsPanel({
|
||||
detail: PlexItemDetail;
|
||||
onFilter?: (patch: Partial<PlexFilters>) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
const tmdb = detail.tmdb;
|
||||
const studios = tmdb?.studios?.length ? tmdb.studios : detail.studio ? [detail.studio] : [];
|
||||
const rows: { key: string; label: string; node: ReactNode }[] = [];
|
||||
@@ -195,7 +178,7 @@ function FactsPanel({
|
||||
rows.push({
|
||||
key: "runtime",
|
||||
label: t("plex.info.facts.runtime"),
|
||||
node: formatRuntime(detail.duration_seconds),
|
||||
node: formatRuntime(detail.duration_seconds, lang),
|
||||
});
|
||||
const cert = detail.content_rating;
|
||||
if (cert)
|
||||
@@ -209,9 +192,17 @@ function FactsPanel({
|
||||
),
|
||||
});
|
||||
if (tmdb?.budget)
|
||||
rows.push({ key: "budget", label: t("plex.info.facts.budget"), node: money(tmdb.budget) });
|
||||
rows.push({
|
||||
key: "budget",
|
||||
label: t("plex.info.facts.budget"),
|
||||
node: formatMoney(tmdb.budget, lang),
|
||||
});
|
||||
if (tmdb?.revenue)
|
||||
rows.push({ key: "revenue", label: t("plex.info.facts.revenue"), node: money(tmdb.revenue) });
|
||||
rows.push({
|
||||
key: "revenue",
|
||||
label: t("plex.info.facts.revenue"),
|
||||
node: formatMoney(tmdb.revenue, lang),
|
||||
});
|
||||
if (studios.length)
|
||||
rows.push({
|
||||
key: "studios",
|
||||
@@ -372,7 +363,8 @@ export default function PlexInfo({
|
||||
onPlayItem,
|
||||
library,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
const qc = useQueryClient();
|
||||
|
||||
// Personal display prefs (default ON), shared with the series detail pages via useDetailPrefs:
|
||||
@@ -601,7 +593,7 @@ export default function PlexInfo({
|
||||
</Filterable>
|
||||
)}
|
||||
{detail.duration_seconds ? (
|
||||
<span className={mutedCls}>{formatRuntime(detail.duration_seconds)}</span>
|
||||
<span className={mutedCls}>{formatRuntime(detail.duration_seconds, lang)}</span>
|
||||
) : null}
|
||||
{detail.studio && (
|
||||
<Filterable
|
||||
|
||||
@@ -25,7 +25,12 @@ beforeAll(() => {
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
|
||||
// the real instance: these tests switch the language with setLanguage
|
||||
const real = (await import("i18next")).default;
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({ i18n: real, t: (key: string) => key }),
|
||||
};
|
||||
});
|
||||
|
||||
const plexPerson = vi.fn();
|
||||
@@ -116,7 +121,8 @@ describe("PlexPersonView", () => {
|
||||
// E30: TMDB's English department, named through the vocab (key-echo i18n here).
|
||||
expect(screen.getByText("vocab.department.Acting")).toBeTruthy();
|
||||
expect(screen.getByText("A celebrated actor.")).toBeTruthy();
|
||||
expect(screen.getByText(/1970-01-01/)).toBeTruthy();
|
||||
// E30 S3: the birth date as a date in the UI language, not the raw ISO string
|
||||
expect(screen.getByText("Jan 1, 1970 · Springfield")).toBeTruthy();
|
||||
expect(screen.getByText(/plex\.person\.aka/)).toBeTruthy();
|
||||
expect(screen.getByText(/Janie D/)).toBeTruthy();
|
||||
// "Known for" rail renders its card.
|
||||
|
||||
@@ -145,7 +145,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
||||
const qc = useQueryClient();
|
||||
const [id, setId] = useState(itemId);
|
||||
// Serve the player's title/summary/rich detail in the active language (E5 S6), matching the grid.
|
||||
const lang = plexLang();
|
||||
const lang = plexLang(i18n.language);
|
||||
const detailQ = useQuery({
|
||||
queryKey: qk.plexItem(id, lang),
|
||||
queryFn: () => api.plexItem(id, lang),
|
||||
@@ -986,7 +986,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
||||
seekTo(playhead(absRef, loadStartRef) + zone * TAP_SKIP_S);
|
||||
flashOsd(
|
||||
t(zone < 0 ? "plex.player.tapBack" : "plex.player.tapForward", {
|
||||
d: formatUnit(skips * TAP_SKIP_S, "second"),
|
||||
d: formatUnit(skips * TAP_SKIP_S, "second", lang),
|
||||
}),
|
||||
);
|
||||
return;
|
||||
@@ -999,7 +999,7 @@ export default function PlexPlayer({ itemId, onClose, queue, fromStart = false }
|
||||
}
|
||||
toggleControls();
|
||||
},
|
||||
[togglePlay, showControls, toggleControls, seekTo, flashOsd, t, uiVisible],
|
||||
[togglePlay, showControls, toggleControls, seekTo, flashOsd, t, lang, uiVisible],
|
||||
);
|
||||
|
||||
// Auto-skip intro/credits (countdown → jump to marker end / next item). The refs bridge to the
|
||||
|
||||
@@ -10,7 +10,7 @@ import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("react-i18next")>()),
|
||||
useTranslation: () => ({ t: (k: string) => k }),
|
||||
useTranslation: () => ({ i18n: { language: "en" }, t: (k: string) => k }),
|
||||
}));
|
||||
|
||||
import { PlexPlayerSettings } from "./PlexPlayerSettings";
|
||||
|
||||
@@ -84,7 +84,7 @@ export function PlexPlayerSettings(props: {
|
||||
min={-10}
|
||||
max={10}
|
||||
step={0.1}
|
||||
suffix="s"
|
||||
seconds
|
||||
onChange={(v) => patchPrefs({ subOffset: v })}
|
||||
onReset={() => patchPrefs({ subOffset: 0 })}
|
||||
/>
|
||||
@@ -94,7 +94,7 @@ export function PlexPlayerSettings(props: {
|
||||
min={-5}
|
||||
max={5}
|
||||
step={0.1}
|
||||
suffix="s"
|
||||
seconds
|
||||
onChange={changeAudioOffset}
|
||||
onReset={() => changeAudioOffset(0)}
|
||||
/>
|
||||
@@ -109,7 +109,7 @@ export function PlexPlayerSettings(props: {
|
||||
min={5}
|
||||
max={30}
|
||||
step={1}
|
||||
suffix="s"
|
||||
seconds
|
||||
onChange={(v) => patchPrefs({ seekStep: v })}
|
||||
/>
|
||||
<Slider
|
||||
@@ -118,7 +118,7 @@ export function PlexPlayerSettings(props: {
|
||||
min={0.1}
|
||||
max={5}
|
||||
step={0.1}
|
||||
suffix="s"
|
||||
seconds
|
||||
onChange={(v) => patchPrefs({ fineSeekStep: v })}
|
||||
/>
|
||||
<Toggle
|
||||
@@ -148,7 +148,7 @@ export function PlexPlayerSettings(props: {
|
||||
min={0}
|
||||
max={10}
|
||||
step={1}
|
||||
suffix="s"
|
||||
seconds
|
||||
onChange={(v) => patchPrefs({ autoSkipDelay: v })}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -9,13 +9,16 @@ import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
|
||||
import { render, cleanup, screen, fireEvent, waitFor, act } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => ({
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const real = (await import("i18next")).default;
|
||||
return {
|
||||
...(await importOriginal<typeof import("react-i18next")>()),
|
||||
useTranslation: () => ({
|
||||
t: (key: string, o?: { title?: string }) => (o?.title ? `${key}:${o.title}` : key),
|
||||
i18n: { language: "en" },
|
||||
i18n: real,
|
||||
}),
|
||||
}));
|
||||
};
|
||||
});
|
||||
const h = vi.hoisted(() => ({ notify: vi.fn(), confirm: vi.fn(async () => true) }));
|
||||
vi.mock("../lib/notifications", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../lib/notifications")>()),
|
||||
|
||||
@@ -406,7 +406,8 @@ export default function PlexPlaylistView({
|
||||
/** A title's media-info page, from its menu's "Details". */
|
||||
onInfoCard?: (card: PlexCard) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
const qc = useQueryClient();
|
||||
const confirm = useConfirm();
|
||||
const [renaming, setRenaming] = useState(false);
|
||||
@@ -755,7 +756,7 @@ export default function PlexPlaylistView({
|
||||
{items.length > 0 && (
|
||||
<div className="mt-0.5 text-sm text-muted">
|
||||
{t("plex.browse.items", { count: items.length })}
|
||||
{totalSeconds > 0 && ` · ${formatRuntime(totalSeconds)}`}
|
||||
{totalSeconds > 0 && ` · ${formatRuntime(totalSeconds, lang)}`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -44,7 +44,7 @@ const DURATION_BUCKETS: { key: string; min: number | null; max: number | null }[
|
||||
];
|
||||
|
||||
export default function PlexSidebar({ layout, setLayout, collapsed, onToggleCollapse }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
// Scope / watch-state / sort / expanded filters live in PlexProvider (shared with PlexBrowse and
|
||||
// the header search box); this rail is the primary editor of them. Opening a playlist is a
|
||||
// hand-off to PlexBrowse via the shared `playlistOpen` slot.
|
||||
@@ -60,7 +60,7 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
|
||||
// Titles are display-only (chip labels) — keep them OUT of the facet query key so renaming/relabelling
|
||||
// never refetches. collectionMode stays IN the key: any/all changes the result set.
|
||||
const { collectionTitles: _cts, ...facetKey } = filters;
|
||||
const lang = plexLang(); // HU/EN genre vocabulary — refetch facets on a language switch
|
||||
const lang = plexLang(i18n.language); // HU/EN genre vocabulary — refetch facets on a language switch
|
||||
const facetsQ = useQuery({
|
||||
queryKey: qk.plexFacets(scope, show, facetKey, lang),
|
||||
queryFn: () => api.plexFacets(scope, filters, show, lang),
|
||||
|
||||
@@ -38,7 +38,8 @@ export function PlexStatsOverlay({
|
||||
durationRef: RefObject<number>;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
const [rows, setRows] = useState<StatRow[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -87,6 +88,7 @@ export function PlexStatsOverlay({
|
||||
|
||||
setRows(
|
||||
buildStatRows({
|
||||
lang,
|
||||
playable,
|
||||
hwEncoder: hwRef.current,
|
||||
tonemap: tonemapRef.current,
|
||||
@@ -110,6 +112,7 @@ export function PlexStatsOverlay({
|
||||
const iv = setInterval(sample, 1000);
|
||||
return () => clearInterval(iv);
|
||||
}, [
|
||||
lang,
|
||||
videoRef,
|
||||
hlsRef,
|
||||
fragMeterRef,
|
||||
|
||||
@@ -11,7 +11,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return { ...actual, useTranslation: () => ({ t: (k: string) => k }) };
|
||||
return { ...actual, useTranslation: () => ({ i18n: { language: "en" }, t: (k: string) => k }) };
|
||||
});
|
||||
|
||||
import Scheduler from "./Scheduler";
|
||||
|
||||
@@ -9,7 +9,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
const h = vi.hoisted(() => ({ confirm: vi.fn(async () => true) }));
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return { ...actual, useTranslation: () => ({ t: (k: string) => k }) };
|
||||
return { ...actual, useTranslation: () => ({ i18n: { language: "en" }, t: (k: string) => k }) };
|
||||
});
|
||||
vi.mock("./confirmContext", () => ({ useConfirm: () => h.confirm }));
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import i18n from "../i18n";
|
||||
import type { TFunction } from "i18next";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Activity,
|
||||
@@ -47,14 +47,14 @@ function secsUntil(iso: string | null): number | null {
|
||||
return Math.round((new Date(iso).getTime() - Date.now()) / 1000);
|
||||
}
|
||||
|
||||
function fmtCountdown(s: number): string {
|
||||
if (s <= 0) return i18n.t("time.eta.now");
|
||||
function fmtCountdown(t: TFunction, s: number, lang: string): string {
|
||||
if (s <= 0) return t("time.eta.now");
|
||||
// The countdown speaks the UI language's units (DESIGN Copy), like every other duration.
|
||||
if (s < 60) return formatUnit(s, "second");
|
||||
if (s < 60) return formatUnit(s, "second", lang);
|
||||
const m = Math.floor(s / 60);
|
||||
if (m < 60) return `${formatUnit(m, "minute")} ${formatUnit(s % 60, "second")}`;
|
||||
if (m < 60) return `${formatUnit(m, "minute", lang)} ${formatUnit(s % 60, "second", lang)}`;
|
||||
const h = Math.floor(m / 60);
|
||||
return `${formatUnit(h, "hour")} ${formatUnit(m % 60, "minute")}`;
|
||||
return `${formatUnit(h, "hour", lang)} ${formatUnit(m % 60, "minute", lang)}`;
|
||||
}
|
||||
|
||||
const DOT_CLASS: Record<string, string> = {
|
||||
@@ -104,7 +104,8 @@ function StatusLegend() {
|
||||
// label and an indeterminate sliver — so every active run is visible, not just the ones
|
||||
// that report numbers.
|
||||
function JobProgress({ p }: { p: SchedulerJob["progress"] }) {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
const phase = p?.phase ? t(`scheduler.phase.${p.phase}`, p.phase) : t("scheduler.phase.working");
|
||||
const pct =
|
||||
p?.total && p.total > 0 ? Math.min(100, Math.round((p.current / p.total) * 100)) : null;
|
||||
@@ -114,9 +115,9 @@ function JobProgress({ p }: { p: SchedulerJob["progress"] }) {
|
||||
<span className="truncate">{phase}</span>
|
||||
<span className="tabular-nums shrink-0">
|
||||
{p?.total != null
|
||||
? `${formatNumber(p.current)} / ${formatNumber(p.total)}`
|
||||
? `${formatNumber(p.current, lang)} / ${formatNumber(p.total, lang)}`
|
||||
: p
|
||||
? formatNumber(p.current)
|
||||
? formatNumber(p.current, lang)
|
||||
: ""}
|
||||
</span>
|
||||
</div>
|
||||
@@ -152,7 +153,8 @@ function JobRow({
|
||||
/** A run-all in flight: transient, natively disabled. */
|
||||
runBusy: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
// The box shows the server's value and holds a draft only while it is being edited, so there is
|
||||
// nothing for an effect to re-sync: `null` means "follow the job". The poll behind this page
|
||||
// refreshes every few seconds, which is exactly when a mirror-plus-effect would have shown the
|
||||
@@ -247,17 +249,17 @@ function JobRow({
|
||||
</div>
|
||||
<div
|
||||
className="text-2xs text-muted truncate"
|
||||
title={(!job.running && formatDateTime(job.last_finished)) || undefined}
|
||||
title={(!job.running && formatDateTime(job.last_finished, lang)) || undefined}
|
||||
>
|
||||
{job.running
|
||||
? t("scheduler.runningNow")
|
||||
: job.last_finished
|
||||
? t("scheduler.lastRun", { time: relativeTime(job.last_finished) })
|
||||
? t("scheduler.lastRun", { time: relativeTime(job.last_finished, lang) })
|
||||
: t("scheduler.neverRun")}
|
||||
{job.last_error ? (
|
||||
<span className="text-danger"> · {job.last_error}</span>
|
||||
) : job.result ? (
|
||||
<span> · {jobResultText(t, job.result)}</span>
|
||||
<span> · {jobResultText(t, job.result, lang)}</span>
|
||||
) : job.last_result ? (
|
||||
<span> · {job.last_result}</span>
|
||||
) : null}
|
||||
@@ -270,7 +272,7 @@ function JobRow({
|
||||
) : next != null ? (
|
||||
<>
|
||||
<div className="uppercase tracking-wide text-3xs">{t("scheduler.nextRun")}</div>
|
||||
<div>{fmtCountdown(next)}</div>
|
||||
<div>{fmtCountdown(t, next, lang)}</div>
|
||||
</>
|
||||
) : (
|
||||
"—"
|
||||
@@ -303,7 +305,8 @@ function MaintenanceSettings({
|
||||
onSave: (batch: number) => void;
|
||||
saving: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
// Same shape as the job row above: a draft while editing, the server's value otherwise. This is
|
||||
// the editor a poll really could overwrite mid-keystroke before E27 S2 — its old effect had no
|
||||
// `editing` guard at all.
|
||||
@@ -363,7 +366,7 @@ function MaintenanceSettings({
|
||||
)}
|
||||
<div className="text-2xs text-muted mt-1.5">
|
||||
{t("scheduler.maintenance.batchNote", {
|
||||
default: formatNumber(m.revalidate_batch_default),
|
||||
default: formatNumber(m.revalidate_batch_default, lang),
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
@@ -406,7 +409,8 @@ function Stat({
|
||||
}
|
||||
|
||||
export default function Scheduler() {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
const qc = useQueryClient();
|
||||
const confirm = useConfirm();
|
||||
// Poll faster while any job is running so live progress is smooth, then ease back when
|
||||
@@ -647,31 +651,31 @@ export default function Scheduler() {
|
||||
icon={Database}
|
||||
label={t("scheduler.queue.recentPending")}
|
||||
hint={t("scheduler.queue.recentPendingHint")}
|
||||
value={formatNumber(data.queue.channels_recent_pending)}
|
||||
value={formatNumber(data.queue.channels_recent_pending, lang)}
|
||||
/>
|
||||
<Stat
|
||||
icon={Database}
|
||||
label={t("scheduler.queue.deepPending")}
|
||||
hint={t("scheduler.queue.deepPendingHint")}
|
||||
value={formatNumber(data.queue.channels_deep_pending)}
|
||||
value={formatNumber(data.queue.channels_deep_pending, lang)}
|
||||
/>
|
||||
<Stat
|
||||
icon={Clock}
|
||||
label={t("scheduler.queue.enrichPending")}
|
||||
hint={t("scheduler.queue.enrichPendingHint")}
|
||||
value={formatNumber(data.queue.videos_pending_enrich)}
|
||||
value={formatNumber(data.queue.videos_pending_enrich, lang)}
|
||||
/>
|
||||
<Stat
|
||||
icon={Clock}
|
||||
label={t("scheduler.queue.shortsPending")}
|
||||
hint={t("scheduler.queue.shortsPendingHint")}
|
||||
value={formatNumber(data.queue.videos_pending_shorts)}
|
||||
value={formatNumber(data.queue.videos_pending_shorts, lang)}
|
||||
/>
|
||||
<Stat
|
||||
icon={RadioTower}
|
||||
label={t("scheduler.queue.liveRefresh")}
|
||||
hint={t("scheduler.queue.liveRefreshHint")}
|
||||
value={formatNumber(data.queue.videos_live_refresh)}
|
||||
value={formatNumber(data.queue.videos_live_refresh, lang)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -684,15 +688,15 @@ export default function Scheduler() {
|
||||
<Gauge className="w-4 h-4 text-muted shrink-0" />
|
||||
<span>
|
||||
{t("scheduler.quotaUsed", {
|
||||
used: formatNumber(data.quota.used_today),
|
||||
budget: formatNumber(data.quota.daily_budget),
|
||||
used: formatNumber(data.quota.used_today, lang),
|
||||
budget: formatNumber(data.quota.daily_budget, lang),
|
||||
})}
|
||||
</span>
|
||||
<span className="flex-1" />
|
||||
<span className="text-muted text-xs">
|
||||
{t("scheduler.quotaRemaining", {
|
||||
count: data.quota.remaining_today,
|
||||
formatted: formatNumber(data.quota.remaining_today),
|
||||
formatted: formatNumber(data.quota.remaining_today, lang),
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from "react";
|
||||
import { withActiveAccount } from "../lib/activeAccount";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { formatDate, formatDateTime } from "../lib/format";
|
||||
import { formatDate, formatDateTime, formatUnit } from "../lib/format";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Activity, Bell, Check, Loader2, Monitor, RefreshCw, Trash2, User } from "lucide-react";
|
||||
@@ -245,7 +245,8 @@ function Appearance({ prefs }: { prefs: PrefsController }) {
|
||||
}
|
||||
|
||||
function Notifications({ prefs }: { prefs: PrefsController }) {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
// Controlled by PrefsProvider (live preview via configureNotifications there), which auto-saves
|
||||
// it. The "send test" button below fires immediately.
|
||||
const { notif: cfg, setNotif } = prefs;
|
||||
@@ -270,7 +271,9 @@ function Notifications({ prefs }: { prefs: PrefsController }) {
|
||||
<div className="py-1.5 text-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted text-xs">{t("settings.notifications.dismissAfter")}</span>
|
||||
<span className="text-muted text-xs">{(cfg.durationMs / 1000).toFixed(0)}s</span>
|
||||
<span className="text-muted text-xs">
|
||||
{formatUnit(Math.round(cfg.durationMs / 1000), "second", lang)}
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
className="mt-1"
|
||||
@@ -522,7 +525,8 @@ function SignInMethods({ me }: { me: Me }) {
|
||||
// and does a one-time "Plex is master" import. Admin-only (rides the server admin token) and only
|
||||
// shown when the Plex module is enabled. Two-way push/pull arrive in later phases.
|
||||
function PlexWatchSync() {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
const qc = useQueryClient();
|
||||
const link = useQuery({ queryKey: qk.plexWatchLink(), queryFn: () => api.plexWatchLink() });
|
||||
const enabled = link.data?.sync_enabled ?? false;
|
||||
@@ -565,7 +569,9 @@ function PlexWatchSync() {
|
||||
// Reflect the toggle's target immediately (optimistic) so the switch responds on click rather than
|
||||
// only flipping once the multi-second import returns — otherwise it reads as "nothing happened".
|
||||
const shown = toggle.isPending ? Boolean(toggle.variables) : enabled;
|
||||
const last = link.data?.last_watch_sync_at ? formatDateTime(link.data.last_watch_sync_at) : null;
|
||||
const last = link.data?.last_watch_sync_at
|
||||
? formatDateTime(link.data.last_watch_sync_at, lang)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Section title={t("settings.plexSync.title")}>
|
||||
@@ -765,7 +771,8 @@ export function DownloadAuth() {
|
||||
/** Who is signed in: name, address, role — and the latest sign-in on any device, so one the user does
|
||||
* not recognise is something they can see. */
|
||||
export function AccountHeader({ me }: { me: Me }) {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
return (
|
||||
<Section title={t("settings.account.title")}>
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
@@ -782,7 +789,7 @@ export function AccountHeader({ me }: { me: Me }) {
|
||||
{!me.is_demo && (
|
||||
<div className="text-xs text-muted" data-testid="account-last-login">
|
||||
{me.last_login_at
|
||||
? t("settings.account.lastLogin", { time: formatDateTime(me.last_login_at) })
|
||||
? t("settings.account.lastLogin", { time: formatDateTime(me.last_login_at, lang) })
|
||||
: t("settings.account.lastLoginNone")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -22,6 +22,7 @@ import { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext";
|
||||
import { useChannelsActions } from "./channelsContext";
|
||||
import { useMe } from "../lib/useMe";
|
||||
import { useTags } from "../lib/useTags";
|
||||
import { compareText } from "../lib/collate";
|
||||
|
||||
// Filter ids; display labels resolved at render time via t("sidebar.sort.<id>") etc.
|
||||
const DATE_PRESETS: { days: number; key: string }[] = [
|
||||
@@ -47,7 +48,8 @@ export default function Sidebar({
|
||||
collapsed: boolean;
|
||||
onToggleCollapse: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
const isDemo = useMe().is_demo;
|
||||
const { openModal } = useSavedViews();
|
||||
const filters = useFeedFilters();
|
||||
@@ -76,7 +78,9 @@ export default function Sidebar({
|
||||
const shown = facetsReady
|
||||
? list.filter((tg) => chipCount(tg) > 0 || filters.tags.includes(tg.id))
|
||||
: list;
|
||||
return [...shown].sort((a, b) => chipCount(b) - chipCount(a) || a.name.localeCompare(b.name));
|
||||
return [...shown].sort(
|
||||
(a, b) => chipCount(b) - chipCount(a) || compareText(a.name, b.name, lang),
|
||||
);
|
||||
};
|
||||
|
||||
const languages = tags.filter((t) => t.category === "language");
|
||||
|
||||
@@ -66,7 +66,8 @@ export default function Stats() {
|
||||
|
||||
// Per-user view: sync status + your own API usage + (for real accounts) the manual sync actions.
|
||||
function Overview({ me }: { me: Me }) {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
const qc = useQueryClient();
|
||||
// The 403 case needs somewhere to go, or the toast names a fix the user cannot reach from here.
|
||||
const { openWizard } = useWizard();
|
||||
@@ -125,14 +126,14 @@ function Overview({ me }: { me: Me }) {
|
||||
label={t("stats.sync.fullHistoryEta")}
|
||||
hint={t("stats.sync.fullHistoryEtaHint", { count: s.deep_pending_count })}
|
||||
>
|
||||
{formatEta(s.deep_eta_seconds)}
|
||||
{formatEta(t, s.deep_eta_seconds)}
|
||||
</SettingRow>
|
||||
)}
|
||||
<SettingRow label={t("stats.sync.myVideos")} hint={t("stats.sync.myVideosHint")}>
|
||||
{formatNumber(s.my_videos)}
|
||||
{formatNumber(s.my_videos, lang)}
|
||||
</SettingRow>
|
||||
<SettingRow label={t("stats.sync.quotaLeft")} hint={t("stats.sync.quotaLeftHint")}>
|
||||
{formatNumber(s.quota_remaining_today)}
|
||||
{formatNumber(s.quota_remaining_today, lang)}
|
||||
</SettingRow>
|
||||
</div>
|
||||
) : status.isError ? (
|
||||
@@ -149,16 +150,16 @@ function Overview({ me }: { me: Me }) {
|
||||
<>
|
||||
<div className="space-y-1.5 text-sm">
|
||||
<SettingRow label={t("stats.sync.today")} hint={t("stats.sync.todayHint")}>
|
||||
{formatNumber(usage.data.today)}
|
||||
{formatNumber(usage.data.today, lang)}
|
||||
</SettingRow>
|
||||
<SettingRow label={t("stats.sync.last7d")}>
|
||||
{formatNumber(usage.data.last_7d)}
|
||||
{formatNumber(usage.data.last_7d, lang)}
|
||||
</SettingRow>
|
||||
<SettingRow label={t("stats.sync.last30d")}>
|
||||
{formatNumber(usage.data.last_30d)}
|
||||
{formatNumber(usage.data.last_30d, lang)}
|
||||
</SettingRow>
|
||||
<SettingRow label={t("stats.sync.allTime")}>
|
||||
{formatNumber(usage.data.all_time)}
|
||||
{formatNumber(usage.data.all_time, lang)}
|
||||
</SettingRow>
|
||||
</div>
|
||||
{Object.keys(usage.data.by_action).length > 0 && (
|
||||
@@ -168,8 +169,8 @@ function Overview({ me }: { me: Me }) {
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([action, units]) => (
|
||||
<div key={action} className="flex justify-between gap-2">
|
||||
<span>{quotaActionLabel(action)}</span>
|
||||
<span className="tabular-nums">{formatNumber(units)}</span>
|
||||
<span>{quotaActionLabel(t, action)}</span>
|
||||
<span className="tabular-nums">{formatNumber(units, lang)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -218,7 +219,8 @@ function Overview({ me }: { me: Me }) {
|
||||
|
||||
// Admin-only view: instance-wide quota dashboard + the global background-sync pause toggle.
|
||||
function SystemStats() {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
const qc = useQueryClient();
|
||||
const [days, setDays] = useState<number>(30);
|
||||
const q = useQuery({ queryKey: qk.adminQuota(days), queryFn: () => api.adminQuota(days) });
|
||||
@@ -263,7 +265,7 @@ function SystemStats() {
|
||||
className="flex items-center gap-1.5"
|
||||
options={RANGES.map((d) => ({
|
||||
value: String(d),
|
||||
label: t("stats.rangeDays", { d: formatUnit(d, "day") }),
|
||||
label: t("stats.rangeDays", { d: formatUnit(d, "day", lang) }),
|
||||
}))}
|
||||
value={String(days)}
|
||||
onChange={(v) => setDays(Number(v))}
|
||||
@@ -290,8 +292,8 @@ function SystemStats() {
|
||||
<div className={`${SURFACE.card} p-3 mb-4`}>
|
||||
<div className="text-xs text-muted mb-2">
|
||||
{t("stats.dailyTotal", {
|
||||
d: formatUnit(data.range_days, "day"),
|
||||
units: formatNumber(grandTotal),
|
||||
d: formatUnit(data.range_days, "day", lang),
|
||||
units: formatNumber(grandTotal, lang),
|
||||
})}
|
||||
</div>
|
||||
{data.daily.length === 0 ? (
|
||||
@@ -304,7 +306,10 @@ function SystemStats() {
|
||||
// The day under the pointer is lit by the B3 layer over its whole column, not by
|
||||
// dimming the bar: an opacity dim reads as "disabled".
|
||||
className="flex-1 h-full flex items-end bg-card/50 rounded-t state-layer"
|
||||
title={t("stats.dailyTooltip", { day: d.day, units: formatNumber(d.total) })}
|
||||
title={t("stats.dailyTooltip", {
|
||||
day: d.day,
|
||||
units: formatNumber(d.total, lang),
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className="w-full bg-accent rounded-t"
|
||||
@@ -333,7 +338,8 @@ function SystemStats() {
|
||||
}
|
||||
|
||||
function UserRow({ row, max }: { row: AdminQuotaRow; max: number }) {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
const [open, setOpen] = useState(false);
|
||||
const actions = Object.entries(row.by_action).sort((a, b) => b[1] - a[1]);
|
||||
const isSystem = row.user_id === null;
|
||||
@@ -349,7 +355,7 @@ function UserRow({ row, max }: { row: AdminQuotaRow; max: number }) {
|
||||
{isSystem ? t("stats.system") : row.email}
|
||||
</span>
|
||||
<span className="text-sm font-semibold tabular-nums shrink-0">
|
||||
{formatNumber(row.total)}
|
||||
{formatNumber(row.total, lang)}
|
||||
</span>
|
||||
</Pressable>
|
||||
{/* Proportion bar */}
|
||||
@@ -363,8 +369,8 @@ function UserRow({ row, max }: { row: AdminQuotaRow; max: number }) {
|
||||
<div className="mt-2 space-y-1 text-xs text-muted">
|
||||
{actions.map(([action, units]) => (
|
||||
<div key={action} className="flex items-center justify-between gap-2">
|
||||
<span>{quotaActionLabel(action)}</span>
|
||||
<span className="tabular-nums">{formatNumber(units)}</span>
|
||||
<span>{quotaActionLabel(t, action)}</span>
|
||||
<span className="tabular-nums">{formatNumber(units, lang)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { CheckCircle2, Clock, Database, History, Loader2, Pause, Play } from "lucide-react";
|
||||
import { api, type MyStatus } from "../lib/api";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
import { formatViews } from "../lib/format";
|
||||
import { formatCompact } from "../lib/format";
|
||||
import { PopoverSurface } from "./ui/AnchoredPopover";
|
||||
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
|
||||
import { Button } from "./ui/Button";
|
||||
@@ -23,7 +23,8 @@ export default function SyncStatus({
|
||||
isAdmin: boolean;
|
||||
onGoToFullHistory: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
const qc = useQueryClient();
|
||||
const [open, setOpen] = useState(false);
|
||||
// A read-out with a couple of controls rather than a list of choices, so it is a `dialog`
|
||||
@@ -114,8 +115,9 @@ export default function SyncStatus({
|
||||
// popover. An accent dot flags the only two things worth interrupting you for (paused / missing
|
||||
// history) so the collapsed chip still tells you when to look.
|
||||
const needsAttention = data.paused || notFull > 0;
|
||||
const countsText = `${formatViews(data.my_videos)} ${t("header.sync.yours")} / ${formatViews(
|
||||
const countsText = `${formatCompact(data.my_videos, lang)} ${t("header.sync.yours")} / ${formatCompact(
|
||||
data.total_videos,
|
||||
lang,
|
||||
)} ${t("header.sync.total")}`;
|
||||
return (
|
||||
<div className="pointer-events-auto shrink-0">
|
||||
@@ -132,7 +134,9 @@ export default function SyncStatus({
|
||||
) : (
|
||||
<Database className="w-3.5 h-3.5 shrink-0" />
|
||||
)}
|
||||
<span className="text-fg font-medium tabular-nums">{formatViews(data.my_videos)}</span>
|
||||
<span className="text-fg font-medium tabular-nums">
|
||||
{formatCompact(data.my_videos, lang)}
|
||||
</span>
|
||||
{needsAttention && (
|
||||
<span className="absolute -top-0.5 -right-0.5 w-2 h-2 rounded-full bg-accent ring-2 ring-bg" />
|
||||
)}
|
||||
@@ -149,10 +153,10 @@ export default function SyncStatus({
|
||||
>
|
||||
<Database className="w-3.5 h-3.5 shrink-0" />
|
||||
<span>
|
||||
<span className="text-fg font-medium">{formatViews(data.my_videos)}</span>{" "}
|
||||
<span className="text-fg font-medium">{formatCompact(data.my_videos, lang)}</span>{" "}
|
||||
{t("header.sync.yours")}
|
||||
<span className="opacity-40"> / </span>
|
||||
{formatViews(data.total_videos)} {t("header.sync.total")}
|
||||
{formatCompact(data.total_videos, lang)} {t("header.sync.total")}
|
||||
</span>
|
||||
</div>
|
||||
{showMain && (
|
||||
|
||||
@@ -11,7 +11,10 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../lib/api", () => ({
|
||||
|
||||
@@ -23,8 +23,8 @@ export function UpNextCard({
|
||||
onAdvance: () => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const lang = plexLang();
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = plexLang(i18n.language);
|
||||
const nextQ = useQuery({
|
||||
queryKey: qk.plexItem(nextId, lang),
|
||||
queryFn: () => api.plexItem(nextId, lang),
|
||||
|
||||
@@ -19,7 +19,10 @@ const h = vi.hoisted(() => ({
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }),
|
||||
};
|
||||
});
|
||||
vi.mock("../lib/api", () => ({
|
||||
api: { uploadDownload: h.uploadDownload, downloadUsage: h.downloadUsage },
|
||||
|
||||
@@ -21,7 +21,8 @@ import { Input } from "./ui/Input";
|
||||
// file is streamed to the server via XHR so this dialog can show real upload progress. On success it
|
||||
// lands in the Library as an already-`done` job, so we invalidate + point the user there.
|
||||
export default function UploadDialog({ onClose }: { onClose: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
const qc = useQueryClient();
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [name, setName] = useState("");
|
||||
@@ -148,7 +149,7 @@ export default function UploadDialog({ onClose }: { onClose: () => void }) {
|
||||
<>
|
||||
<FileIcon className="w-6 h-6 text-muted" />
|
||||
<span className="text-sm font-medium break-all">{file.name}</span>
|
||||
<span className="text-xs text-muted">{formatBytes(file.size)}</span>
|
||||
<span className="text-xs text-muted">{formatBytes(file.size, lang)}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
@@ -172,7 +173,7 @@ export default function UploadDialog({ onClose }: { onClose: () => void }) {
|
||||
|
||||
{tooBig && (
|
||||
<p key={pickSeq} id={refusalId} className="text-xs text-danger mt-2" role="alert">
|
||||
{t("downloads.errors.upload.tooLarge", { size: formatBytes(cap ?? 0) })}
|
||||
{t("downloads.errors.upload.tooLarge", { size: formatBytes(cap ?? 0, lang) })}
|
||||
</p>
|
||||
)}
|
||||
|
||||
|
||||
@@ -17,7 +17,13 @@ import { navItemAttrs } from "../lib/listNav";
|
||||
import clsx from "clsx";
|
||||
import type { Video, VideoStatus } from "../lib/api";
|
||||
import { FEED_VIEW_SPEC, type FeedView } from "../lib/feedView";
|
||||
import { formatDate, formatDuration, formatNumber, formatViews, relativeTime } from "../lib/format";
|
||||
import {
|
||||
formatDate,
|
||||
formatDuration,
|
||||
formatNumber,
|
||||
formatCompact,
|
||||
relativeTime,
|
||||
} from "../lib/format";
|
||||
import { isPlainLeftClick } from "../lib/nav";
|
||||
import { endsTextSelection } from "../lib/textSelection";
|
||||
import ChannelNameLink from "./ChannelNameLink";
|
||||
@@ -391,6 +397,7 @@ function VideoCard({
|
||||
shelf?: boolean;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
const { openChannel } = useNavigationActions();
|
||||
const spec = FEED_VIEW_SPEC[view];
|
||||
const watched = video.status === "watched";
|
||||
@@ -405,14 +412,14 @@ function VideoCard({
|
||||
// on every line and the tooltip carries the figure regardless.
|
||||
const viewsCell = (withWord: boolean) =>
|
||||
video.view_count != null && (
|
||||
<span title={`${formatNumber(video.view_count)} ${t("card.views")}`}>
|
||||
{formatViews(video.view_count)}
|
||||
<span title={`${formatNumber(video.view_count, lang)} ${t("card.views")}`}>
|
||||
{formatCompact(video.view_count, lang)}
|
||||
{withWord && ` ${t("card.views")}`}
|
||||
</span>
|
||||
);
|
||||
const published = (
|
||||
<>
|
||||
{relativeTime(video.published_at)}
|
||||
{relativeTime(video.published_at, lang)}
|
||||
{video.published_at && (
|
||||
<>
|
||||
{" · "}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { api, type Video } from "../lib/api";
|
||||
import { formatDate, formatDuration, formatViews } from "../lib/format";
|
||||
import { formatDate, formatDuration, formatCompact } from "../lib/format";
|
||||
import { linkify } from "../lib/linkify";
|
||||
import Avatar from "./ui/Avatar";
|
||||
import { PopoverSurface } from "./ui/AnchoredPopover";
|
||||
@@ -34,6 +34,7 @@ export default function VideoDetailsAt({
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
const pop = useAnchoredPopover({
|
||||
open: true,
|
||||
onOpenChange: (next) => {
|
||||
@@ -72,7 +73,7 @@ export default function VideoDetailsAt({
|
||||
|
||||
const description = detail?.description?.trim();
|
||||
const metaBits = [
|
||||
video.view_count != null ? `${formatViews(video.view_count)} ${t("card.views")}` : null,
|
||||
video.view_count != null ? `${formatCompact(video.view_count, lang)} ${t("card.views")}` : null,
|
||||
video.duration_seconds != null ? formatDuration(video.duration_seconds) : null,
|
||||
video.published_at ? formatDate(video.published_at, i18n.language) : null,
|
||||
].filter(Boolean);
|
||||
|
||||
@@ -317,7 +317,7 @@ export default function WatchPage() {
|
||||
{meta.title || T.file}
|
||||
</h1>
|
||||
<div className="text-sm text-muted mt-1">
|
||||
{[meta.container?.toUpperCase(), formatBytes(meta.size_bytes)]
|
||||
{[meta.container?.toUpperCase(), formatBytes(meta.size_bytes, lang)]
|
||||
.filter(Boolean)
|
||||
.join(" · ")}
|
||||
</div>
|
||||
|
||||
@@ -12,7 +12,8 @@ import { isFullySynced } from "./channelSync";
|
||||
// full history are both in — folding the retired Sync column's at-a-glance status into the value it
|
||||
// describes. Full detail stays in the hover tooltip.
|
||||
export function StoredCell({ c }: { c: ManagedChannel }) {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
return (
|
||||
<span className="inline-flex items-center justify-end gap-1 text-muted tabular-nums">
|
||||
{isFullySynced(c) && (
|
||||
@@ -24,7 +25,7 @@ export function StoredCell({ c }: { c: ManagedChannel }) {
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
{formatNumber(c.stored_videos)}
|
||||
{formatNumber(c.stored_videos, lang)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -78,6 +78,7 @@ describe("buildChannelColumns — the row's end", () => {
|
||||
const noop = () => {};
|
||||
const cols = buildChannelColumns({
|
||||
t: ((k: string) => k) as never,
|
||||
lang: "en",
|
||||
prioOptions: [],
|
||||
userTags: [],
|
||||
bumpPriority: noop,
|
||||
|
||||
@@ -14,7 +14,10 @@ import { RelativeTime } from "./ui/RelativeTime";
|
||||
/** The shared "subscribers" DataTable column (right-aligned, sortable, em-dash for null) — identical
|
||||
* in the Channels manager and the ChannelDiscovery table, which show subscriber counts on different
|
||||
* channel row shapes. */
|
||||
export function subsColumn<T extends { subscriber_count: number | null }>(t: TFunction): Column<T> {
|
||||
export function subsColumn<T extends { subscriber_count: number | null }>(
|
||||
t: TFunction,
|
||||
lang: string,
|
||||
): Column<T> {
|
||||
return {
|
||||
key: "subs",
|
||||
header: t("channels.cols.subs"),
|
||||
@@ -23,7 +26,7 @@ export function subsColumn<T extends { subscriber_count: number | null }>(t: TFu
|
||||
sortable: true,
|
||||
sortValue: (c) => c.subscriber_count ?? -1,
|
||||
render: (c) => (
|
||||
<span className="text-muted tabular-nums">{formatCountOrDash(c.subscriber_count)}</span>
|
||||
<span className="text-muted tabular-nums">{formatCountOrDash(c.subscriber_count, lang)}</span>
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -50,6 +53,8 @@ export function tagsColumnTest(c: ManagedChannel, values: string[]): boolean {
|
||||
* column block lives outside the page component (its cell components are in channelCells). */
|
||||
export interface ChannelColumnDeps {
|
||||
t: TFunction;
|
||||
/** The UI language, for the numbers — the compiler re-builds the columns when it changes. */
|
||||
lang: string;
|
||||
prioOptions: { value: string; label: string }[];
|
||||
userTags: ComponentProps<typeof ChannelTagsControl>["userTags"];
|
||||
bumpPriority: (id: string, delta: number) => void;
|
||||
@@ -69,6 +74,7 @@ export interface ChannelColumnDeps {
|
||||
export function buildChannelColumns(deps: ChannelColumnDeps): Column<ManagedChannel>[] {
|
||||
const {
|
||||
t,
|
||||
lang,
|
||||
prioOptions,
|
||||
userTags,
|
||||
bumpPriority,
|
||||
@@ -144,7 +150,7 @@ export function buildChannelColumns(deps: ChannelColumnDeps): Column<ManagedChan
|
||||
// The stored count carries the "fully synced" ✓ charm (R14 folded the retired Sync column in).
|
||||
render: (c) => <StoredCell c={c} />,
|
||||
},
|
||||
{ ...subsColumn<ManagedChannel>(t), width: "6rem" },
|
||||
{ ...subsColumn<ManagedChannel>(t, lang), width: "6rem" },
|
||||
{
|
||||
key: "lastUpload",
|
||||
header: t("channels.cols.lastUpload"),
|
||||
@@ -165,7 +171,7 @@ export function buildChannelColumns(deps: ChannelColumnDeps): Column<ManagedChan
|
||||
sortValue: (c) => c.total_duration_seconds,
|
||||
render: (c) => (
|
||||
<span className="text-muted tabular-nums">
|
||||
{formatTotalHours(c.total_duration_seconds)}
|
||||
{formatTotalHours(c.total_duration_seconds, lang)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// E30 S3: a Plex query follows a language switch. `plexLang()` read the i18n singleton, and under the
|
||||
// React Compiler a value with no reactive input is computed once: after switching to Hungarian the
|
||||
// comfort shelf kept asking for its English cards. The language now comes off useTranslation. Real
|
||||
// i18n (the key-echo mock does not re-render on a switch, which is the very thing under test).
|
||||
import { describe, it, expect, afterEach, vi } from "vitest";
|
||||
import type { ReactNode } from "react";
|
||||
import { renderHook, act, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { setLanguage } from "../i18n";
|
||||
import { api } from "../lib/api";
|
||||
import { useComfortPin } from "./comfortPin";
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
setLanguage("en");
|
||||
});
|
||||
|
||||
function wrapper({ children }: { children: ReactNode }) {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return <QueryClientProvider client={qc}>{children}</QueryClientProvider>;
|
||||
}
|
||||
|
||||
describe("the comfort shelf after a language switch", () => {
|
||||
it("asks for its cards in the new language", async () => {
|
||||
const get = vi.spyOn(api, "comfortPins").mockImplementation(() => new Promise(() => {}));
|
||||
setLanguage("en");
|
||||
renderHook(() => useComfortPin(true), { wrapper });
|
||||
await waitFor(() => expect(get).toHaveBeenCalledWith("en"));
|
||||
act(() => setLanguage("hu"));
|
||||
await waitFor(() => expect(get).toHaveBeenCalledWith("hu"));
|
||||
});
|
||||
});
|
||||
@@ -95,7 +95,7 @@ export interface ComfortPin {
|
||||
* every feed load would be a real request for a string almost nobody reads that session.
|
||||
*/
|
||||
export function useComfortPin(open: boolean): ComfortPin {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
// Latch: react-query keeps the data cached when `enabled` goes false again, so this only decides
|
||||
// WHEN the first fetch happens, not whether the answer survives the menu closing.
|
||||
@@ -105,8 +105,8 @@ export function useComfortPin(open: boolean): ComfortPin {
|
||||
if (open && !everOpen) setEverOpen(true);
|
||||
|
||||
const shelf = useQuery({
|
||||
queryKey: qk.comfortPins(plexLang()),
|
||||
queryFn: () => api.comfortPins(plexLang()),
|
||||
queryKey: qk.comfortPins(plexLang(i18n.language)),
|
||||
queryFn: () => api.comfortPins(plexLang(i18n.language)),
|
||||
enabled: everOpen,
|
||||
// The shelf is hand-curated and short; re-asking on every menu open would be noise.
|
||||
staleTime: 60_000,
|
||||
|
||||
@@ -160,7 +160,8 @@ function QuotaUserPicker({ onPick }: { onPick: (u: { id: number; email: string }
|
||||
}
|
||||
|
||||
export function AdminSystem() {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
const storage = useLiveQuery(qk.adminStorage(), api.adminDownloadStorage, { intervalMs: 5000 });
|
||||
const jobs = useLiveQuery(qk.adminDownloads(), api.adminDownloads, { intervalMs: 5000 });
|
||||
const [quotaFor, setQuotaFor] = useState<{ id: number; email: string } | null>(null);
|
||||
@@ -171,10 +172,10 @@ export function AdminSystem() {
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-5">
|
||||
{[
|
||||
[t("downloads.admin.readyFiles"), String(s.ready_files)],
|
||||
[t("downloads.admin.totalSize"), formatBytes(s.total_bytes)],
|
||||
[t("downloads.admin.totalSize"), formatBytes(s.total_bytes, lang)],
|
||||
[
|
||||
t("downloads.admin.cap"),
|
||||
s.total_cap_bytes ? formatBytes(s.total_cap_bytes) : t("downloads.admin.noCap"),
|
||||
s.total_cap_bytes ? formatBytes(s.total_cap_bytes, lang) : t("downloads.admin.noCap"),
|
||||
],
|
||||
].map(([label, val]) => (
|
||||
<div key={label} className={`${SURFACE.card} p-3`}>
|
||||
@@ -199,7 +200,7 @@ export function AdminSystem() {
|
||||
className="flex items-center gap-2 text-sm px-3 py-1.5 rounded-lg bg-card/40"
|
||||
>
|
||||
<span className="flex-1 truncate">{u.email}</span>
|
||||
<span className="text-muted">{formatBytes(u.footprint_bytes)}</span>
|
||||
<span className="text-muted">{formatBytes(u.footprint_bytes, lang)}</span>
|
||||
<IconButton
|
||||
onClick={() => setQuotaFor({ id: u.user_id, email: u.email ?? "" })}
|
||||
label={t("downloads.admin.editQuota")}
|
||||
@@ -226,7 +227,7 @@ export function AdminSystem() {
|
||||
<span className="text-muted truncate w-40">{j.user_email}</span>
|
||||
<StatusPill job={j} />
|
||||
<span className="text-muted w-16 text-right">
|
||||
{j.asset?.size_bytes ? formatBytes(j.asset.size_bytes) : ""}
|
||||
{j.asset?.size_bytes ? formatBytes(j.asset.size_bytes, lang) : ""}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -9,7 +9,10 @@ import { render, screen, cleanup, fireEvent } from "@testing-library/react";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }),
|
||||
};
|
||||
});
|
||||
|
||||
import { DownloadRow, IconBtn } from "./downloadRow";
|
||||
|
||||
@@ -240,7 +240,8 @@ export function DownloadRow({
|
||||
more?: ReactNode;
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
const running = job.status === "running";
|
||||
const linkTitle = !hasOwnTitle(job) && !!job.source_url;
|
||||
const sourceLink = linkTitle ? null : job.source_url;
|
||||
@@ -294,16 +295,16 @@ export function DownloadRow({
|
||||
<span className="text-muted uppercase"> · {job.asset.container}</span>
|
||||
) : null}
|
||||
{job.asset?.size_bytes ? (
|
||||
<span className="text-muted"> · {formatBytes(job.asset.size_bytes)}</span>
|
||||
<span className="text-muted"> · {formatBytes(job.asset.size_bytes, lang)}</span>
|
||||
) : null}
|
||||
{job.asset?.duration_s ? (
|
||||
<span className="text-muted"> · {formatDuration(job.asset.duration_s)}</span>
|
||||
) : null}
|
||||
{job.status === "done" && job.created_at ? (
|
||||
<span className="text-muted" title={formatDateTime(job.created_at)}>
|
||||
<span className="text-muted" title={formatDateTime(job.created_at, lang)}>
|
||||
{" · "}
|
||||
<CalendarClock className="inline w-3.5 h-3.5 align-[-0.15em]" />{" "}
|
||||
{relativeTime(job.created_at)}
|
||||
{relativeTime(job.created_at, lang)}
|
||||
</span>
|
||||
) : null}
|
||||
{job.status === "error" ? (
|
||||
@@ -360,8 +361,8 @@ export function DownloadRow({
|
||||
</div>
|
||||
<div className="text-xs text-muted mt-0.5 flex gap-2">
|
||||
<span>{job.progress}%</span>
|
||||
{job.speed_bps ? <span>{formatSpeed(job.speed_bps)}</span> : null}
|
||||
{job.eta_s ? <span>{formatEta(job.eta_s)}</span> : null}
|
||||
{job.speed_bps ? <span>{formatSpeed(job.speed_bps, lang)}</span> : null}
|
||||
{job.eta_s ? <span>{formatEta(t, job.eta_s)}</span> : null}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@@ -373,7 +374,8 @@ export function DownloadRow({
|
||||
}
|
||||
|
||||
export function UsageBar() {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
// Poll live (like the library list) so the footprint updates the moment the worker finishes an
|
||||
// edit/download — enqueue only creates a queued job (0 bytes), the size lands asynchronously, so
|
||||
// a one-shot fetch at enqueue time would show a stale 0 B until a manual reload. The footprint
|
||||
@@ -391,8 +393,8 @@ export function UsageBar() {
|
||||
<div className="flex justify-between text-sm mb-1.5">
|
||||
<span className="font-medium">{t("downloads.usage.title")}</span>
|
||||
<span className="text-muted">
|
||||
{formatBytes(u.footprint_bytes)}
|
||||
{u.unlimited ? "" : ` / ${formatBytes(u.max_bytes)}`} ·{" "}
|
||||
{formatBytes(u.footprint_bytes, lang)}
|
||||
{u.unlimited ? "" : ` / ${formatBytes(u.max_bytes, lang)}`} ·{" "}
|
||||
{u.unlimited
|
||||
? t("downloads.usage.unlimited")
|
||||
: t("downloads.usage.items", { used: u.active_jobs, max: u.max_jobs })}
|
||||
|
||||
@@ -19,10 +19,10 @@ import { SURFACE } from "./ui/surfaces";
|
||||
|
||||
// A collection tile's sub-line: item count · total runtime (each dropped when unknown, e.g. show-only
|
||||
// collections have no movie runtime).
|
||||
function collectionMeta(c: PlexCollection, t: TFunction): string {
|
||||
function collectionMeta(c: PlexCollection, t: TFunction, lang: string): string {
|
||||
const parts: string[] = [];
|
||||
if (c.child_count != null) parts.push(t("plex.browse.items", { count: c.child_count }));
|
||||
const rt = formatRuntime(c.total_runtime_s);
|
||||
const rt = formatRuntime(c.total_runtime_s, lang);
|
||||
if (rt) parts.push(rt);
|
||||
return parts.join(" · ");
|
||||
}
|
||||
@@ -162,7 +162,7 @@ function NewCollectionPanel({
|
||||
|
||||
// A grid of Plex collections (searchable — the union can run to hundreds); a tile opens its info page.
|
||||
export function PlexCollectionsBrowse({ onPick }: { onPick: (id: string, title: string) => void }) {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const isAdmin = qc.getQueryData<{ role?: string }>(qk.me())?.role === "admin";
|
||||
const [search, setSearch] = useState("");
|
||||
@@ -223,7 +223,7 @@ export function PlexCollectionsBrowse({ onPick }: { onPick: (id: string, title:
|
||||
navIndex={i}
|
||||
title={c.title}
|
||||
art={c.art}
|
||||
subtitle={collectionMeta(c, t)}
|
||||
subtitle={collectionMeta(c, t, i18n.language)}
|
||||
topRight={
|
||||
isIntrinsicReadOnly(c) ? (
|
||||
<Lock
|
||||
|
||||
@@ -9,7 +9,10 @@ import { render, screen, cleanup, fireEvent, act } from "@testing-library/react"
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }),
|
||||
};
|
||||
});
|
||||
|
||||
import { PlexPosterCard, SeasonCard, EpisodeCard } from "./plexCards";
|
||||
|
||||
@@ -11,7 +11,7 @@ vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
const t = (key: string, o?: Record<string, unknown>) =>
|
||||
o ? `${key}(${Object.values(o).join("|")})` : key;
|
||||
return { ...actual, useTranslation: () => ({ t }) };
|
||||
return { ...actual, useTranslation: () => ({ i18n: { language: "en" }, t }) };
|
||||
});
|
||||
|
||||
import { PlexPosterCard, SeasonCard } from "./plexCards";
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// E30 S3: a card's numbers follow a language switch. The formatters read the UI language, but the
|
||||
// React Compiler memoizes a call on its ARGUMENTS — `formatRuntime(card.duration_seconds)` stayed
|
||||
// "2 hr 4 min" after switching to Hungarian while the hero beside it said "2 ó 16 p". Real catalog.
|
||||
import { describe, it, expect, afterEach, vi } from "vitest";
|
||||
import { render, screen, cleanup, act } from "@testing-library/react";
|
||||
import { setLanguage } from "../i18n";
|
||||
import { PlexPosterCard } from "./plexCards";
|
||||
import type { PlexCard } from "../lib/api";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
setLanguage("en");
|
||||
});
|
||||
|
||||
const movie: PlexCard = {
|
||||
id: "m1",
|
||||
type: "movie",
|
||||
title: "Heat",
|
||||
thumb: "/api/plex/image/m1",
|
||||
duration_seconds: 2 * 3600 + 4 * 60,
|
||||
rating: 8.3,
|
||||
};
|
||||
|
||||
describe("a card after a language switch", () => {
|
||||
it("says its runtime and rating in the new language", () => {
|
||||
setLanguage("en");
|
||||
render(<PlexPosterCard card={movie} onOpen={vi.fn()} />);
|
||||
expect(screen.getByText("2 hr 4 min")).toBeTruthy();
|
||||
act(() => setLanguage("hu"));
|
||||
expect(screen.getByText("2 ó 4 p")).toBeTruthy();
|
||||
expect(screen.getByText("8,3")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { CheckCircle2, Circle, Info, ListPlus, Star } from "lucide-react";
|
||||
import clsx from "clsx";
|
||||
import { type ReactNode, useRef } from "react";
|
||||
import { formatRuntime } from "../lib/format";
|
||||
import { formatDecimal, formatRuntime } from "../lib/format";
|
||||
import { Carousel } from "./ui/Carousel";
|
||||
import type { PlexCard, PlexCastMember, PlexFilters, PlexSeasonDetail } from "../lib/api";
|
||||
import { seasonTitle } from "../lib/plexDetail";
|
||||
@@ -287,7 +287,8 @@ export function PlexPosterCard({
|
||||
/** The show's page, beside a `next` episode. */
|
||||
onInfo?: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
const preview = useMediaPreview();
|
||||
// An episode in a poster rail (Discover's "Recently watched") stands as its SHOW: the show's poster
|
||||
// and name, the episode on the line under it (E30, option A). Its own 16:9 still in a poster slot
|
||||
@@ -302,7 +303,7 @@ export function PlexPosterCard({
|
||||
const acted = ep ?? card;
|
||||
const watched = card.status === "watched";
|
||||
const actedWatched = acted.status === "watched";
|
||||
const runtime = formatRuntime(card.duration_seconds);
|
||||
const runtime = formatRuntime(card.duration_seconds, lang);
|
||||
const pct = ep ? progressPct(ep) : watched ? 0 : progressPct(card);
|
||||
const m = more(card, menuButtonProps, menuOpen);
|
||||
const toggle = isPlayable && onToggleWatched ? () => onToggleWatched(acted) : undefined;
|
||||
@@ -317,7 +318,7 @@ export function PlexPosterCard({
|
||||
const rating = card.rating != null && (
|
||||
<span className="inline-flex items-center gap-0.5">
|
||||
<Star className="w-3 h-3 fill-current text-rating" aria-hidden />
|
||||
{card.rating.toFixed(1)}
|
||||
{formatDecimal(card.rating, lang)}
|
||||
</span>
|
||||
);
|
||||
return (
|
||||
@@ -649,11 +650,12 @@ export function EpisodeCard({
|
||||
menuOpen?: boolean;
|
||||
navIndex?: number;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
const watched = ep.status === "watched";
|
||||
const pct = watched ? 0 : progressPct(ep);
|
||||
const m = more(ep, menuButtonProps, menuOpen);
|
||||
const runtime = formatRuntime(ep.duration_seconds);
|
||||
const runtime = formatRuntime(ep.duration_seconds, lang);
|
||||
return (
|
||||
<MediaCard
|
||||
dense
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { RotateCcw } from "lucide-react";
|
||||
import { IconButton } from "./ui/IconButton";
|
||||
import { Slider as Range, CheckRow } from "./ui/NativeControls";
|
||||
import { formatDecimal, formatUnit } from "../lib/format";
|
||||
|
||||
// --- settings-panel building blocks ------------------------------------------------------------
|
||||
export function PanelHead({ children }: { children: ReactNode }) {
|
||||
@@ -17,6 +18,7 @@ export function Slider({
|
||||
max,
|
||||
step,
|
||||
suffix,
|
||||
seconds,
|
||||
onChange,
|
||||
onReset,
|
||||
}: {
|
||||
@@ -25,20 +27,24 @@ export function Slider({
|
||||
min: number;
|
||||
max: number;
|
||||
step: number;
|
||||
/** A symbol after the number ("%", "px"). */
|
||||
suffix?: string;
|
||||
/** The value is seconds: named in the UI language's unit ("0.5 sec" / "0,5 mp"). */
|
||||
seconds?: boolean;
|
||||
onChange: (v: number) => void;
|
||||
onReset?: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
const shown = seconds
|
||||
? formatUnit(value, "second", lang)
|
||||
: `${formatDecimal(value, lang, 1, 0)}${suffix ?? ""}`;
|
||||
return (
|
||||
<div className="px-1 py-1">
|
||||
<div className="mb-1 flex items-center justify-between text-[0.75rem] text-white/80">
|
||||
<span>{label}</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="tabular-nums text-white/60">
|
||||
{Number.isInteger(value) ? value : value.toFixed(1)}
|
||||
{suffix ?? ""}
|
||||
</span>
|
||||
<span className="tabular-nums text-white/60">{shown}</span>
|
||||
{onReset && (
|
||||
<IconButton
|
||||
variant="overlay"
|
||||
|
||||
@@ -21,7 +21,10 @@ beforeAll(() => {
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }),
|
||||
};
|
||||
});
|
||||
|
||||
const ep = (id: string, season: number, episode: number, position = 0) => ({
|
||||
|
||||
@@ -58,6 +58,7 @@ import { Pressable } from "./ui/Pressable";
|
||||
import { SURFACE } from "./ui/surfaces";
|
||||
import { PageHeading } from "./ui/Headings";
|
||||
import { vocab } from "../lib/vocab";
|
||||
import { formatDate, formatDecimal } from "../lib/format";
|
||||
|
||||
// The Plex sub-router's view branches, split out of PlexBrowse. Each is invoked by PlexBrowse with
|
||||
// shell callbacks as props (onBack/onPlay/onFilter/…) — no view reaches back into the shell's state,
|
||||
@@ -82,8 +83,8 @@ export function PlexInfoView({
|
||||
onFilter: (patch: Partial<PlexFilters>) => void;
|
||||
onOpenPerson: (name: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const lang = plexLang();
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = plexLang(i18n.language);
|
||||
const q = useQuery({ queryKey: qk.plexItem(id, lang), queryFn: () => api.plexItem(id, lang) });
|
||||
return (
|
||||
<div className="w-[90%] max-w-400 mx-auto">
|
||||
@@ -137,13 +138,13 @@ export function PlexPersonView({
|
||||
onInfoCard: (card: PlexCard) => void;
|
||||
onFilter: (patch: Partial<PlexFilters>) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
// ONE menu for this whole page — the "known for" rail and the two filmography grids are three
|
||||
// lists on one screen, and the popover anchors where it opened, so a second controller would only
|
||||
// mean a second closed popover to keep shut (see ui/ContextMenu).
|
||||
const menu = useContextMenu<PlexCard>();
|
||||
const lang = plexLang();
|
||||
const lang = plexLang(i18n.language);
|
||||
const personKey = qk.plexPerson(name, lang);
|
||||
const q = useQuery({ queryKey: personKey, queryFn: () => api.plexPerson(name, lang) });
|
||||
// E5 S8: cross-dissolve the backdrop through the person's films' art (the touch the roadmap calls
|
||||
@@ -255,12 +256,13 @@ export function PlexPersonView({
|
||||
{(d.birthday || d.place_of_birth) && (
|
||||
<p>
|
||||
<span className="text-fg/70">{t("plex.person.born")}:</span>{" "}
|
||||
{[d.birthday, d.place_of_birth].filter(Boolean).join(" · ")}
|
||||
{[formatDate(d.birthday, lang), d.place_of_birth].filter(Boolean).join(" · ")}
|
||||
</p>
|
||||
)}
|
||||
{d.deathday && (
|
||||
<p>
|
||||
<span className="text-fg/70">{t("plex.person.died")}:</span> {d.deathday}
|
||||
<span className="text-fg/70">{t("plex.person.died")}:</span>{" "}
|
||||
{formatDate(d.deathday, lang)}
|
||||
</p>
|
||||
)}
|
||||
{d.also_known_as.length > 0 && (
|
||||
@@ -345,10 +347,10 @@ export function PlexShowView({
|
||||
onFilter?: (patch: Partial<PlexFilters>) => void;
|
||||
onOpenPerson?: (name: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const isAdmin = qc.getQueryData<{ role?: string }>(qk.me())?.role === "admin";
|
||||
const lang = plexLang();
|
||||
const lang = plexLang(i18n.language);
|
||||
const q = useQuery({
|
||||
queryKey: qk.plexShow(showId, lang),
|
||||
queryFn: () => api.plexShow(showId, lang),
|
||||
@@ -535,7 +537,7 @@ export function PlexShowView({
|
||||
>
|
||||
{/* the rating gold — its own token, not --warn: a score is not a warning */}
|
||||
<Star className="w-3.5 h-3.5 text-rating" fill="currentColor" />
|
||||
{score.toFixed(1)}
|
||||
{formatDecimal(score, lang)}
|
||||
</Pressable>
|
||||
);
|
||||
})()}
|
||||
@@ -671,9 +673,9 @@ export function PlexSeasonView({
|
||||
onBack: () => void;
|
||||
onPlay: (epRk: string, queue: string[], fromStart?: boolean) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const lang = plexLang();
|
||||
const lang = plexLang(i18n.language);
|
||||
const q = useQuery({
|
||||
queryKey: qk.plexShow(showId, lang),
|
||||
queryFn: () => api.plexShow(showId, lang),
|
||||
|
||||
@@ -10,7 +10,10 @@ import DataTable, { type Column } from "./DataTable";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }),
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
@@ -10,7 +10,10 @@ import DataTable, { type Column } from "./DataTable";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }),
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
@@ -132,7 +132,8 @@ export default function DataTable<T>({
|
||||
// the hidden-filter cue leaves those out, it would name them twice.
|
||||
surfacedFilters?: string[];
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
const initial = loadPersist(persistKey);
|
||||
// Sort is controlled when the caller passes onSortChange (it then owns/persists it); otherwise
|
||||
// the table keeps its own, seeded from and saved to persistKey.
|
||||
@@ -212,7 +213,7 @@ export default function DataTable<T>({
|
||||
setPage(0);
|
||||
}, [resetFiltersToken, commitFilters]);
|
||||
|
||||
const sorted = sortRows(filterRows(rows, columns, filters), columns, sort);
|
||||
const sorted = sortRows(filterRows(rows, columns, filters), columns, sort, lang);
|
||||
|
||||
// size <= 0 means "All" — one page with every row.
|
||||
const allRows = size <= 0;
|
||||
|
||||
@@ -13,6 +13,7 @@ vi.mock("react-i18next", async (importOriginal) => {
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
i18n: { language: "en" },
|
||||
t: (key: string, o?: { name?: string; count?: number }) =>
|
||||
o?.name ? `${key}(${o.name})` : o?.count !== undefined ? `${key}#${o.count}` : key,
|
||||
}),
|
||||
|
||||
@@ -30,7 +30,7 @@ describe("ItemCount", () => {
|
||||
render(<ItemCount count={192045} />);
|
||||
// Separators come from the shared formatNumber — compare against it, not a fixed en-US string,
|
||||
// so the test asserts ItemCount's formatting path rather than the CI locale.
|
||||
expect(text()).toBe(`${formatNumber(192045)} items`);
|
||||
expect(text()).toBe(`${formatNumber(192045, "en")} items`);
|
||||
});
|
||||
|
||||
// The pill used `count.toLocaleString()`, which groups in the BROWSER's locale: a Hungarian UI on
|
||||
@@ -52,7 +52,9 @@ describe("ItemCount", () => {
|
||||
it("collapses a count of a million or more to 1M+, with the exact figure in the tooltip", () => {
|
||||
render(<ItemCount count={1_000_000} />);
|
||||
expect(text()).toBe("1M+ items"); // the "1M+" collapse is locale-independent
|
||||
expect(screen.getByTestId("item-count").getAttribute("title")).toBe(formatNumber(1_000_000));
|
||||
expect(screen.getByTestId("item-count").getAttribute("title")).toBe(
|
||||
formatNumber(1_000_000, "en"),
|
||||
);
|
||||
});
|
||||
|
||||
it("shows the icon-only fixed box (empty label) while the count is undefined", () => {
|
||||
|
||||
@@ -14,7 +14,8 @@ const OVERFLOW_AT = 1_000_000;
|
||||
// "1M+", with the exact figure in the title tooltip. Deliberately generic ("item(s)", dynamic plural)
|
||||
// so any surface can reuse it for whatever it counts.
|
||||
export default function ItemCount({ count }: { count?: number }) {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
// `count` is undefined only on a first, un-cached load — keep the fixed box (nothing shifts), just
|
||||
// show the icon until the number arrives.
|
||||
const label =
|
||||
@@ -22,12 +23,12 @@ export default function ItemCount({ count }: { count?: number }) {
|
||||
? ""
|
||||
: t("common.itemCount", {
|
||||
count,
|
||||
formattedCount: count >= OVERFLOW_AT ? "1M+" : formatNumber(count),
|
||||
formattedCount: count >= OVERFLOW_AT ? "1M+" : formatNumber(count, lang),
|
||||
});
|
||||
return (
|
||||
<span
|
||||
data-testid="item-count"
|
||||
title={count == null ? undefined : formatNumber(count)}
|
||||
title={count == null ? undefined : formatNumber(count, lang)}
|
||||
className="inline-flex h-7 min-w-[9rem] shrink-0 items-center justify-center gap-1.5 rounded-full bg-fg/8 px-3 text-sm whitespace-nowrap text-muted"
|
||||
>
|
||||
<Layers className="h-3.5 w-3.5 shrink-0 text-muted" aria-hidden="true" />
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { cleanup, render } from "@testing-library/react";
|
||||
import { formatDateTime, formatDateTimeSec, relativeTime } from "../../lib/format";
|
||||
import "../../i18n"; // the real instance, in English: the component names time in the UI language
|
||||
import { RelativeTime } from "./RelativeTime";
|
||||
|
||||
afterEach(cleanup);
|
||||
@@ -14,8 +15,8 @@ describe("RelativeTime", () => {
|
||||
it("says how long ago, and names the exact minute on hover", () => {
|
||||
const { container } = render(<RelativeTime at={THREE_DAYS_AGO} />);
|
||||
const el = container.querySelector("time")!;
|
||||
expect(el.textContent).toBe(relativeTime(THREE_DAYS_AGO));
|
||||
expect(el.getAttribute("title")).toBe(formatDateTime(THREE_DAYS_AGO));
|
||||
expect(el.textContent).toBe(relativeTime(THREE_DAYS_AGO, "en"));
|
||||
expect(el.getAttribute("title")).toBe(formatDateTime(THREE_DAYS_AGO, "en"));
|
||||
expect(el.getAttribute("dateTime")).toBe(THREE_DAYS_AGO);
|
||||
});
|
||||
|
||||
@@ -23,7 +24,7 @@ describe("RelativeTime", () => {
|
||||
const ms = Date.parse(THREE_DAYS_AGO);
|
||||
const { container } = render(<RelativeTime at={ms} seconds />);
|
||||
expect(container.querySelector("time")!.getAttribute("title")).toBe(
|
||||
formatDateTimeSec(THREE_DAYS_AGO),
|
||||
formatDateTimeSec(THREE_DAYS_AGO, "en"),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { formatDateTime, formatDateTimeSec, relativeFromMs } from "../../lib/format";
|
||||
|
||||
// docs/DESIGN.md "Copy": a relative time ("3d ago") says how recent something is in a list, and the
|
||||
@@ -16,14 +17,15 @@ export function RelativeTime({
|
||||
empty?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const lang = useTranslation().i18n.language;
|
||||
const ms = typeof at === "number" ? at : at ? new Date(at).getTime() : NaN;
|
||||
const text = Number.isFinite(ms) ? relativeFromMs(ms) : "";
|
||||
const text = Number.isFinite(ms) ? relativeFromMs(ms, lang) : "";
|
||||
if (!text) return empty ? <span className={className}>{empty}</span> : null;
|
||||
const iso = new Date(ms).toISOString();
|
||||
return (
|
||||
<time
|
||||
dateTime={iso}
|
||||
title={seconds ? formatDateTimeSec(iso) : formatDateTime(iso)}
|
||||
title={seconds ? formatDateTimeSec(iso, lang) : formatDateTime(iso, lang)}
|
||||
className={className}
|
||||
>
|
||||
{text}
|
||||
|
||||
@@ -18,7 +18,10 @@ const h = vi.hoisted(() => ({
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }),
|
||||
};
|
||||
});
|
||||
vi.mock("../lib/notifications", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../lib/notifications")>()),
|
||||
|
||||
@@ -5,6 +5,7 @@ import { qk } from "../lib/queryKeys";
|
||||
import { invalidateSubscription } from "../lib/subscriptionCache";
|
||||
import { api, type ManagedChannel, type Tag } from "../lib/api";
|
||||
import { priorityChoices } from "../lib/channelOrder";
|
||||
import { compareText } from "../lib/collate";
|
||||
import { channelYouTubeUrl } from "../lib/format";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
||||
@@ -40,7 +41,8 @@ export function useChannelActions({
|
||||
onPickPriority: (c: ManagedChannel, priority: number) => void;
|
||||
}) {
|
||||
const qc = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
const confirm = useConfirm();
|
||||
const me = useMe();
|
||||
const isAdmin = me.role === "admin";
|
||||
@@ -166,7 +168,7 @@ export function useChannelActions({
|
||||
return {
|
||||
...base,
|
||||
items: [...tags]
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.sort((a, b) => compareText(a.name, b.name, lang))
|
||||
.map((tg) => ({
|
||||
id: `tag-${tg.id}`,
|
||||
label: tg.name,
|
||||
|
||||
@@ -13,12 +13,12 @@ import { reinsertAt } from "../lib/reinsert";
|
||||
* Split from `PlexPlaylistView` so the failure paths are reachable from a test: the reorder is
|
||||
* driven by a dnd-kit drag, and neither of its sensors activates under jsdom. */
|
||||
export function usePlexPlaylistEdits(playlistId: number) {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const [busy, setBusy] = useState(false);
|
||||
// The rows' titles are in the reader's language (E30), so the cache entry is per language — and ONE
|
||||
// key serves the read and every write below (setQueryData matches exactly).
|
||||
const lang = plexLang();
|
||||
const lang = plexLang(i18n.language);
|
||||
const key = qk.plexPlaylist(playlistId, lang);
|
||||
const q = useQuery({
|
||||
queryKey: key,
|
||||
|
||||
@@ -67,11 +67,13 @@ export function setLanguage(code: LangCode): void {
|
||||
document.documentElement.lang = code;
|
||||
}
|
||||
|
||||
// The Plex overlay serves only EN or HU metadata (E5 S6). Collapse the active UI language to one of
|
||||
// those for the `?lang=` param / query keys. Components read `i18n.language` off useTranslation (which
|
||||
// re-renders on switch); this singleton reader is for non-component code (e.g. plexPlayerHooks).
|
||||
export function plexLang(): "en" | "hu" {
|
||||
return i18n.language?.startsWith("hu") ? "hu" : "en";
|
||||
// The Plex overlay serves only EN or HU metadata (E5 S6). Collapse a UI language to one of those for
|
||||
// the `?lang=` param / query keys. It takes the language instead of reading the singleton (E30 S3):
|
||||
// under the React Compiler a component's `plexLang()` was a value with no reactive input, so it kept
|
||||
// the language the component first rendered in — a switch left every Plex query on the old language.
|
||||
// A component passes `i18n.language` from useTranslation; non-component code the singleton's.
|
||||
export function plexLang(lang: string | undefined): "en" | "hu" {
|
||||
return lang?.startsWith("hu") ? "hu" : "en";
|
||||
}
|
||||
|
||||
export default i18n;
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
// so the runtime graph is a clean barrel → domain → core with no cycles.
|
||||
import { getActiveToasts, notify, remove } from "../notifications";
|
||||
import i18n from "../../i18n";
|
||||
import { formatBytes } from "../format";
|
||||
import { formatBytes, formatDecimal } from "../format";
|
||||
import { reportError } from "../errorDialog";
|
||||
import { activeAccountId, pinActiveAccount } from "../activeAccount";
|
||||
import { logRequest, QUIET_HEADER, REQUEST_ID_HEADER, ROUTE_HEADER, send } from "../http";
|
||||
@@ -86,9 +86,7 @@ function localizeDetail(d: unknown): string {
|
||||
| undefined;
|
||||
if (o?.code === "quota") {
|
||||
if (o.reason === "max_bytes") {
|
||||
const gb = new Intl.NumberFormat(i18n.language, { maximumFractionDigits: 1 }).format(
|
||||
(o.limit || 0) / 1_073_741_824,
|
||||
);
|
||||
const gb = formatDecimal((o.limit || 0) / 1_073_741_824, i18n.language, 1, 0);
|
||||
return t("downloads.errors.quota.max_bytes", { size: gb });
|
||||
}
|
||||
if (o.reason === "max_jobs") return t("downloads.errors.quota.max_jobs", { limit: o.limit });
|
||||
@@ -117,7 +115,7 @@ function localizeDetail(d: unknown): string {
|
||||
: t("welcome.auth.deadLink.short");
|
||||
// Local-upload ingest (E7 S5): the per-file cap carries the limit in bytes; empty is a bare code.
|
||||
if (o?.code === "upload_too_large")
|
||||
return t("downloads.errors.upload.tooLarge", { size: formatBytes(o.limit) });
|
||||
return t("downloads.errors.upload.tooLarge", { size: formatBytes(o.limit, i18n.language) });
|
||||
if (o?.code === "upload_empty") return t("downloads.errors.upload.empty");
|
||||
// The EDGE ceiling's refusal (`bodylimit.TOO_LARGE_BODY`), shared by every route and carrying no
|
||||
// number. Not a theoretical case: on a DEFAULT install it is what an over-cap upload gets,
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
// Alphabetical order in the UI language (E30 S3): Hungarian puts "Ö" after "O" and "Cs" after "Cu". A
|
||||
// bare `localeCompare` compares in the BROWSER's locale; the server sorts by the same ICU rules
|
||||
// (backend `app/collation.py`), so a list sorted here agrees with one the server sorted. Its own
|
||||
// module, apart from lib/format.ts, because it needs no i18n: the pure sort helpers import it.
|
||||
const collators = new Map<string, Intl.Collator>();
|
||||
export function compareText(a: string, b: string, lang: string): number {
|
||||
let c = collators.get(lang);
|
||||
if (!c) {
|
||||
c = new Intl.Collator(lang);
|
||||
collators.set(lang, c);
|
||||
}
|
||||
return c.compare(a, b);
|
||||
}
|
||||
@@ -27,37 +27,51 @@ const columns = [
|
||||
|
||||
describe("sortRows", () => {
|
||||
it("returns the same array reference when there is no sort", () => {
|
||||
expect(sortRows(rows, columns, null)).toBe(rows);
|
||||
expect(sortRows(rows, columns, null, "en")).toBe(rows);
|
||||
});
|
||||
|
||||
it("returns the input unchanged for a column that has no sortValue", () => {
|
||||
expect(sortRows(rows, columns, { key: "nosort", dir: "asc" })).toBe(rows);
|
||||
expect(sortRows(rows, columns, { key: "nosort", dir: "asc" }, "en")).toBe(rows);
|
||||
});
|
||||
|
||||
it("sorts numbers by numeric difference, not string order", () => {
|
||||
// string order would put "100" before "30"; numeric must not.
|
||||
const asc = sortRows(rows, columns, { key: "views", dir: "asc" }).map((r) => r.views);
|
||||
const asc = sortRows(rows, columns, { key: "views", dir: "asc" }, "en").map((r) => r.views);
|
||||
expect(asc).toEqual([5, 30, 100]);
|
||||
const desc = sortRows(rows, columns, { key: "views", dir: "desc" }).map((r) => r.views);
|
||||
const desc = sortRows(rows, columns, { key: "views", dir: "desc" }, "en").map((r) => r.views);
|
||||
expect(desc).toEqual([100, 30, 5]);
|
||||
});
|
||||
|
||||
it("sorts strings with locale compare, ascending and descending", () => {
|
||||
expect(sortRows(rows, columns, { key: "name", dir: "asc" }).map((r) => r.name)).toEqual([
|
||||
expect(sortRows(rows, columns, { key: "name", dir: "asc" }, "en").map((r) => r.name)).toEqual([
|
||||
"alpha",
|
||||
"beta",
|
||||
"gamma",
|
||||
]);
|
||||
expect(sortRows(rows, columns, { key: "name", dir: "desc" }).map((r) => r.name)).toEqual([
|
||||
expect(sortRows(rows, columns, { key: "name", dir: "desc" }, "en").map((r) => r.name)).toEqual([
|
||||
"gamma",
|
||||
"beta",
|
||||
"alpha",
|
||||
]);
|
||||
});
|
||||
|
||||
// E30 S3: text sorts in the UI language's alphabet, not the browser's — Hungarian puts "Ö" after
|
||||
// "O" and "Cs" after "Cu". Both languages are asserted and differ, so a bare `localeCompare` (one
|
||||
// runtime locale for both) cannot pass on any machine.
|
||||
it("sorts text in the UI language's alphabet", () => {
|
||||
const words = ["Zorró", "Ötödik", "oroszlán", "Csillag", "Életrajzi", "Cukor"].map((name) => ({
|
||||
name,
|
||||
views: 0,
|
||||
}));
|
||||
const by = (lang: string) =>
|
||||
sortRows(words, columns, { key: "name", dir: "asc" }, lang).map((r) => r.name);
|
||||
expect(by("hu")).toEqual(["Cukor", "Csillag", "Életrajzi", "oroszlán", "Ötödik", "Zorró"]);
|
||||
expect(by("en")).toEqual(["Csillag", "Cukor", "Életrajzi", "oroszlán", "Ötödik", "Zorró"]);
|
||||
});
|
||||
|
||||
it("does not mutate the input array", () => {
|
||||
const before = rows.map((r) => r.name);
|
||||
sortRows(rows, columns, { key: "name", dir: "asc" });
|
||||
sortRows(rows, columns, { key: "name", dir: "asc" }, "en");
|
||||
expect(rows.map((r) => r.name)).toEqual(before);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
// manager's card layout) can sort the same rows the same way — one comparator, one source of truth.
|
||||
// Kept structural (no `Column` import) so it has zero coupling to DataTable.
|
||||
|
||||
import { compareText } from "./collate";
|
||||
|
||||
export type SortState = { key: string; dir: "asc" | "desc" } | null;
|
||||
|
||||
interface Sortable<T> {
|
||||
@@ -9,9 +11,10 @@ interface Sortable<T> {
|
||||
sortValue?: (row: T) => string | number;
|
||||
}
|
||||
|
||||
/** Sort a copy of `rows` by the sortable column named in `sort` (numeric diff or locale compare).
|
||||
* Returns `rows` unchanged when there's no sort or the column has no `sortValue`. */
|
||||
export function sortRows<T>(rows: T[], columns: Sortable<T>[], sort: SortState): T[] {
|
||||
/** Sort a copy of `rows` by the sortable column named in `sort` (numeric diff, or text in the UI
|
||||
* language's alphabet — `lang`). Returns `rows` unchanged when there's no sort or the column has no
|
||||
* `sortValue`. */
|
||||
export function sortRows<T>(rows: T[], columns: Sortable<T>[], sort: SortState, lang: string): T[] {
|
||||
if (!sort) return rows;
|
||||
const get = columns.find((c) => c.key === sort.key)?.sortValue;
|
||||
if (!get) return rows;
|
||||
@@ -21,7 +24,7 @@ export function sortRows<T>(rows: T[], columns: Sortable<T>[], sort: SortState):
|
||||
const cmp =
|
||||
typeof av === "number" && typeof bv === "number"
|
||||
? av - bv
|
||||
: String(av).localeCompare(String(bv));
|
||||
: compareText(String(av), String(bv), lang);
|
||||
return sort.dir === "asc" ? cmp : -cmp;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
// chips can label a sort without re-deriving the mapping.
|
||||
import type { Video } from "./api";
|
||||
import type { SortOption } from "./columnSort";
|
||||
import { compareText } from "./collate";
|
||||
|
||||
export type SortKey =
|
||||
| "date"
|
||||
@@ -100,7 +101,12 @@ const YT_SORT_VALUE: Partial<Record<SortKey, (v: Video) => number | string | nul
|
||||
* Results missing the value sink to the END in both directions (a video whose duration YouTube did
|
||||
* not report is not "the shortest"), and ties keep the provider's ranking, in both directions —
|
||||
* hence a signed comparator rather than sorting and reversing, which would flip the ties too. */
|
||||
export function sortYtResults(items: Video[], key: SortKey, dir: "asc" | "desc"): Video[] {
|
||||
export function sortYtResults(
|
||||
items: Video[],
|
||||
key: SortKey,
|
||||
dir: "asc" | "desc",
|
||||
lang: string,
|
||||
): Video[] {
|
||||
const value = YT_SORT_VALUE[key];
|
||||
if (!value) return items; // relevance, and anything a stale pref names that we no longer offer
|
||||
const sign = dir === "asc" ? 1 : -1;
|
||||
@@ -109,7 +115,7 @@ export function sortYtResults(items: Video[], key: SortKey, dir: "asc" | "desc")
|
||||
have.sort((a, b) => {
|
||||
const x = value(a)!;
|
||||
const y = value(b)!;
|
||||
return sign * (typeof x === "string" ? x.localeCompare(String(y)) : x - Number(y));
|
||||
return sign * (typeof x === "string" ? compareText(x, String(y), lang) : x - Number(y));
|
||||
});
|
||||
return [...have, ...missing];
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ describe("YT_SORT_KEYS", () => {
|
||||
|
||||
describe("sortYtResults", () => {
|
||||
it("leaves the provider's ranking alone on `relevance`", () => {
|
||||
expect(titles(sortYtResults(RESULTS, "relevance", "desc"))).toEqual([
|
||||
expect(titles(sortYtResults(RESULTS, "relevance", "desc", "en"))).toEqual([
|
||||
"b-old-short",
|
||||
"a-new-long",
|
||||
"c-mid-mid",
|
||||
@@ -54,12 +54,12 @@ describe("sortYtResults", () => {
|
||||
});
|
||||
|
||||
it("orders by upload date in both directions", () => {
|
||||
expect(titles(sortYtResults(RESULTS, "date", "desc"))).toEqual([
|
||||
expect(titles(sortYtResults(RESULTS, "date", "desc", "en"))).toEqual([
|
||||
"a-new-long",
|
||||
"c-mid-mid",
|
||||
"b-old-short",
|
||||
]);
|
||||
expect(titles(sortYtResults(RESULTS, "date", "asc"))).toEqual([
|
||||
expect(titles(sortYtResults(RESULTS, "date", "asc", "en"))).toEqual([
|
||||
"b-old-short",
|
||||
"c-mid-mid",
|
||||
"a-new-long",
|
||||
@@ -67,14 +67,18 @@ describe("sortYtResults", () => {
|
||||
});
|
||||
|
||||
it("orders by views and by length", () => {
|
||||
expect(titles(sortYtResults(RESULTS, "popular", "desc"))[0]).toBe("c-mid-mid");
|
||||
expect(titles(sortYtResults(RESULTS, "duration", "desc"))[0]).toBe("a-new-long");
|
||||
expect(titles(sortYtResults(RESULTS, "duration", "asc"))[0]).toBe("b-old-short");
|
||||
expect(titles(sortYtResults(RESULTS, "popular", "desc", "en"))[0]).toBe("c-mid-mid");
|
||||
expect(titles(sortYtResults(RESULTS, "duration", "desc", "en"))[0]).toBe("a-new-long");
|
||||
expect(titles(sortYtResults(RESULTS, "duration", "asc", "en"))[0]).toBe("b-old-short");
|
||||
});
|
||||
|
||||
it("orders by title alphabetically, not by code point", () => {
|
||||
const accented = [v({ title: "Zebra" }), v({ title: "Ábel" }), v({ title: "alma" })];
|
||||
expect(titles(sortYtResults(accented, "title", "asc"))).toEqual(["Ábel", "alma", "Zebra"]);
|
||||
expect(titles(sortYtResults(accented, "title", "asc", "en"))).toEqual([
|
||||
"Ábel",
|
||||
"alma",
|
||||
"Zebra",
|
||||
]);
|
||||
});
|
||||
|
||||
it("sinks results with no value to the END in both directions", () => {
|
||||
@@ -84,8 +88,16 @@ describe("sortYtResults", () => {
|
||||
v({ title: "long", duration_seconds: 900 }),
|
||||
v({ title: "short", duration_seconds: 60 }),
|
||||
];
|
||||
expect(titles(sortYtResults(mixed, "duration", "desc"))).toEqual(["long", "short", "unknown"]);
|
||||
expect(titles(sortYtResults(mixed, "duration", "asc"))).toEqual(["short", "long", "unknown"]);
|
||||
expect(titles(sortYtResults(mixed, "duration", "desc", "en"))).toEqual([
|
||||
"long",
|
||||
"short",
|
||||
"unknown",
|
||||
]);
|
||||
expect(titles(sortYtResults(mixed, "duration", "asc", "en"))).toEqual([
|
||||
"short",
|
||||
"long",
|
||||
"unknown",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps the provider's ranking inside a tie, in both directions", () => {
|
||||
@@ -95,19 +107,27 @@ describe("sortYtResults", () => {
|
||||
v({ title: "second", view_count: 100 }),
|
||||
v({ title: "third", view_count: 5 }),
|
||||
];
|
||||
expect(titles(sortYtResults(tied, "popular", "desc"))).toEqual(["first", "second", "third"]);
|
||||
expect(titles(sortYtResults(tied, "popular", "asc"))).toEqual(["third", "first", "second"]);
|
||||
expect(titles(sortYtResults(tied, "popular", "desc", "en"))).toEqual([
|
||||
"first",
|
||||
"second",
|
||||
"third",
|
||||
]);
|
||||
expect(titles(sortYtResults(tied, "popular", "asc", "en"))).toEqual([
|
||||
"third",
|
||||
"first",
|
||||
"second",
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not reorder anything for a key it cannot honour", () => {
|
||||
// A pref stored before this view offered a control can name `shuffle` or `priority`.
|
||||
expect(titles(sortYtResults(RESULTS, "priority", "desc"))).toEqual(titles(RESULTS));
|
||||
expect(titles(sortYtResults(RESULTS, "shuffle", "desc"))).toEqual(titles(RESULTS));
|
||||
expect(titles(sortYtResults(RESULTS, "priority", "desc", "en"))).toEqual(titles(RESULTS));
|
||||
expect(titles(sortYtResults(RESULTS, "shuffle", "desc", "en"))).toEqual(titles(RESULTS));
|
||||
});
|
||||
|
||||
it("does not mutate the array it was given", () => {
|
||||
const input = [...RESULTS];
|
||||
sortYtResults(input, "date", "asc");
|
||||
sortYtResults(input, "date", "asc", "en");
|
||||
expect(titles(input)).toEqual(titles(RESULTS));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
RELATIVE_UNITS,
|
||||
formatBytes,
|
||||
formatCompact,
|
||||
formatCountOrDash,
|
||||
formatDecimal,
|
||||
formatMoney,
|
||||
formatDate,
|
||||
formatDateTime,
|
||||
formatDateTimeSec,
|
||||
@@ -69,29 +74,29 @@ describe("relativeFromMs", () => {
|
||||
|
||||
for (const [secs, tier, expectedEn, expectedHu] of CASES) {
|
||||
it(`${secs}s is ${tier} — "${expectedEn}" / "${expectedHu}"`, () => {
|
||||
expect(relativeFromMs(NOW - secs * 1000)).toBe(expectedEn);
|
||||
expect(relativeFromMs(NOW - secs * 1000, i18nStub.language)).toBe(expectedEn);
|
||||
i18nStub.language = "hu";
|
||||
expect(relativeFromMs(NOW - secs * 1000)).toBe(expectedHu);
|
||||
expect(relativeFromMs(NOW - secs * 1000, i18nStub.language)).toBe(expectedHu);
|
||||
});
|
||||
}
|
||||
|
||||
it("follows a language switch at runtime", () => {
|
||||
const twoHoursAgo = NOW - 2 * HOUR * 1000;
|
||||
expect(relativeFromMs(twoHoursAgo)).toBe("2h ago");
|
||||
expect(relativeFromMs(twoHoursAgo, i18nStub.language)).toBe("2h ago");
|
||||
i18nStub.language = "hu";
|
||||
expect(relativeFromMs(twoHoursAgo)).toBe("2 órája");
|
||||
expect(relativeFromMs(twoHoursAgo, i18nStub.language)).toBe("2 órája");
|
||||
i18nStub.language = "en";
|
||||
expect(relativeFromMs(twoHoursAgo)).toBe("2h ago");
|
||||
expect(relativeFromMs(twoHoursAgo, i18nStub.language)).toBe("2h ago");
|
||||
});
|
||||
|
||||
it("clamps a future timestamp instead of counting up", () => {
|
||||
expect(relativeFromMs(NOW + 5 * DAY * 1000)).toBe("time.justNow");
|
||||
expect(relativeFromMs(NOW + 5 * DAY * 1000, i18nStub.language)).toBe("time.justNow");
|
||||
});
|
||||
|
||||
it("renders nothing for a timestamp it cannot read", () => {
|
||||
expect(relativeTime("not-a-date")).toBe("");
|
||||
expect(relativeTime(null)).toBe("");
|
||||
expect(relativeFromMs(NaN)).toBe("");
|
||||
expect(relativeTime("not-a-date", i18nStub.language)).toBe("");
|
||||
expect(relativeTime(null, i18nStub.language)).toBe("");
|
||||
expect(relativeFromMs(NaN, i18nStub.language)).toBe("");
|
||||
});
|
||||
|
||||
// Derived from RELATIVE_UNITS, not from CASES, so adding a tier to the table without covering
|
||||
@@ -135,9 +140,9 @@ describe("formatNumber", () => {
|
||||
});
|
||||
|
||||
it("groups in the UI language, not in the runtime's", () => {
|
||||
const asEnglish = formatNumber(1234567);
|
||||
const asEnglish = formatNumber(1234567, i18nStub.language);
|
||||
i18nStub.language = "hu";
|
||||
const asHungarian = formatNumber(1234567);
|
||||
const asHungarian = formatNumber(1234567, i18nStub.language);
|
||||
expect(asEnglish).toBe(new Intl.NumberFormat("en").format(1234567));
|
||||
expect(asHungarian).toBe(new Intl.NumberFormat("hu").format(1234567));
|
||||
// The load-bearing half: under `toLocaleString()` both come out in the runtime's one locale.
|
||||
@@ -145,24 +150,26 @@ describe("formatNumber", () => {
|
||||
});
|
||||
|
||||
it("follows a language switch at runtime (the per-language cache is keyed, not frozen)", () => {
|
||||
expect(formatNumber(1000)).toBe(new Intl.NumberFormat("en").format(1000));
|
||||
expect(formatNumber(1000, i18nStub.language)).toBe(new Intl.NumberFormat("en").format(1000));
|
||||
i18nStub.language = "hu";
|
||||
expect(formatNumber(1000)).toBe(new Intl.NumberFormat("hu").format(1000));
|
||||
expect(formatNumber(1000, i18nStub.language)).toBe(new Intl.NumberFormat("hu").format(1000));
|
||||
i18nStub.language = "en";
|
||||
expect(formatNumber(1000)).toBe(new Intl.NumberFormat("en").format(1000));
|
||||
expect(formatNumber(1000, i18nStub.language)).toBe(new Intl.NumberFormat("en").format(1000));
|
||||
});
|
||||
|
||||
it("renders nothing for a value it cannot read, so a caller's dash fallback covers it", () => {
|
||||
expect(formatNumber(null)).toBe("");
|
||||
expect(formatNumber(undefined)).toBe("");
|
||||
expect(formatNumber(NaN)).toBe("");
|
||||
expect(formatNumber(Infinity)).toBe("");
|
||||
expect(formatNumber(0)).toBe("0"); // a real zero is a value, not a missing one
|
||||
expect(formatNumber(null, i18nStub.language)).toBe("");
|
||||
expect(formatNumber(undefined, i18nStub.language)).toBe("");
|
||||
expect(formatNumber(NaN, i18nStub.language)).toBe("");
|
||||
expect(formatNumber(Infinity, i18nStub.language)).toBe("");
|
||||
expect(formatNumber(0, i18nStub.language)).toBe("0"); // a real zero is a value, not a missing one
|
||||
});
|
||||
|
||||
it("carries the UI language into the channel total-hours label", () => {
|
||||
i18nStub.language = "hu";
|
||||
expect(formatTotalHours(12345 * 3600)).toBe(`${new Intl.NumberFormat("hu").format(12345)} ó`);
|
||||
expect(formatTotalHours(12345 * 3600, i18nStub.language)).toBe(
|
||||
`${new Intl.NumberFormat("hu").format(12345)} ó`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -185,18 +192,18 @@ describe("formatRuntime", () => {
|
||||
];
|
||||
|
||||
it.each(CASES)("formats %o as %o", (sec, expected) => {
|
||||
expect(formatRuntime(sec)).toBe(expected);
|
||||
expect(formatRuntime(sec, i18nStub.language)).toBe(expected);
|
||||
});
|
||||
|
||||
it("speaks the UI language's units (DESIGN Copy: Intl short), not English letters", () => {
|
||||
i18nStub.language = "hu";
|
||||
expect(formatRuntime(5025)).toBe("1 ó 23 p");
|
||||
expect(formatRuntime(5025, i18nStub.language)).toBe("1 ó 23 p");
|
||||
i18nStub.language = "en";
|
||||
});
|
||||
|
||||
it("never reports a non-zero duration as zero", () => {
|
||||
for (let sec = 1; sec < 3600; sec++) {
|
||||
expect(formatRuntime(sec), `${sec}s`).not.toBe("0 min");
|
||||
expect(formatRuntime(sec, i18nStub.language), `${sec}s`).not.toBe("0 min");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -216,9 +223,9 @@ describe("formatDateTime / formatDate", () => {
|
||||
|
||||
it("formats a timestamp in the UI language, not in the runtime's", () => {
|
||||
const iso = "2026-09-17T08:30:14Z";
|
||||
const asEnglish = formatDateTime(iso);
|
||||
const asEnglish = formatDateTime(iso, i18nStub.language);
|
||||
i18nStub.language = "hu";
|
||||
const asHungarian = formatDateTime(iso);
|
||||
const asHungarian = formatDateTime(iso, i18nStub.language);
|
||||
expect(asEnglish).not.toBe(asHungarian);
|
||||
expect(formatDate(iso, "en")).not.toBe(formatDate(iso, "hu"));
|
||||
});
|
||||
@@ -226,10 +233,10 @@ describe("formatDateTime / formatDate", () => {
|
||||
it("names the month and stops at the minute; seconds only in the log form (DESIGN Copy)", () => {
|
||||
process.env.TZ = "UTC";
|
||||
const iso = "2026-09-17T08:30:14Z";
|
||||
expect(formatDateTime(iso)).toBe("Sep 17, 2026, 08:30 AM");
|
||||
expect(formatDateTimeSec(iso)).toBe("Sep 17, 2026, 08:30:14 AM");
|
||||
expect(formatDateTime(iso, i18nStub.language)).toBe("Sep 17, 2026, 08:30 AM");
|
||||
expect(formatDateTimeSec(iso, i18nStub.language)).toBe("Sep 17, 2026, 08:30:14 AM");
|
||||
i18nStub.language = "hu";
|
||||
expect(formatDateTime(iso)).toBe("2026. szept. 17. 08:30");
|
||||
expect(formatDateTime(iso, i18nStub.language)).toBe("2026. szept. 17. 08:30");
|
||||
expect(formatDate(iso, "hu")).toBe("2026. szept. 17.");
|
||||
});
|
||||
|
||||
@@ -240,3 +247,39 @@ describe("formatDateTime / formatDate", () => {
|
||||
expect(formatDate("2026-09-17", "en")).toBe("Sep 17, 2026");
|
||||
});
|
||||
});
|
||||
|
||||
// E30 S3: every number a reader sees is in the UI language — the decimal mark, the grouping and the
|
||||
// compact suffix. `toFixed` and hand-built "1.2K" wrote English in both languages. Both languages are
|
||||
// asserted and differ, so an implementation that ignores the language cannot pass.
|
||||
describe("numbers in the UI language", () => {
|
||||
it("writes a rating with the language's decimal mark", () => {
|
||||
expect(formatDecimal(8.25, "en")).toBe("8.3");
|
||||
expect(formatDecimal(8.25, "hu")).toBe("8,3");
|
||||
expect(formatDecimal(8, "hu")).toBe("8,0"); // a rating keeps its decimal
|
||||
expect(formatDecimal(5, "hu", 1, 0)).toBe("5"); // unless asked to drop a zero one
|
||||
});
|
||||
|
||||
it("writes a big count compact, with the language's suffix", () => {
|
||||
// Intl keeps the number and its suffix together with a no-break space
|
||||
const hu = (n: number) => formatCompact(n, "hu").replace(/\u00a0/g, " ");
|
||||
expect(formatCompact(1234, "en")).toBe("1.2K");
|
||||
expect(hu(1234)).toBe("1,2 E");
|
||||
expect(formatCompact(3_400_000, "en")).toBe("3.4M");
|
||||
expect(hu(3_400_000)).toBe("3,4 M");
|
||||
expect(formatCompact(999, "hu")).toBe("999");
|
||||
expect(formatCompact(null, "hu")).toBe("");
|
||||
expect(formatCountOrDash(null, "hu")).toBe("—");
|
||||
});
|
||||
|
||||
it("writes a file size with the language's decimal mark", () => {
|
||||
expect(formatBytes(5 * 1024 ** 3, "en")).toBe("5.0 GB");
|
||||
expect(formatBytes(5 * 1024 ** 3, "hu")).toBe("5,0 GB");
|
||||
expect(formatBytes(501747, "hu")).toBe("490 KB");
|
||||
expect(formatBytes(0, "hu")).toBe("0 B");
|
||||
});
|
||||
|
||||
it("writes a dollar sum compact, never padded", () => {
|
||||
expect(formatMoney(160_000_000, "en")).toBe("$160M");
|
||||
expect(formatMoney(160_000_000, "hu")).not.toBe(formatMoney(160_000_000, "en"));
|
||||
});
|
||||
});
|
||||
|
||||
+98
-57
@@ -1,12 +1,36 @@
|
||||
import type { TFunction } from "i18next";
|
||||
import i18n from "../i18n";
|
||||
|
||||
export function relativeTime(iso: string | null): string {
|
||||
if (!iso) return "";
|
||||
return relativeFromMs(new Date(iso).getTime());
|
||||
// The one home of the app's LOCALE forms — numbers, units, dates (alphabetical order: `collate.ts`) —
|
||||
// all in the UI language, never the browser's (lint: `local/locale-format` refuses a hand-built one elsewhere).
|
||||
//
|
||||
// Every formatter takes the language (or `t`) as an ARGUMENT, and that is load-bearing, not style
|
||||
// (E30 S3): the React Compiler memoizes a call on its arguments, so a formatter that read the language
|
||||
// off the i18n singleton kept its old answer after a language switch — a card still said "2 hr 4 min"
|
||||
// beside a hero saying "2 ó 16 p". Passing `i18n.language` (from useTranslation) makes the language a
|
||||
// dependency the compiler can see; the signature makes forgetting it a type error.
|
||||
|
||||
// Intl formatters are expensive to build and every grid formats one per card: one per language ×
|
||||
// options, built lazily. Keyed by language because the user can switch it at runtime.
|
||||
const numberFormats = new Map<string, Intl.NumberFormat>();
|
||||
function numberFormat(lang: string, opts: Intl.NumberFormatOptions = {}): Intl.NumberFormat {
|
||||
const key = `${lang}|${JSON.stringify(opts)}`;
|
||||
let f = numberFormats.get(key);
|
||||
if (!f) {
|
||||
f = new Intl.NumberFormat(lang, opts);
|
||||
numberFormats.set(key, f);
|
||||
}
|
||||
return f;
|
||||
}
|
||||
|
||||
/** Human file size, binary units (1024). e.g. 501747 -> "490 KB", 5368709120 -> "5.0 GB". */
|
||||
export function formatBytes(bytes: number | null | undefined): string {
|
||||
export function relativeTime(iso: string | null, lang: string): string {
|
||||
if (!iso) return "";
|
||||
return relativeFromMs(new Date(iso).getTime(), lang);
|
||||
}
|
||||
|
||||
/** Human file size, binary units (1024), the number in the UI language: 501747 -> "490 KB",
|
||||
* 5368709120 -> "5.0 GB" / "5,0 GB". The unit symbols are the same in both languages. */
|
||||
export function formatBytes(bytes: number | null | undefined, lang: string): string {
|
||||
const n = Number(bytes);
|
||||
if (!n || n < 0) return "0 B";
|
||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||
@@ -16,13 +40,14 @@ export function formatBytes(bytes: number | null | undefined): string {
|
||||
v /= 1024;
|
||||
i++;
|
||||
}
|
||||
return `${v >= 100 || i === 0 ? Math.round(v) : v.toFixed(1)} ${units[i]}`;
|
||||
const num = v >= 100 || i === 0 ? formatNumber(Math.round(v), lang) : formatDecimal(v, lang);
|
||||
return `${num} ${units[i]}`;
|
||||
}
|
||||
|
||||
/** Download speed from bytes/sec, e.g. "2.4 MB/s". */
|
||||
export function formatSpeed(bps: number | null | undefined): string {
|
||||
export function formatSpeed(bps: number | null | undefined, lang: string): string {
|
||||
if (!bps) return "";
|
||||
return `${formatBytes(bps)}/s`;
|
||||
return `${formatBytes(bps, lang)}/s`;
|
||||
}
|
||||
|
||||
/** Relative-time tiers as [seconds-per-unit, Intl unit]. The unit is what the divisor produces,
|
||||
@@ -65,18 +90,18 @@ function relativeFormatter(lang: string): Intl.RelativeTimeFormat {
|
||||
* client-side notifications whose timestamps are already numbers). One implementation shared
|
||||
* instead of re-implemented per component. Empty string for a timestamp that can't be read, so a
|
||||
* caller's `|| "—"` fallback covers it like a missing one. */
|
||||
export function relativeFromMs(ms: number): string {
|
||||
export function relativeFromMs(ms: number, lang: string): string {
|
||||
const secs = Math.max(0, (Date.now() - ms) / 1000);
|
||||
if (!Number.isFinite(secs)) return "";
|
||||
// Sub-minute is the one case Intl has no wording for — it would say "0m ago".
|
||||
if (secs < RELATIVE_UNITS[0][0]) return i18n.t("time.justNow");
|
||||
if (secs < RELATIVE_UNITS[0][0]) return i18n.t("time.justNow", { lng: lang });
|
||||
let [div, unit] = RELATIVE_UNITS[0];
|
||||
for (const [unitSecs, unitName] of RELATIVE_UNITS) {
|
||||
if (secs < unitSecs) break;
|
||||
div = unitSecs;
|
||||
unit = unitName;
|
||||
}
|
||||
return relativeFormatter(i18n.language).format(-Math.floor(secs / div), unit);
|
||||
return relativeFormatter(lang).format(-Math.floor(secs / div), unit);
|
||||
}
|
||||
|
||||
// The canonical YouTube URL for a channel: its @handle when known (nicer), else the
|
||||
@@ -100,15 +125,17 @@ export function formatDuration(sec: number | null): string {
|
||||
// S3g): "90 min", "2 hr" / "90 p", "2 ó". Hand-typed "90m" / "2h" were English in both languages and
|
||||
// came in three spellings. One formatter per language × unit, built lazily (the grids format one per card).
|
||||
export type DurationUnit = "second" | "minute" | "hour" | "day";
|
||||
const unitFormatters = new Map<string, Intl.NumberFormat>();
|
||||
export function formatUnit(value: number, unit: DurationUnit): string {
|
||||
const key = `${i18n.language}|${unit}`;
|
||||
let f = unitFormatters.get(key);
|
||||
if (!f) {
|
||||
f = new Intl.NumberFormat(i18n.language, { style: "unit", unit, unitDisplay: "short" });
|
||||
unitFormatters.set(key, f);
|
||||
}
|
||||
return f.format(value);
|
||||
export function formatUnit(
|
||||
value: number,
|
||||
unit: DurationUnit,
|
||||
lang: string,
|
||||
digits?: number,
|
||||
): string {
|
||||
const fraction =
|
||||
digits == null ? {} : { minimumFractionDigits: digits, maximumFractionDigits: digits };
|
||||
return numberFormat(lang, { style: "unit", unit, unitDisplay: "short", ...fraction }).format(
|
||||
value,
|
||||
);
|
||||
}
|
||||
|
||||
/** Compact human runtime ("1 hr 30 min", "45 min", "45 sec"; HU "1 ó 30 p") from seconds. Formats
|
||||
@@ -118,70 +145,84 @@ export function formatUnit(value: number, unit: DurationUnit): string {
|
||||
* The sub-minute tier is load-bearing, not decoration: an aggregate is the only input that can land
|
||||
* between 1 and 59 seconds (a playlist of Shorts), and flooring that to a flat "0 min" made a
|
||||
* non-empty playlist report no runtime at all. Keep it, or that case silently regresses. */
|
||||
export function formatRuntime(sec?: number | null): string {
|
||||
export function formatRuntime(sec: number | null | undefined, lang: string): string {
|
||||
if (!sec) return "";
|
||||
if (sec < 60) return formatUnit(Math.floor(sec), "second");
|
||||
if (sec < 60) return formatUnit(Math.floor(sec), "second", lang);
|
||||
const h = Math.floor(sec / 3600);
|
||||
const m = Math.floor((sec % 3600) / 60);
|
||||
return h ? `${formatUnit(h, "hour")} ${formatUnit(m, "minute")}` : formatUnit(m, "minute");
|
||||
return h
|
||||
? `${formatUnit(h, "hour", lang)} ${formatUnit(m, "minute", lang)}`
|
||||
: formatUnit(m, "minute", lang);
|
||||
}
|
||||
|
||||
// Human label for a canonical quota-action key (see backend app.quota.QuotaAction). Resolved
|
||||
// from i18n (quotaActions.<key>) so it's translated in all UI languages; falls back to the raw
|
||||
// key for any unmapped/legacy value.
|
||||
export function quotaActionLabel(action: string): string {
|
||||
return i18n.t(`quotaActions.${action}`, { defaultValue: action });
|
||||
export function quotaActionLabel(t: TFunction, action: string): string {
|
||||
return t(`quotaActions.${action}`, { defaultValue: action });
|
||||
}
|
||||
|
||||
/** Coarse, human ETA for a remaining duration in seconds ("~3 hours", "~2 days"). Translated
|
||||
* via the `time.eta.*` keys (was hardcoded English, violating the trilingual rule). */
|
||||
export function formatEta(seconds: number): string {
|
||||
if (seconds <= 0) return i18n.t("time.eta.done");
|
||||
export function formatEta(t: TFunction, seconds: number): string {
|
||||
if (seconds <= 0) return t("time.eta.done");
|
||||
const hours = seconds / 3600;
|
||||
if (hours < 1) return i18n.t("time.eta.lessThanHour");
|
||||
if (hours < 24) return i18n.t("time.eta.hours", { count: Math.round(hours) });
|
||||
return i18n.t("time.eta.days", { count: Math.round(hours / 24) });
|
||||
if (hours < 1) return t("time.eta.lessThanHour");
|
||||
if (hours < 24) return t("time.eta.hours", { count: Math.round(hours) });
|
||||
return t("time.eta.days", { count: Math.round(hours / 24) });
|
||||
}
|
||||
|
||||
/** Compact whole-channel total length ("12 hr", "45 min"), hours-scale so H:MM:SS isn't huge. */
|
||||
export function formatTotalHours(sec: number): string {
|
||||
export function formatTotalHours(sec: number, lang: string): string {
|
||||
if (!sec) return "—";
|
||||
const h = Math.round(sec / 3600);
|
||||
return h >= 1 ? formatUnit(h, "hour") : formatUnit(Math.max(1, Math.round(sec / 60)), "minute");
|
||||
return h >= 1
|
||||
? formatUnit(h, "hour", lang)
|
||||
: formatUnit(Math.max(1, Math.round(sec / 60)), "minute", lang);
|
||||
}
|
||||
|
||||
// One grouped-number formatter per language, built lazily — the same reason as the relative-time
|
||||
// formatters above: the stats tables and the feed format a number per row, and constructing an Intl
|
||||
// formatter is the expensive part. Keyed by language because the user can switch it at runtime.
|
||||
const numberFormatters = new Map<string, Intl.NumberFormat>();
|
||||
|
||||
/** A grouped number in the UI language ("1,234,567" in English, "1 234 567" in Hungarian). A bare
|
||||
* `n.toLocaleString()` groups in the BROWSER's locale instead, so an English UI in a Hungarian
|
||||
* browser showed space-grouped figures and vice versa — the number half of the same defect
|
||||
* `formatDateTime` fixes for dates. Empty string for a missing/non-finite value, so a caller's
|
||||
* `|| "—"` fallback covers it like a missing one. */
|
||||
export function formatNumber(n: number | null | undefined): string {
|
||||
export function formatNumber(n: number | null | undefined, lang: string): string {
|
||||
if (n == null || !Number.isFinite(n)) return "";
|
||||
const lang = i18n.language;
|
||||
let formatter = numberFormatters.get(lang);
|
||||
if (!formatter) {
|
||||
formatter = new Intl.NumberFormat(lang);
|
||||
numberFormatters.set(lang, formatter);
|
||||
}
|
||||
return formatter.format(n);
|
||||
return numberFormat(lang).format(n);
|
||||
}
|
||||
|
||||
export function formatViews(n: number | null): string {
|
||||
if (n == null) return "";
|
||||
if (n >= 1e9) return `${(n / 1e9).toFixed(1).replace(/\.0$/, "")}B`;
|
||||
if (n >= 1e6) return `${(n / 1e6).toFixed(1).replace(/\.0$/, "")}M`;
|
||||
if (n >= 1e3) return `${(n / 1e3).toFixed(1).replace(/\.0$/, "")}K`;
|
||||
return String(n);
|
||||
/** A number with `digits` decimals in the UI language — a rating "8.3" / "8,3" (a bare `toFixed`
|
||||
* always wrote the English decimal point). `minDigits` below `digits` drops trailing zeros: "5" GB. */
|
||||
export function formatDecimal(n: number, lang: string, digits = 1, minDigits = digits): string {
|
||||
const opts = { minimumFractionDigits: minDigits, maximumFractionDigits: digits };
|
||||
return numberFormat(lang, opts).format(n);
|
||||
}
|
||||
|
||||
/** A big count, compact, in the UI language: "1.2K" / "1,2 E", "3.4M" / "3,4 M". The minimum is
|
||||
* pinned for the reason `formatMoney` gives. Empty for null. */
|
||||
export function formatCompact(n: number | null | undefined, lang: string): string {
|
||||
if (n == null || !Number.isFinite(n)) return "";
|
||||
const opts = { notation: "compact", minimumFractionDigits: 0, maximumFractionDigits: 1 } as const;
|
||||
return numberFormat(lang, opts).format(n);
|
||||
}
|
||||
|
||||
/** A US-dollar sum, compact ("$160M"): TMDB budgets and revenues are USD whatever the reader's
|
||||
* language. The minimum is load-bearing, not a default written out: given only a maximum, a compact
|
||||
* format is free to pad to it. The same options render "$160.0M" on Node 22 and "$160M" on Node 24
|
||||
* (measured 2026-09-20), and browsers split along the same line. Pin it rather than inherit it. */
|
||||
export function formatMoney(n: number, lang: string): string {
|
||||
return numberFormat(lang, {
|
||||
notation: "compact",
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 1,
|
||||
}).format(n);
|
||||
}
|
||||
|
||||
// A count for a right-aligned table cell: the humanized number, or an em-dash when unknown (null).
|
||||
export function formatCountOrDash(n: number | null | undefined): string {
|
||||
return n != null ? formatViews(n) : "—";
|
||||
export function formatCountOrDash(n: number | null | undefined, lang: string): string {
|
||||
return n != null ? formatCompact(n, lang) : "—";
|
||||
}
|
||||
|
||||
// The app's date forms (docs/DESIGN.md "Copy", E28 S3g), all in the UI language — never a bare
|
||||
@@ -195,12 +236,12 @@ export function formatCountOrDash(n: number | null | undefined): string {
|
||||
const DAY: Intl.DateTimeFormatOptions = { year: "numeric", month: "short", day: "numeric" };
|
||||
const MINUTE: Intl.DateTimeFormatOptions = { ...DAY, hour: "2-digit", minute: "2-digit" };
|
||||
|
||||
export function formatDateTime(iso: string | null | undefined): string {
|
||||
return iso ? new Date(iso).toLocaleString(i18n.language, MINUTE) : "";
|
||||
export function formatDateTime(iso: string | null | undefined, lang: string): string {
|
||||
return iso ? new Date(iso).toLocaleString(lang, MINUTE) : "";
|
||||
}
|
||||
|
||||
export function formatDateTimeSec(iso: string | null | undefined): string {
|
||||
return iso ? new Date(iso).toLocaleString(i18n.language, { ...MINUTE, second: "2-digit" }) : "";
|
||||
export function formatDateTimeSec(iso: string | null | undefined, lang: string): string {
|
||||
return iso ? new Date(iso).toLocaleString(lang, { ...MINUTE, second: "2-digit" }) : "";
|
||||
}
|
||||
|
||||
// Absolute day-precision date (e.g. "12 Jan 2017"), shown alongside a relative time. Empty for null.
|
||||
|
||||
@@ -12,12 +12,16 @@ describe("a job's result", () => {
|
||||
it("names each count, and a phase's own counts inside it", () => {
|
||||
setLanguage("hu");
|
||||
const t = i18n.getFixedT("hu");
|
||||
expect(jobResultText(t, { new: 3, failed: 0 })).toBe("új videó: 3 · sikertelen: 0");
|
||||
expect(jobResultText(t, { new: 3, failed: 0 }, "hu")).toBe("új videó: 3 · sikertelen: 0");
|
||||
expect(
|
||||
jobResultText(t, {
|
||||
jobResultText(
|
||||
t,
|
||||
{
|
||||
recent: { channels_processed: 0, videos_added: 0, reason: "no service user" },
|
||||
deep: { channels_processed: 2, videos_added: 5, reconciled_full: 1 },
|
||||
}),
|
||||
},
|
||||
"hu",
|
||||
),
|
||||
).toBe(
|
||||
"friss (feldolgozott csatorna: 0 · hozzáadott videó: 0 · ok: nincs szolgáltatásfiók) · " +
|
||||
"mély (feldolgozott csatorna: 2 · hozzáadott videó: 5 · teljesen egyeztetve: 1)",
|
||||
@@ -26,15 +30,15 @@ describe("a job's result", () => {
|
||||
|
||||
it("names a skip reason; an upstream error stays its own text", () => {
|
||||
const t = i18n.getFixedT("hu");
|
||||
expect(jobResultText(t, { enriched: 0, skipped: "tmdb_api_key not set" })).toBe(
|
||||
expect(jobResultText(t, { enriched: 0, skipped: "tmdb_api_key not set" }, "hu")).toBe(
|
||||
"kiegészítve: 0 · kihagyva: nincs TMDB API-kulcs",
|
||||
);
|
||||
expect(jobResultText(t, { error: "Plex returned 500" })).toBe("hiba: Plex returned 500");
|
||||
expect(jobResultText(t, { error: "Plex returned 500" }, "hu")).toBe("hiba: Plex returned 500");
|
||||
});
|
||||
|
||||
it("shows a key or word it does not know as it is, readable", () => {
|
||||
const t = i18n.getFixedT("hu");
|
||||
expect(jobResultText(t, { brand_new_count: 3, skipped: "moon is full" })).toBe(
|
||||
expect(jobResultText(t, { brand_new_count: 3, skipped: "moon is full" }, "hu")).toBe(
|
||||
"brand new count: 3 · kihagyva: moon is full",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -22,16 +22,16 @@ export function parseSummary(summary: string | null | undefined): JobResult | nu
|
||||
* stored "new=3, failed=0" — English keys, a Python dict repr for a nested phase. A count's key is
|
||||
* named from `scheduler.result`, a `skipped` / `reason` word from `scheduler.skip`; an unnamed key or
|
||||
* word shows as it is, and an `error` stays the upstream's own text (it is diagnostic). */
|
||||
export function jobResultText(t: TFunction, result: JobResult): string {
|
||||
export function jobResultText(t: TFunction, result: JobResult, lang: string): string {
|
||||
return Object.entries(result)
|
||||
.map(([key, value]) => {
|
||||
const label = t(`scheduler.result.${key}`, { defaultValue: key.replace(/_/g, " ") });
|
||||
if (typeof value === "number") return `${label}: ${formatNumber(value)}`;
|
||||
if (typeof value === "number") return `${label}: ${formatNumber(value, lang)}`;
|
||||
if (typeof value === "string")
|
||||
return key === "error"
|
||||
? `${label}: ${value}`
|
||||
: `${label}: ${t(`scheduler.skip.${vocabKey(value)}`, { defaultValue: value })}`;
|
||||
return `${label} (${jobResultText(t, value)})`;
|
||||
return `${label} (${jobResultText(t, value, lang)})`;
|
||||
})
|
||||
.join(" · ");
|
||||
}
|
||||
|
||||
@@ -2,18 +2,19 @@ import { describe, expect, it } from "vitest";
|
||||
|
||||
import { eslint } from "./lintProbe.testkit";
|
||||
|
||||
// `local/date-format` (eslint.config.js): docs/DESIGN.md "Copy" — a date is shown in the forms
|
||||
// lib/format.ts builds, in the UI language. This pins that the rule fires on the ways a date used to
|
||||
// be formatted by hand, and where it stands down.
|
||||
const RULE = "local/date-format";
|
||||
// `local/locale-format` (eslint.config.js): docs/DESIGN.md "Copy" — a date, a number and an
|
||||
// alphabetical order are in the UI language, built in lib/format.ts (text compared in lib/collate.ts),
|
||||
// and a component takes the language off useTranslation, never the i18n singleton. This pins that the
|
||||
// rule fires on the ways each used to be done by hand, and where it stands down.
|
||||
const RULE = "local/locale-format";
|
||||
|
||||
async function hits(source: string, filePath = "src/components/__probe.tsx"): Promise<number> {
|
||||
const [result] = await eslint.lintText(source, { filePath });
|
||||
return (result?.messages ?? []).filter((m) => m.ruleId === RULE).length;
|
||||
}
|
||||
|
||||
describe("a hand-formatted date", () => {
|
||||
it("is refused as toLocale*String or an Intl date formatter", async () => {
|
||||
describe("a hand-formatted date or number", () => {
|
||||
it("is refused as toLocale*String or an Intl date or number formatter", async () => {
|
||||
expect(await hits("export const a = (d: Date) => d.toLocaleString('en');\n")).toBe(1);
|
||||
expect(await hits("export const b = (d: Date) => d.toLocaleDateString();\n")).toBe(1);
|
||||
expect(await hits("export const c = (d: Date) => d.toLocaleTimeString();\n")).toBe(1);
|
||||
@@ -21,12 +22,48 @@ describe("a hand-formatted date", () => {
|
||||
await hits("export const e = new Intl.DateTimeFormat('en', { month: 'short' });\n"),
|
||||
).toBe(1);
|
||||
expect(await hits("export const f = new Intl.RelativeTimeFormat('en');\n")).toBe(1);
|
||||
expect(await hits("export const h = new Intl.NumberFormat('en');\n")).toBe(1);
|
||||
});
|
||||
|
||||
it("is left alone in lib/format.ts, in a test and for a number format", async () => {
|
||||
it("is left alone in lib/format.ts and in a test", async () => {
|
||||
const src = "export const g = (d: Date) => d.toLocaleString('en');\n";
|
||||
expect(await hits(src, "src/lib/format.ts")).toBe(0);
|
||||
expect(await hits(src, "src/lib/format.test.ts")).toBe(0);
|
||||
expect(await hits("export const h = new Intl.NumberFormat('en');\n")).toBe(0);
|
||||
expect(await hits("export const h = new Intl.NumberFormat('en');\n", "src/lib/format.ts")).toBe(
|
||||
0,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a hand-rolled alphabetical order", () => {
|
||||
it("is refused as localeCompare, or an Intl.Collator outside lib/collate.ts", async () => {
|
||||
expect(await hits("export const s = (a: string, b: string) => a.localeCompare(b);\n")).toBe(1);
|
||||
expect(
|
||||
await hits(
|
||||
"export const s = (a: string, b: string) => a.localeCompare(b);\n",
|
||||
"src/lib/x.ts",
|
||||
),
|
||||
).toBe(1);
|
||||
expect(await hits("export const c = new Intl.Collator('hu');\n")).toBe(1);
|
||||
expect(await hits("export const c = new Intl.Collator('hu');\n", "src/lib/collate.ts")).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the language in a component", () => {
|
||||
it("is refused off the i18n singleton", async () => {
|
||||
const src = 'import i18n from "../i18n";\nexport const L = () => <b>{i18n.language}</b>;\n';
|
||||
expect(await hits(src)).toBe(1);
|
||||
const next =
|
||||
'import i18next from "i18next";\nexport const L = () => <b>{i18next.language}</b>;\n';
|
||||
expect(await hits(next)).toBe(1);
|
||||
});
|
||||
|
||||
it("is taken from useTranslation, and the singleton stays usable outside components", async () => {
|
||||
const hook =
|
||||
'import { useTranslation } from "react-i18next";\n' +
|
||||
"export const L = () => {\n const { i18n } = useTranslation();\n return <b>{i18n.language}</b>;\n};\n";
|
||||
expect(await hits(hook)).toBe(0);
|
||||
const lib = 'import i18n from "../i18n";\nexport const lang = () => i18n.language;\n';
|
||||
expect(await hits(lib, "src/lib/__probe.ts")).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -46,17 +46,17 @@ describe("applyView", () => {
|
||||
];
|
||||
|
||||
it("returns the stored order untouched with no sort and no grouping", () => {
|
||||
expect(applyView(rows, columns, null, false)).toBe(rows);
|
||||
expect(applyView(rows, columns, null, false, "en")).toBe(rows);
|
||||
});
|
||||
|
||||
it("sorts by the named column", () => {
|
||||
expect(titles(applyView(rows, columns, { key: "title", dir: "asc" }, false))).toEqual([
|
||||
expect(titles(applyView(rows, columns, { key: "title", dir: "asc" }, false, "en"))).toEqual([
|
||||
"alpha",
|
||||
"bravo",
|
||||
"charlie",
|
||||
"delta",
|
||||
]);
|
||||
expect(titles(applyView(rows, columns, { key: "title", dir: "desc" }, false))).toEqual([
|
||||
expect(titles(applyView(rows, columns, { key: "title", dir: "desc" }, false, "en"))).toEqual([
|
||||
"delta",
|
||||
"charlie",
|
||||
"bravo",
|
||||
@@ -65,7 +65,7 @@ describe("applyView", () => {
|
||||
});
|
||||
|
||||
it("groups by channel, ordering the groups by name and sorting within each", () => {
|
||||
expect(titles(applyView(rows, columns, { key: "title", dir: "asc" }, true))).toEqual([
|
||||
expect(titles(applyView(rows, columns, { key: "title", dir: "asc" }, true, "en"))).toEqual([
|
||||
"alpha", // Alpha
|
||||
"bravo", // Alpha
|
||||
"charlie", // Beta
|
||||
@@ -74,7 +74,7 @@ describe("applyView", () => {
|
||||
});
|
||||
|
||||
it("reverses the GROUP order too when the sort is descending", () => {
|
||||
expect(titles(applyView(rows, columns, { key: "title", dir: "desc" }, true))).toEqual([
|
||||
expect(titles(applyView(rows, columns, { key: "title", dir: "desc" }, true, "en"))).toEqual([
|
||||
"delta", // Beta first, because the direction flips the group order as well
|
||||
"charlie",
|
||||
"bravo", // then Alpha
|
||||
@@ -83,7 +83,7 @@ describe("applyView", () => {
|
||||
});
|
||||
|
||||
it("groups without a sort by keeping each group's stored order", () => {
|
||||
expect(titles(applyView(rows, columns, null, true))).toEqual([
|
||||
expect(titles(applyView(rows, columns, null, true, "en"))).toEqual([
|
||||
"alpha",
|
||||
"bravo",
|
||||
"delta",
|
||||
@@ -93,11 +93,9 @@ describe("applyView", () => {
|
||||
|
||||
it("parks unknown durations at the end of an ascending sort, not at zero", () => {
|
||||
const mixed = [v("1", "long", "A", 900), v("2", "live", "A", null), v("3", "short", "A", 30)];
|
||||
expect(titles(applyView(mixed, columns, { key: "duration", dir: "asc" }, false))).toEqual([
|
||||
"short",
|
||||
"long",
|
||||
"live",
|
||||
]);
|
||||
expect(titles(applyView(mixed, columns, { key: "duration", dir: "asc" }, false, "en"))).toEqual(
|
||||
["short", "long", "live"],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { sortRows, type SortState } from "./columnSort";
|
||||
import { compareText } from "./collate";
|
||||
|
||||
// The playlist detail's VIEW derivation: what the reader sees, as opposed to the playlist's stored
|
||||
// order. Kept structural (no `Column`, no component imports) like `columnSort.ts`, so it stays pure
|
||||
@@ -21,8 +22,9 @@ export function applyView<T extends Grouped>(
|
||||
columns: Sortable<T>[],
|
||||
sort: SortState,
|
||||
group: boolean,
|
||||
lang: string,
|
||||
): T[] {
|
||||
const sorted = sortRows(rows, columns, sort);
|
||||
const sorted = sortRows(rows, columns, sort, lang);
|
||||
if (!group) return sorted;
|
||||
const groups = new Map<string, T[]>();
|
||||
for (const v of sorted) {
|
||||
@@ -32,7 +34,7 @@ export function applyView<T extends Grouped>(
|
||||
else groups.set(k, [v]);
|
||||
}
|
||||
const sign = sort?.dir === "desc" ? -1 : 1;
|
||||
const keys = [...groups.keys()].sort((a, b) => sign * a.localeCompare(b));
|
||||
const keys = [...groups.keys()].sort((a, b) => sign * compareText(a, b, lang));
|
||||
return keys.flatMap((k) => groups.get(k)!);
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
} from "./fullscreen";
|
||||
import { api, type PlexItemDetail, type PlexMarker } from "./api";
|
||||
import { qk } from "./queryKeys";
|
||||
import { plexLang } from "../i18n";
|
||||
import i18n, { plexLang } from "../i18n";
|
||||
import { type PlexPlayerPrefs } from "./plexPlayerPrefs";
|
||||
import { PLEX_SCALE, normalizeVolumeIn, stepVolumeIn } from "./playerVolume";
|
||||
|
||||
@@ -264,7 +264,7 @@ export function usePlexProgress(opts: {
|
||||
// checkpoint (the resume point when leaving).
|
||||
const settle = () => {
|
||||
// Match the language-keyed detail query (E5 S6) so the optimistic resume patch lands.
|
||||
const key = qk.plexItem(id, plexLang());
|
||||
const key = qk.plexItem(id, plexLang(i18n.language));
|
||||
if (finished) {
|
||||
// A refused finish claims nothing: the server has the item unwatched at the position the
|
||||
// rollback restored, and the catch below has already dropped this entry, so the next open
|
||||
@@ -311,7 +311,7 @@ export function usePlexProgress(opts: {
|
||||
// `settle` may already have patched the detail to watched / 0 on the way out. Dropping that
|
||||
// entry rather than invalidating it: an invalidated query with no observer keeps serving its
|
||||
// stale data, and this one is a lie — no data is better, the next open fetches the truth.
|
||||
qc.removeQueries({ queryKey: qk.plexItem(id, plexLang()) });
|
||||
qc.removeQueries({ queryKey: qk.plexItem(id, plexLang(i18n.language)) });
|
||||
});
|
||||
};
|
||||
return () => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user