From 9eaec27c7c43dd550ea806e1af5b6840d29bc952 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Mon, 29 Jun 2026 00:22:21 +0200 Subject: [PATCH 1/2] refactor(i18n,format): translate ETA strings; consolidate time/duration formatters - formatEta was hardcoded English (violated the trilingual rule) -> time.eta.* keys in EN/HU/DE; Scheduler's countdown 'now' likewise. - relativeFromMs added to format.ts; NotificationsPanel drops its duplicate relativeTime/relativeFromMs and the now-orphaned notifications.time.* keys. - Channels' fmtTotalDuration moved to format.ts as formatTotalHours. --- frontend/src/components/Channels.tsx | 11 ++----- .../src/components/NotificationsPanel.tsx | 22 ++----------- frontend/src/components/Scheduler.tsx | 3 +- .../src/i18n/locales/de/notifications.json | 8 +---- frontend/src/i18n/locales/de/time.json | 11 ++++++- .../src/i18n/locales/en/notifications.json | 8 +---- frontend/src/i18n/locales/en/time.json | 11 ++++++- .../src/i18n/locales/hu/notifications.json | 8 +---- frontend/src/i18n/locales/hu/time.json | 11 ++++++- frontend/src/lib/format.ts | 32 ++++++++++++------- 10 files changed, 61 insertions(+), 64 deletions(-) diff --git a/frontend/src/components/Channels.tsx b/frontend/src/components/Channels.tsx index 385448d..21cce00 100644 --- a/frontend/src/components/Channels.tsx +++ b/frontend/src/components/Channels.tsx @@ -15,7 +15,7 @@ import { } from "lucide-react"; import { api, HttpError, type ManagedChannel, type Tag } from "../lib/api"; import { useDismiss } from "../lib/useDismiss"; -import { formatEta, formatViews, relativeTime } from "../lib/format"; +import { formatEta, formatTotalHours, formatViews, relativeTime } from "../lib/format"; import { notify } from "../lib/notifications"; import Tooltip from "./Tooltip"; import DataTable, { type Column } from "./DataTable"; @@ -28,13 +28,6 @@ export type ChannelsView = "subscribed" | "discovery"; export type ChannelStatusFilter = "all" | "needs_full" | "fully_synced" | "hidden"; -// Compact total-duration label for a whole channel (hours-scale, so H:MM:SS would be huge). -function fmtTotalDuration(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`; -} - const STATUS_FILTERS: { id: ChannelStatusFilter; labelKey: string }[] = [ { id: "all", labelKey: "channels.filters.all" }, { id: "needs_full", labelKey: "channels.filters.needsFull" }, @@ -287,7 +280,7 @@ export default function Channels({ nowrap: true, sortable: true, sortValue: (c) => c.total_duration_seconds, - render: (c) => {fmtTotalDuration(c.total_duration_seconds)}, + render: (c) => {formatTotalHours(c.total_duration_seconds)}, }, { key: "types", diff --git a/frontend/src/components/NotificationsPanel.tsx b/frontend/src/components/NotificationsPanel.tsx index 044074d..8844da3 100644 --- a/frontend/src/components/NotificationsPanel.tsx +++ b/frontend/src/components/NotificationsPanel.tsx @@ -19,7 +19,7 @@ import { type VideoHiddenMeta, type VideoWatchedMeta, } from "../lib/notifications"; -import { channelYouTubeUrl } from "../lib/format"; +import { channelYouTubeUrl, relativeFromMs, relativeTime } from "../lib/format"; import type { Page } from "../lib/urlState"; import { LEVEL_STYLE } from "./Toaster"; @@ -29,22 +29,6 @@ import { LEVEL_STYLE } from "./Toaster"; // there's one indicator and one place to look. Transient toasts still pop via the Toaster. const POLL_MS = 8000; -function relativeTime(iso: string | null, t: (k: string, o?: any) => string): string { - if (!iso) return ""; - return relativeFromMs(new Date(iso).getTime(), t); -} - -function relativeFromMs(ms: number, t: (k: string, o?: any) => string): string { - const secs = Math.max(0, Math.round((Date.now() - ms) / 1000)); - // Reuse the bell's relative-time strings (notifications.time.*) — same format, no dupes. - if (secs < 60) return t("notifications.time.justNow"); - const mins = Math.round(secs / 60); - if (mins < 60) return t("notifications.time.minutes", { count: mins }); - const hours = Math.round(mins / 60); - if (hours < 24) return t("notifications.time.hours", { count: hours }); - return t("notifications.time.days", { count: Math.round(hours / 24) }); -} - export default function NotificationsPanel({ filters, setFilters, @@ -253,7 +237,7 @@ function NotificationRow({
{title} - {relativeTime(n.created_at, t)} + {relativeTime(n.created_at)}
{body &&
{body}
} @@ -338,7 +322,7 @@ function ClientActivityRow({ {n.title &&
{n.title}
}
{n.message}
- {relativeFromMs(n.ts, t)} + {relativeFromMs(n.ts)} {needsAction && ( {t("notifications.actionNeeded")} diff --git a/frontend/src/components/Scheduler.tsx b/frontend/src/components/Scheduler.tsx index 5a2556d..394434a 100644 --- a/frontend/src/components/Scheduler.tsx +++ b/frontend/src/components/Scheduler.tsx @@ -1,5 +1,6 @@ import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; +import i18n from "../i18n"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { Activity, @@ -31,7 +32,7 @@ function secsUntil(iso: string | null): number | null { } function fmtCountdown(s: number): string { - if (s <= 0) return "now"; + if (s <= 0) return i18n.t("time.eta.now"); if (s < 60) return `${s}s`; const m = Math.floor(s / 60); if (m < 60) return `${m}m ${s % 60}s`; diff --git a/frontend/src/i18n/locales/de/notifications.json b/frontend/src/i18n/locales/de/notifications.json index ec7f745..5c7fdb3 100644 --- a/frontend/src/i18n/locales/de/notifications.json +++ b/frontend/src/i18n/locales/de/notifications.json @@ -11,11 +11,5 @@ "openInManager": "Kanalverwaltung", "openOnYouTube": "Auf YouTube öffnen", "unhidden": "Eingeblendet „{{title}}”", - "unwatched": "Als nicht angesehen markiert „{{title}}”", - "time": { - "justNow": "gerade eben", - "minutes": "vor {{count}} Min.", - "hours": "vor {{count}} Std.", - "days": "vor {{count}} T." - } + "unwatched": "Als nicht angesehen markiert „{{title}}”" } diff --git a/frontend/src/i18n/locales/de/time.json b/frontend/src/i18n/locales/de/time.json index 1f1424b..80a00f7 100644 --- a/frontend/src/i18n/locales/de/time.json +++ b/frontend/src/i18n/locales/de/time.json @@ -6,5 +6,14 @@ "daysAgo": "vor {{count}} T.", "weeksAgo": "vor {{count}} Wo.", "monthsAgo": "vor {{count}} Mon.", - "yearsAgo": "vor {{count}} J." + "yearsAgo": "vor {{count}} J.", + "eta": { + "now": "jetzt", + "done": "fertig", + "lessThanHour": "< 1 Stunde", + "hours_one": "~{{count}} Stunde", + "hours_other": "~{{count}} Stunden", + "days_one": "~{{count}} Tag", + "days_other": "~{{count}} Tage" + } } diff --git a/frontend/src/i18n/locales/en/notifications.json b/frontend/src/i18n/locales/en/notifications.json index 08a23dd..6a59040 100644 --- a/frontend/src/i18n/locales/en/notifications.json +++ b/frontend/src/i18n/locales/en/notifications.json @@ -11,11 +11,5 @@ "openInManager": "Channel manager", "openOnYouTube": "Open on YouTube", "unhidden": "Unhidden “{{title}}”", - "unwatched": "Unwatched “{{title}}”", - "time": { - "justNow": "just now", - "minutes": "{{count}}m ago", - "hours": "{{count}}h ago", - "days": "{{count}}d ago" - } + "unwatched": "Unwatched “{{title}}”" } diff --git a/frontend/src/i18n/locales/en/time.json b/frontend/src/i18n/locales/en/time.json index c08ee22..f6d16ee 100644 --- a/frontend/src/i18n/locales/en/time.json +++ b/frontend/src/i18n/locales/en/time.json @@ -6,5 +6,14 @@ "daysAgo": "{{count}}d ago", "weeksAgo": "{{count}} wk ago", "monthsAgo": "{{count}} mo ago", - "yearsAgo": "{{count}} yr ago" + "yearsAgo": "{{count}} yr ago", + "eta": { + "now": "now", + "done": "done", + "lessThanHour": "< 1 hour", + "hours_one": "~{{count}} hour", + "hours_other": "~{{count}} hours", + "days_one": "~{{count}} day", + "days_other": "~{{count}} days" + } } diff --git a/frontend/src/i18n/locales/hu/notifications.json b/frontend/src/i18n/locales/hu/notifications.json index e305c4b..5c37dbf 100644 --- a/frontend/src/i18n/locales/hu/notifications.json +++ b/frontend/src/i18n/locales/hu/notifications.json @@ -11,11 +11,5 @@ "openInManager": "Csatornakezelő", "openOnYouTube": "Megnyitás a YouTube-on", "unhidden": "Megjelenítve: „{{title}}”", - "unwatched": "Megtekintés visszavonva: „{{title}}”", - "time": { - "justNow": "az imént", - "minutes": "{{count}} perce", - "hours": "{{count}} órája", - "days": "{{count}} napja" - } + "unwatched": "Megtekintés visszavonva: „{{title}}”" } diff --git a/frontend/src/i18n/locales/hu/time.json b/frontend/src/i18n/locales/hu/time.json index f3413b9..71ce423 100644 --- a/frontend/src/i18n/locales/hu/time.json +++ b/frontend/src/i18n/locales/hu/time.json @@ -6,5 +6,14 @@ "daysAgo": "{{count}} napja", "weeksAgo": "{{count}} hete", "monthsAgo": "{{count}} hónapja", - "yearsAgo": "{{count}} éve" + "yearsAgo": "{{count}} éve", + "eta": { + "now": "most", + "done": "kész", + "lessThanHour": "< 1 óra", + "hours_one": "~{{count}} óra", + "hours_other": "~{{count}} óra", + "days_one": "~{{count}} nap", + "days_other": "~{{count}} nap" + } } diff --git a/frontend/src/lib/format.ts b/frontend/src/lib/format.ts index d7ebe17..e0bb0d4 100644 --- a/frontend/src/lib/format.ts +++ b/frontend/src/lib/format.ts @@ -2,8 +2,14 @@ import i18n from "../i18n"; export function relativeTime(iso: string | null): string { if (!iso) return ""; - const then = new Date(iso).getTime(); - const secs = Math.max(0, (Date.now() - then) / 1000); + return relativeFromMs(new Date(iso).getTime()); +} + +/** 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"], @@ -51,17 +57,21 @@ 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"). */ +/** 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 "done"; + if (seconds <= 0) return i18n.t("time.eta.done"); const hours = seconds / 3600; - if (hours < 1) return "< 1 hour"; - if (hours < 24) { - const h = Math.round(hours); - return `~${h} hour${h === 1 ? "" : "s"}`; - } - const d = Math.round(hours / 24); - return `~${d} day${d === 1 ? "" : "s"}`; + 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 { From 83540b01eb218298f7f00ee4d82a4b2986cc33c3 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Mon, 29 Jun 2026 00:24:49 +0200 Subject: [PATCH 2/2] refactor(player): extract description link-rendering into lib/descriptionLinks Moves the ~140 lines of pure URL/timestamp/hashtag parsing + renderDescription out of the 659-line PlayerModal into a standalone, testable module. No behavior change. --- frontend/src/components/PlayerModal.tsx | 148 +----------------------- frontend/src/lib/descriptionLinks.tsx | 147 +++++++++++++++++++++++ 2 files changed, 149 insertions(+), 146 deletions(-) create mode 100644 frontend/src/lib/descriptionLinks.tsx diff --git a/frontend/src/components/PlayerModal.tsx b/frontend/src/components/PlayerModal.tsx index fcbc7bb..88a1483 100644 --- a/frontend/src/components/PlayerModal.tsx +++ b/frontend/src/components/PlayerModal.tsx @@ -1,6 +1,5 @@ -import { useEffect, useRef, useState, type ReactNode } from "react"; +import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; -import type { TFunction } from "i18next"; import { createPortal } from "react-dom"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { AlertTriangle, ArrowLeft, Check, CheckCheck, ExternalLink, SkipBack, SkipForward, X } from "lucide-react"; @@ -8,152 +7,9 @@ import Avatar from "./Avatar"; import AddToPlaylist from "./AddToPlaylist"; import { api, type Video } from "../lib/api"; import { channelYouTubeUrl, formatDuration, formatViews, relativeTime } from "../lib/format"; +import { renderDescription } from "../lib/descriptionLinks"; import { useBackToClose } from "../lib/history"; -// Turn a description into clickable nodes: -// - bare timestamps (mm:ss / hh:mm:ss) → seek the inline player -// - a YouTube link to the *current* video → seek (to its t= if any) -// - a YouTube link to *another* video → load it in the inline player -// - emails → mailto:, hashtags → YouTube's hashtag page, other URLs → new tab -const URL_RE = /(https?:\/\/[^\s<>]+)/g; -// One pass over plain text for the three inline token kinds (email | hashtag | timestamp). -const INLINE_RE = - /([A-Za-z0-9._%+-]+@[A-Za-z0-9-]+\.[A-Za-z0-9.-]+)|(#[\p{L}\p{N}_]+)|(\b(?:\d{1,2}:)?\d{1,2}:\d{2}\b)/gu; - -function tsToSeconds(ts: string): number { - const parts = ts.split(":").map((n) => parseInt(n, 10)); - return parts.length === 3 - ? parts[0] * 3600 + parts[1] * 60 + parts[2] - : parts[0] * 60 + parts[1]; -} - -// Parse a YouTube `t`/`start` param: "90", "90s", or "1h2m3s". -function parseStart(t: string | null): number | null { - if (!t) return null; - if (/^\d+$/.test(t)) return parseInt(t, 10) || null; - const m = t.match(/^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$/); - if (!m) return null; - const total = - parseInt(m[1] || "0", 10) * 3600 + - parseInt(m[2] || "0", 10) * 60 + - parseInt(m[3] || "0", 10); - return total || null; -} - -// Extract a video id (+ optional start) from a YouTube URL, else null. -function parseYouTube(url: string): { id: string; start: number | null } | null { - let u: URL; - try { - u = new URL(url); - } catch { - return null; - } - const host = u.hostname.replace(/^www\./, ""); - let id: string | null = null; - if (host === "youtu.be") { - id = u.pathname.slice(1) || null; - } else if (host === "youtube.com" || host === "m.youtube.com" || host === "music.youtube.com") { - if (u.pathname === "/watch") id = u.searchParams.get("v"); - else { - const m = u.pathname.match(/^\/(?:shorts|embed|live)\/([^/?#]+)/); - if (m) id = m[1]; - } - } - if (!id) return null; - return { id, start: parseStart(u.searchParams.get("t") || u.searchParams.get("start")) }; -} - -function renderDescription( - text: string, - opts: { - currentId: string; - onSeek: (seconds: number) => void; - onLoadVideo: (id: string, start: number | null) => void; - t: TFunction; - } -): ReactNode[] { - const { t } = opts; - const out: ReactNode[] = []; - let key = 0; - const linkCls = "text-accent hover:underline break-all"; - // Tidy YouTube descriptions: drop trailing spaces and remove blank lines entirely - // (they're just noise in the popover), keeping single line breaks. - const clean = text.replace(/[ \t]+\n/g, "\n").replace(/\n{2,}/g, "\n").trim(); - for (const chunk of clean.split(URL_RE)) { - if (/^https?:\/\//.test(chunk)) { - const href = chunk.replace(/[.,;:!?)\]]+$/, ""); - const yt = parseYouTube(href); - if (yt) { - const sameVideo = yt.id === opts.currentId; - out.push( - - ); - } else { - out.push( - - {href} - - ); - } - continue; - } - // Plain text: pull out emails, hashtags and bare timestamps. - INLINE_RE.lastIndex = 0; - let last = 0; - let m: RegExpExecArray | null; - while ((m = INLINE_RE.exec(chunk))) { - if (m.index > last) out.push({chunk.slice(last, m.index)}); - if (m[1]) { - // Email — trim trailing punctuation the domain rule may have swallowed. - const email = m[1].replace(/[.,;:]+$/, ""); - out.push( - - {email} - - ); - if (m[1].length > email.length) out.push({m[1].slice(email.length)}); - } else if (m[2]) { - // Hashtag → YouTube's hashtag feed (mirrors native YouTube behavior). - const tag = m[2].slice(1); - out.push( - - {m[2]} - - ); - } else { - const ts = m[3]; - out.push( - - ); - } - last = m.index + m[0].length; - } - if (last < chunk.length) out.push({chunk.slice(last)}); - } - return out; -} - // Experiment (branch experiment/inline-player): play the video in-app via the // YouTube IFrame Player API (not a bare embed) so we can read playback position // and resume where the user left off. The modal closes via the in-card Close diff --git a/frontend/src/lib/descriptionLinks.tsx b/frontend/src/lib/descriptionLinks.tsx new file mode 100644 index 0000000..dfc25b4 --- /dev/null +++ b/frontend/src/lib/descriptionLinks.tsx @@ -0,0 +1,147 @@ +import type { ReactNode } from "react"; +import type { TFunction } from "i18next"; + +// Turn a video description into clickable nodes: +// - bare timestamps (mm:ss / hh:mm:ss) → seek the inline player +// - a YouTube link to the *current* video → seek (to its t= if any) +// - a YouTube link to *another* video → load it in the inline player +// - emails → mailto:, hashtags → YouTube's hashtag page, other URLs → new tab +// Pure logic (no React state), factored out of PlayerModal so it's testable on its own. +const URL_RE = /(https?:\/\/[^\s<>]+)/g; +// One pass over plain text for the three inline token kinds (email | hashtag | timestamp). +const INLINE_RE = + /([A-Za-z0-9._%+-]+@[A-Za-z0-9-]+\.[A-Za-z0-9.-]+)|(#[\p{L}\p{N}_]+)|(\b(?:\d{1,2}:)?\d{1,2}:\d{2}\b)/gu; + +function tsToSeconds(ts: string): number { + const parts = ts.split(":").map((n) => parseInt(n, 10)); + return parts.length === 3 + ? parts[0] * 3600 + parts[1] * 60 + parts[2] + : parts[0] * 60 + parts[1]; +} + +// Parse a YouTube `t`/`start` param: "90", "90s", or "1h2m3s". +function parseStart(t: string | null): number | null { + if (!t) return null; + if (/^\d+$/.test(t)) return parseInt(t, 10) || null; + const m = t.match(/^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$/); + if (!m) return null; + const total = + parseInt(m[1] || "0", 10) * 3600 + + parseInt(m[2] || "0", 10) * 60 + + parseInt(m[3] || "0", 10); + return total || null; +} + +// Extract a video id (+ optional start) from a YouTube URL, else null. +function parseYouTube(url: string): { id: string; start: number | null } | null { + let u: URL; + try { + u = new URL(url); + } catch { + return null; + } + const host = u.hostname.replace(/^www\./, ""); + let id: string | null = null; + if (host === "youtu.be") { + id = u.pathname.slice(1) || null; + } else if (host === "youtube.com" || host === "m.youtube.com" || host === "music.youtube.com") { + if (u.pathname === "/watch") id = u.searchParams.get("v"); + else { + const m = u.pathname.match(/^\/(?:shorts|embed|live)\/([^/?#]+)/); + if (m) id = m[1]; + } + } + if (!id) return null; + return { id, start: parseStart(u.searchParams.get("t") || u.searchParams.get("start")) }; +} + +export function renderDescription( + text: string, + opts: { + currentId: string; + onSeek: (seconds: number) => void; + onLoadVideo: (id: string, start: number | null) => void; + t: TFunction; + } +): ReactNode[] { + const { t } = opts; + const out: ReactNode[] = []; + let key = 0; + const linkCls = "text-accent hover:underline break-all"; + // Tidy YouTube descriptions: drop trailing spaces and remove blank lines entirely + // (they're just noise in the popover), keeping single line breaks. + const clean = text.replace(/[ \t]+\n/g, "\n").replace(/\n{2,}/g, "\n").trim(); + for (const chunk of clean.split(URL_RE)) { + if (/^https?:\/\//.test(chunk)) { + const href = chunk.replace(/[.,;:!?)\]]+$/, ""); + const yt = parseYouTube(href); + if (yt) { + const sameVideo = yt.id === opts.currentId; + out.push( + + ); + } else { + out.push( + + {href} + + ); + } + continue; + } + // Plain text: pull out emails, hashtags and bare timestamps. + INLINE_RE.lastIndex = 0; + let last = 0; + let m: RegExpExecArray | null; + while ((m = INLINE_RE.exec(chunk))) { + if (m.index > last) out.push({chunk.slice(last, m.index)}); + if (m[1]) { + // Email — trim trailing punctuation the domain rule may have swallowed. + const email = m[1].replace(/[.,;:]+$/, ""); + out.push( + + {email} + + ); + if (m[1].length > email.length) out.push({m[1].slice(email.length)}); + } else if (m[2]) { + // Hashtag → YouTube's hashtag feed (mirrors native YouTube behavior). + const tag = m[2].slice(1); + out.push( + + {m[2]} + + ); + } else { + const ts = m[3]; + out.push( + + ); + } + last = m.index + m[0].length; + } + if (last < chunk.length) out.push({chunk.slice(last)}); + } + return out; +}