- Extract notifyYouTubeActionError (new lib/youtubeErrors.ts): the "403 → Connect your YouTube account, else fallback toast" logic was duplicated in Channels.tsx and ChannelDiscovery.tsx. (Separate file, not lib/notifications, to avoid the api ↔ notifications import cycle.) - Extract format.formatCountOrDash(): the `n != null ? formatViews(n) : "—"` count cell was repeated across the subs/videos columns in Channels + ChannelDiscovery. - Register channelsTable / channelDiscoveryTable in storage.ts LS instead of bare "siftlode.*" string literals. - Delete 7 dead channels.json keys (filterPlaceholder, tags.newTag/createTag, row.stored/subs/getFullHistory/getFullHistoryHint) from en/hu/de — verified 0 refs (createTag matches were the unrelated api.createTag method, not the key). Behavior-neutral. tsc green, knip no new unused, localdev boots.
109 lines
4.2 KiB
TypeScript
109 lines
4.2 KiB
TypeScript
import i18n from "../i18n";
|
|
|
|
export function relativeTime(iso: string | null): string {
|
|
if (!iso) return "";
|
|
return relativeFromMs(new Date(iso).getTime());
|
|
}
|
|
|
|
/** Human file size, binary units (1024). e.g. 501747 -> "490 KB", 5368709120 -> "5.0 GB". */
|
|
export function formatBytes(bytes: number | null | undefined): 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++;
|
|
}
|
|
return `${v >= 100 || i === 0 ? Math.round(v) : v.toFixed(1)} ${units[i]}`;
|
|
}
|
|
|
|
/** Download speed from bytes/sec, e.g. "2.4 MB/s". */
|
|
export function formatSpeed(bps: number | null | undefined): string {
|
|
if (!bps) return "";
|
|
return `${formatBytes(bps)}/s`;
|
|
}
|
|
|
|
/** 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, one
|
|
* set of `time.*` strings — shared instead of re-implemented per component. */
|
|
export function relativeFromMs(ms: number): string {
|
|
const secs = Math.max(0, (Date.now() - ms) / 1000);
|
|
const units: [number, string][] = [
|
|
[60, "time.secondsAgo"],
|
|
[3600, "time.minutesAgo"],
|
|
[86400, "time.hoursAgo"],
|
|
[604800, "time.daysAgo"],
|
|
[2592000, "time.weeksAgo"],
|
|
[31536000, "time.monthsAgo"],
|
|
];
|
|
if (secs < 60) return i18n.t("time.justNow");
|
|
for (let i = 0; i < units.length - 1; i++) {
|
|
const [, key] = units[i];
|
|
const next = units[i + 1][0];
|
|
if (secs < next) {
|
|
const v = Math.floor(secs / units[i][0]);
|
|
return i18n.t(key, { count: v });
|
|
}
|
|
}
|
|
const years = Math.floor(secs / 31536000);
|
|
if (years >= 1) return i18n.t("time.yearsAgo", { count: years });
|
|
const months = Math.floor(secs / 2592000);
|
|
return i18n.t("time.monthsAgo", { count: months });
|
|
}
|
|
|
|
// 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)}`;
|
|
}
|
|
|
|
// 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 });
|
|
}
|
|
|
|
/** 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");
|
|
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) });
|
|
}
|
|
|
|
/** Compact whole-channel total length ("12 h", "45 m"), hours-scale so H:MM:SS isn't huge. */
|
|
export function formatTotalHours(sec: number): string {
|
|
if (!sec) return "—";
|
|
const h = Math.round(sec / 3600);
|
|
return h >= 1 ? `${h.toLocaleString()} h` : `${Math.max(1, Math.round(sec / 60))} m`;
|
|
}
|
|
|
|
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 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) : "—";
|
|
}
|