From 9c61bd898df3ea608d3a025713cde1bc65df8769 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Mon, 15 Jun 2026 00:47:04 +0200 Subject: [PATCH] feat(i18n): translate remaining components (HU/EN/DE) Feed, VideoCard, Sidebar, PlayerModal, Channels, Stats, SettingsPanel, OnboardingWizard, NotificationCenter, Toaster, ErrorBoundary and the relativeTime helper are now fully translated in Hungarian, English and German, each with its own locale area file (auto-loaded). Key parity verified across all three languages. --- frontend/src/components/Channels.tsx | 124 ++++++----- frontend/src/components/ErrorBoundary.tsx | 11 +- frontend/src/components/Feed.tsx | 36 +-- .../src/components/NotificationCenter.tsx | 44 ++-- frontend/src/components/OnboardingWizard.tsx | 85 +++---- frontend/src/components/PlayerModal.tsx | 38 ++-- frontend/src/components/SettingsPanel.tsx | 209 ++++++++++-------- frontend/src/components/Sidebar.tsx | 116 +++++----- frontend/src/components/Stats.tsx | 32 ++- frontend/src/components/Toaster.tsx | 28 +-- frontend/src/components/VideoCard.tsx | 32 ++- frontend/src/i18n/locales/de/card.json | 17 ++ frontend/src/i18n/locales/de/channels.json | 69 ++++++ frontend/src/i18n/locales/de/errors.json | 9 + frontend/src/i18n/locales/de/feed.json | 17 ++ .../src/i18n/locales/de/notifications.json | 19 ++ frontend/src/i18n/locales/de/onboarding.json | 29 +++ frontend/src/i18n/locales/de/player.json | 17 ++ frontend/src/i18n/locales/de/settings.json | 101 +++++++++ frontend/src/i18n/locales/de/sidebar.json | 65 ++++++ frontend/src/i18n/locales/de/stats.json | 13 ++ frontend/src/i18n/locales/de/time.json | 10 + frontend/src/i18n/locales/en/card.json | 17 ++ frontend/src/i18n/locales/en/channels.json | 69 ++++++ frontend/src/i18n/locales/en/errors.json | 9 + frontend/src/i18n/locales/en/feed.json | 17 ++ .../src/i18n/locales/en/notifications.json | 19 ++ frontend/src/i18n/locales/en/onboarding.json | 29 +++ frontend/src/i18n/locales/en/player.json | 17 ++ frontend/src/i18n/locales/en/settings.json | 101 +++++++++ frontend/src/i18n/locales/en/sidebar.json | 65 ++++++ frontend/src/i18n/locales/en/stats.json | 13 ++ frontend/src/i18n/locales/en/time.json | 10 + frontend/src/i18n/locales/hu/card.json | 17 ++ frontend/src/i18n/locales/hu/channels.json | 69 ++++++ frontend/src/i18n/locales/hu/errors.json | 9 + frontend/src/i18n/locales/hu/feed.json | 17 ++ .../src/i18n/locales/hu/notifications.json | 19 ++ frontend/src/i18n/locales/hu/onboarding.json | 29 +++ frontend/src/i18n/locales/hu/player.json | 17 ++ frontend/src/i18n/locales/hu/settings.json | 101 +++++++++ frontend/src/i18n/locales/hu/sidebar.json | 65 ++++++ frontend/src/i18n/locales/hu/stats.json | 13 ++ frontend/src/i18n/locales/hu/time.json | 10 + frontend/src/lib/format.ts | 24 +- 45 files changed, 1522 insertions(+), 355 deletions(-) create mode 100644 frontend/src/i18n/locales/de/card.json create mode 100644 frontend/src/i18n/locales/de/channels.json create mode 100644 frontend/src/i18n/locales/de/errors.json create mode 100644 frontend/src/i18n/locales/de/feed.json create mode 100644 frontend/src/i18n/locales/de/notifications.json create mode 100644 frontend/src/i18n/locales/de/onboarding.json create mode 100644 frontend/src/i18n/locales/de/player.json create mode 100644 frontend/src/i18n/locales/de/settings.json create mode 100644 frontend/src/i18n/locales/de/sidebar.json create mode 100644 frontend/src/i18n/locales/de/stats.json create mode 100644 frontend/src/i18n/locales/de/time.json create mode 100644 frontend/src/i18n/locales/en/card.json create mode 100644 frontend/src/i18n/locales/en/channels.json create mode 100644 frontend/src/i18n/locales/en/errors.json create mode 100644 frontend/src/i18n/locales/en/feed.json create mode 100644 frontend/src/i18n/locales/en/notifications.json create mode 100644 frontend/src/i18n/locales/en/onboarding.json create mode 100644 frontend/src/i18n/locales/en/player.json create mode 100644 frontend/src/i18n/locales/en/settings.json create mode 100644 frontend/src/i18n/locales/en/sidebar.json create mode 100644 frontend/src/i18n/locales/en/stats.json create mode 100644 frontend/src/i18n/locales/en/time.json create mode 100644 frontend/src/i18n/locales/hu/card.json create mode 100644 frontend/src/i18n/locales/hu/channels.json create mode 100644 frontend/src/i18n/locales/hu/errors.json create mode 100644 frontend/src/i18n/locales/hu/feed.json create mode 100644 frontend/src/i18n/locales/hu/notifications.json create mode 100644 frontend/src/i18n/locales/hu/onboarding.json create mode 100644 frontend/src/i18n/locales/hu/player.json create mode 100644 frontend/src/i18n/locales/hu/settings.json create mode 100644 frontend/src/i18n/locales/hu/sidebar.json create mode 100644 frontend/src/i18n/locales/hu/stats.json create mode 100644 frontend/src/i18n/locales/hu/time.json diff --git a/frontend/src/components/Channels.tsx b/frontend/src/components/Channels.tsx index a9ffc63..8cd4d94 100644 --- a/frontend/src/components/Channels.tsx +++ b/frontend/src/components/Channels.tsx @@ -1,4 +1,5 @@ import { useState } from "react"; +import { Trans, useTranslation } from "react-i18next"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { ArrowDown, @@ -20,11 +21,11 @@ import Avatar from "./Avatar"; export type ChannelStatusFilter = "all" | "needs_full" | "fully_synced" | "hidden"; -const STATUS_FILTERS: { id: ChannelStatusFilter; label: string }[] = [ - { id: "all", label: "All" }, - { id: "needs_full", label: "Needs full history" }, - { id: "fully_synced", label: "Fully synced" }, - { id: "hidden", label: "Hidden" }, +const STATUS_FILTERS: { id: ChannelStatusFilter; labelKey: string }[] = [ + { id: "all", labelKey: "channels.filters.all" }, + { id: "needs_full", labelKey: "channels.filters.needsFull" }, + { id: "fully_synced", labelKey: "channels.filters.fullySynced" }, + { id: "hidden", labelKey: "channels.filters.hidden" }, ]; export default function Channels({ @@ -38,6 +39,7 @@ export default function Channels({ statusFilter: ChannelStatusFilter; setStatusFilter: (f: ChannelStatusFilter) => void; }) { + const { t } = useTranslation(); const qc = useQueryClient(); const channelsQuery = useQuery({ queryKey: ["channels"], queryFn: api.channels }); const tagsQuery = useQuery({ queryKey: ["tags"], queryFn: api.tags }); @@ -92,9 +94,9 @@ export default function Channels({ mutationFn: () => api.syncSubscriptions(), onSuccess: (r: { subscriptions?: number }) => { invalidate(); - notify({ level: "success", message: `Synced ${r.subscriptions ?? 0} subscriptions` }); + notify({ level: "success", message: t("channels.notify.synced", { count: r.subscriptions ?? 0 }) }); }, - onError: () => notify({ level: "error", message: "Subscription sync failed" }), + onError: () => notify({ level: "error", message: t("channels.notify.syncFailed") }), }); const createTag = useMutation({ mutationFn: (name: string) => api.createTag({ name, category: "other" }), @@ -112,9 +114,9 @@ export default function Channels({ onSuccess: () => { qc.invalidateQueries({ queryKey: ["channels"] }); qc.invalidateQueries({ queryKey: ["my-status"] }); - notify({ level: "success", message: "Unsubscribed on YouTube" }); + notify({ level: "success", message: t("channels.notify.unsubscribed") }); }, - onError: () => notify({ level: "error", message: "Unsubscribe failed" }), + onError: () => notify({ level: "error", message: t("channels.notify.unsubscribeFailed") }), }); const deepAll = useMutation({ mutationFn: () => api.deepAll(true), @@ -123,10 +125,10 @@ export default function Channels({ qc.invalidateQueries({ queryKey: ["my-status"] }); notify({ level: "success", - message: `Full history requested for ${r.updated ?? 0} channels`, + message: t("channels.notify.fullHistoryRequested", { count: r.updated ?? 0 }), }); }, - onError: () => notify({ level: "error", message: "Couldn't request full history" }), + onError: () => notify({ level: "error", message: t("channels.notify.fullHistoryFailed") }), }); const channels = (channelsQuery.data ?? []) @@ -147,29 +149,29 @@ export default function Channels({ {/* Per-user sync status */} {s && (
- + {s.deep_pending_count > 0 && ( )} - +
)} @@ -181,13 +183,13 @@ export default function Channels({ setQ(e.target.value)} - placeholder="Filter channels…" + placeholder={t("channels.filterPlaceholder")} className="w-full bg-card border border-border rounded-full pl-9 pr-4 py-2 text-sm outline-none focus:border-accent" /> @@ -225,22 +227,27 @@ export default function Channels({ : "bg-card border-border text-muted hover:border-accent" }`} > - {f.label} + {t(f.labelKey)} ))}

- Set a channel's priority to push its videos up when you sort - by “Channel priority”, attach your own tags to filter the feed, - or hide a channel to drop it from the feed without unsubscribing. + , + , + , + ]} + />

{/* Your tags */}
- + - Your tags + {t("channels.tags.yourTags")} {userTags.map((t) => ( @@ -264,10 +271,10 @@ export default function Channels({ setNewTag(e.target.value)} - placeholder="new tag" + placeholder={t("channels.tags.newTag")} className="w-24 bg-card border border-border rounded-full px-2.5 py-1 text-xs outline-none focus:border-accent" /> - @@ -275,9 +282,9 @@ export default function Channels({ {/* Channel list */} {channelsQuery.isLoading ? ( -
Loading channels…
+
{t("channels.loading")}
) : channels.length === 0 ? ( -
No channels.
+
{t("channels.empty")}
) : (
{channels.map((c) => ( @@ -289,12 +296,12 @@ export default function Channels({ onUnsubscribe={() => { if ( window.confirm( - `Unsubscribe from "${c.title ?? c.id}" on YouTube? This changes your real YouTube account. To just remove it from your feed, hide it instead.` + t("channels.confirmUnsubscribe", { name: c.title ?? c.id }) ) ) unsubscribe.mutate(c.id); }} - onView={() => onViewChannel(c.id, c.title ?? "This channel")} + onView={() => onViewChannel(c.id, c.title ?? t("channels.row.thisChannel"))} onPriority={(d) => bumpPriority(c.id, c.priority, d)} onHide={() => patch.mutate({ id: c.id, body: { hidden: !c.hidden } })} onDeep={() => @@ -366,19 +373,20 @@ function ChannelRow({ onDeep: () => void; onToggleTag: (tagId: number) => void; }) { + const { t } = useTranslation(); return (
- +
- {c.priority} -
@@ -395,42 +403,42 @@ function ChannelRow({ {c.title ?? c.id}
- {c.stored_videos.toLocaleString()} stored - {c.subscriber_count != null && · {c.subscriber_count.toLocaleString()} subs} + {t("channels.row.stored", { count: c.stored_videos, formatted: c.stored_videos.toLocaleString() })} + {c.subscriber_count != null && · {t("channels.row.subs", { count: c.subscriber_count, formatted: c.subscriber_count.toLocaleString() })}}
{c.backfill_done ? ( - + ) : c.deep_requested ? ( - + ) : c.deep_in_queue ? ( - + - full history queued + {t("channels.row.fullHistoryQueued")} ) : ( - + )} @@ -454,23 +462,19 @@ function ChannelRow({
- {canWrite && ( - + diff --git a/frontend/src/components/ErrorBoundary.tsx b/frontend/src/components/ErrorBoundary.tsx index ce2acd5..f72999f 100644 --- a/frontend/src/components/ErrorBoundary.tsx +++ b/frontend/src/components/ErrorBoundary.tsx @@ -1,4 +1,5 @@ import { Component, type ReactNode } from "react"; +import i18n from "../i18n"; import { notify } from "../lib/notifications"; interface Props { @@ -20,8 +21,8 @@ export default class ErrorBoundary extends Component { componentDidCatch(error: Error) { notify({ level: "fatal", - title: "Something broke", - message: error.message || "Unexpected error", + title: i18n.t("errors.boundary.notifTitle"), + message: error.message || i18n.t("errors.boundary.notifMessage"), requiresInteraction: true, }); } @@ -31,13 +32,13 @@ export default class ErrorBoundary extends Component { return (
-
Something went wrong.
-
The app hit an unexpected error.
+
{i18n.t("errors.boundary.title")}
+
{i18n.t("errors.boundary.subtitle")}
diff --git a/frontend/src/components/Feed.tsx b/frontend/src/components/Feed.tsx index bbdd000..09a05cf 100644 --- a/frontend/src/components/Feed.tsx +++ b/frontend/src/components/Feed.tsx @@ -1,6 +1,8 @@ import { useCallback, useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; import { useInfiniteQuery, useQuery, useQueryClient } from "@tanstack/react-query"; import { api, type FeedFilters, type Video } from "../lib/api"; +import i18n from "../i18n"; import { notify } from "../lib/notifications"; import VideoCard from "./VideoCard"; import PlayerModal from "./PlayerModal"; @@ -38,6 +40,7 @@ export default function Feed({ canRead: boolean; onOpenWizard: () => void; }) { + const { t } = useTranslation(); const [overrides, setOverrides] = useState>({}); // The open player: which video and where to start (null = resume from saved position). const [activeVideo, setActiveVideo] = useState<{ video: Video; startAt: number | null } | null>( @@ -103,8 +106,10 @@ export default function Feed({ if (status === "hidden") { const v = loadedRef.current.find((x) => x.id === id); notify({ - message: v?.title ? `Hidden “${v.title}”` : "Video hidden", - action: { label: "Undo", onClick: () => onState(id, "new") }, + message: v?.title + ? i18n.t("feed.hiddenNamed", { title: v.title }) + : i18n.t("feed.hidden"), + action: { label: i18n.t("feed.undo"), onClick: () => onState(id, "new") }, meta: { kind: "video-hidden", videoId: id, @@ -116,8 +121,10 @@ export default function Feed({ } else if (status === "watched") { const v = loadedRef.current.find((x) => x.id === id); notify({ - message: v?.title ? `Marked watched “${v.title}”` : "Marked watched", - action: { label: "Unwatch", onClick: () => onState(id, "new") }, + message: v?.title + ? i18n.t("feed.markedWatchedNamed", { title: v.title }) + : i18n.t("feed.markedWatched"), + action: { label: i18n.t("feed.unwatch"), onClick: () => onState(id, "new") }, meta: { kind: "video-watched", videoId: id, title: v?.title ?? "this video" }, }); } @@ -138,34 +145,35 @@ export default function Feed({ .map((v) => (overrides[v.id] ? { ...v, status: overrides[v.id] } : v)) .filter((v) => matchesView(v.status, filters.show)); - if (query.isLoading) return
Loading feed…
; - if (query.isError) return
Couldn't load the feed.
; + if (query.isLoading) return
{t("feed.loading")}
; + if (query.isError) return
{t("feed.loadError")}
; if (items.length === 0) { if (!canRead) return (
-

Your feed is empty

-

- Connect your YouTube account to import your subscriptions and build your feed. -

+

{t("feed.emptyTitle")}

+

{t("feed.emptyBody")}

); - return
No videos match these filters.
; + return
{t("feed.noMatches")}
; } return (
{countQuery.data - ? `${countQuery.data.count.toLocaleString()} video${countQuery.data.count === 1 ? "" : "s"}` + ? t("feed.videoCount", { + count: countQuery.data.count, + formattedCount: countQuery.data.count.toLocaleString(), + }) : " "}
{view === "grid" ? ( @@ -205,7 +213,7 @@ export default function Feed({ )}
{isFetchingNextPage && ( -
Loading more…
+
{t("feed.loadingMore")}
)}
); diff --git a/frontend/src/components/NotificationCenter.tsx b/frontend/src/components/NotificationCenter.tsx index 4603218..299ebb0 100644 --- a/frontend/src/components/NotificationCenter.tsx +++ b/frontend/src/components/NotificationCenter.tsx @@ -1,4 +1,6 @@ import { useEffect, useRef, useState, useSyncExternalStore } from "react"; +import { useTranslation } from "react-i18next"; +import type { TFunction } from "i18next"; import { useQueryClient } from "@tanstack/react-query"; import { Bell, Eye, RotateCcw, Search, Trash2 } from "lucide-react"; import { api, type FeedFilters } from "../lib/api"; @@ -15,15 +17,15 @@ import { } from "../lib/notifications"; import { LEVEL_STYLE } from "./Toaster"; -function relTime(ts: number): string { +function relTime(ts: number, t: TFunction): string { const s = Math.round((Date.now() - ts) / 1000); - if (s < 60) return "just now"; + if (s < 60) return t("notifications.time.justNow"); const m = Math.round(s / 60); - if (m < 60) return `${m}m ago`; + if (m < 60) return t("notifications.time.minutes", { count: m }); const h = Math.round(m / 60); - if (h < 24) return `${h}h ago`; + if (h < 24) return t("notifications.time.hours", { count: h }); const d = Math.round(h / 24); - return `${d}d ago`; + return t("notifications.time.days", { count: d }); } export default function NotificationCenter({ @@ -33,6 +35,7 @@ export default function NotificationCenter({ filters: FeedFilters; setFilters: (f: FeedFilters) => void; }) { + const { t } = useTranslation(); const notifications = useSyncExternalStore(subscribe, getNotifications, getNotifications); const unread = useSyncExternalStore(subscribe, getUnreadCount, getUnreadCount); const [open, setOpen] = useState(false); @@ -64,13 +67,16 @@ export default function NotificationCenter({ setOpen(false); } - function revertState(meta: VideoHiddenMeta | VideoWatchedMeta, verb: string) { + function revertState( + meta: VideoHiddenMeta | VideoWatchedMeta, + messageKey: "notifications.unhidden" | "notifications.unwatched" + ) { api .setState(meta.videoId, "new") .then(() => { qc.invalidateQueries({ queryKey: ["feed"] }); qc.invalidateQueries({ queryKey: ["feed-count"] }); - notify({ level: "success", message: `${verb} “${meta.title}”` }); + notify({ level: "success", message: t(messageKey, { title: meta.title }) }); }) .catch(() => {}); } @@ -79,7 +85,7 @@ export default function NotificationCenter({
)}
{notifications.length === 0 ? ( -
No notifications yet.
+
{t("notifications.empty")}
) : ( notifications.map((n) => { const { icon: Icon, color } = LEVEL_STYLE[n.level]; @@ -127,10 +133,10 @@ export default function NotificationCenter({ {n.title &&
{n.title}
}
{n.message}
- {relTime(n.ts)} + {relTime(n.ts, t)} {needsAction && ( - Action needed + {t("notifications.actionNeeded")} )}
@@ -142,24 +148,24 @@ export default function NotificationCenter({ className="flex items-center gap-1 text-accent text-sm font-semibold hover:underline" > - Find in feed + {t("notifications.findInFeed")}
) : watched ? (
) : ( diff --git a/frontend/src/components/OnboardingWizard.tsx b/frontend/src/components/OnboardingWizard.tsx index 51ac1dd..2a7a8d7 100644 --- a/frontend/src/components/OnboardingWizard.tsx +++ b/frontend/src/components/OnboardingWizard.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef } from "react"; +import { Trans, useTranslation } from "react-i18next"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { AlertTriangle, Check, Loader2, RefreshCw, ShieldCheck, X, Youtube } from "lucide-react"; import { api, type Me } from "../lib/api"; @@ -20,20 +21,20 @@ function ConsentHeadsUp() {

- Google will show a "Google hasn't verified this app"{" "} - screen — that's expected, because Siftlode hasn't gone through Google's full review. - It's safe to continue: click Advanced →{" "} - Go to Siftlode. You can revoke access anytime from - your{" "} - - Google Account - - . + , + , + , + , + ]} + />

); @@ -55,6 +56,7 @@ function Dots({ index, total }: { index: number; total: number }) { } export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: () => void }) { + const { t } = useTranslation(); const qc = useQueryClient(); const importTriggered = useRef(false); @@ -126,7 +128,7 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: () @@ -136,11 +138,12 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: ()
-

Connect your YouTube subscriptions

+

{t("onboarding.read.title")}

- You're signed in. To build your feed, Siftlode needs{" "} - read-only access to the channels you're - subscribed to on YouTube. It never posts, deletes, or changes anything with this. + ]} + />

@@ -148,13 +151,13 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: () onClick={() => beginGrant("read")} className="inline-flex items-center justify-center gap-2 px-5 py-3 rounded-xl font-semibold bg-accent text-accent-fg hover:opacity-90 transition" > - Connect (read-only) + {t("onboarding.read.connect")}
@@ -166,10 +169,9 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: ()
-

Building your feed…

+

{t("onboarding.importing.title")}

- Importing your YouTube subscriptions. Channels already in Siftlode show up - instantly; any new ones fill in automatically in the background. + {t("onboarding.importing.body")}

)} @@ -179,24 +181,24 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: ()
-

You're connected

+

{t("onboarding.write.title")}

{importSubs.isError ? ( - <>Your YouTube account is connected, but importing subscriptions failed — you - can retry from Settings → Sync. + <>{t("onboarding.write.importFailed")} ) : ( <> - Your feed is ready - {myStatus.data ? ` (${myStatus.data.channels_total} channels)` : ""}. Optionally, - let Siftlode{" "} + {t("onboarding.write.feedReady", { + channels: myStatus.data + ? t("onboarding.write.feedReadyChannels", { count: myStatus.data.channels_total }) + : "", + })} )} {!importSubs.isError && ( - <> - unsubscribe from channels on YouTube for you - — this needs an extra write permission. Skip it to stay read-only; you can - always enable it later in Settings. - + ]} + /> )}

{importSubs.isError && ( @@ -204,7 +206,7 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: () onClick={() => importSubs.mutate()} className="mb-3 inline-flex items-center justify-center gap-2 px-4 py-2 rounded-xl text-sm bg-card border border-border hover:border-accent transition" > - Retry import + {t("onboarding.write.retryImport")} )} @@ -213,7 +215,7 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: () onClick={() => beginGrant("write")} className="inline-flex items-center justify-center gap-2 px-5 py-3 rounded-xl font-semibold bg-accent text-accent-fg hover:opacity-90 transition" > - Enable unsubscribe + {t("onboarding.write.enableUnsubscribe")}
@@ -233,10 +235,9 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: ()
-

All set

+

{t("onboarding.done.title")}

- Read and unsubscribe access are both granted. You can review or revoke either one - anytime in Settings → Account, or from your Google Account. + {t("onboarding.done.body")}

)} diff --git a/frontend/src/components/PlayerModal.tsx b/frontend/src/components/PlayerModal.tsx index 1d09399..0e51280 100644 --- a/frontend/src/components/PlayerModal.tsx +++ b/frontend/src/components/PlayerModal.tsx @@ -1,4 +1,6 @@ import { useEffect, useRef, useState, type ReactNode } 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 { ArrowLeft, Check, CheckCheck, X } from "lucide-react"; @@ -65,8 +67,10 @@ function renderDescription( 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"; @@ -86,7 +90,7 @@ function renderDescription( sameVideo ? opts.onSeek(yt.start ?? 0) : opts.onLoadVideo(yt.id, yt.start) } className={linkCls} - title={sameVideo ? "Jump to this time" : "Play in the in-app player"} + title={sameVideo ? t("player.jumpToTime") : t("player.playInApp")} > {href} @@ -189,6 +193,7 @@ export default function PlayerModal({ onClose: () => void; onState: (id: string, status: string) => void; }) { + const { t } = useTranslation(); const qc = useQueryClient(); // Resume point for the video we opened with. const resumeAt = startAt != null ? startAt : video.position_seconds || 0; @@ -395,17 +400,17 @@ export default function PlayerModal({ onMouseEnter={openDesc} onMouseLeave={scheduleCloseDesc} > - {navigated ? liveData?.title ?? "Loading…" : video.title} + {navigated ? liveData?.title ?? t("player.loading") : video.title} {navigated && ( )} {showDesc && @@ -422,30 +427,31 @@ export default function PlayerModal({ onMouseLeave={scheduleCloseDesc} >
- Description + {t("player.description")}
{detail.isLoading ? ( -
Loading…
+
{t("player.loading")}
) : detail.data?.description ? (
{renderDescription(detail.data.description, { currentId: currentVideoId, onSeek: seekTo, onLoadVideo: loadVideo, + t, })}
) : ( -
No description.
+
{t("player.noDescription")}
)}
, document.body )}
@@ -469,7 +475,7 @@ export default function PlayerModal({ rel="noreferrer" className="font-medium hover:text-accent shrink-0" > - {liveData?.author ?? detail.data.channel_title ?? "Channel"} + {liveData?.author ?? detail.data.channel_title ?? t("player.channel")} ) : ( {liveData?.author ?? ""} @@ -486,14 +492,16 @@ export default function PlayerModal({ )} {!navigated ? (
- {video.view_count != null && · {formatViews(video.view_count)} views} + {video.view_count != null && ( + · {t("player.views", { count: video.view_count, formattedCount: formatViews(video.view_count) })} + )} · {relativeTime(video.published_at)} {video.duration_seconds != null && ( · {formatDuration(video.duration_seconds)} )} {video.live_status === "was_live" && ( - stream + {t("player.stream")} )}
@@ -501,7 +509,7 @@ export default function PlayerModal({ // Linked video's stats come from the (already-fetched) video detail.
{detail.data?.view_count != null && ( - · {formatViews(detail.data.view_count)} views + · {t("player.views", { count: detail.data.view_count, formattedCount: formatViews(detail.data.view_count) })} )} {detail.data?.published_at && · {relativeTime(detail.data.published_at)}} {detail.data?.duration_seconds != null && ( @@ -513,7 +521,7 @@ export default function PlayerModal({ {!navigated && ( )}
diff --git a/frontend/src/components/SettingsPanel.tsx b/frontend/src/components/SettingsPanel.tsx index 7c3329d..b5c6ccc 100644 --- a/frontend/src/components/SettingsPanel.tsx +++ b/frontend/src/components/SettingsPanel.tsx @@ -1,4 +1,5 @@ import { useCallback, useEffect, useState, useSyncExternalStore } from "react"; +import { useTranslation } from "react-i18next"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Bell, Check, History, Monitor, Pause, Play, RefreshCw, User, UserPlus, X } from "lucide-react"; import { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme"; @@ -15,11 +16,11 @@ import { hintsEnabled, setHintsEnabled, subscribeHints } from "../lib/hints"; import Tooltip from "./Tooltip"; type TabId = "appearance" | "notifications" | "sync" | "account"; -const TABS: { id: TabId; label: string; icon: typeof Monitor }[] = [ - { id: "appearance", label: "Appearance", icon: Monitor }, - { id: "notifications", label: "Notifications", icon: Bell }, - { id: "sync", label: "Sync", icon: RefreshCw }, - { id: "account", label: "Account", icon: User }, +const TABS: { id: TabId; icon: typeof Monitor }[] = [ + { id: "appearance", icon: Monitor }, + { id: "notifications", icon: Bell }, + { id: "sync", icon: RefreshCw }, + { id: "account", icon: User }, ]; export default function SettingsPanel({ @@ -39,6 +40,7 @@ export default function SettingsPanel({ onClose: () => void; onOpenWizard: () => void; }) { + const { t } = useTranslation(); const [tab, setTab] = useState("appearance"); const [closing, setClosing] = useState(false); @@ -71,7 +73,7 @@ export default function SettingsPanel({ }`} >
-
Settings
+
{t("settings.title")}
); })} @@ -105,19 +107,19 @@ export default function SettingsPanel({ {/* All tabs stacked in one grid cell so the panel sizes to the tallest tab (stable height, no jump on switch); the active one is shown on top. */}
- {TABS.map((t) => ( + {TABS.map((tabItem) => (
- {t.id === "appearance" && ( + {tabItem.id === "appearance" && ( )} - {t.id === "notifications" && } - {t.id === "sync" && } - {t.id === "account" && } + {tabItem.id === "notifications" && } + {tabItem.id === "sync" && } + {tabItem.id === "account" && }
))}
@@ -190,6 +192,7 @@ function Appearance({ view: "grid" | "list"; setView: (v: "grid" | "list") => void; }) { + const { t } = useTranslation(); const [perf, setPerf] = useState(() => localStorage.getItem(PERF_KEY) === "1"); const hints = useSyncExternalStore(subscribeHints, hintsEnabled, hintsEnabled); @@ -208,7 +211,7 @@ function Appearance({ return ( <> -
+
{SCHEMES.map((s) => ( @@ -224,31 +227,31 @@ function Appearance({
-
- +
+ setTheme({ ...theme, mode: v ? "dark" : "light" })} /> - + setView(v ? "list" : "grid")} />
-
+
(() => getNotifSettings()); function update(p: Partial) { const next = { ...cfg, ...p }; @@ -275,23 +279,23 @@ function Notifications() { const autoOn = cfg.durationMs > 0; return ( -
+
update({ sound: v })} /> update({ durationMs: v ? 6000 : 0 })} /> {autoOn && (
- Dismiss after + {t("settings.notifications.dismissAfter")} {(cfg.durationMs / 1000).toFixed(0)}s
notify({ level: "info", - title: "Test notification", - message: "This is what a notification looks like.", + title: t("settings.notifications.testTitle"), + message: t("settings.notifications.testMessage"), sound: true, }) } className="mt-2 text-sm text-accent hover:underline" > - Send a test notification + {t("settings.notifications.sendTest")}
); } function Sync({ me }: { me: Me }) { + const { t } = useTranslation(); const qc = useQueryClient(); const status = useQuery({ queryKey: ["my-status"], queryFn: api.myStatus }); const usage = useQuery({ queryKey: ["my-usage"], queryFn: api.myUsage }); @@ -332,9 +337,12 @@ function Sync({ me }: { me: Me }) { onSuccess: (r: { subscriptions?: number }) => { qc.invalidateQueries({ queryKey: ["my-status"] }); qc.invalidateQueries({ queryKey: ["channels"] }); - notify({ level: "success", message: `Synced ${r.subscriptions ?? 0} subscriptions` }); + notify({ + level: "success", + message: t("settings.sync.synced", { count: r.subscriptions ?? 0 }), + }); }, - onError: () => notify({ level: "error", message: "Sync failed" }), + onError: () => notify({ level: "error", message: t("settings.sync.syncFailed") }), }); const pauseResume = useMutation({ mutationFn: (paused: boolean) => (paused ? api.resumeSync() : api.pauseSync()), @@ -347,69 +355,69 @@ function Sync({ me }: { me: Me }) { qc.invalidateQueries({ queryKey: ["channels"] }); notify({ level: "success", - message: `Full history requested for ${r.updated ?? 0} channels`, + message: t("settings.sync.fullHistoryRequested", { count: r.updated ?? 0 }), }); }, - onError: () => notify({ level: "error", message: "Couldn't request full history" }), + onError: () => notify({ level: "error", message: t("settings.sync.fullHistoryFailed") }), }); return ( <> -
+
{s ? (
- + {s.channels_total} {`${s.channels_recent_synced}/${s.channels_total}`} {`${s.channels_deep_done}/${s.channels_deep_requested}`} {s.deep_pending_count > 0 && ( {formatEta(s.deep_eta_seconds)} )} - + {s.my_videos.toLocaleString()} {s.quota_remaining_today.toLocaleString()}
) : ( -
Loading…
+
{t("settings.sync.loading")}
)}
-
+
{usage.data ? ( <>
- + {usage.data.today.toLocaleString()} - {usage.data.last_7d.toLocaleString()} - {usage.data.last_30d.toLocaleString()} - {usage.data.all_time.toLocaleString()} + {usage.data.last_7d.toLocaleString()} + {usage.data.last_30d.toLocaleString()} + {usage.data.all_time.toLocaleString()}
{Object.keys(usage.data.by_action).length > 0 && (
-
By action (30d)
+
{t("settings.sync.byAction")}
{Object.entries(usage.data.by_action) .sort((a, b) => b[1] - a[1]) .map(([action, units]) => ( @@ -422,42 +430,44 @@ function Sync({ me }: { me: Me }) { )} ) : ( -
No usage yet.
+
{t("settings.sync.noUsage")}
)}
-
- +
+ - +
{me.role === "admin" && s && ( -
- +
+
@@ -479,6 +489,7 @@ function AccessRow({ enableHint: string; onEnable: () => void; }) { + const { t } = useTranslation(); return (
@@ -489,15 +500,15 @@ function AccessRow({
{granted ? ( - Granted + {t("settings.account.granted")} ) : ( - + )} @@ -506,9 +517,10 @@ function AccessRow({ } function Account({ me, onOpenWizard }: { me: Me; onOpenWizard: () => void }) { + const { t } = useTranslation(); return ( <> -
+
void }) {
-
+

- Sign-in only shares your name and email. YouTube access is granted separately, below — - each is optional and revocable anytime in your Google Account. + {t("settings.account.youtubeAccessIntro")}

{ window.location.href = "/auth/upgrade?access=read"; }} /> { window.location.href = "/auth/upgrade?access=write"; }} @@ -552,7 +563,7 @@ function Account({ me, onOpenWizard }: { me: Me; onOpenWizard: () => void }) { onClick={onOpenWizard} className="mt-2 text-sm text-accent hover:underline" > - Walk me through setup + {t("settings.account.walkMeThrough")}
{me.role === "admin" && } @@ -561,6 +572,7 @@ function Account({ me, onOpenWizard }: { me: Me; onOpenWizard: () => void }) { } function AdminInvites() { + const { t } = useTranslation(); const qc = useQueryClient(); const invites = useQuery({ queryKey: ["admin-invites"], queryFn: () => api.adminInvites() }); const [newEmail, setNewEmail] = useState(""); @@ -572,23 +584,23 @@ function AdminInvites() { mutationFn: (id: number) => api.approveInvite(id), onSuccess: () => { refresh(); - notify({ level: "success", message: "Approved — they can sign in now" }); + notify({ level: "success", message: t("settings.invites.approved") }); }, - onError: () => notify({ level: "error", message: "Approve failed" }), + onError: () => notify({ level: "error", message: t("settings.invites.approveFailed") }), }); const deny = useMutation({ mutationFn: (id: number) => api.denyInvite(id), onSuccess: refresh, - onError: () => notify({ level: "error", message: "Deny failed" }), + onError: () => notify({ level: "error", message: t("settings.invites.denyFailed") }), }); const add = useMutation({ mutationFn: (email: string) => api.addInvite(email), onSuccess: () => { setNewEmail(""); refresh(); - notify({ level: "success", message: "Added to the whitelist" }); + notify({ level: "success", message: t("settings.invites.addedToWhitelist") }); }, - onError: () => notify({ level: "error", message: "Couldn't add that email" }), + onError: () => notify({ level: "error", message: t("settings.invites.addFailed") }), }); const list = invites.data ?? []; @@ -596,9 +608,9 @@ function AdminInvites() { const decided = list.filter((i) => i.status !== "pending"); return ( -
+
{pending.length === 0 ? ( -

No pending requests.

+

{t("settings.invites.noPending")}

) : (
{pending.map((i) => ( @@ -624,21 +636,21 @@ function AdminInvites() { type="email" value={newEmail} onChange={(e) => setNewEmail(e.target.value)} - placeholder="Add an email directly…" + placeholder={t("settings.invites.addPlaceholder")} className="flex-1 min-w-0 bg-card border border-border rounded-xl px-3 py-2 text-sm outline-none focus:border-accent" /> {decided.length > 0 && (
- {decided.length} decided + {t("settings.invites.decided", { count: decided.length })}
{decided.map((i) => ( @@ -667,32 +679,35 @@ function InviteRow({ onDeny: () => void; busy: boolean; }) { + const { t } = useTranslation(); return (
{inv.email}
{inv.requested_at && (
- requested {new Date(inv.requested_at).toLocaleString()} + {t("settings.invites.requested", { + time: new Date(inv.requested_at).toLocaleString(), + })}
)}
- + - + diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 63dc123..0a95fdb 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -1,4 +1,5 @@ import { useState } from "react"; +import { useTranslation } from "react-i18next"; import { useQuery } from "@tanstack/react-query"; import { Check, @@ -28,38 +29,31 @@ import { CSS } from "@dnd-kit/utilities"; import { api, type FeedFilters, type Tag } from "../lib/api"; import { DEFAULT_LAYOUT, - WIDGET_TITLES, type SidebarLayout, type WidgetId, } from "../lib/sidebarLayout"; -const SORTS = [ - { id: "newest", label: "Newest" }, - { id: "oldest", label: "Oldest" }, - { id: "views", label: "Most viewed" }, - { id: "duration_desc", label: "Longest" }, - { id: "duration_asc", label: "Shortest" }, - { id: "title", label: "Name (A–Z)" }, - { id: "subscribers", label: "Channel subscribers" }, - { id: "priority", label: "Channel priority" }, - { id: "shuffle", label: "Surprise me" }, +// Filter ids; display labels resolved at render time via t("sidebar.sort.") etc. +const SORT_IDS = [ + "newest", + "oldest", + "views", + "duration_desc", + "duration_asc", + "title", + "subscribers", + "priority", + "shuffle", ]; -const SHOWS = [ - { id: "unwatched", label: "Unwatched" }, - { id: "in_progress", label: "In progress" }, - { id: "all", label: "All" }, - { id: "watched", label: "Watched" }, - { id: "saved", label: "Saved" }, - { id: "hidden", label: "Hidden" }, -]; +const SHOW_IDS = ["unwatched", "in_progress", "all", "watched", "saved", "hidden"]; -const DATE_PRESETS: { days: number; label: string }[] = [ - { days: 1, label: "24h" }, - { days: 7, label: "1 week" }, - { days: 30, label: "1 month" }, - { days: 180, label: "6 months" }, - { days: 365, label: "1 year" }, +const DATE_PRESETS: { days: number; key: string }[] = [ + { days: 1, key: "24h" }, + { days: 7, key: "1week" }, + { days: 30, key: "1month" }, + { days: 180, key: "6months" }, + { days: 365, key: "1year" }, ]; // Filter values owned by the sidebar (everything except the header search `q`). @@ -82,10 +76,11 @@ function TagChip({ active: boolean; onClick: () => void; }) { + const { t } = useTranslation(); return ( ))}
@@ -214,9 +213,9 @@ export default function Sidebar({ onChange={(e) => setFilters({ ...filters, sort: e.target.value })} className="w-full bg-card border border-border rounded-lg px-2 py-1.5 text-sm outline-none focus:border-accent" > - {SORTS.map((s) => ( - ))} @@ -244,7 +243,7 @@ export default function Sidebar({ : "bg-card border-border hover:border-accent" }`} > - {p.label} + {t("sidebar.datePreset." + p.key)} ); })} @@ -260,14 +259,14 @@ export default function Sidebar({ : "bg-card border-border hover:border-accent" }`} > - Custom + {t("sidebar.custom")}
{(customDates || filters.dateFrom || filters.dateTo) && (
@@ -314,17 +313,17 @@ export default function Sidebar({ return ( <> setFilters({ ...filters, includeNormal: v })} /> setFilters({ ...filters, includeShorts: v })} /> setFilters({ ...filters, includeLive: v })} /> @@ -352,9 +351,9 @@ export default function Sidebar({ setFilters({ ...filters, tagMode: filters.tagMode === "or" ? "and" : "or" }) } className="text-[10px] uppercase tracking-wide text-muted hover:text-accent" - title="Match any vs all selected tags" + title={t("sidebar.tagModeTooltip")} > - {filters.tagMode === "or" ? "Any" : "All"} + {filters.tagMode === "or" ? t("sidebar.any") : t("sidebar.all")}
@@ -381,9 +380,11 @@ export default function Sidebar({