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
b38ae92d8d
commit
9c61bd898d
45 changed files with 1522 additions and 355 deletions
|
|
@ -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 && (
|
||||
<div className="flex flex-wrap items-center gap-x-5 gap-y-1 text-sm text-muted mb-4">
|
||||
<Stat label="Channels" value={s.channels_total} hint="Channels you're subscribed to." />
|
||||
<Stat label={t("channels.stats.channels")} value={s.channels_total} hint={t("channels.stats.channelsHint")} />
|
||||
<Stat
|
||||
label="Recent synced"
|
||||
label={t("channels.stats.recentSynced")}
|
||||
value={`${s.channels_recent_synced}/${s.channels_total}`}
|
||||
hint="Channels whose recent uploads are in the local DB and show in your feed."
|
||||
hint={t("channels.stats.recentSyncedHint")}
|
||||
/>
|
||||
<Stat
|
||||
label="Full history"
|
||||
label={t("channels.stats.fullHistory")}
|
||||
value={`${s.channels_deep_done}/${s.channels_deep_requested}`}
|
||||
hint="Channels whose entire back-catalog is fetched, out of the ones you've requested full history for."
|
||||
hint={t("channels.stats.fullHistoryHint")}
|
||||
/>
|
||||
{s.deep_pending_count > 0 && (
|
||||
<Stat
|
||||
label="left"
|
||||
label={t("channels.stats.left")}
|
||||
value={formatEta(s.deep_eta_seconds)}
|
||||
hint={`Rough estimate to finish full-history backfill of ${s.deep_pending_count} pending channel(s), based on the shared daily quota.`}
|
||||
hint={t("channels.stats.leftHint", { count: s.deep_pending_count })}
|
||||
/>
|
||||
)}
|
||||
<Stat label="My videos" value={s.my_videos.toLocaleString()} hint="Total videos available across your channels." />
|
||||
<Stat label={t("channels.stats.myVideos")} value={s.my_videos.toLocaleString()} hint={t("channels.stats.myVideosHint")} />
|
||||
<Stat
|
||||
label="Quota left"
|
||||
label={t("channels.stats.quotaLeft")}
|
||||
value={s.quota_remaining_today.toLocaleString()}
|
||||
hint="Shared YouTube API budget left today (resets midnight US Pacific)."
|
||||
hint={t("channels.stats.quotaLeftHint")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -181,13 +183,13 @@ export default function Channels({
|
|||
<input
|
||||
value={q}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
<Tooltip
|
||||
side="bottom"
|
||||
hint="Re-import your subscription list from YouTube — adds channels you've newly followed and drops ones you've unfollowed. The videos themselves keep syncing automatically in the background; this does not re-fetch them."
|
||||
hint={t("channels.syncSubscriptionsHint")}
|
||||
>
|
||||
<button
|
||||
onClick={() => syncSubs.mutate()}
|
||||
|
|
@ -195,12 +197,12 @@ export default function Channels({
|
|||
className="glass-card glass-hover flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${syncSubs.isPending ? "animate-spin" : ""}`} />
|
||||
Sync subscriptions
|
||||
{t("channels.syncSubscriptions")}
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
side="bottom"
|
||||
hint="Request full back-catalog backfill for every channel you're subscribed to. Older videos and search become complete as the shared daily quota allows — this can take a while."
|
||||
hint={t("channels.backfillEverythingHint")}
|
||||
>
|
||||
<button
|
||||
onClick={() => deepAll.mutate()}
|
||||
|
|
@ -208,7 +210,7 @@ export default function Channels({
|
|||
className="glass-card glass-hover flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition"
|
||||
>
|
||||
<History className={`w-4 h-4 ${deepAll.isPending ? "animate-pulse" : ""}`} />
|
||||
Backfill everything
|
||||
{t("channels.backfillEverything")}
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
|
@ -225,22 +227,27 @@ export default function Channels({
|
|||
: "bg-card border-border text-muted hover:border-accent"
|
||||
}`}
|
||||
>
|
||||
{f.label}
|
||||
{t(f.labelKey)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted mb-4 leading-relaxed">
|
||||
Set a channel's <b className="text-fg/80">priority</b> to push its videos up when you sort
|
||||
by “Channel priority”, attach your own <b className="text-fg/80">tags</b> to filter the feed,
|
||||
or <b className="text-fg/80">hide</b> a channel to drop it from the feed without unsubscribing.
|
||||
<Trans
|
||||
i18nKey="channels.intro"
|
||||
components={[
|
||||
<b className="text-fg/80" />,
|
||||
<b className="text-fg/80" />,
|
||||
<b className="text-fg/80" />,
|
||||
]}
|
||||
/>
|
||||
</p>
|
||||
|
||||
{/* Your tags */}
|
||||
<div className="flex flex-wrap items-center gap-1.5 mb-4">
|
||||
<Tooltip hint="Your personal labels. Attach them to channels below, then filter the feed by tag from the sidebar. (Separate from the automatic language/topic tags.)">
|
||||
<Tooltip hint={t("channels.tags.yourTagsHint")}>
|
||||
<span className="text-xs uppercase tracking-wide text-muted mr-1 cursor-help underline decoration-dotted decoration-muted/40 underline-offset-4">
|
||||
Your tags
|
||||
{t("channels.tags.yourTags")}
|
||||
</span>
|
||||
</Tooltip>
|
||||
{userTags.map((t) => (
|
||||
|
|
@ -264,10 +271,10 @@ export default function Channels({
|
|||
<input
|
||||
value={newTag}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<button type="submit" className="text-muted hover:text-accent" title="Create tag">
|
||||
<button type="submit" className="text-muted hover:text-accent" title={t("channels.tags.createTag")}>
|
||||
<Plus className="w-4 h-4" />
|
||||
</button>
|
||||
</form>
|
||||
|
|
@ -275,9 +282,9 @@ export default function Channels({
|
|||
|
||||
{/* Channel list */}
|
||||
{channelsQuery.isLoading ? (
|
||||
<div className="text-muted py-8">Loading channels…</div>
|
||||
<div className="text-muted py-8">{t("channels.loading")}</div>
|
||||
) : channels.length === 0 ? (
|
||||
<div className="text-muted py-8">No channels.</div>
|
||||
<div className="text-muted py-8">{t("channels.empty")}</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{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 (
|
||||
<div
|
||||
className={`glass-card glass-hover flex items-center gap-3 p-2.5 rounded-xl transition ${
|
||||
c.hidden ? "opacity-60" : ""
|
||||
}`}
|
||||
>
|
||||
<Tooltip hint="Your ranking for this channel. Sort the feed by “Channel priority” to bring higher-priority channels to the top.">
|
||||
<Tooltip hint={t("channels.row.priorityHint")}>
|
||||
<div className="flex flex-col items-center cursor-help">
|
||||
<button onClick={() => onPriority(1)} className="text-muted hover:text-accent" aria-label="Raise priority">
|
||||
<button onClick={() => onPriority(1)} className="text-muted hover:text-accent" aria-label={t("channels.row.raisePriority")}>
|
||||
<ArrowUp className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<span className="text-xs text-muted tabular-nums">{c.priority}</span>
|
||||
<button onClick={() => onPriority(-1)} className="text-muted hover:text-accent" aria-label="Lower priority">
|
||||
<button onClick={() => onPriority(-1)} className="text-muted hover:text-accent" aria-label={t("channels.row.lowerPriority")}>
|
||||
<ArrowDown className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -395,42 +403,42 @@ function ChannelRow({
|
|||
{c.title ?? c.id}
|
||||
</button>
|
||||
<div className="flex items-center gap-2 text-[11px] text-muted">
|
||||
<span>{c.stored_videos.toLocaleString()} stored</span>
|
||||
{c.subscriber_count != null && <span>· {c.subscriber_count.toLocaleString()} subs</span>}
|
||||
<span>{t("channels.row.stored", { count: c.stored_videos, formatted: c.stored_videos.toLocaleString() })}</span>
|
||||
{c.subscriber_count != null && <span>· {t("channels.row.subs", { count: c.subscriber_count, formatted: c.subscriber_count.toLocaleString() })}</span>}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-1 mt-1">
|
||||
<SyncBadge
|
||||
ok={c.recent_synced}
|
||||
label="recent"
|
||||
hint={c.recent_synced ? "Recent uploads synced." : "Recent uploads not fetched yet."}
|
||||
label={t("channels.row.recent")}
|
||||
hint={c.recent_synced ? t("channels.row.recentSyncedHint") : t("channels.row.recentNotSyncedHint")}
|
||||
/>
|
||||
{c.backfill_done ? (
|
||||
<SyncBadge ok label="full" hint="Full history fetched." />
|
||||
<SyncBadge ok label={t("channels.row.full")} hint={t("channels.row.fullHint")} />
|
||||
) : c.deep_requested ? (
|
||||
<Tooltip hint="Full history requested — this channel's whole back-catalog will backfill as the shared quota allows. Click to cancel your request.">
|
||||
<Tooltip hint={t("channels.row.queuedRequestedHint")}>
|
||||
<button
|
||||
onClick={onDeep}
|
||||
className="inline-flex items-center gap-1 text-[10px] px-1.5 py-0.5 rounded-full border border-accent bg-accent text-accent-fg transition"
|
||||
>
|
||||
<History className="w-3 h-3" />
|
||||
full history queued
|
||||
{t("channels.row.fullHistoryQueued")}
|
||||
</button>
|
||||
</Tooltip>
|
||||
) : c.deep_in_queue ? (
|
||||
<Tooltip hint="Another subscriber already requested this channel's full history, so its whole back-catalog is on its way to everyone — nothing to do here.">
|
||||
<Tooltip hint={t("channels.row.queuedByOtherHint")}>
|
||||
<span className="inline-flex items-center gap-1 text-[10px] px-1.5 py-0.5 rounded-full border border-accent/40 text-accent">
|
||||
<History className="w-3 h-3" />
|
||||
full history queued
|
||||
{t("channels.row.fullHistoryQueued")}
|
||||
</span>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Tooltip hint="Only recent uploads so far. Click to request this channel's full back-catalog (older videos + complete search).">
|
||||
<Tooltip hint={t("channels.row.getFullHistoryHint")}>
|
||||
<button
|
||||
onClick={onDeep}
|
||||
className="inline-flex items-center gap-1 text-[10px] px-1.5 py-0.5 rounded-full border bg-card border-border text-muted hover:border-accent transition"
|
||||
>
|
||||
<History className="w-3 h-3" />
|
||||
get full history
|
||||
{t("channels.row.getFullHistory")}
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
|
@ -454,23 +462,19 @@ function ChannelRow({
|
|||
</div>
|
||||
|
||||
<Tooltip
|
||||
hint={
|
||||
c.hidden
|
||||
? "Hidden — this channel's videos are kept out of your feed. Click to show them again."
|
||||
: "Hide this channel's videos from your feed. It stays subscribed; this doesn't unsubscribe on YouTube."
|
||||
}
|
||||
hint={c.hidden ? t("channels.row.hiddenHint") : t("channels.row.hideHint")}
|
||||
>
|
||||
<button onClick={onHide} className="text-muted hover:text-fg shrink-0" aria-label={c.hidden ? "Unhide" : "Hide from feed"}>
|
||||
<button onClick={onHide} className="text-muted hover:text-fg shrink-0" aria-label={c.hidden ? t("channels.row.unhide") : t("channels.row.hideFromFeed")}>
|
||||
{c.hidden ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
{canWrite && (
|
||||
<Tooltip hint="Unsubscribe from this channel on YouTube (changes your real account). Read-only mode hides this — use Hide instead.">
|
||||
<Tooltip hint={t("channels.row.unsubscribeHint")}>
|
||||
<button
|
||||
onClick={onUnsubscribe}
|
||||
className="text-muted hover:text-red-400 shrink-0"
|
||||
aria-label="Unsubscribe on YouTube"
|
||||
aria-label={t("channels.row.unsubscribeOnYoutube")}
|
||||
>
|
||||
<UserMinus className="w-4 h-4" />
|
||||
</button>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue