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,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>

View file

@ -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<Props, State> {
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<Props, State> {
return (
<div className="min-h-screen grid place-items-center text-muted p-6 text-center">
<div>
<div className="text-lg font-semibold text-fg mb-1">Something went wrong.</div>
<div className="text-sm mb-3">The app hit an unexpected error.</div>
<div className="text-lg font-semibold text-fg mb-1">{i18n.t("errors.boundary.title")}</div>
<div className="text-sm mb-3">{i18n.t("errors.boundary.subtitle")}</div>
<button
onClick={() => location.reload()}
className="text-accent hover:underline text-sm font-semibold"
>
Reload
{i18n.t("errors.boundary.reload")}
</button>
</div>
</div>

View file

@ -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<Record<string, string>>({});
// 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 <div className="p-8 text-muted">Loading feed</div>;
if (query.isError) return <div className="p-8 text-muted">Couldn't load the feed.</div>;
if (query.isLoading) return <div className="p-8 text-muted">{t("feed.loading")}</div>;
if (query.isError) return <div className="p-8 text-muted">{t("feed.loadError")}</div>;
if (items.length === 0) {
if (!canRead)
return (
<div className="p-8 grid place-items-center text-center">
<div className="max-w-sm">
<h2 className="text-lg font-semibold">Your feed is empty</h2>
<p className="text-sm text-muted mt-2 mb-4">
Connect your YouTube account to import your subscriptions and build your feed.
</p>
<h2 className="text-lg font-semibold">{t("feed.emptyTitle")}</h2>
<p className="text-sm text-muted mt-2 mb-4">{t("feed.emptyBody")}</p>
<button
onClick={onOpenWizard}
className="inline-flex items-center justify-center gap-2 px-5 py-2.5 rounded-xl font-semibold bg-accent text-accent-fg hover:opacity-90 transition"
>
Set up my feed
{t("feed.setUp")}
</button>
</div>
</div>
);
return <div className="p-8 text-muted">No videos match these filters.</div>;
return <div className="p-8 text-muted">{t("feed.noMatches")}</div>;
}
return (
<div className="p-4">
<div className="pb-3 text-sm text-muted">
{countQuery.data
? `${countQuery.data.count.toLocaleString()} video${countQuery.data.count === 1 ? "" : "s"}`
? t("feed.videoCount", {
count: countQuery.data.count,
formattedCount: countQuery.data.count.toLocaleString(),
})
: " "}
</div>
{view === "grid" ? (
@ -205,7 +213,7 @@ export default function Feed({
)}
<div ref={sentinel} className="h-10" />
{isFetchingNextPage && (
<div className="text-center text-muted py-4">Loading more</div>
<div className="text-center text-muted py-4">{t("feed.loadingMore")}</div>
)}
</div>
);

View file

@ -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({
<div className="relative" ref={wrap}>
<button
onClick={() => setOpen((o) => !o)}
title="Notifications"
title={t("notifications.title")}
className="relative p-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"
>
<Bell className="w-5 h-5" />
@ -93,22 +99,22 @@ export default function NotificationCenter({
{open && (
<div className="glass absolute right-0 mt-2 w-80 max-w-[calc(100vw-2rem)] rounded-xl z-30 flex flex-col max-h-[70vh] animate-[popIn_0.16s_ease]">
<div className="flex items-center justify-between px-3 py-2 border-b border-border">
<div className="text-sm font-semibold">Notifications</div>
<div className="text-sm font-semibold">{t("notifications.title")}</div>
{notifications.length > 0 && (
<button
onClick={clearAll}
className="flex items-center gap-1 text-xs text-muted hover:text-accent transition"
title="Clear all"
title={t("notifications.clearAll")}
>
<Trash2 className="w-3.5 h-3.5" />
Clear
{t("notifications.clear")}
</button>
)}
</div>
<div className="overflow-y-auto">
{notifications.length === 0 ? (
<div className="px-3 py-8 text-center text-sm text-muted">No notifications yet.</div>
<div className="px-3 py-8 text-center text-sm text-muted">{t("notifications.empty")}</div>
) : (
notifications.map((n) => {
const { icon: Icon, color } = LEVEL_STYLE[n.level];
@ -127,10 +133,10 @@ export default function NotificationCenter({
{n.title && <div className="text-sm font-semibold">{n.title}</div>}
<div className="text-sm break-words">{n.message}</div>
<div className="flex items-center gap-2 mt-0.5">
<span className="text-[11px] text-muted">{relTime(n.ts)}</span>
<span className="text-[11px] text-muted">{relTime(n.ts, t)}</span>
{needsAction && (
<span className="text-[10px] uppercase tracking-wide font-semibold text-accent">
Action needed
{t("notifications.actionNeeded")}
</span>
)}
</div>
@ -142,24 +148,24 @@ export default function NotificationCenter({
className="flex items-center gap-1 text-accent text-sm font-semibold hover:underline"
>
<Search className="w-3.5 h-3.5" />
Find in feed
{t("notifications.findInFeed")}
</button>
<button
onClick={() => revertState(hidden, "Unhidden")}
onClick={() => revertState(hidden, "notifications.unhidden")}
className="flex items-center gap-1 text-accent text-sm font-semibold hover:underline"
>
<Eye className="w-3.5 h-3.5" />
Unhide
{t("notifications.unhide")}
</button>
</div>
) : watched ? (
<div className="flex items-center gap-3 mt-1">
<button
onClick={() => revertState(watched, "Unwatched")}
onClick={() => revertState(watched, "notifications.unwatched")}
className="flex items-center gap-1 text-accent text-sm font-semibold hover:underline"
>
<RotateCcw className="w-3.5 h-3.5" />
Unwatch
{t("notifications.unwatch")}
</button>
</div>
) : (

View file

@ -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() {
<div className="flex gap-2.5 rounded-xl border border-amber-400/30 bg-amber-400/5 p-3 text-left">
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5 text-amber-400" />
<p className="text-xs leading-relaxed text-fg/80">
Google will show a <span className="font-medium">"Google hasn't verified this app"</span>{" "}
screen that's expected, because Siftlode hasn't gone through Google's full review.
It's safe to continue: click <span className="font-medium">Advanced</span> {" "}
<span className="font-medium">Go to Siftlode</span>. You can revoke access anytime from
your{" "}
<a
href="https://myaccount.google.com/permissions"
target="_blank"
rel="noreferrer"
className="text-accent hover:underline"
>
Google Account
</a>
.
<Trans
i18nKey="onboarding.consent"
components={[
<span className="font-medium" />,
<span className="font-medium" />,
<span className="font-medium" />,
<a
href="https://myaccount.google.com/permissions"
target="_blank"
rel="noreferrer"
className="text-accent hover:underline"
/>,
]}
/>
</p>
</div>
);
@ -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: ()
<button
onClick={close}
className="absolute top-3 right-3 p-2 rounded-lg text-muted hover:text-fg hover:bg-card/60 transition"
aria-label="Close"
aria-label={t("onboarding.close")}
>
<X className="w-4 h-4" />
</button>
@ -136,11 +138,12 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: ()
<div className="mx-auto mb-4 grid place-items-center w-12 h-12 rounded-2xl bg-accent/15 text-accent">
<Youtube className="w-6 h-6" />
</div>
<h2 className="text-lg font-semibold">Connect your YouTube subscriptions</h2>
<h2 className="text-lg font-semibold">{t("onboarding.read.title")}</h2>
<p className="text-sm text-muted leading-relaxed mt-2 mb-4">
You're signed in. To build your feed, Siftlode needs{" "}
<span className="text-fg/90">read-only</span> access to the channels you're
subscribed to on YouTube. It never posts, deletes, or changes anything with this.
<Trans
i18nKey="onboarding.read.body"
components={[<span className="text-fg/90" />]}
/>
</p>
<ConsentHeadsUp />
<div className="mt-5 flex flex-col gap-2">
@ -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"
>
<Youtube className="w-4 h-4" /> Connect (read-only)
<Youtube className="w-4 h-4" /> {t("onboarding.read.connect")}
</button>
<button
onClick={close}
className="px-5 py-2 rounded-xl text-sm text-muted hover:text-fg transition"
>
Skip for now
{t("onboarding.read.skip")}
</button>
</div>
</>
@ -166,10 +169,9 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: ()
<div className="mx-auto mb-4 grid place-items-center w-12 h-12 rounded-2xl bg-accent/15 text-accent">
<Loader2 className="w-6 h-6 animate-spin" />
</div>
<h2 className="text-lg font-semibold">Building your feed</h2>
<h2 className="text-lg font-semibold">{t("onboarding.importing.title")}</h2>
<p className="text-sm text-muted leading-relaxed mt-2 mb-2">
Importing your YouTube subscriptions. Channels already in Siftlode show up
instantly; any new ones fill in automatically in the background.
{t("onboarding.importing.body")}
</p>
</>
)}
@ -179,24 +181,24 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: ()
<div className="mx-auto mb-4 grid place-items-center w-12 h-12 rounded-2xl bg-accent/15 text-accent">
<Check className="w-6 h-6" />
</div>
<h2 className="text-lg font-semibold">You're connected</h2>
<h2 className="text-lg font-semibold">{t("onboarding.write.title")}</h2>
<p className="text-sm text-muted leading-relaxed mt-2 mb-4">
{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 && (
<>
<span className="text-fg/90">unsubscribe</span> 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.
</>
<Trans
i18nKey="onboarding.write.rationale"
components={[<span className="text-fg/90" />]}
/>
)}
</p>
{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"
>
<RefreshCw className="w-4 h-4" /> Retry import
<RefreshCw className="w-4 h-4" /> {t("onboarding.write.retryImport")}
</button>
)}
<ConsentHeadsUp />
@ -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"
>
<ShieldCheck className="w-4 h-4" /> Enable unsubscribe
<ShieldCheck className="w-4 h-4" /> {t("onboarding.write.enableUnsubscribe")}
</button>
<button
onClick={() => {
@ -222,7 +224,7 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: ()
}}
className="px-5 py-2 rounded-xl text-sm text-muted hover:text-fg transition"
>
No thanks keep it read-only
{t("onboarding.write.keepReadOnly")}
</button>
</div>
</>
@ -233,10 +235,9 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: ()
<div className="mx-auto mb-4 grid place-items-center w-12 h-12 rounded-2xl bg-accent/15 text-accent">
<Check className="w-6 h-6" />
</div>
<h2 className="text-lg font-semibold">All set</h2>
<h2 className="text-lg font-semibold">{t("onboarding.done.title")}</h2>
<p className="text-sm text-muted leading-relaxed mt-2 mb-5">
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")}
</p>
<button
onClick={() => {
@ -245,7 +246,7 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: ()
}}
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 w-full"
>
Done
{t("onboarding.done.done")}
</button>
</>
)}

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>

View file

@ -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<TabId>("appearance");
const [closing, setClosing] = useState(false);
@ -71,7 +73,7 @@ export default function SettingsPanel({
}`}
>
<div className="flex items-center justify-between px-4 h-14 border-b border-border/60 shrink-0">
<div className="text-base font-semibold">Settings</div>
<div className="text-base font-semibold">{t("settings.title")}</div>
<button
onClick={close}
className="p-2 rounded-lg text-muted hover:text-fg hover:bg-card/60 transition"
@ -83,20 +85,20 @@ export default function SettingsPanel({
<div className="flex flex-1 min-h-0">
{/* Vertical tab rail — never wraps; active is a clear accent pill. */}
<nav className="w-32 sm:w-36 shrink-0 border-r border-border/60 p-2 flex flex-col gap-1">
{TABS.map((t) => {
const active = tab === t.id;
{TABS.map((tabItem) => {
const active = tab === tabItem.id;
return (
<button
key={t.id}
onClick={() => setTab(t.id)}
key={tabItem.id}
onClick={() => setTab(tabItem.id)}
className={`flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-left transition ${
active
? "bg-accent text-accent-fg font-medium shadow-md shadow-accent/20"
: "text-muted hover:text-fg hover:bg-card/60"
}`}
>
<t.icon className="w-4 h-4 shrink-0" />
{t.label}
<tabItem.icon className="w-4 h-4 shrink-0" />
{t(`settings.tabs.${tabItem.id}`)}
</button>
);
})}
@ -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. */}
<div className="grid flex-1 overflow-y-auto">
{TABS.map((t) => (
{TABS.map((tabItem) => (
<div
key={t.id}
key={tabItem.id}
className={`[grid-area:1/1] p-4 ${
tab === t.id ? "" : "invisible pointer-events-none"
tab === tabItem.id ? "" : "invisible pointer-events-none"
}`}
>
{t.id === "appearance" && (
{tabItem.id === "appearance" && (
<Appearance theme={theme} setTheme={setTheme} view={view} setView={setView} />
)}
{t.id === "notifications" && <Notifications />}
{t.id === "sync" && <Sync me={me} />}
{t.id === "account" && <Account me={me} onOpenWizard={onOpenWizard} />}
{tabItem.id === "notifications" && <Notifications />}
{tabItem.id === "sync" && <Sync me={me} />}
{tabItem.id === "account" && <Account me={me} onOpenWizard={onOpenWizard} />}
</div>
))}
</div>
@ -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 (
<>
<Section title="Color scheme">
<Section title={t("settings.appearance.colorScheme")}>
<div className="grid grid-cols-4 gap-2">
{SCHEMES.map((s) => (
<Tooltip key={s.id} hint={s.name}>
@ -224,31 +227,31 @@ function Appearance({
</div>
</Section>
<Section title="Display">
<Row label="Dark mode">
<Section title={t("settings.appearance.display")}>
<Row label={t("settings.appearance.darkMode")}>
<Switch
checked={theme.mode === "dark"}
onChange={(v) => setTheme({ ...theme, mode: v ? "dark" : "light" })}
/>
</Row>
<Row label="List view" hint="Show the feed as a compact list instead of a grid of cards.">
<Row label={t("settings.appearance.listView")} hint={t("settings.appearance.listViewHint")}>
<Switch checked={view === "list"} onChange={(v) => setView(v ? "list" : "grid")} />
</Row>
<Row
label="Performance mode"
hint="Turns off the translucent glass blur and soft shadows for snappier rendering on slower machines."
label={t("settings.appearance.performanceMode")}
hint={t("settings.appearance.performanceModeHint")}
>
<Switch checked={perf} onChange={togglePerf} />
</Row>
<Row
label="Show hints"
hint="These little hover explanations. Turn them off once you know your way around."
label={t("settings.appearance.showHints")}
hint={t("settings.appearance.showHintsHint")}
>
<Switch checked={hints} onChange={toggleHints} />
</Row>
</Section>
<Section title="Text size">
<Section title={t("settings.appearance.textSize")}>
<input
type="range"
min={0.9}
@ -265,6 +268,7 @@ function Appearance({
}
function Notifications() {
const { t } = useTranslation();
const [cfg, setCfg] = useState<NotifSettings>(() => getNotifSettings());
function update(p: Partial<NotifSettings>) {
const next = { ...cfg, ...p };
@ -275,23 +279,23 @@ function Notifications() {
const autoOn = cfg.durationMs > 0;
return (
<Section title="Notifications">
<Section title={t("settings.notifications.title")}>
<Row
label="Sound on alerts"
hint="Play a short tone when a notification needs your attention (e.g. errors, prompts)."
label={t("settings.notifications.sound")}
hint={t("settings.notifications.soundHint")}
>
<Switch checked={cfg.sound} onChange={(v) => update({ sound: v })} />
</Row>
<Row
label="Auto-dismiss toasts"
hint="When off, pop-up toasts stay until you close them. When on, they fade after the set time."
label={t("settings.notifications.autoDismiss")}
hint={t("settings.notifications.autoDismissHint")}
>
<Switch checked={autoOn} onChange={(v) => update({ durationMs: v ? 6000 : 0 })} />
</Row>
{autoOn && (
<div className="py-1.5 text-sm">
<div className="flex items-center justify-between">
<span className="text-muted text-xs">Dismiss after</span>
<span className="text-muted text-xs">{t("settings.notifications.dismissAfter")}</span>
<span className="text-muted text-xs">{(cfg.durationMs / 1000).toFixed(0)}s</span>
</div>
<input
@ -309,20 +313,21 @@ function Notifications() {
onClick={() =>
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")}
</button>
</Section>
);
}
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 (
<>
<Section title="My sync status">
<Section title={t("settings.sync.myStatus")}>
{s ? (
<div className="space-y-1.5 text-sm">
<Row label="Channels" hint="How many channels you're subscribed to.">
<Row label={t("settings.sync.channels")} hint={t("settings.sync.channelsHint")}>
{s.channels_total}
</Row>
<Row
label="Recent synced"
hint="Channels whose recent uploads (~last year) are already in the local database, so they show in your feed."
label={t("settings.sync.recentSynced")}
hint={t("settings.sync.recentSyncedHint")}
>
{`${s.channels_recent_synced}/${s.channels_total}`}
</Row>
<Row
label="Full history"
hint="Channels whose entire back-catalog has been fetched, out of the ones you've requested full history for (opt in per channel on the Channels page)."
label={t("settings.sync.fullHistory")}
hint={t("settings.sync.fullHistoryHint")}
>
{`${s.channels_deep_done}/${s.channels_deep_requested}`}
</Row>
{s.deep_pending_count > 0 && (
<Row
label="Full history ETA"
hint={`Rough estimate to finish full-history backfill of ${s.deep_pending_count} pending channel(s), based on the shared daily quota.`}
label={t("settings.sync.fullHistoryEta")}
hint={t("settings.sync.fullHistoryEtaHint", { count: s.deep_pending_count })}
>
{formatEta(s.deep_eta_seconds)}
</Row>
)}
<Row label="My videos" hint="Total videos available to you across your subscribed channels.">
<Row label={t("settings.sync.myVideos")} hint={t("settings.sync.myVideosHint")}>
{s.my_videos.toLocaleString()}
</Row>
<Row
label="Quota left today"
hint="Shared YouTube API budget remaining today (resets midnight US Pacific). Backfill yields to it."
label={t("settings.sync.quotaLeft")}
hint={t("settings.sync.quotaLeftHint")}
>
{s.quota_remaining_today.toLocaleString()}
</Row>
</div>
) : (
<div className="text-muted text-sm">Loading</div>
<div className="text-muted text-sm">{t("settings.sync.loading")}</div>
)}
</Section>
<Section title="Your API usage">
<Section title={t("settings.sync.apiUsage")}>
{usage.data ? (
<>
<div className="space-y-1.5 text-sm">
<Row label="Today" hint="YouTube API units your own actions used today (resets midnight US Pacific). Background sync isn't counted here.">
<Row label={t("settings.sync.today")} hint={t("settings.sync.todayHint")}>
{usage.data.today.toLocaleString()}
</Row>
<Row label="Last 7 days">{usage.data.last_7d.toLocaleString()}</Row>
<Row label="Last 30 days">{usage.data.last_30d.toLocaleString()}</Row>
<Row label="All time">{usage.data.all_time.toLocaleString()}</Row>
<Row label={t("settings.sync.last7d")}>{usage.data.last_7d.toLocaleString()}</Row>
<Row label={t("settings.sync.last30d")}>{usage.data.last_30d.toLocaleString()}</Row>
<Row label={t("settings.sync.allTime")}>{usage.data.all_time.toLocaleString()}</Row>
</div>
{Object.keys(usage.data.by_action).length > 0 && (
<div className="mt-2 pt-2 border-t border-border space-y-1 text-xs text-muted">
<div className="uppercase tracking-wide">By action (30d)</div>
<div className="uppercase tracking-wide">{t("settings.sync.byAction")}</div>
{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 }) {
)}
</>
) : (
<div className="text-muted text-sm">No usage yet.</div>
<div className="text-muted text-sm">{t("settings.sync.noUsage")}</div>
)}
</Section>
<Section title="Actions">
<Tooltip hint="Re-import your YouTube subscriptions and pull in any newly-followed channels.">
<Section title={t("settings.sync.actions")}>
<Tooltip hint={t("settings.sync.syncSubscriptionsHint")}>
<button
onClick={() => syncSubs.mutate()}
disabled={syncSubs.isPending}
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("settings.sync.syncSubscriptions")}
</button>
</Tooltip>
<Tooltip hint="Request full back-catalog backfill for every channel you're subscribed to. Older videos + complete search fill in as the shared daily quota allows.">
<Tooltip hint={t("settings.sync.backfillEverythingHint")}>
<button
onClick={() => deepAll.mutate()}
disabled={deepAll.isPending}
className="glass-card glass-hover flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition mt-2"
>
<History className={`w-4 h-4 ${deepAll.isPending ? "animate-pulse" : ""}`} />
Backfill everything
{t("settings.sync.backfillEverything")}
</button>
</Tooltip>
</Section>
{me.role === "admin" && s && (
<Section title="Admin">
<Tooltip hint="Pause or resume the background sync for the whole app (all users).">
<Section title={t("settings.sync.admin")}>
<Tooltip hint={t("settings.sync.adminPauseHint")}>
<button
onClick={() => pauseResume.mutate(s.paused)}
className="glass-card glass-hover flex items-center gap-2 px-3 py-2 rounded-xl text-sm transition"
>
{s.paused ? <Play className="w-4 h-4" /> : <Pause className="w-4 h-4" />}
{s.paused ? "Resume background sync" : "Pause background sync"}
{s.paused
? t("settings.sync.resumeBackgroundSync")
: t("settings.sync.pauseBackgroundSync")}
</button>
</Tooltip>
</Section>
@ -479,6 +489,7 @@ function AccessRow({
enableHint: string;
onEnable: () => void;
}) {
const { t } = useTranslation();
return (
<div className="flex items-start justify-between gap-3 py-2">
<div className="min-w-0">
@ -489,15 +500,15 @@ function AccessRow({
</div>
{granted ? (
<span className="shrink-0 text-[11px] px-2 py-1 rounded-full border border-accent/40 text-accent">
Granted
{t("settings.account.granted")}
</span>
) : (
<Tooltip hint="Redirects to Google to grant the permission. Google shows an 'unverified app' notice — that's expected; click Advanced → Go to Siftlode.">
<Tooltip hint={t("settings.account.enableHint")}>
<button
onClick={onEnable}
className="shrink-0 glass-card glass-hover px-3 py-1.5 rounded-xl text-sm transition"
>
Enable
{t("settings.account.enable")}
</button>
</Tooltip>
)}
@ -506,9 +517,10 @@ function AccessRow({
}
function Account({ me, onOpenWizard }: { me: Me; onOpenWizard: () => void }) {
const { t } = useTranslation();
return (
<>
<Section title="Account">
<Section title={t("settings.account.title")}>
<div className="flex items-center gap-3 mb-3">
<Avatar
src={me.avatar_url}
@ -523,26 +535,25 @@ function Account({ me, onOpenWizard }: { me: Me; onOpenWizard: () => void }) {
</div>
</Section>
<Section title="YouTube access">
<Section title={t("settings.account.youtubeAccess")}>
<p className="text-xs text-muted leading-relaxed mb-1">
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")}
</p>
<div className="divide-y divide-border">
<AccessRow
title="Read subscriptions (your feed)"
title={t("settings.account.readTitle")}
granted={me.can_read}
grantedHint="Granted. Siftlode reads the channels you follow to build your feed. It never changes your YouTube account with this."
enableHint="Read-only access to your subscriptions — required to build your feed. Siftlode can't modify anything with it."
grantedHint={t("settings.account.readGrantedHint")}
enableHint={t("settings.account.readEnableHint")}
onEnable={() => {
window.location.href = "/auth/upgrade?access=read";
}}
/>
<AccessRow
title="Unsubscribe (write)"
title={t("settings.account.writeTitle")}
granted={me.can_write}
grantedHint="Granted. You can unsubscribe from channels on YouTube from inside Siftlode. It only writes when you ask it to."
enableHint="Lets Siftlode unsubscribe from channels on YouTube for you. Optional — skip it to stay read-only."
grantedHint={t("settings.account.writeGrantedHint")}
enableHint={t("settings.account.writeEnableHint")}
onEnable={() => {
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")}
</button>
</Section>
{me.role === "admin" && <AdminInvites />}
@ -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 (
<Section title="Access requests">
<Section title={t("settings.invites.title")}>
{pending.length === 0 ? (
<p className="text-sm text-muted">No pending requests.</p>
<p className="text-sm text-muted">{t("settings.invites.noPending")}</p>
) : (
<div className="space-y-1.5">
{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"
/>
<button
type="submit"
className="shrink-0 glass-card glass-hover flex items-center gap-1.5 px-3 py-2 rounded-xl text-sm transition"
>
<UserPlus className="w-4 h-4" /> Add
<UserPlus className="w-4 h-4" /> {t("settings.invites.add")}
</button>
</form>
{decided.length > 0 && (
<details className="mt-3">
<summary className="text-xs text-muted cursor-pointer">
{decided.length} decided
{t("settings.invites.decided", { count: decided.length })}
</summary>
<div className="mt-2 space-y-1 text-xs text-muted">
{decided.map((i) => (
@ -667,32 +679,35 @@ function InviteRow({
onDeny: () => void;
busy: boolean;
}) {
const { t } = useTranslation();
return (
<div className="glass-card flex items-center gap-2 p-2.5 rounded-xl">
<div className="min-w-0 flex-1">
<div className="text-sm truncate">{inv.email}</div>
{inv.requested_at && (
<div className="text-[11px] text-muted">
requested {new Date(inv.requested_at).toLocaleString()}
{t("settings.invites.requested", {
time: new Date(inv.requested_at).toLocaleString(),
})}
</div>
)}
</div>
<Tooltip hint="Approve — whitelist this email and email them they're in.">
<Tooltip hint={t("settings.invites.approveHint")}>
<button
onClick={onApprove}
disabled={busy}
className="shrink-0 p-1.5 rounded-lg border border-accent/40 text-accent hover:bg-accent/10 disabled:opacity-50 transition"
aria-label="Approve"
aria-label={t("settings.invites.approve")}
>
<Check className="w-4 h-4" />
</button>
</Tooltip>
<Tooltip hint="Deny this request.">
<Tooltip hint={t("settings.invites.denyHint")}>
<button
onClick={onDeny}
disabled={busy}
className="shrink-0 p-1.5 rounded-lg border border-border text-muted hover:text-red-400 disabled:opacity-50 transition"
aria-label="Deny"
aria-label={t("settings.invites.deny")}
>
<X className="w-4 h-4" />
</button>

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

View file

@ -1,4 +1,5 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query";
import { api, type AdminQuotaRow } from "../lib/api";
import { quotaActionLabel } from "../lib/format";
@ -6,6 +7,7 @@ import { quotaActionLabel } from "../lib/format";
const RANGES = [7, 30, 90] as const;
export default function Stats() {
const { t } = useTranslation();
const [days, setDays] = useState<number>(30);
const q = useQuery({
queryKey: ["admin-quota", days],
@ -18,7 +20,7 @@ export default function Stats() {
return (
<div className="p-4 max-w-4xl mx-auto">
<div className="flex items-center justify-between gap-3 mb-4">
<h2 className="text-lg font-semibold">API quota usage</h2>
<h2 className="text-lg font-semibold">{t("stats.title")}</h2>
<div className="flex items-center gap-1">
{RANGES.map((d) => (
<button
@ -30,30 +32,34 @@ export default function Stats() {
: "bg-card border-border text-muted hover:border-accent"
}`}
>
{d}d
{t("stats.rangeDays", { count: d })}
</button>
))}
</div>
</div>
<p className="text-xs text-muted mb-4 leading-relaxed">
YouTube Data API units attributed by who triggered the spend. <b className="text-fg/80">System</b> is
shared background work (scheduled backfill, enrichment, resync) that isn't tied to one person.
{t("stats.introBefore")}
<b className="text-fg/80">{t("stats.introSystem")}</b>
{t("stats.introAfter")}
</p>
{q.isLoading ? (
<div className="text-muted py-8">Loading</div>
<div className="text-muted py-8">{t("stats.loading")}</div>
) : !data ? (
<div className="text-muted py-8">No data.</div>
<div className="text-muted py-8">{t("stats.noData")}</div>
) : (
<>
{/* Daily totals (instance-wide, Pacific days) */}
<div className="glass-card rounded-xl p-3 mb-4">
<div className="text-xs text-muted mb-2">
Daily total ({data.range_days}d · {grandTotal.toLocaleString()} units)
{t("stats.dailyTotal", {
count: data.range_days,
units: grandTotal.toLocaleString(),
})}
</div>
{data.daily.length === 0 ? (
<div className="text-muted text-sm">No usage in this range.</div>
<div className="text-muted text-sm">{t("stats.noUsageInRange")}</div>
) : (
<div className="flex items-end gap-0.5 h-24">
{data.daily.map((d) => (
@ -61,7 +67,10 @@ export default function Stats() {
key={d.day}
className="flex-1 bg-accent/70 hover:bg-accent rounded-t transition"
style={{ height: `${Math.max(2, (d.total / maxDaily) * 100)}%` }}
title={`${d.day}: ${d.total.toLocaleString()} units`}
title={t("stats.dailyTooltip", {
day: d.day,
units: d.total.toLocaleString(),
})}
/>
))}
</div>
@ -71,7 +80,7 @@ export default function Stats() {
{/* Per-user breakdown */}
<div className="flex flex-col gap-1.5">
{data.rows.length === 0 ? (
<div className="text-muted py-4">No usage in this range.</div>
<div className="text-muted py-4">{t("stats.noUsageInRange")}</div>
) : (
data.rows.map((r) => (
<UserRow key={r.user_id ?? "system"} row={r} max={data.rows[0]?.total || 1} />
@ -85,6 +94,7 @@ export default function Stats() {
}
function UserRow({ row, max }: { row: AdminQuotaRow; max: number }) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const actions = Object.entries(row.by_action).sort((a, b) => b[1] - a[1]);
const isSystem = row.user_id === null;
@ -95,7 +105,7 @@ function UserRow({ row, max }: { row: AdminQuotaRow; max: number }) {
className="w-full flex items-center gap-3 text-left"
>
<span className={`text-sm font-medium truncate flex-1 ${isSystem ? "text-muted" : ""}`}>
{isSystem ? "System (background)" : row.email}
{isSystem ? t("stats.system") : row.email}
</span>
<span className="text-sm font-semibold tabular-nums shrink-0">
{row.total.toLocaleString()}

View file

@ -1,4 +1,5 @@
import { useSyncExternalStore } from "react";
import { useTranslation } from "react-i18next";
import {
AlertCircle,
AlertTriangle,
@ -26,44 +27,45 @@ export const LEVEL_STYLE: Record<
};
export default function Toaster() {
const { t } = useTranslation();
const toasts = useSyncExternalStore(subscribe, getActiveToasts, getActiveToasts);
return (
<div className="fixed top-4 right-4 z-50 flex flex-col gap-2 w-80 max-w-[calc(100vw-2rem)]">
{toasts.map((t) => {
const { icon: Icon, color, bar } = LEVEL_STYLE[t.level];
{toasts.map((toast) => {
const { icon: Icon, color, bar } = LEVEL_STYLE[toast.level];
return (
<div
key={t.id}
key={toast.id}
className="glass relative overflow-hidden rounded-xl px-3 py-3 flex items-start gap-3 animate-[popIn_0.16s_ease]"
>
<Icon className={`w-5 h-5 shrink-0 mt-0.5 ${color}`} />
<div className="min-w-0 flex-1">
{t.title && <div className="text-sm font-semibold">{t.title}</div>}
<div className="text-sm break-words">{t.message}</div>
{t.action && (
{toast.title && <div className="text-sm font-semibold">{toast.title}</div>}
<div className="text-sm break-words">{toast.message}</div>
{toast.action && (
<button
onClick={() => {
t.action!.onClick();
dismiss(t.id);
toast.action!.onClick();
dismiss(toast.id);
}}
className="mt-1 text-accent text-sm font-semibold hover:underline"
>
{t.action.label}
{toast.action.label}
</button>
)}
</div>
<button
onClick={() => dismiss(t.id)}
onClick={() => dismiss(toast.id)}
className="shrink-0 text-muted hover:text-fg"
title="Dismiss"
title={t("notifications.dismiss")}
>
<X className="w-4 h-4" />
</button>
{t.duration && (
{toast.duration && (
<div
className={`absolute left-0 bottom-0 h-0.5 w-full origin-left ${bar}`}
style={{ animation: `toastbar ${t.duration}ms linear forwards` }}
style={{ animation: `toastbar ${toast.duration}ms linear forwards` }}
/>
)}
</div>

View file

@ -1,4 +1,5 @@
import { memo } from "react";
import { useTranslation } from "react-i18next";
import {
Bookmark,
Check,
@ -23,6 +24,7 @@ function Actions({
onState: (id: string, status: string) => void;
onChannelFilter?: (channelId: string, channelName: string) => void;
}) {
const { t } = useTranslation();
const act = (status: string) => (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
@ -32,7 +34,7 @@ function Actions({
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition">
<button
onClick={act("watched")}
title={video.status === "watched" ? "Watched — click to unmark" : "Mark watched"}
title={video.status === "watched" ? t("card.watchedUnmark") : t("card.markWatched")}
className={clsx(
"p-1.5 rounded-md hover:bg-surface text-muted hover:text-fg",
video.status === "watched" && "text-accent"
@ -46,7 +48,7 @@ function Actions({
</button>
<button
onClick={act("saved")}
title={video.status === "saved" ? "Saved — click to remove" : "Save for later"}
title={video.status === "saved" ? t("card.savedRemove") : t("card.saveForLater")}
className={clsx(
"p-1.5 rounded-md hover:bg-surface text-muted hover:text-fg",
video.status === "saved" && "fill-current text-accent"
@ -56,7 +58,7 @@ function Actions({
</button>
<button
onClick={act("hidden")}
title={video.status === "hidden" ? "Unhide" : "Hide"}
title={video.status === "hidden" ? t("card.unhide") : t("card.hide")}
className={clsx(
"p-1.5 rounded-md hover:bg-surface text-muted hover:text-fg",
video.status === "hidden" && "text-accent"
@ -73,9 +75,9 @@ function Actions({
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onChannelFilter(video.channel_id, video.channel_title ?? "This channel");
onChannelFilter(video.channel_id, video.channel_title ?? t("card.thisChannel"));
}}
title="Only this channel"
title={t("card.onlyThisChannel")}
className="p-1.5 rounded-md hover:bg-surface text-muted hover:text-fg"
>
<ListFilter className="w-4 h-4" />
@ -106,6 +108,7 @@ function Thumb({
className?: string;
onOpen?: (v: Video, startAt?: number | null) => void;
}) {
const { t } = useTranslation();
// A non-zero saved position on an unfinished video = "in progress": show a resume
// progress bar and offer Continue / Restart instead of a plain Play.
const inProgress = video.position_seconds > 0 && video.status !== "watched";
@ -149,7 +152,7 @@ function Thumb({
)}
{video.live_status === "was_live" && (
<span className="absolute top-1.5 left-1.5 bg-accent text-accent-fg text-[10px] font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded">
stream
{t("card.stream")}
</span>
)}
{video.status === "saved" && (
@ -164,25 +167,25 @@ function Thumb({
<>
<button
onClick={open(null)}
title="Continue where you left off"
title={t("card.continueTitle")}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-semibold bg-accent text-accent-fg shadow-lg hover:opacity-90 transition"
>
<Play className="w-4 h-4 fill-current" />
Continue
{t("card.continue")}
</button>
<button
onClick={open(0)}
title="Play from the beginning"
title={t("card.restartTitle")}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium bg-white/15 text-white backdrop-blur-sm hover:bg-white/25 transition"
>
<RotateCcw className="w-4 h-4" />
Restart
{t("card.restart")}
</button>
</>
) : (
<button
onClick={open(null)}
title="Play"
title={t("card.play")}
className="inline-flex items-center justify-center w-12 h-12 rounded-full bg-accent text-accent-fg shadow-lg hover:scale-105 transition"
>
<Play className="w-5 h-5 fill-current translate-x-[1px]" />
@ -213,10 +216,15 @@ function VideoCard({
onChannelFilter?: (channelId: string, channelName: string) => void;
onOpen?: (v: Video, startAt?: number | null) => void;
}) {
const { t } = useTranslation();
const watched = video.status === "watched";
const meta = (
<>
{video.view_count != null && <>{formatViews(video.view_count)} views · </>}
{video.view_count != null && (
<>
{formatViews(video.view_count)} {t("card.views")} ·{" "}
</>
)}
{relativeTime(video.published_at)}
</>
);