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 941fb7d756
commit ea317c0009
45 changed files with 1522 additions and 355 deletions

View file

@ -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 (AZ)" },
{ 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.<id>") 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 (
<button
onClick={onClick}
title={`${tag.channel_count} channel${tag.channel_count === 1 ? "" : "s"}`}
title={t("sidebar.channelCount", { count: tag.channel_count })}
className={`text-xs px-2.5 py-1 rounded-full border shadow-sm hover:shadow active:translate-y-px transition ${
active
? "bg-accent text-accent-fg border-accent"
@ -108,6 +103,7 @@ export default function Sidebar({
layout: SidebarLayout;
setLayout: (l: SidebarLayout) => void;
}) {
const { t } = useTranslation();
const tagsQuery = useQuery({ queryKey: ["tags"], queryFn: api.tags });
const tags = tagsQuery.data ?? [];
@ -128,7 +124,10 @@ export default function Sidebar({
undefined;
// Don't flash the misleading "This channel" fallback while the name is still resolving.
const channelChipLabel =
resolvedChannelName ?? (needChannelName && channelsQuery.isLoading ? "Loading…" : "This channel");
resolvedChannelName ??
(needChannelName && channelsQuery.isLoading
? t("sidebar.loading")
: t("sidebar.thisChannel"));
const languages = tags.filter((t) => t.category === "language");
const topics = tags.filter((t) => t.category === "topic");
const [customDates, setCustomDates] = useState(false);
@ -192,17 +191,17 @@ export default function Sidebar({
case "show":
return (
<div className="grid grid-cols-2 gap-1.5">
{SHOWS.map((s) => (
{SHOW_IDS.map((id) => (
<button
key={s.id}
onClick={() => setFilters({ ...filters, show: s.id })}
key={id}
onClick={() => setFilters({ ...filters, show: id })}
className={`text-xs py-1.5 rounded-lg border shadow-sm active:translate-y-px transition ${
filters.show === s.id
filters.show === id
? "bg-accent text-accent-fg border-accent"
: "bg-card border-border hover:border-accent"
}`}
>
{s.label}
{t("sidebar.show." + id)}
</button>
))}
</div>
@ -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) => (
<option key={s.id} value={s.id}>
{s.label}
{SORT_IDS.map((id) => (
<option key={id} value={id}>
{t("sidebar.sort." + id)}
</option>
))}
</select>
@ -244,7 +243,7 @@ export default function Sidebar({
: "bg-card border-border hover:border-accent"
}`}
>
{p.label}
{t("sidebar.datePreset." + p.key)}
</button>
);
})}
@ -260,14 +259,14 @@ export default function Sidebar({
: "bg-card border-border hover:border-accent"
}`}
>
Custom
{t("sidebar.custom")}
</button>
</div>
{(customDates || filters.dateFrom || filters.dateTo) && (
<div className="flex flex-col gap-1.5 mt-2">
<label className="flex items-center justify-between gap-2 text-xs">
<span className="text-muted">From</span>
<span className="text-muted">{t("sidebar.from")}</span>
<input
type="date"
value={filters.dateFrom ?? ""}
@ -282,7 +281,7 @@ export default function Sidebar({
/>
</label>
<label className="flex items-center justify-between gap-2 text-xs">
<span className="text-muted">To</span>
<span className="text-muted">{t("sidebar.to")}</span>
<input
type="date"
value={filters.dateTo ?? ""}
@ -303,7 +302,7 @@ export default function Sidebar({
}
className="text-[11px] text-muted hover:text-accent self-end"
>
clear dates
{t("sidebar.clearDates")}
</button>
)}
</div>
@ -314,17 +313,17 @@ export default function Sidebar({
return (
<>
<Toggle
label="Normal"
label={t("sidebar.content.normal")}
checked={filters.includeNormal}
onChange={(v) => setFilters({ ...filters, includeNormal: v })}
/>
<Toggle
label="Shorts"
label={t("sidebar.content.shorts")}
checked={filters.includeShorts}
onChange={(v) => setFilters({ ...filters, includeShorts: v })}
/>
<Toggle
label="Live / Upcoming"
label={t("sidebar.content.live")}
checked={filters.includeLive}
onChange={(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")}
</button>
</div>
<div className="flex flex-wrap gap-1.5">
@ -381,9 +380,11 @@ export default function Sidebar({
<aside className="w-64 shrink-0 border-r border-border bg-surface/40 overflow-y-auto p-4 space-y-3 hidden md:block">
<div className="flex items-center justify-between">
<div className="text-sm font-semibold">
Filters
{t("sidebar.filters")}
{activeCount > 0 && (
<span className="ml-1.5 text-xs font-medium text-accent">{activeCount} active</span>
<span className="ml-1.5 text-xs font-medium text-accent">
{t("sidebar.activeCount", { count: activeCount })}
</span>
)}
</div>
<div className="flex items-center gap-0.5">
@ -393,13 +394,13 @@ export default function Sidebar({
disabled={!active}
className="text-xs text-muted enabled:hover:text-accent disabled:opacity-40 transition px-1"
>
Clear all
{t("sidebar.clearAll")}
</button>
)}
{editing && (
<button
onClick={() => setLayout({ ...DEFAULT_LAYOUT })}
title="Reset to defaults"
title={t("sidebar.resetDefaults")}
className="p-1.5 rounded-lg text-muted hover:text-fg hover:bg-card transition"
>
<RotateCcw className="w-4 h-4" />
@ -407,7 +408,7 @@ export default function Sidebar({
)}
<button
onClick={() => setEditing((e) => !e)}
title={editing ? "Done" : "Customize sidebar"}
title={editing ? t("sidebar.done") : t("sidebar.customize")}
className={`p-1.5 rounded-lg transition hover:bg-card ${
editing ? "text-accent" : "text-muted hover:text-fg"
}`}
@ -418,12 +419,14 @@ export default function Sidebar({
</div>
{editing && (
<div className="text-[11px] text-muted -mt-1">Drag to reorder · eye to show/hide</div>
<div className="text-[11px] text-muted -mt-1">{t("sidebar.editHint")}</div>
)}
{filters.channelId && !editing && (
<div className="glass-card rounded-xl">
<div className="px-3 pt-2 text-xs uppercase tracking-wide text-muted">Channel</div>
<div className="px-3 pt-2 text-xs uppercase tracking-wide text-muted">
{t("sidebar.channel")}
</div>
<div className="px-3 pb-3 pt-2">
<button
onClick={() =>
@ -445,7 +448,7 @@ export default function Sidebar({
<WidgetCard
key={id}
id={id}
title={WIDGET_TITLES[id]}
title={t("sidebar.widget." + id)}
editing={editing}
collapsed={!!layout.collapsed[id]}
hidden={!!layout.hidden[id]}
@ -481,6 +484,7 @@ function WidgetCard({
onToggleHidden: () => void;
children: React.ReactNode;
}) {
const { t } = useTranslation();
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id,
disabled: !editing,
@ -504,7 +508,7 @@ function WidgetCard({
<button
{...attributes}
{...listeners}
title="Drag to reorder"
title={t("sidebar.dragToReorder")}
className="-ml-1 cursor-grab active:cursor-grabbing text-muted hover:text-fg touch-none"
>
<GripVertical className="w-4 h-4" />
@ -516,7 +520,7 @@ function WidgetCard({
{editing ? (
<button
onClick={onToggleHidden}
title={hidden ? "Show widget" : "Hide widget"}
title={hidden ? t("sidebar.showWidget") : t("sidebar.hideWidget")}
className="text-muted hover:text-fg"
>
{hidden ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
@ -524,7 +528,7 @@ function WidgetCard({
) : (
<button
onClick={onToggleCollapse}
title={collapsed ? "Expand" : "Collapse"}
title={collapsed ? t("sidebar.expand") : t("sidebar.collapse")}
className="text-muted hover:text-fg"
>
<ChevronDown