Files
siftlode/frontend/src/lib/format.ts
T
peter 078194731b feat(files): the Downloads page goes — the tray, a Storage tab and a Downloads settings tab take its place
- 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.
2026-10-11 19:37:13 +02:00

254 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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" } : {}),
});
}