- The Downloads module, its rail icon and badge are gone. A bookmark, the remembered page and an old
history entry that name it land on Files (urlState.resolvePage); `g d` goes to Files too.
- Users page: a Storage tab — the disk in numbers, every user's use against their own limit with
their file count, every user's downloads; aligned tables, one menu per row (edit / reset quota,
open source, copy, the owner's quota). The admin storage rows carry quota + file count, the admin
job list its owner.
- Settings: a Downloads tab — the download formats in one list (duplicate a built-in, edit or delete
your own) and the site sign-ins, moved from Account.
- "View" on an added download, a clip or a converting upload opens the tray on that row (unfolded,
back if closed; a finished one opens as a file; under a player it opens without taking focus).
- The download dialog opens on the format used last.
- DataTable: `pager={false}` for a short list, `cardRow` to give a lone "…" its own card line.
- Welcome and README speak of Files; a new Files screenshot.
254 lines
13 KiB
TypeScript
254 lines
13 KiB
TypeScript
import type { TFunction } from "i18next";
|
||
|
||
// 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:
|
||
// 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. It is also why
|
||
// this file never imports the app's i18n: the public /watch entry formats a file size through it, and
|
||
// that import alone carried every locale of every area into the page a shared link opens.
|
||
|
||
// 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;
|
||
}
|
||
|
||
export function relativeTime(iso: string | null, lang: string, t: TFunction): string {
|
||
if (!iso) return "";
|
||
return relativeFromMs(new Date(iso).getTime(), lang, t);
|
||
}
|
||
|
||
/** 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"];
|
||
let i = 0;
|
||
let v = n;
|
||
while (v >= 1024 && i < units.length - 1) {
|
||
v /= 1024;
|
||
i++;
|
||
}
|
||
const num = v >= 100 || i === 0 ? formatNumber(Math.round(v), lang) : formatDecimal(v, lang);
|
||
return `${num} ${units[i]}`;
|
||
}
|
||
|
||
/** Relative-time tiers as [seconds-per-unit, Intl unit]. The unit is what the divisor produces,
|
||
* and Intl.RelativeTimeFormat turns the pair into the localized words — so a divisor can no
|
||
* longer be paired with the wrong label, which is exactly what shifted every tier by one from
|
||
* ea317c0 until 2026-07-20 (minutes rendered as "Ns ago", hours as "N min ago", on up the scale).
|
||
*
|
||
* MUST stay sorted ascending, with the first row doubling as the "just now" threshold: sorting it
|
||
* largest-first — the natural instinct — would make every timestamp under a year read "just now".
|
||
* Module-level because it is constant, and exported so the tests can hold that invariant. */
|
||
// A non-empty tuple type (first element + rest) so `RELATIVE_UNITS[0]` is known-present under
|
||
// noUncheckedIndexedAccess — the "sub-minute" branch relies on that first entry always existing.
|
||
export const RELATIVE_UNITS: readonly [
|
||
[number, Intl.RelativeTimeFormatUnit],
|
||
...[number, Intl.RelativeTimeFormatUnit][],
|
||
] = [
|
||
[60, "minute"],
|
||
[3600, "hour"],
|
||
[86400, "day"],
|
||
[604800, "week"],
|
||
[2592000, "month"],
|
||
[31536000, "year"],
|
||
];
|
||
|
||
// One formatter per language, built lazily: relativeFromMs runs once per rendered timestamp
|
||
// across a virtualized feed, and constructing an Intl formatter is the expensive part. Keyed by
|
||
// language because the user can switch it at runtime. `narrow` is the style that matches the
|
||
// app's compact timestamps; `numeric: "always"` keeps it counting ("1d ago", never "yesterday").
|
||
const relativeFormatters = new Map<string, Intl.RelativeTimeFormat>();
|
||
function relativeFormatter(lang: string): Intl.RelativeTimeFormat {
|
||
let formatter = relativeFormatters.get(lang);
|
||
if (!formatter) {
|
||
formatter = new Intl.RelativeTimeFormat(lang, { style: "narrow", numeric: "always" });
|
||
relativeFormatters.set(lang, formatter);
|
||
}
|
||
return formatter;
|
||
}
|
||
|
||
/** Relative-time label from an epoch-millis timestamp (the ms-based half of relativeTime, for
|
||
* 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, lang: string, t: TFunction): 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 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(lang).format(-Math.floor(secs / div), unit);
|
||
}
|
||
|
||
// The canonical YouTube URL for a channel: its @handle when known (nicer), else the
|
||
// /channel/<id> form. One place so every channel link across the app stays consistent.
|
||
export function channelYouTubeUrl(id: string, handle?: string | null): string {
|
||
return handle
|
||
? `https://www.youtube.com/@${handle.replace(/^@/, "")}`
|
||
: `https://www.youtube.com/channel/${id}`;
|
||
}
|
||
|
||
export function formatDuration(sec: number | null): string {
|
||
if (sec == null) return "";
|
||
const h = Math.floor(sec / 3600);
|
||
const m = Math.floor((sec % 3600) / 60);
|
||
const s = Math.floor(sec % 60);
|
||
const pad = (n: number) => String(n).padStart(2, "0");
|
||
return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`;
|
||
}
|
||
|
||
// A duration's unit in the UI language, in the browser's own short form (docs/DESIGN.md "Copy"):
|
||
// "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";
|
||
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
|
||
* BOTH a single item's length (Plex movies/episodes) and an aggregate total (playlist playtime, on
|
||
* the YouTube and the Plex side alike) — it is the one renderer for a playlist total, so a total
|
||
* never appears in two shapes on one screen. Empty string for a missing/zero duration.
|
||
* 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 | undefined, lang: string): string {
|
||
if (!sec) return "";
|
||
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", 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(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(t: TFunction, seconds: number): string {
|
||
if (seconds <= 0) return t("time.eta.done");
|
||
const hours = seconds / 3600;
|
||
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, lang: string): string {
|
||
if (!sec) return "—";
|
||
const h = Math.round(sec / 3600);
|
||
return h >= 1
|
||
? formatUnit(h, "hour", lang)
|
||
: formatUnit(Math.max(1, Math.round(sec / 60)), "minute", lang);
|
||
}
|
||
|
||
/** 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, lang: string): string {
|
||
if (n == null || !Number.isFinite(n)) return "";
|
||
return numberFormat(lang).format(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, lang: string): string {
|
||
return n != null ? formatCompact(n, lang) : "—";
|
||
}
|
||
|
||
// The app's date forms (docs/DESIGN.md "Copy"), all in the UI language — never a bare
|
||
// `toLocaleString()`, which formats in the BROWSER's locale (an English UI showed a Hungarian-shaped
|
||
// date) and, with no options, as numbers with seconds ("9/27/2026, 8:30:14 AM"):
|
||
// relativeTime "3d ago" / "3 napja" — recency in a list; the exact form on hover
|
||
// formatDate "Sep 27, 2026" / "2026. szept. 27."
|
||
// formatDateTime "Sep 27, 2026, 08:30 AM" / "2026. szept. 27. 08:30"
|
||
// formatDateTimeSec the same with seconds — only the server log, the audit and the recorder
|
||
// lint (`no-restricted-syntax`) keeps date formatting in this file. Empty for null.
|
||
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, lang: string): string {
|
||
return iso ? new Date(iso).toLocaleString(lang, MINUTE) : "";
|
||
}
|
||
|
||
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.
|
||
// A DATE-only value ("2026-09-17": a release, the legal pages' last update) is a calendar day, not an
|
||
// instant: `new Date` reads it as UTC midnight, which is still the day before west of UTC, so it is
|
||
// shown in UTC. A timestamp is shown in the viewer's own zone, as it should be.
|
||
export function formatDate(iso: string | null | undefined, lang: string): string {
|
||
if (!iso) return "";
|
||
const dateOnly = /^\d{4}-\d{2}-\d{2}$/.test(iso);
|
||
return new Date(iso).toLocaleDateString(lang, {
|
||
...DAY,
|
||
...(dateOnly ? { timeZone: "UTC" } : {}),
|
||
});
|
||
}
|