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.
This commit is contained in:
npeter83 2026-06-15 00:47:04 +02:00
parent b38ae92d8d
commit 9c61bd898d
45 changed files with 1522 additions and 355 deletions

View file

@ -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}
</button>
@ -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}
</span>
</h2>
{navigated && (
<button
onClick={() => loadVideo(video.id, null)}
title="Back to the original video"
title={t("player.backToOriginal")}
className="shrink-0 inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg text-muted hover:text-fg hover:bg-surface transition"
>
<ArrowLeft className="w-4 h-4" />
Back
{t("player.back")}
</button>
)}
{showDesc &&
@ -422,30 +427,31 @@ export default function PlayerModal({
onMouseLeave={scheduleCloseDesc}
>
<div className="text-xs uppercase tracking-wide text-muted mb-2">
Description
{t("player.description")}
</div>
{detail.isLoading ? (
<div className="text-sm text-muted">Loading</div>
<div className="text-sm text-muted">{t("player.loading")}</div>
) : detail.data?.description ? (
<div className="text-sm whitespace-pre-wrap break-words max-h-64 overflow-y-auto leading-relaxed">
{renderDescription(detail.data.description, {
currentId: currentVideoId,
onSeek: seekTo,
onLoadVideo: loadVideo,
t,
})}
</div>
) : (
<div className="text-sm text-muted">No description.</div>
<div className="text-sm text-muted">{t("player.noDescription")}</div>
)}
</div>,
document.body
)}
<button
onClick={onClose}
title="Close (Esc)"
title={t("player.closeEsc")}
className="shrink-0 inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg text-muted hover:text-fg hover:bg-surface transition"
>
Close
{t("player.close")}
<X className="w-4 h-4" />
</button>
</div>
@ -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")}
</a>
) : (
<span className="font-medium shrink-0">{liveData?.author ?? ""}</span>
@ -486,14 +492,16 @@ export default function PlayerModal({
)}
{!navigated ? (
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-sm text-muted min-w-0">
{video.view_count != null && <span>· {formatViews(video.view_count)} views</span>}
{video.view_count != null && (
<span>· {t("player.views", { count: video.view_count, formattedCount: formatViews(video.view_count) })}</span>
)}
<span>· {relativeTime(video.published_at)}</span>
{video.duration_seconds != null && (
<span>· {formatDuration(video.duration_seconds)}</span>
)}
{video.live_status === "was_live" && (
<span className="bg-accent text-accent-fg text-[10px] font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded">
stream
{t("player.stream")}
</span>
)}
</div>
@ -501,7 +509,7 @@ export default function PlayerModal({
// Linked video's stats come from the (already-fetched) video detail.
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-sm text-muted min-w-0">
{detail.data?.view_count != null && (
<span>· {formatViews(detail.data.view_count)} views</span>
<span>· {t("player.views", { count: detail.data.view_count, formattedCount: formatViews(detail.data.view_count) })}</span>
)}
{detail.data?.published_at && <span>· {relativeTime(detail.data.published_at)}</span>}
{detail.data?.duration_seconds != null && (
@ -513,7 +521,7 @@ export default function PlayerModal({
{!navigated && (
<button
onClick={() => setWatched(!watched)}
title={watched ? "Watched — click to unmark" : "Mark watched"}
title={watched ? t("player.watchedUnmark") : t("player.markWatched")}
className={
"ml-auto shrink-0 inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg transition " +
(watched
@ -522,7 +530,7 @@ export default function PlayerModal({
}
>
{watched ? <CheckCheck className="w-4 h-4" /> : <Check className="w-4 h-4" />}
{watched ? "Watched" : "Mark watched"}
{watched ? t("player.watched") : t("player.markWatched")}
</button>
)}
</div>