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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user