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:
parent
941fb7d756
commit
ea317c0009
45 changed files with 1522 additions and 355 deletions
|
|
@ -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.<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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue