feat(i18n): translate remaining components (HU/EN/DE)
Feed, VideoCard, Sidebar, PlayerModal, Channels, Stats, SettingsPanel, OnboardingWizard, NotificationCenter, Toaster, ErrorBoundary and the relativeTime helper are now fully translated in Hungarian, English and German, each with its own locale area file (auto-loaded). Key parity verified across all three languages.
This commit is contained in:
parent
941fb7d756
commit
ea317c0009
45 changed files with 1522 additions and 355 deletions
|
|
@ -1,4 +1,5 @@
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { Trans, useTranslation } from "react-i18next";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
ArrowDown,
|
ArrowDown,
|
||||||
|
|
@ -20,11 +21,11 @@ import Avatar from "./Avatar";
|
||||||
|
|
||||||
export type ChannelStatusFilter = "all" | "needs_full" | "fully_synced" | "hidden";
|
export type ChannelStatusFilter = "all" | "needs_full" | "fully_synced" | "hidden";
|
||||||
|
|
||||||
const STATUS_FILTERS: { id: ChannelStatusFilter; label: string }[] = [
|
const STATUS_FILTERS: { id: ChannelStatusFilter; labelKey: string }[] = [
|
||||||
{ id: "all", label: "All" },
|
{ id: "all", labelKey: "channels.filters.all" },
|
||||||
{ id: "needs_full", label: "Needs full history" },
|
{ id: "needs_full", labelKey: "channels.filters.needsFull" },
|
||||||
{ id: "fully_synced", label: "Fully synced" },
|
{ id: "fully_synced", labelKey: "channels.filters.fullySynced" },
|
||||||
{ id: "hidden", label: "Hidden" },
|
{ id: "hidden", labelKey: "channels.filters.hidden" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function Channels({
|
export default function Channels({
|
||||||
|
|
@ -38,6 +39,7 @@ export default function Channels({
|
||||||
statusFilter: ChannelStatusFilter;
|
statusFilter: ChannelStatusFilter;
|
||||||
setStatusFilter: (f: ChannelStatusFilter) => void;
|
setStatusFilter: (f: ChannelStatusFilter) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const channelsQuery = useQuery({ queryKey: ["channels"], queryFn: api.channels });
|
const channelsQuery = useQuery({ queryKey: ["channels"], queryFn: api.channels });
|
||||||
const tagsQuery = useQuery({ queryKey: ["tags"], queryFn: api.tags });
|
const tagsQuery = useQuery({ queryKey: ["tags"], queryFn: api.tags });
|
||||||
|
|
@ -92,9 +94,9 @@ export default function Channels({
|
||||||
mutationFn: () => api.syncSubscriptions(),
|
mutationFn: () => api.syncSubscriptions(),
|
||||||
onSuccess: (r: { subscriptions?: number }) => {
|
onSuccess: (r: { subscriptions?: number }) => {
|
||||||
invalidate();
|
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({
|
const createTag = useMutation({
|
||||||
mutationFn: (name: string) => api.createTag({ name, category: "other" }),
|
mutationFn: (name: string) => api.createTag({ name, category: "other" }),
|
||||||
|
|
@ -112,9 +114,9 @@ export default function Channels({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: ["channels"] });
|
qc.invalidateQueries({ queryKey: ["channels"] });
|
||||||
qc.invalidateQueries({ queryKey: ["my-status"] });
|
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({
|
const deepAll = useMutation({
|
||||||
mutationFn: () => api.deepAll(true),
|
mutationFn: () => api.deepAll(true),
|
||||||
|
|
@ -123,10 +125,10 @@ export default function Channels({
|
||||||
qc.invalidateQueries({ queryKey: ["my-status"] });
|
qc.invalidateQueries({ queryKey: ["my-status"] });
|
||||||
notify({
|
notify({
|
||||||
level: "success",
|
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 ?? [])
|
const channels = (channelsQuery.data ?? [])
|
||||||
|
|
@ -147,29 +149,29 @@ export default function Channels({
|
||||||
{/* Per-user sync status */}
|
{/* Per-user sync status */}
|
||||||
{s && (
|
{s && (
|
||||||
<div className="flex flex-wrap items-center gap-x-5 gap-y-1 text-sm text-muted mb-4">
|
<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
|
<Stat
|
||||||
label="Recent synced"
|
label={t("channels.stats.recentSynced")}
|
||||||
value={`${s.channels_recent_synced}/${s.channels_total}`}
|
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
|
<Stat
|
||||||
label="Full history"
|
label={t("channels.stats.fullHistory")}
|
||||||
value={`${s.channels_deep_done}/${s.channels_deep_requested}`}
|
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 && (
|
{s.deep_pending_count > 0 && (
|
||||||
<Stat
|
<Stat
|
||||||
label="left"
|
label={t("channels.stats.left")}
|
||||||
value={formatEta(s.deep_eta_seconds)}
|
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
|
<Stat
|
||||||
label="Quota left"
|
label={t("channels.stats.quotaLeft")}
|
||||||
value={s.quota_remaining_today.toLocaleString()}
|
value={s.quota_remaining_today.toLocaleString()}
|
||||||
hint="Shared YouTube API budget left today (resets midnight US Pacific)."
|
hint={t("channels.stats.quotaLeftHint")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -181,13 +183,13 @@ export default function Channels({
|
||||||
<input
|
<input
|
||||||
value={q}
|
value={q}
|
||||||
onChange={(e) => setQ(e.target.value)}
|
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"
|
className="w-full bg-card border border-border rounded-full pl-9 pr-4 py-2 text-sm outline-none focus:border-accent"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
side="bottom"
|
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
|
<button
|
||||||
onClick={() => syncSubs.mutate()}
|
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"
|
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" : ""}`} />
|
<RefreshCw className={`w-4 h-4 ${syncSubs.isPending ? "animate-spin" : ""}`} />
|
||||||
Sync subscriptions
|
{t("channels.syncSubscriptions")}
|
||||||
</button>
|
</button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
side="bottom"
|
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
|
<button
|
||||||
onClick={() => deepAll.mutate()}
|
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"
|
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" : ""}`} />
|
<History className={`w-4 h-4 ${deepAll.isPending ? "animate-pulse" : ""}`} />
|
||||||
Backfill everything
|
{t("channels.backfillEverything")}
|
||||||
</button>
|
</button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -225,22 +227,27 @@ export default function Channels({
|
||||||
: "bg-card border-border text-muted hover:border-accent"
|
: "bg-card border-border text-muted hover:border-accent"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{f.label}
|
{t(f.labelKey)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-xs text-muted mb-4 leading-relaxed">
|
<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
|
<Trans
|
||||||
by “Channel priority”, attach your own <b className="text-fg/80">tags</b> to filter the feed,
|
i18nKey="channels.intro"
|
||||||
or <b className="text-fg/80">hide</b> a channel to drop it from the feed without unsubscribing.
|
components={[
|
||||||
|
<b className="text-fg/80" />,
|
||||||
|
<b className="text-fg/80" />,
|
||||||
|
<b className="text-fg/80" />,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Your tags */}
|
{/* Your tags */}
|
||||||
<div className="flex flex-wrap items-center gap-1.5 mb-4">
|
<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">
|
<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>
|
</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
{userTags.map((t) => (
|
{userTags.map((t) => (
|
||||||
|
|
@ -264,10 +271,10 @@ export default function Channels({
|
||||||
<input
|
<input
|
||||||
value={newTag}
|
value={newTag}
|
||||||
onChange={(e) => setNewTag(e.target.value)}
|
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"
|
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" />
|
<Plus className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
@ -275,9 +282,9 @@ export default function Channels({
|
||||||
|
|
||||||
{/* Channel list */}
|
{/* Channel list */}
|
||||||
{channelsQuery.isLoading ? (
|
{channelsQuery.isLoading ? (
|
||||||
<div className="text-muted py-8">Loading channels…</div>
|
<div className="text-muted py-8">{t("channels.loading")}</div>
|
||||||
) : channels.length === 0 ? (
|
) : 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">
|
<div className="flex flex-col gap-1.5">
|
||||||
{channels.map((c) => (
|
{channels.map((c) => (
|
||||||
|
|
@ -289,12 +296,12 @@ export default function Channels({
|
||||||
onUnsubscribe={() => {
|
onUnsubscribe={() => {
|
||||||
if (
|
if (
|
||||||
window.confirm(
|
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);
|
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)}
|
onPriority={(d) => bumpPriority(c.id, c.priority, d)}
|
||||||
onHide={() => patch.mutate({ id: c.id, body: { hidden: !c.hidden } })}
|
onHide={() => patch.mutate({ id: c.id, body: { hidden: !c.hidden } })}
|
||||||
onDeep={() =>
|
onDeep={() =>
|
||||||
|
|
@ -366,19 +373,20 @@ function ChannelRow({
|
||||||
onDeep: () => void;
|
onDeep: () => void;
|
||||||
onToggleTag: (tagId: number) => void;
|
onToggleTag: (tagId: number) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`glass-card glass-hover flex items-center gap-3 p-2.5 rounded-xl transition ${
|
className={`glass-card glass-hover flex items-center gap-3 p-2.5 rounded-xl transition ${
|
||||||
c.hidden ? "opacity-60" : ""
|
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">
|
<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" />
|
<ArrowUp className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
<span className="text-xs text-muted tabular-nums">{c.priority}</span>
|
<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" />
|
<ArrowDown className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -395,42 +403,42 @@ function ChannelRow({
|
||||||
{c.title ?? c.id}
|
{c.title ?? c.id}
|
||||||
</button>
|
</button>
|
||||||
<div className="flex items-center gap-2 text-[11px] text-muted">
|
<div className="flex items-center gap-2 text-[11px] text-muted">
|
||||||
<span>{c.stored_videos.toLocaleString()} stored</span>
|
<span>{t("channels.row.stored", { count: c.stored_videos, formatted: c.stored_videos.toLocaleString() })}</span>
|
||||||
{c.subscriber_count != null && <span>· {c.subscriber_count.toLocaleString()} subs</span>}
|
{c.subscriber_count != null && <span>· {t("channels.row.subs", { count: c.subscriber_count, formatted: c.subscriber_count.toLocaleString() })}</span>}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center gap-1 mt-1">
|
<div className="flex flex-wrap items-center gap-1 mt-1">
|
||||||
<SyncBadge
|
<SyncBadge
|
||||||
ok={c.recent_synced}
|
ok={c.recent_synced}
|
||||||
label="recent"
|
label={t("channels.row.recent")}
|
||||||
hint={c.recent_synced ? "Recent uploads synced." : "Recent uploads not fetched yet."}
|
hint={c.recent_synced ? t("channels.row.recentSyncedHint") : t("channels.row.recentNotSyncedHint")}
|
||||||
/>
|
/>
|
||||||
{c.backfill_done ? (
|
{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 ? (
|
) : 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
|
<button
|
||||||
onClick={onDeep}
|
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"
|
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" />
|
<History className="w-3 h-3" />
|
||||||
full history queued
|
{t("channels.row.fullHistoryQueued")}
|
||||||
</button>
|
</button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
) : c.deep_in_queue ? (
|
) : 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">
|
<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" />
|
<History className="w-3 h-3" />
|
||||||
full history queued
|
{t("channels.row.fullHistoryQueued")}
|
||||||
</span>
|
</span>
|
||||||
</Tooltip>
|
</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
|
<button
|
||||||
onClick={onDeep}
|
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"
|
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" />
|
<History className="w-3 h-3" />
|
||||||
get full history
|
{t("channels.row.getFullHistory")}
|
||||||
</button>
|
</button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
|
|
@ -454,23 +462,19 @@ function ChannelRow({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Tooltip
|
<Tooltip
|
||||||
hint={
|
hint={c.hidden ? t("channels.row.hiddenHint") : t("channels.row.hideHint")}
|
||||||
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."
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<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" />}
|
{c.hidden ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||||
</button>
|
</button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
{canWrite && (
|
{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
|
<button
|
||||||
onClick={onUnsubscribe}
|
onClick={onUnsubscribe}
|
||||||
className="text-muted hover:text-red-400 shrink-0"
|
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" />
|
<UserMinus className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { Component, type ReactNode } from "react";
|
import { Component, type ReactNode } from "react";
|
||||||
|
import i18n from "../i18n";
|
||||||
import { notify } from "../lib/notifications";
|
import { notify } from "../lib/notifications";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|
@ -20,8 +21,8 @@ export default class ErrorBoundary extends Component<Props, State> {
|
||||||
componentDidCatch(error: Error) {
|
componentDidCatch(error: Error) {
|
||||||
notify({
|
notify({
|
||||||
level: "fatal",
|
level: "fatal",
|
||||||
title: "Something broke",
|
title: i18n.t("errors.boundary.notifTitle"),
|
||||||
message: error.message || "Unexpected error",
|
message: error.message || i18n.t("errors.boundary.notifMessage"),
|
||||||
requiresInteraction: true,
|
requiresInteraction: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -31,13 +32,13 @@ export default class ErrorBoundary extends Component<Props, State> {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen grid place-items-center text-muted p-6 text-center">
|
<div className="min-h-screen grid place-items-center text-muted p-6 text-center">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-lg font-semibold text-fg mb-1">Something went wrong.</div>
|
<div className="text-lg font-semibold text-fg mb-1">{i18n.t("errors.boundary.title")}</div>
|
||||||
<div className="text-sm mb-3">The app hit an unexpected error.</div>
|
<div className="text-sm mb-3">{i18n.t("errors.boundary.subtitle")}</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => location.reload()}
|
onClick={() => location.reload()}
|
||||||
className="text-accent hover:underline text-sm font-semibold"
|
className="text-accent hover:underline text-sm font-semibold"
|
||||||
>
|
>
|
||||||
Reload
|
{i18n.t("errors.boundary.reload")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { useInfiniteQuery, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useInfiniteQuery, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { api, type FeedFilters, type Video } from "../lib/api";
|
import { api, type FeedFilters, type Video } from "../lib/api";
|
||||||
|
import i18n from "../i18n";
|
||||||
import { notify } from "../lib/notifications";
|
import { notify } from "../lib/notifications";
|
||||||
import VideoCard from "./VideoCard";
|
import VideoCard from "./VideoCard";
|
||||||
import PlayerModal from "./PlayerModal";
|
import PlayerModal from "./PlayerModal";
|
||||||
|
|
@ -38,6 +40,7 @@ export default function Feed({
|
||||||
canRead: boolean;
|
canRead: boolean;
|
||||||
onOpenWizard: () => void;
|
onOpenWizard: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [overrides, setOverrides] = useState<Record<string, string>>({});
|
const [overrides, setOverrides] = useState<Record<string, string>>({});
|
||||||
// The open player: which video and where to start (null = resume from saved position).
|
// The open player: which video and where to start (null = resume from saved position).
|
||||||
const [activeVideo, setActiveVideo] = useState<{ video: Video; startAt: number | null } | null>(
|
const [activeVideo, setActiveVideo] = useState<{ video: Video; startAt: number | null } | null>(
|
||||||
|
|
@ -103,8 +106,10 @@ export default function Feed({
|
||||||
if (status === "hidden") {
|
if (status === "hidden") {
|
||||||
const v = loadedRef.current.find((x) => x.id === id);
|
const v = loadedRef.current.find((x) => x.id === id);
|
||||||
notify({
|
notify({
|
||||||
message: v?.title ? `Hidden “${v.title}”` : "Video hidden",
|
message: v?.title
|
||||||
action: { label: "Undo", onClick: () => onState(id, "new") },
|
? i18n.t("feed.hiddenNamed", { title: v.title })
|
||||||
|
: i18n.t("feed.hidden"),
|
||||||
|
action: { label: i18n.t("feed.undo"), onClick: () => onState(id, "new") },
|
||||||
meta: {
|
meta: {
|
||||||
kind: "video-hidden",
|
kind: "video-hidden",
|
||||||
videoId: id,
|
videoId: id,
|
||||||
|
|
@ -116,8 +121,10 @@ export default function Feed({
|
||||||
} else if (status === "watched") {
|
} else if (status === "watched") {
|
||||||
const v = loadedRef.current.find((x) => x.id === id);
|
const v = loadedRef.current.find((x) => x.id === id);
|
||||||
notify({
|
notify({
|
||||||
message: v?.title ? `Marked watched “${v.title}”` : "Marked watched",
|
message: v?.title
|
||||||
action: { label: "Unwatch", onClick: () => onState(id, "new") },
|
? 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" },
|
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))
|
.map((v) => (overrides[v.id] ? { ...v, status: overrides[v.id] } : v))
|
||||||
.filter((v) => matchesView(v.status, filters.show));
|
.filter((v) => matchesView(v.status, filters.show));
|
||||||
|
|
||||||
if (query.isLoading) return <div className="p-8 text-muted">Loading 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">Couldn't load the feed.</div>;
|
if (query.isError) return <div className="p-8 text-muted">{t("feed.loadError")}</div>;
|
||||||
if (items.length === 0) {
|
if (items.length === 0) {
|
||||||
if (!canRead)
|
if (!canRead)
|
||||||
return (
|
return (
|
||||||
<div className="p-8 grid place-items-center text-center">
|
<div className="p-8 grid place-items-center text-center">
|
||||||
<div className="max-w-sm">
|
<div className="max-w-sm">
|
||||||
<h2 className="text-lg font-semibold">Your feed is empty</h2>
|
<h2 className="text-lg font-semibold">{t("feed.emptyTitle")}</h2>
|
||||||
<p className="text-sm text-muted mt-2 mb-4">
|
<p className="text-sm text-muted mt-2 mb-4">{t("feed.emptyBody")}</p>
|
||||||
Connect your YouTube account to import your subscriptions and build your feed.
|
|
||||||
</p>
|
|
||||||
<button
|
<button
|
||||||
onClick={onOpenWizard}
|
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"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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 (
|
return (
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<div className="pb-3 text-sm text-muted">
|
<div className="pb-3 text-sm text-muted">
|
||||||
{countQuery.data
|
{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>
|
</div>
|
||||||
{view === "grid" ? (
|
{view === "grid" ? (
|
||||||
|
|
@ -205,7 +213,7 @@ export default function Feed({
|
||||||
)}
|
)}
|
||||||
<div ref={sentinel} className="h-10" />
|
<div ref={sentinel} className="h-10" />
|
||||||
{isFetchingNextPage && (
|
{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>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
|
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import type { TFunction } from "i18next";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { Bell, Eye, RotateCcw, Search, Trash2 } from "lucide-react";
|
import { Bell, Eye, RotateCcw, Search, Trash2 } from "lucide-react";
|
||||||
import { api, type FeedFilters } from "../lib/api";
|
import { api, type FeedFilters } from "../lib/api";
|
||||||
|
|
@ -15,15 +17,15 @@ import {
|
||||||
} from "../lib/notifications";
|
} from "../lib/notifications";
|
||||||
import { LEVEL_STYLE } from "./Toaster";
|
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);
|
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);
|
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);
|
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);
|
const d = Math.round(h / 24);
|
||||||
return `${d}d ago`;
|
return t("notifications.time.days", { count: d });
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function NotificationCenter({
|
export default function NotificationCenter({
|
||||||
|
|
@ -33,6 +35,7 @@ export default function NotificationCenter({
|
||||||
filters: FeedFilters;
|
filters: FeedFilters;
|
||||||
setFilters: (f: FeedFilters) => void;
|
setFilters: (f: FeedFilters) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const notifications = useSyncExternalStore(subscribe, getNotifications, getNotifications);
|
const notifications = useSyncExternalStore(subscribe, getNotifications, getNotifications);
|
||||||
const unread = useSyncExternalStore(subscribe, getUnreadCount, getUnreadCount);
|
const unread = useSyncExternalStore(subscribe, getUnreadCount, getUnreadCount);
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
@ -64,13 +67,16 @@ export default function NotificationCenter({
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
function revertState(meta: VideoHiddenMeta | VideoWatchedMeta, verb: string) {
|
function revertState(
|
||||||
|
meta: VideoHiddenMeta | VideoWatchedMeta,
|
||||||
|
messageKey: "notifications.unhidden" | "notifications.unwatched"
|
||||||
|
) {
|
||||||
api
|
api
|
||||||
.setState(meta.videoId, "new")
|
.setState(meta.videoId, "new")
|
||||||
.then(() => {
|
.then(() => {
|
||||||
qc.invalidateQueries({ queryKey: ["feed"] });
|
qc.invalidateQueries({ queryKey: ["feed"] });
|
||||||
qc.invalidateQueries({ queryKey: ["feed-count"] });
|
qc.invalidateQueries({ queryKey: ["feed-count"] });
|
||||||
notify({ level: "success", message: `${verb} “${meta.title}”` });
|
notify({ level: "success", message: t(messageKey, { title: meta.title }) });
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
@ -79,7 +85,7 @@ export default function NotificationCenter({
|
||||||
<div className="relative" ref={wrap}>
|
<div className="relative" ref={wrap}>
|
||||||
<button
|
<button
|
||||||
onClick={() => setOpen((o) => !o)}
|
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"
|
className="relative p-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"
|
||||||
>
|
>
|
||||||
<Bell className="w-5 h-5" />
|
<Bell className="w-5 h-5" />
|
||||||
|
|
@ -93,22 +99,22 @@ export default function NotificationCenter({
|
||||||
{open && (
|
{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="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="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 && (
|
{notifications.length > 0 && (
|
||||||
<button
|
<button
|
||||||
onClick={clearAll}
|
onClick={clearAll}
|
||||||
className="flex items-center gap-1 text-xs text-muted hover:text-accent transition"
|
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" />
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
Clear
|
{t("notifications.clear")}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="overflow-y-auto">
|
<div className="overflow-y-auto">
|
||||||
{notifications.length === 0 ? (
|
{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) => {
|
notifications.map((n) => {
|
||||||
const { icon: Icon, color } = LEVEL_STYLE[n.level];
|
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>}
|
{n.title && <div className="text-sm font-semibold">{n.title}</div>}
|
||||||
<div className="text-sm break-words">{n.message}</div>
|
<div className="text-sm break-words">{n.message}</div>
|
||||||
<div className="flex items-center gap-2 mt-0.5">
|
<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 && (
|
{needsAction && (
|
||||||
<span className="text-[10px] uppercase tracking-wide font-semibold text-accent">
|
<span className="text-[10px] uppercase tracking-wide font-semibold text-accent">
|
||||||
Action needed
|
{t("notifications.actionNeeded")}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -142,24 +148,24 @@ export default function NotificationCenter({
|
||||||
className="flex items-center gap-1 text-accent text-sm font-semibold hover:underline"
|
className="flex items-center gap-1 text-accent text-sm font-semibold hover:underline"
|
||||||
>
|
>
|
||||||
<Search className="w-3.5 h-3.5" />
|
<Search className="w-3.5 h-3.5" />
|
||||||
Find in feed
|
{t("notifications.findInFeed")}
|
||||||
</button>
|
</button>
|
||||||
<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"
|
className="flex items-center gap-1 text-accent text-sm font-semibold hover:underline"
|
||||||
>
|
>
|
||||||
<Eye className="w-3.5 h-3.5" />
|
<Eye className="w-3.5 h-3.5" />
|
||||||
Unhide
|
{t("notifications.unhide")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : watched ? (
|
) : watched ? (
|
||||||
<div className="flex items-center gap-3 mt-1">
|
<div className="flex items-center gap-3 mt-1">
|
||||||
<button
|
<button
|
||||||
onClick={() => revertState(watched, "Unwatched")}
|
onClick={() => revertState(watched, "notifications.unwatched")}
|
||||||
className="flex items-center gap-1 text-accent text-sm font-semibold hover:underline"
|
className="flex items-center gap-1 text-accent text-sm font-semibold hover:underline"
|
||||||
>
|
>
|
||||||
<RotateCcw className="w-3.5 h-3.5" />
|
<RotateCcw className="w-3.5 h-3.5" />
|
||||||
Unwatch
|
{t("notifications.unwatch")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
|
import { Trans, useTranslation } from "react-i18next";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { AlertTriangle, Check, Loader2, RefreshCw, ShieldCheck, X, Youtube } from "lucide-react";
|
import { AlertTriangle, Check, Loader2, RefreshCw, ShieldCheck, X, Youtube } from "lucide-react";
|
||||||
import { api, type Me } from "../lib/api";
|
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">
|
<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" />
|
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5 text-amber-400" />
|
||||||
<p className="text-xs leading-relaxed text-fg/80">
|
<p className="text-xs leading-relaxed text-fg/80">
|
||||||
Google will show a <span className="font-medium">"Google hasn't verified this app"</span>{" "}
|
<Trans
|
||||||
screen — that's expected, because Siftlode hasn't gone through Google's full review.
|
i18nKey="onboarding.consent"
|
||||||
It's safe to continue: click <span className="font-medium">Advanced</span> →{" "}
|
components={[
|
||||||
<span className="font-medium">Go to Siftlode</span>. You can revoke access anytime from
|
<span className="font-medium" />,
|
||||||
your{" "}
|
<span className="font-medium" />,
|
||||||
<a
|
<span className="font-medium" />,
|
||||||
href="https://myaccount.google.com/permissions"
|
<a
|
||||||
target="_blank"
|
href="https://myaccount.google.com/permissions"
|
||||||
rel="noreferrer"
|
target="_blank"
|
||||||
className="text-accent hover:underline"
|
rel="noreferrer"
|
||||||
>
|
className="text-accent hover:underline"
|
||||||
Google Account
|
/>,
|
||||||
</a>
|
]}
|
||||||
.
|
/>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -55,6 +56,7 @@ function Dots({ index, total }: { index: number; total: number }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: () => void }) {
|
export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: () => void }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const importTriggered = useRef(false);
|
const importTriggered = useRef(false);
|
||||||
|
|
||||||
|
|
@ -126,7 +128,7 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: ()
|
||||||
<button
|
<button
|
||||||
onClick={close}
|
onClick={close}
|
||||||
className="absolute top-3 right-3 p-2 rounded-lg text-muted hover:text-fg hover:bg-card/60 transition"
|
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" />
|
<X className="w-4 h-4" />
|
||||||
</button>
|
</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">
|
<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" />
|
<Youtube className="w-6 h-6" />
|
||||||
</div>
|
</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">
|
<p className="text-sm text-muted leading-relaxed mt-2 mb-4">
|
||||||
You're signed in. To build your feed, Siftlode needs{" "}
|
<Trans
|
||||||
<span className="text-fg/90">read-only</span> access to the channels you're
|
i18nKey="onboarding.read.body"
|
||||||
subscribed to on YouTube. It never posts, deletes, or changes anything with this.
|
components={[<span className="text-fg/90" />]}
|
||||||
|
/>
|
||||||
</p>
|
</p>
|
||||||
<ConsentHeadsUp />
|
<ConsentHeadsUp />
|
||||||
<div className="mt-5 flex flex-col gap-2">
|
<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")}
|
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"
|
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>
|
||||||
<button
|
<button
|
||||||
onClick={close}
|
onClick={close}
|
||||||
className="px-5 py-2 rounded-xl text-sm text-muted hover:text-fg transition"
|
className="px-5 py-2 rounded-xl text-sm text-muted hover:text-fg transition"
|
||||||
>
|
>
|
||||||
Skip for now
|
{t("onboarding.read.skip")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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">
|
<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" />
|
<Loader2 className="w-6 h-6 animate-spin" />
|
||||||
</div>
|
</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">
|
<p className="text-sm text-muted leading-relaxed mt-2 mb-2">
|
||||||
Importing your YouTube subscriptions. Channels already in Siftlode show up
|
{t("onboarding.importing.body")}
|
||||||
instantly; any new ones fill in automatically in the background.
|
|
||||||
</p>
|
</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">
|
<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" />
|
<Check className="w-6 h-6" />
|
||||||
</div>
|
</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">
|
<p className="text-sm text-muted leading-relaxed mt-2 mb-4">
|
||||||
{importSubs.isError ? (
|
{importSubs.isError ? (
|
||||||
<>Your YouTube account is connected, but importing subscriptions failed — you
|
<>{t("onboarding.write.importFailed")}</>
|
||||||
can retry from Settings → Sync. </>
|
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
Your feed is ready
|
{t("onboarding.write.feedReady", {
|
||||||
{myStatus.data ? ` (${myStatus.data.channels_total} channels)` : ""}. Optionally,
|
channels: myStatus.data
|
||||||
let Siftlode{" "}
|
? t("onboarding.write.feedReadyChannels", { count: myStatus.data.channels_total })
|
||||||
|
: "",
|
||||||
|
})}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{!importSubs.isError && (
|
{!importSubs.isError && (
|
||||||
<>
|
<Trans
|
||||||
<span className="text-fg/90">unsubscribe</span> from channels on YouTube for you
|
i18nKey="onboarding.write.rationale"
|
||||||
— this needs an extra write permission. Skip it to stay read-only; you can
|
components={[<span className="text-fg/90" />]}
|
||||||
always enable it later in Settings.
|
/>
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
{importSubs.isError && (
|
{importSubs.isError && (
|
||||||
|
|
@ -204,7 +206,7 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: ()
|
||||||
onClick={() => importSubs.mutate()}
|
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"
|
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>
|
</button>
|
||||||
)}
|
)}
|
||||||
<ConsentHeadsUp />
|
<ConsentHeadsUp />
|
||||||
|
|
@ -213,7 +215,7 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: ()
|
||||||
onClick={() => beginGrant("write")}
|
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"
|
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>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
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"
|
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>
|
</button>
|
||||||
</div>
|
</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">
|
<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" />
|
<Check className="w-6 h-6" />
|
||||||
</div>
|
</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">
|
<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
|
{t("onboarding.done.body")}
|
||||||
anytime in Settings → Account, or from your Google Account.
|
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
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"
|
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>
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import type { TFunction } from "i18next";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { ArrowLeft, Check, CheckCheck, X } from "lucide-react";
|
import { ArrowLeft, Check, CheckCheck, X } from "lucide-react";
|
||||||
|
|
@ -65,8 +67,10 @@ function renderDescription(
|
||||||
currentId: string;
|
currentId: string;
|
||||||
onSeek: (seconds: number) => void;
|
onSeek: (seconds: number) => void;
|
||||||
onLoadVideo: (id: string, start: number | null) => void;
|
onLoadVideo: (id: string, start: number | null) => void;
|
||||||
|
t: TFunction;
|
||||||
}
|
}
|
||||||
): ReactNode[] {
|
): ReactNode[] {
|
||||||
|
const { t } = opts;
|
||||||
const out: ReactNode[] = [];
|
const out: ReactNode[] = [];
|
||||||
let key = 0;
|
let key = 0;
|
||||||
const linkCls = "text-accent hover:underline break-all";
|
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)
|
sameVideo ? opts.onSeek(yt.start ?? 0) : opts.onLoadVideo(yt.id, yt.start)
|
||||||
}
|
}
|
||||||
className={linkCls}
|
className={linkCls}
|
||||||
title={sameVideo ? "Jump to this time" : "Play in the in-app player"}
|
title={sameVideo ? t("player.jumpToTime") : t("player.playInApp")}
|
||||||
>
|
>
|
||||||
{href}
|
{href}
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -189,6 +193,7 @@ export default function PlayerModal({
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onState: (id: string, status: string) => void;
|
onState: (id: string, status: string) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
// Resume point for the video we opened with.
|
// Resume point for the video we opened with.
|
||||||
const resumeAt = startAt != null ? startAt : video.position_seconds || 0;
|
const resumeAt = startAt != null ? startAt : video.position_seconds || 0;
|
||||||
|
|
@ -395,17 +400,17 @@ export default function PlayerModal({
|
||||||
onMouseEnter={openDesc}
|
onMouseEnter={openDesc}
|
||||||
onMouseLeave={scheduleCloseDesc}
|
onMouseLeave={scheduleCloseDesc}
|
||||||
>
|
>
|
||||||
{navigated ? liveData?.title ?? "Loading…" : video.title}
|
{navigated ? liveData?.title ?? t("player.loading") : video.title}
|
||||||
</span>
|
</span>
|
||||||
</h2>
|
</h2>
|
||||||
{navigated && (
|
{navigated && (
|
||||||
<button
|
<button
|
||||||
onClick={() => loadVideo(video.id, null)}
|
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"
|
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" />
|
<ArrowLeft className="w-4 h-4" />
|
||||||
Back
|
{t("player.back")}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{showDesc &&
|
{showDesc &&
|
||||||
|
|
@ -422,30 +427,31 @@ export default function PlayerModal({
|
||||||
onMouseLeave={scheduleCloseDesc}
|
onMouseLeave={scheduleCloseDesc}
|
||||||
>
|
>
|
||||||
<div className="text-xs uppercase tracking-wide text-muted mb-2">
|
<div className="text-xs uppercase tracking-wide text-muted mb-2">
|
||||||
Description
|
{t("player.description")}
|
||||||
</div>
|
</div>
|
||||||
{detail.isLoading ? (
|
{detail.isLoading ? (
|
||||||
<div className="text-sm text-muted">Loading…</div>
|
<div className="text-sm text-muted">{t("player.loading")}</div>
|
||||||
) : detail.data?.description ? (
|
) : detail.data?.description ? (
|
||||||
<div className="text-sm whitespace-pre-wrap break-words max-h-64 overflow-y-auto leading-relaxed">
|
<div className="text-sm whitespace-pre-wrap break-words max-h-64 overflow-y-auto leading-relaxed">
|
||||||
{renderDescription(detail.data.description, {
|
{renderDescription(detail.data.description, {
|
||||||
currentId: currentVideoId,
|
currentId: currentVideoId,
|
||||||
onSeek: seekTo,
|
onSeek: seekTo,
|
||||||
onLoadVideo: loadVideo,
|
onLoadVideo: loadVideo,
|
||||||
|
t,
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-sm text-muted">No description.</div>
|
<div className="text-sm text-muted">{t("player.noDescription")}</div>
|
||||||
)}
|
)}
|
||||||
</div>,
|
</div>,
|
||||||
document.body
|
document.body
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
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"
|
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" />
|
<X className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -469,7 +475,7 @@ export default function PlayerModal({
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
className="font-medium hover:text-accent shrink-0"
|
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>
|
</a>
|
||||||
) : (
|
) : (
|
||||||
<span className="font-medium shrink-0">{liveData?.author ?? ""}</span>
|
<span className="font-medium shrink-0">{liveData?.author ?? ""}</span>
|
||||||
|
|
@ -486,14 +492,16 @@ export default function PlayerModal({
|
||||||
)}
|
)}
|
||||||
{!navigated ? (
|
{!navigated ? (
|
||||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-sm text-muted min-w-0">
|
<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>
|
<span>· {relativeTime(video.published_at)}</span>
|
||||||
{video.duration_seconds != null && (
|
{video.duration_seconds != null && (
|
||||||
<span>· {formatDuration(video.duration_seconds)}</span>
|
<span>· {formatDuration(video.duration_seconds)}</span>
|
||||||
)}
|
)}
|
||||||
{video.live_status === "was_live" && (
|
{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">
|
<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>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -501,7 +509,7 @@ export default function PlayerModal({
|
||||||
// Linked video's stats come from the (already-fetched) video detail.
|
// 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">
|
<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 && (
|
{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?.published_at && <span>· {relativeTime(detail.data.published_at)}</span>}
|
||||||
{detail.data?.duration_seconds != null && (
|
{detail.data?.duration_seconds != null && (
|
||||||
|
|
@ -513,7 +521,7 @@ export default function PlayerModal({
|
||||||
{!navigated && (
|
{!navigated && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setWatched(!watched)}
|
onClick={() => setWatched(!watched)}
|
||||||
title={watched ? "Watched — click to unmark" : "Mark watched"}
|
title={watched ? t("player.watchedUnmark") : t("player.markWatched")}
|
||||||
className={
|
className={
|
||||||
"ml-auto shrink-0 inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg transition " +
|
"ml-auto shrink-0 inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg transition " +
|
||||||
(watched
|
(watched
|
||||||
|
|
@ -522,7 +530,7 @@ export default function PlayerModal({
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{watched ? <CheckCheck className="w-4 h-4" /> : <Check className="w-4 h-4" />}
|
{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>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
|
import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { Bell, Check, History, Monitor, Pause, Play, RefreshCw, User, UserPlus, X } from "lucide-react";
|
import { Bell, Check, History, Monitor, Pause, Play, RefreshCw, User, UserPlus, X } from "lucide-react";
|
||||||
import { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme";
|
import { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme";
|
||||||
|
|
@ -15,11 +16,11 @@ import { hintsEnabled, setHintsEnabled, subscribeHints } from "../lib/hints";
|
||||||
import Tooltip from "./Tooltip";
|
import Tooltip from "./Tooltip";
|
||||||
|
|
||||||
type TabId = "appearance" | "notifications" | "sync" | "account";
|
type TabId = "appearance" | "notifications" | "sync" | "account";
|
||||||
const TABS: { id: TabId; label: string; icon: typeof Monitor }[] = [
|
const TABS: { id: TabId; icon: typeof Monitor }[] = [
|
||||||
{ id: "appearance", label: "Appearance", icon: Monitor },
|
{ id: "appearance", icon: Monitor },
|
||||||
{ id: "notifications", label: "Notifications", icon: Bell },
|
{ id: "notifications", icon: Bell },
|
||||||
{ id: "sync", label: "Sync", icon: RefreshCw },
|
{ id: "sync", icon: RefreshCw },
|
||||||
{ id: "account", label: "Account", icon: User },
|
{ id: "account", icon: User },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function SettingsPanel({
|
export default function SettingsPanel({
|
||||||
|
|
@ -39,6 +40,7 @@ export default function SettingsPanel({
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onOpenWizard: () => void;
|
onOpenWizard: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [tab, setTab] = useState<TabId>("appearance");
|
const [tab, setTab] = useState<TabId>("appearance");
|
||||||
const [closing, setClosing] = useState(false);
|
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="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
|
<button
|
||||||
onClick={close}
|
onClick={close}
|
||||||
className="p-2 rounded-lg text-muted hover:text-fg hover:bg-card/60 transition"
|
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">
|
<div className="flex flex-1 min-h-0">
|
||||||
{/* Vertical tab rail — never wraps; active is a clear accent pill. */}
|
{/* 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">
|
<nav className="w-32 sm:w-36 shrink-0 border-r border-border/60 p-2 flex flex-col gap-1">
|
||||||
{TABS.map((t) => {
|
{TABS.map((tabItem) => {
|
||||||
const active = tab === t.id;
|
const active = tab === tabItem.id;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={t.id}
|
key={tabItem.id}
|
||||||
onClick={() => setTab(t.id)}
|
onClick={() => setTab(tabItem.id)}
|
||||||
className={`flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-left transition ${
|
className={`flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-left transition ${
|
||||||
active
|
active
|
||||||
? "bg-accent text-accent-fg font-medium shadow-md shadow-accent/20"
|
? "bg-accent text-accent-fg font-medium shadow-md shadow-accent/20"
|
||||||
: "text-muted hover:text-fg hover:bg-card/60"
|
: "text-muted hover:text-fg hover:bg-card/60"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<t.icon className="w-4 h-4 shrink-0" />
|
<tabItem.icon className="w-4 h-4 shrink-0" />
|
||||||
{t.label}
|
{t(`settings.tabs.${tabItem.id}`)}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
@ -105,19 +107,19 @@ export default function SettingsPanel({
|
||||||
{/* All tabs stacked in one grid cell so the panel sizes to the tallest tab
|
{/* 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. */}
|
(stable height, no jump on switch); the active one is shown on top. */}
|
||||||
<div className="grid flex-1 overflow-y-auto">
|
<div className="grid flex-1 overflow-y-auto">
|
||||||
{TABS.map((t) => (
|
{TABS.map((tabItem) => (
|
||||||
<div
|
<div
|
||||||
key={t.id}
|
key={tabItem.id}
|
||||||
className={`[grid-area:1/1] p-4 ${
|
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} />
|
<Appearance theme={theme} setTheme={setTheme} view={view} setView={setView} />
|
||||||
)}
|
)}
|
||||||
{t.id === "notifications" && <Notifications />}
|
{tabItem.id === "notifications" && <Notifications />}
|
||||||
{t.id === "sync" && <Sync me={me} />}
|
{tabItem.id === "sync" && <Sync me={me} />}
|
||||||
{t.id === "account" && <Account me={me} onOpenWizard={onOpenWizard} />}
|
{tabItem.id === "account" && <Account me={me} onOpenWizard={onOpenWizard} />}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -190,6 +192,7 @@ function Appearance({
|
||||||
view: "grid" | "list";
|
view: "grid" | "list";
|
||||||
setView: (v: "grid" | "list") => void;
|
setView: (v: "grid" | "list") => void;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [perf, setPerf] = useState(() => localStorage.getItem(PERF_KEY) === "1");
|
const [perf, setPerf] = useState(() => localStorage.getItem(PERF_KEY) === "1");
|
||||||
const hints = useSyncExternalStore(subscribeHints, hintsEnabled, hintsEnabled);
|
const hints = useSyncExternalStore(subscribeHints, hintsEnabled, hintsEnabled);
|
||||||
|
|
||||||
|
|
@ -208,7 +211,7 @@ function Appearance({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Section title="Color scheme">
|
<Section title={t("settings.appearance.colorScheme")}>
|
||||||
<div className="grid grid-cols-4 gap-2">
|
<div className="grid grid-cols-4 gap-2">
|
||||||
{SCHEMES.map((s) => (
|
{SCHEMES.map((s) => (
|
||||||
<Tooltip key={s.id} hint={s.name}>
|
<Tooltip key={s.id} hint={s.name}>
|
||||||
|
|
@ -224,31 +227,31 @@ function Appearance({
|
||||||
</div>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
<Section title="Display">
|
<Section title={t("settings.appearance.display")}>
|
||||||
<Row label="Dark mode">
|
<Row label={t("settings.appearance.darkMode")}>
|
||||||
<Switch
|
<Switch
|
||||||
checked={theme.mode === "dark"}
|
checked={theme.mode === "dark"}
|
||||||
onChange={(v) => setTheme({ ...theme, mode: v ? "dark" : "light" })}
|
onChange={(v) => setTheme({ ...theme, mode: v ? "dark" : "light" })}
|
||||||
/>
|
/>
|
||||||
</Row>
|
</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")} />
|
<Switch checked={view === "list"} onChange={(v) => setView(v ? "list" : "grid")} />
|
||||||
</Row>
|
</Row>
|
||||||
<Row
|
<Row
|
||||||
label="Performance mode"
|
label={t("settings.appearance.performanceMode")}
|
||||||
hint="Turns off the translucent glass blur and soft shadows for snappier rendering on slower machines."
|
hint={t("settings.appearance.performanceModeHint")}
|
||||||
>
|
>
|
||||||
<Switch checked={perf} onChange={togglePerf} />
|
<Switch checked={perf} onChange={togglePerf} />
|
||||||
</Row>
|
</Row>
|
||||||
<Row
|
<Row
|
||||||
label="Show hints"
|
label={t("settings.appearance.showHints")}
|
||||||
hint="These little hover explanations. Turn them off once you know your way around."
|
hint={t("settings.appearance.showHintsHint")}
|
||||||
>
|
>
|
||||||
<Switch checked={hints} onChange={toggleHints} />
|
<Switch checked={hints} onChange={toggleHints} />
|
||||||
</Row>
|
</Row>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
<Section title="Text size">
|
<Section title={t("settings.appearance.textSize")}>
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
min={0.9}
|
min={0.9}
|
||||||
|
|
@ -265,6 +268,7 @@ function Appearance({
|
||||||
}
|
}
|
||||||
|
|
||||||
function Notifications() {
|
function Notifications() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [cfg, setCfg] = useState<NotifSettings>(() => getNotifSettings());
|
const [cfg, setCfg] = useState<NotifSettings>(() => getNotifSettings());
|
||||||
function update(p: Partial<NotifSettings>) {
|
function update(p: Partial<NotifSettings>) {
|
||||||
const next = { ...cfg, ...p };
|
const next = { ...cfg, ...p };
|
||||||
|
|
@ -275,23 +279,23 @@ function Notifications() {
|
||||||
const autoOn = cfg.durationMs > 0;
|
const autoOn = cfg.durationMs > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Section title="Notifications">
|
<Section title={t("settings.notifications.title")}>
|
||||||
<Row
|
<Row
|
||||||
label="Sound on alerts"
|
label={t("settings.notifications.sound")}
|
||||||
hint="Play a short tone when a notification needs your attention (e.g. errors, prompts)."
|
hint={t("settings.notifications.soundHint")}
|
||||||
>
|
>
|
||||||
<Switch checked={cfg.sound} onChange={(v) => update({ sound: v })} />
|
<Switch checked={cfg.sound} onChange={(v) => update({ sound: v })} />
|
||||||
</Row>
|
</Row>
|
||||||
<Row
|
<Row
|
||||||
label="Auto-dismiss toasts"
|
label={t("settings.notifications.autoDismiss")}
|
||||||
hint="When off, pop-up toasts stay until you close them. When on, they fade after the set time."
|
hint={t("settings.notifications.autoDismissHint")}
|
||||||
>
|
>
|
||||||
<Switch checked={autoOn} onChange={(v) => update({ durationMs: v ? 6000 : 0 })} />
|
<Switch checked={autoOn} onChange={(v) => update({ durationMs: v ? 6000 : 0 })} />
|
||||||
</Row>
|
</Row>
|
||||||
{autoOn && (
|
{autoOn && (
|
||||||
<div className="py-1.5 text-sm">
|
<div className="py-1.5 text-sm">
|
||||||
<div className="flex items-center justify-between">
|
<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>
|
<span className="text-muted text-xs">{(cfg.durationMs / 1000).toFixed(0)}s</span>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
|
|
@ -309,20 +313,21 @@ function Notifications() {
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
notify({
|
notify({
|
||||||
level: "info",
|
level: "info",
|
||||||
title: "Test notification",
|
title: t("settings.notifications.testTitle"),
|
||||||
message: "This is what a notification looks like.",
|
message: t("settings.notifications.testMessage"),
|
||||||
sound: true,
|
sound: true,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
className="mt-2 text-sm text-accent hover:underline"
|
className="mt-2 text-sm text-accent hover:underline"
|
||||||
>
|
>
|
||||||
Send a test notification
|
{t("settings.notifications.sendTest")}
|
||||||
</button>
|
</button>
|
||||||
</Section>
|
</Section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Sync({ me }: { me: Me }) {
|
function Sync({ me }: { me: Me }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const status = useQuery({ queryKey: ["my-status"], queryFn: api.myStatus });
|
const status = useQuery({ queryKey: ["my-status"], queryFn: api.myStatus });
|
||||||
const usage = useQuery({ queryKey: ["my-usage"], queryFn: api.myUsage });
|
const usage = useQuery({ queryKey: ["my-usage"], queryFn: api.myUsage });
|
||||||
|
|
@ -332,9 +337,12 @@ function Sync({ me }: { me: Me }) {
|
||||||
onSuccess: (r: { subscriptions?: number }) => {
|
onSuccess: (r: { subscriptions?: number }) => {
|
||||||
qc.invalidateQueries({ queryKey: ["my-status"] });
|
qc.invalidateQueries({ queryKey: ["my-status"] });
|
||||||
qc.invalidateQueries({ queryKey: ["channels"] });
|
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({
|
const pauseResume = useMutation({
|
||||||
mutationFn: (paused: boolean) => (paused ? api.resumeSync() : api.pauseSync()),
|
mutationFn: (paused: boolean) => (paused ? api.resumeSync() : api.pauseSync()),
|
||||||
|
|
@ -347,69 +355,69 @@ function Sync({ me }: { me: Me }) {
|
||||||
qc.invalidateQueries({ queryKey: ["channels"] });
|
qc.invalidateQueries({ queryKey: ["channels"] });
|
||||||
notify({
|
notify({
|
||||||
level: "success",
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Section title="My sync status">
|
<Section title={t("settings.sync.myStatus")}>
|
||||||
{s ? (
|
{s ? (
|
||||||
<div className="space-y-1.5 text-sm">
|
<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}
|
{s.channels_total}
|
||||||
</Row>
|
</Row>
|
||||||
<Row
|
<Row
|
||||||
label="Recent synced"
|
label={t("settings.sync.recentSynced")}
|
||||||
hint="Channels whose recent uploads (~last year) are already in the local database, so they show in your feed."
|
hint={t("settings.sync.recentSyncedHint")}
|
||||||
>
|
>
|
||||||
{`${s.channels_recent_synced}/${s.channels_total}`}
|
{`${s.channels_recent_synced}/${s.channels_total}`}
|
||||||
</Row>
|
</Row>
|
||||||
<Row
|
<Row
|
||||||
label="Full history"
|
label={t("settings.sync.fullHistory")}
|
||||||
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)."
|
hint={t("settings.sync.fullHistoryHint")}
|
||||||
>
|
>
|
||||||
{`${s.channels_deep_done}/${s.channels_deep_requested}`}
|
{`${s.channels_deep_done}/${s.channels_deep_requested}`}
|
||||||
</Row>
|
</Row>
|
||||||
{s.deep_pending_count > 0 && (
|
{s.deep_pending_count > 0 && (
|
||||||
<Row
|
<Row
|
||||||
label="Full history ETA"
|
label={t("settings.sync.fullHistoryEta")}
|
||||||
hint={`Rough estimate to finish full-history backfill of ${s.deep_pending_count} pending channel(s), based on the shared daily quota.`}
|
hint={t("settings.sync.fullHistoryEtaHint", { count: s.deep_pending_count })}
|
||||||
>
|
>
|
||||||
{formatEta(s.deep_eta_seconds)}
|
{formatEta(s.deep_eta_seconds)}
|
||||||
</Row>
|
</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()}
|
{s.my_videos.toLocaleString()}
|
||||||
</Row>
|
</Row>
|
||||||
<Row
|
<Row
|
||||||
label="Quota left today"
|
label={t("settings.sync.quotaLeft")}
|
||||||
hint="Shared YouTube API budget remaining today (resets midnight US Pacific). Backfill yields to it."
|
hint={t("settings.sync.quotaLeftHint")}
|
||||||
>
|
>
|
||||||
{s.quota_remaining_today.toLocaleString()}
|
{s.quota_remaining_today.toLocaleString()}
|
||||||
</Row>
|
</Row>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-muted text-sm">Loading…</div>
|
<div className="text-muted text-sm">{t("settings.sync.loading")}</div>
|
||||||
)}
|
)}
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
<Section title="Your API usage">
|
<Section title={t("settings.sync.apiUsage")}>
|
||||||
{usage.data ? (
|
{usage.data ? (
|
||||||
<>
|
<>
|
||||||
<div className="space-y-1.5 text-sm">
|
<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()}
|
{usage.data.today.toLocaleString()}
|
||||||
</Row>
|
</Row>
|
||||||
<Row label="Last 7 days">{usage.data.last_7d.toLocaleString()}</Row>
|
<Row label={t("settings.sync.last7d")}>{usage.data.last_7d.toLocaleString()}</Row>
|
||||||
<Row label="Last 30 days">{usage.data.last_30d.toLocaleString()}</Row>
|
<Row label={t("settings.sync.last30d")}>{usage.data.last_30d.toLocaleString()}</Row>
|
||||||
<Row label="All time">{usage.data.all_time.toLocaleString()}</Row>
|
<Row label={t("settings.sync.allTime")}>{usage.data.all_time.toLocaleString()}</Row>
|
||||||
</div>
|
</div>
|
||||||
{Object.keys(usage.data.by_action).length > 0 && (
|
{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="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)
|
{Object.entries(usage.data.by_action)
|
||||||
.sort((a, b) => b[1] - a[1])
|
.sort((a, b) => b[1] - a[1])
|
||||||
.map(([action, units]) => (
|
.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>
|
||||||
|
|
||||||
<Section title="Actions">
|
<Section title={t("settings.sync.actions")}>
|
||||||
<Tooltip hint="Re-import your YouTube subscriptions and pull in any newly-followed channels.">
|
<Tooltip hint={t("settings.sync.syncSubscriptionsHint")}>
|
||||||
<button
|
<button
|
||||||
onClick={() => syncSubs.mutate()}
|
onClick={() => syncSubs.mutate()}
|
||||||
disabled={syncSubs.isPending}
|
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"
|
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" : ""}`} />
|
<RefreshCw className={`w-4 h-4 ${syncSubs.isPending ? "animate-spin" : ""}`} />
|
||||||
Sync subscriptions
|
{t("settings.sync.syncSubscriptions")}
|
||||||
</button>
|
</button>
|
||||||
</Tooltip>
|
</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
|
<button
|
||||||
onClick={() => deepAll.mutate()}
|
onClick={() => deepAll.mutate()}
|
||||||
disabled={deepAll.isPending}
|
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"
|
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" : ""}`} />
|
<History className={`w-4 h-4 ${deepAll.isPending ? "animate-pulse" : ""}`} />
|
||||||
Backfill everything
|
{t("settings.sync.backfillEverything")}
|
||||||
</button>
|
</button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
{me.role === "admin" && s && (
|
{me.role === "admin" && s && (
|
||||||
<Section title="Admin">
|
<Section title={t("settings.sync.admin")}>
|
||||||
<Tooltip hint="Pause or resume the background sync for the whole app (all users).">
|
<Tooltip hint={t("settings.sync.adminPauseHint")}>
|
||||||
<button
|
<button
|
||||||
onClick={() => pauseResume.mutate(s.paused)}
|
onClick={() => pauseResume.mutate(s.paused)}
|
||||||
className="glass-card glass-hover flex items-center gap-2 px-3 py-2 rounded-xl text-sm transition"
|
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 ? <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>
|
</button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
@ -479,6 +489,7 @@ function AccessRow({
|
||||||
enableHint: string;
|
enableHint: string;
|
||||||
onEnable: () => void;
|
onEnable: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<div className="flex items-start justify-between gap-3 py-2">
|
<div className="flex items-start justify-between gap-3 py-2">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
|
|
@ -489,15 +500,15 @@ function AccessRow({
|
||||||
</div>
|
</div>
|
||||||
{granted ? (
|
{granted ? (
|
||||||
<span className="shrink-0 text-[11px] px-2 py-1 rounded-full border border-accent/40 text-accent">
|
<span className="shrink-0 text-[11px] px-2 py-1 rounded-full border border-accent/40 text-accent">
|
||||||
Granted
|
{t("settings.account.granted")}
|
||||||
</span>
|
</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
|
<button
|
||||||
onClick={onEnable}
|
onClick={onEnable}
|
||||||
className="shrink-0 glass-card glass-hover px-3 py-1.5 rounded-xl text-sm transition"
|
className="shrink-0 glass-card glass-hover px-3 py-1.5 rounded-xl text-sm transition"
|
||||||
>
|
>
|
||||||
Enable
|
{t("settings.account.enable")}
|
||||||
</button>
|
</button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
|
|
@ -506,9 +517,10 @@ function AccessRow({
|
||||||
}
|
}
|
||||||
|
|
||||||
function Account({ me, onOpenWizard }: { me: Me; onOpenWizard: () => void }) {
|
function Account({ me, onOpenWizard }: { me: Me; onOpenWizard: () => void }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Section title="Account">
|
<Section title={t("settings.account.title")}>
|
||||||
<div className="flex items-center gap-3 mb-3">
|
<div className="flex items-center gap-3 mb-3">
|
||||||
<Avatar
|
<Avatar
|
||||||
src={me.avatar_url}
|
src={me.avatar_url}
|
||||||
|
|
@ -523,26 +535,25 @@ function Account({ me, onOpenWizard }: { me: Me; onOpenWizard: () => void }) {
|
||||||
</div>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
<Section title="YouTube access">
|
<Section title={t("settings.account.youtubeAccess")}>
|
||||||
<p className="text-xs text-muted leading-relaxed mb-1">
|
<p className="text-xs text-muted leading-relaxed mb-1">
|
||||||
Sign-in only shares your name and email. YouTube access is granted separately, below —
|
{t("settings.account.youtubeAccessIntro")}
|
||||||
each is optional and revocable anytime in your Google Account.
|
|
||||||
</p>
|
</p>
|
||||||
<div className="divide-y divide-border">
|
<div className="divide-y divide-border">
|
||||||
<AccessRow
|
<AccessRow
|
||||||
title="Read subscriptions (your feed)"
|
title={t("settings.account.readTitle")}
|
||||||
granted={me.can_read}
|
granted={me.can_read}
|
||||||
grantedHint="Granted. Siftlode reads the channels you follow to build your feed. It never changes your YouTube account with this."
|
grantedHint={t("settings.account.readGrantedHint")}
|
||||||
enableHint="Read-only access to your subscriptions — required to build your feed. Siftlode can't modify anything with it."
|
enableHint={t("settings.account.readEnableHint")}
|
||||||
onEnable={() => {
|
onEnable={() => {
|
||||||
window.location.href = "/auth/upgrade?access=read";
|
window.location.href = "/auth/upgrade?access=read";
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<AccessRow
|
<AccessRow
|
||||||
title="Unsubscribe (write)"
|
title={t("settings.account.writeTitle")}
|
||||||
granted={me.can_write}
|
granted={me.can_write}
|
||||||
grantedHint="Granted. You can unsubscribe from channels on YouTube from inside Siftlode. It only writes when you ask it to."
|
grantedHint={t("settings.account.writeGrantedHint")}
|
||||||
enableHint="Lets Siftlode unsubscribe from channels on YouTube for you. Optional — skip it to stay read-only."
|
enableHint={t("settings.account.writeEnableHint")}
|
||||||
onEnable={() => {
|
onEnable={() => {
|
||||||
window.location.href = "/auth/upgrade?access=write";
|
window.location.href = "/auth/upgrade?access=write";
|
||||||
}}
|
}}
|
||||||
|
|
@ -552,7 +563,7 @@ function Account({ me, onOpenWizard }: { me: Me; onOpenWizard: () => void }) {
|
||||||
onClick={onOpenWizard}
|
onClick={onOpenWizard}
|
||||||
className="mt-2 text-sm text-accent hover:underline"
|
className="mt-2 text-sm text-accent hover:underline"
|
||||||
>
|
>
|
||||||
Walk me through setup
|
{t("settings.account.walkMeThrough")}
|
||||||
</button>
|
</button>
|
||||||
</Section>
|
</Section>
|
||||||
{me.role === "admin" && <AdminInvites />}
|
{me.role === "admin" && <AdminInvites />}
|
||||||
|
|
@ -561,6 +572,7 @@ function Account({ me, onOpenWizard }: { me: Me; onOpenWizard: () => void }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function AdminInvites() {
|
function AdminInvites() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const invites = useQuery({ queryKey: ["admin-invites"], queryFn: () => api.adminInvites() });
|
const invites = useQuery({ queryKey: ["admin-invites"], queryFn: () => api.adminInvites() });
|
||||||
const [newEmail, setNewEmail] = useState("");
|
const [newEmail, setNewEmail] = useState("");
|
||||||
|
|
@ -572,23 +584,23 @@ function AdminInvites() {
|
||||||
mutationFn: (id: number) => api.approveInvite(id),
|
mutationFn: (id: number) => api.approveInvite(id),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
refresh();
|
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({
|
const deny = useMutation({
|
||||||
mutationFn: (id: number) => api.denyInvite(id),
|
mutationFn: (id: number) => api.denyInvite(id),
|
||||||
onSuccess: refresh,
|
onSuccess: refresh,
|
||||||
onError: () => notify({ level: "error", message: "Deny failed" }),
|
onError: () => notify({ level: "error", message: t("settings.invites.denyFailed") }),
|
||||||
});
|
});
|
||||||
const add = useMutation({
|
const add = useMutation({
|
||||||
mutationFn: (email: string) => api.addInvite(email),
|
mutationFn: (email: string) => api.addInvite(email),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
setNewEmail("");
|
setNewEmail("");
|
||||||
refresh();
|
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 ?? [];
|
const list = invites.data ?? [];
|
||||||
|
|
@ -596,9 +608,9 @@ function AdminInvites() {
|
||||||
const decided = list.filter((i) => i.status !== "pending");
|
const decided = list.filter((i) => i.status !== "pending");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Section title="Access requests">
|
<Section title={t("settings.invites.title")}>
|
||||||
{pending.length === 0 ? (
|
{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">
|
<div className="space-y-1.5">
|
||||||
{pending.map((i) => (
|
{pending.map((i) => (
|
||||||
|
|
@ -624,21 +636,21 @@ function AdminInvites() {
|
||||||
type="email"
|
type="email"
|
||||||
value={newEmail}
|
value={newEmail}
|
||||||
onChange={(e) => setNewEmail(e.target.value)}
|
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"
|
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
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="shrink-0 glass-card glass-hover flex items-center gap-1.5 px-3 py-2 rounded-xl text-sm transition"
|
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>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{decided.length > 0 && (
|
{decided.length > 0 && (
|
||||||
<details className="mt-3">
|
<details className="mt-3">
|
||||||
<summary className="text-xs text-muted cursor-pointer">
|
<summary className="text-xs text-muted cursor-pointer">
|
||||||
{decided.length} decided
|
{t("settings.invites.decided", { count: decided.length })}
|
||||||
</summary>
|
</summary>
|
||||||
<div className="mt-2 space-y-1 text-xs text-muted">
|
<div className="mt-2 space-y-1 text-xs text-muted">
|
||||||
{decided.map((i) => (
|
{decided.map((i) => (
|
||||||
|
|
@ -667,32 +679,35 @@ function InviteRow({
|
||||||
onDeny: () => void;
|
onDeny: () => void;
|
||||||
busy: boolean;
|
busy: boolean;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<div className="glass-card flex items-center gap-2 p-2.5 rounded-xl">
|
<div className="glass-card flex items-center gap-2 p-2.5 rounded-xl">
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="text-sm truncate">{inv.email}</div>
|
<div className="text-sm truncate">{inv.email}</div>
|
||||||
{inv.requested_at && (
|
{inv.requested_at && (
|
||||||
<div className="text-[11px] text-muted">
|
<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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Tooltip hint="Approve — whitelist this email and email them they're in.">
|
<Tooltip hint={t("settings.invites.approveHint")}>
|
||||||
<button
|
<button
|
||||||
onClick={onApprove}
|
onClick={onApprove}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
className="shrink-0 p-1.5 rounded-lg border border-accent/40 text-accent hover:bg-accent/10 disabled:opacity-50 transition"
|
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" />
|
<Check className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip hint="Deny this request.">
|
<Tooltip hint={t("settings.invites.denyHint")}>
|
||||||
<button
|
<button
|
||||||
onClick={onDeny}
|
onClick={onDeny}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
className="shrink-0 p-1.5 rounded-lg border border-border text-muted hover:text-red-400 disabled:opacity-50 transition"
|
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" />
|
<X className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
Check,
|
Check,
|
||||||
|
|
@ -28,38 +29,31 @@ import { CSS } from "@dnd-kit/utilities";
|
||||||
import { api, type FeedFilters, type Tag } from "../lib/api";
|
import { api, type FeedFilters, type Tag } from "../lib/api";
|
||||||
import {
|
import {
|
||||||
DEFAULT_LAYOUT,
|
DEFAULT_LAYOUT,
|
||||||
WIDGET_TITLES,
|
|
||||||
type SidebarLayout,
|
type SidebarLayout,
|
||||||
type WidgetId,
|
type WidgetId,
|
||||||
} from "../lib/sidebarLayout";
|
} from "../lib/sidebarLayout";
|
||||||
|
|
||||||
const SORTS = [
|
// Filter ids; display labels resolved at render time via t("sidebar.sort.<id>") etc.
|
||||||
{ id: "newest", label: "Newest" },
|
const SORT_IDS = [
|
||||||
{ id: "oldest", label: "Oldest" },
|
"newest",
|
||||||
{ id: "views", label: "Most viewed" },
|
"oldest",
|
||||||
{ id: "duration_desc", label: "Longest" },
|
"views",
|
||||||
{ id: "duration_asc", label: "Shortest" },
|
"duration_desc",
|
||||||
{ id: "title", label: "Name (A–Z)" },
|
"duration_asc",
|
||||||
{ id: "subscribers", label: "Channel subscribers" },
|
"title",
|
||||||
{ id: "priority", label: "Channel priority" },
|
"subscribers",
|
||||||
{ id: "shuffle", label: "Surprise me" },
|
"priority",
|
||||||
|
"shuffle",
|
||||||
];
|
];
|
||||||
|
|
||||||
const SHOWS = [
|
const SHOW_IDS = ["unwatched", "in_progress", "all", "watched", "saved", "hidden"];
|
||||||
{ 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 DATE_PRESETS: { days: number; label: string }[] = [
|
const DATE_PRESETS: { days: number; key: string }[] = [
|
||||||
{ days: 1, label: "24h" },
|
{ days: 1, key: "24h" },
|
||||||
{ days: 7, label: "1 week" },
|
{ days: 7, key: "1week" },
|
||||||
{ days: 30, label: "1 month" },
|
{ days: 30, key: "1month" },
|
||||||
{ days: 180, label: "6 months" },
|
{ days: 180, key: "6months" },
|
||||||
{ days: 365, label: "1 year" },
|
{ days: 365, key: "1year" },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Filter values owned by the sidebar (everything except the header search `q`).
|
// Filter values owned by the sidebar (everything except the header search `q`).
|
||||||
|
|
@ -82,10 +76,11 @@ function TagChip({
|
||||||
active: boolean;
|
active: boolean;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={onClick}
|
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 ${
|
className={`text-xs px-2.5 py-1 rounded-full border shadow-sm hover:shadow active:translate-y-px transition ${
|
||||||
active
|
active
|
||||||
? "bg-accent text-accent-fg border-accent"
|
? "bg-accent text-accent-fg border-accent"
|
||||||
|
|
@ -108,6 +103,7 @@ export default function Sidebar({
|
||||||
layout: SidebarLayout;
|
layout: SidebarLayout;
|
||||||
setLayout: (l: SidebarLayout) => void;
|
setLayout: (l: SidebarLayout) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const tagsQuery = useQuery({ queryKey: ["tags"], queryFn: api.tags });
|
const tagsQuery = useQuery({ queryKey: ["tags"], queryFn: api.tags });
|
||||||
const tags = tagsQuery.data ?? [];
|
const tags = tagsQuery.data ?? [];
|
||||||
|
|
||||||
|
|
@ -128,7 +124,10 @@ export default function Sidebar({
|
||||||
undefined;
|
undefined;
|
||||||
// Don't flash the misleading "This channel" fallback while the name is still resolving.
|
// Don't flash the misleading "This channel" fallback while the name is still resolving.
|
||||||
const channelChipLabel =
|
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 languages = tags.filter((t) => t.category === "language");
|
||||||
const topics = tags.filter((t) => t.category === "topic");
|
const topics = tags.filter((t) => t.category === "topic");
|
||||||
const [customDates, setCustomDates] = useState(false);
|
const [customDates, setCustomDates] = useState(false);
|
||||||
|
|
@ -192,17 +191,17 @@ export default function Sidebar({
|
||||||
case "show":
|
case "show":
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-2 gap-1.5">
|
<div className="grid grid-cols-2 gap-1.5">
|
||||||
{SHOWS.map((s) => (
|
{SHOW_IDS.map((id) => (
|
||||||
<button
|
<button
|
||||||
key={s.id}
|
key={id}
|
||||||
onClick={() => setFilters({ ...filters, show: s.id })}
|
onClick={() => setFilters({ ...filters, show: id })}
|
||||||
className={`text-xs py-1.5 rounded-lg border shadow-sm active:translate-y-px transition ${
|
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-accent text-accent-fg border-accent"
|
||||||
: "bg-card border-border hover:border-accent"
|
: "bg-card border-border hover:border-accent"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{s.label}
|
{t("sidebar.show." + id)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -214,9 +213,9 @@ export default function Sidebar({
|
||||||
onChange={(e) => setFilters({ ...filters, sort: e.target.value })}
|
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"
|
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) => (
|
{SORT_IDS.map((id) => (
|
||||||
<option key={s.id} value={s.id}>
|
<option key={id} value={id}>
|
||||||
{s.label}
|
{t("sidebar.sort." + id)}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|
@ -244,7 +243,7 @@ export default function Sidebar({
|
||||||
: "bg-card border-border hover:border-accent"
|
: "bg-card border-border hover:border-accent"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{p.label}
|
{t("sidebar.datePreset." + p.key)}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
@ -260,14 +259,14 @@ export default function Sidebar({
|
||||||
: "bg-card border-border hover:border-accent"
|
: "bg-card border-border hover:border-accent"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
Custom
|
{t("sidebar.custom")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{(customDates || filters.dateFrom || filters.dateTo) && (
|
{(customDates || filters.dateFrom || filters.dateTo) && (
|
||||||
<div className="flex flex-col gap-1.5 mt-2">
|
<div className="flex flex-col gap-1.5 mt-2">
|
||||||
<label className="flex items-center justify-between gap-2 text-xs">
|
<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
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
value={filters.dateFrom ?? ""}
|
value={filters.dateFrom ?? ""}
|
||||||
|
|
@ -282,7 +281,7 @@ export default function Sidebar({
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="flex items-center justify-between gap-2 text-xs">
|
<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
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
value={filters.dateTo ?? ""}
|
value={filters.dateTo ?? ""}
|
||||||
|
|
@ -303,7 +302,7 @@ export default function Sidebar({
|
||||||
}
|
}
|
||||||
className="text-[11px] text-muted hover:text-accent self-end"
|
className="text-[11px] text-muted hover:text-accent self-end"
|
||||||
>
|
>
|
||||||
clear dates
|
{t("sidebar.clearDates")}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -314,17 +313,17 @@ export default function Sidebar({
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Toggle
|
<Toggle
|
||||||
label="Normal"
|
label={t("sidebar.content.normal")}
|
||||||
checked={filters.includeNormal}
|
checked={filters.includeNormal}
|
||||||
onChange={(v) => setFilters({ ...filters, includeNormal: v })}
|
onChange={(v) => setFilters({ ...filters, includeNormal: v })}
|
||||||
/>
|
/>
|
||||||
<Toggle
|
<Toggle
|
||||||
label="Shorts"
|
label={t("sidebar.content.shorts")}
|
||||||
checked={filters.includeShorts}
|
checked={filters.includeShorts}
|
||||||
onChange={(v) => setFilters({ ...filters, includeShorts: v })}
|
onChange={(v) => setFilters({ ...filters, includeShorts: v })}
|
||||||
/>
|
/>
|
||||||
<Toggle
|
<Toggle
|
||||||
label="Live / Upcoming"
|
label={t("sidebar.content.live")}
|
||||||
checked={filters.includeLive}
|
checked={filters.includeLive}
|
||||||
onChange={(v) => setFilters({ ...filters, includeLive: v })}
|
onChange={(v) => setFilters({ ...filters, includeLive: v })}
|
||||||
/>
|
/>
|
||||||
|
|
@ -352,9 +351,9 @@ export default function Sidebar({
|
||||||
setFilters({ ...filters, tagMode: filters.tagMode === "or" ? "and" : "or" })
|
setFilters({ ...filters, tagMode: filters.tagMode === "or" ? "and" : "or" })
|
||||||
}
|
}
|
||||||
className="text-[10px] uppercase tracking-wide text-muted hover:text-accent"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<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">
|
<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="flex items-center justify-between">
|
||||||
<div className="text-sm font-semibold">
|
<div className="text-sm font-semibold">
|
||||||
Filters
|
{t("sidebar.filters")}
|
||||||
{activeCount > 0 && (
|
{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>
|
||||||
<div className="flex items-center gap-0.5">
|
<div className="flex items-center gap-0.5">
|
||||||
|
|
@ -393,13 +394,13 @@ export default function Sidebar({
|
||||||
disabled={!active}
|
disabled={!active}
|
||||||
className="text-xs text-muted enabled:hover:text-accent disabled:opacity-40 transition px-1"
|
className="text-xs text-muted enabled:hover:text-accent disabled:opacity-40 transition px-1"
|
||||||
>
|
>
|
||||||
Clear all
|
{t("sidebar.clearAll")}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{editing && (
|
{editing && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setLayout({ ...DEFAULT_LAYOUT })}
|
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"
|
className="p-1.5 rounded-lg text-muted hover:text-fg hover:bg-card transition"
|
||||||
>
|
>
|
||||||
<RotateCcw className="w-4 h-4" />
|
<RotateCcw className="w-4 h-4" />
|
||||||
|
|
@ -407,7 +408,7 @@ export default function Sidebar({
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => setEditing((e) => !e)}
|
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 ${
|
className={`p-1.5 rounded-lg transition hover:bg-card ${
|
||||||
editing ? "text-accent" : "text-muted hover:text-fg"
|
editing ? "text-accent" : "text-muted hover:text-fg"
|
||||||
}`}
|
}`}
|
||||||
|
|
@ -418,12 +419,14 @@ export default function Sidebar({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{editing && (
|
{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 && (
|
{filters.channelId && !editing && (
|
||||||
<div className="glass-card rounded-xl">
|
<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">
|
<div className="px-3 pb-3 pt-2">
|
||||||
<button
|
<button
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
|
|
@ -445,7 +448,7 @@ export default function Sidebar({
|
||||||
<WidgetCard
|
<WidgetCard
|
||||||
key={id}
|
key={id}
|
||||||
id={id}
|
id={id}
|
||||||
title={WIDGET_TITLES[id]}
|
title={t("sidebar.widget." + id)}
|
||||||
editing={editing}
|
editing={editing}
|
||||||
collapsed={!!layout.collapsed[id]}
|
collapsed={!!layout.collapsed[id]}
|
||||||
hidden={!!layout.hidden[id]}
|
hidden={!!layout.hidden[id]}
|
||||||
|
|
@ -481,6 +484,7 @@ function WidgetCard({
|
||||||
onToggleHidden: () => void;
|
onToggleHidden: () => void;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||||
id,
|
id,
|
||||||
disabled: !editing,
|
disabled: !editing,
|
||||||
|
|
@ -504,7 +508,7 @@ function WidgetCard({
|
||||||
<button
|
<button
|
||||||
{...attributes}
|
{...attributes}
|
||||||
{...listeners}
|
{...listeners}
|
||||||
title="Drag to reorder"
|
title={t("sidebar.dragToReorder")}
|
||||||
className="-ml-1 cursor-grab active:cursor-grabbing text-muted hover:text-fg touch-none"
|
className="-ml-1 cursor-grab active:cursor-grabbing text-muted hover:text-fg touch-none"
|
||||||
>
|
>
|
||||||
<GripVertical className="w-4 h-4" />
|
<GripVertical className="w-4 h-4" />
|
||||||
|
|
@ -516,7 +520,7 @@ function WidgetCard({
|
||||||
{editing ? (
|
{editing ? (
|
||||||
<button
|
<button
|
||||||
onClick={onToggleHidden}
|
onClick={onToggleHidden}
|
||||||
title={hidden ? "Show widget" : "Hide widget"}
|
title={hidden ? t("sidebar.showWidget") : t("sidebar.hideWidget")}
|
||||||
className="text-muted hover:text-fg"
|
className="text-muted hover:text-fg"
|
||||||
>
|
>
|
||||||
{hidden ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
{hidden ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||||
|
|
@ -524,7 +528,7 @@ function WidgetCard({
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
onClick={onToggleCollapse}
|
onClick={onToggleCollapse}
|
||||||
title={collapsed ? "Expand" : "Collapse"}
|
title={collapsed ? t("sidebar.expand") : t("sidebar.collapse")}
|
||||||
className="text-muted hover:text-fg"
|
className="text-muted hover:text-fg"
|
||||||
>
|
>
|
||||||
<ChevronDown
|
<ChevronDown
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { api, type AdminQuotaRow } from "../lib/api";
|
import { api, type AdminQuotaRow } from "../lib/api";
|
||||||
import { quotaActionLabel } from "../lib/format";
|
import { quotaActionLabel } from "../lib/format";
|
||||||
|
|
@ -6,6 +7,7 @@ import { quotaActionLabel } from "../lib/format";
|
||||||
const RANGES = [7, 30, 90] as const;
|
const RANGES = [7, 30, 90] as const;
|
||||||
|
|
||||||
export default function Stats() {
|
export default function Stats() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [days, setDays] = useState<number>(30);
|
const [days, setDays] = useState<number>(30);
|
||||||
const q = useQuery({
|
const q = useQuery({
|
||||||
queryKey: ["admin-quota", days],
|
queryKey: ["admin-quota", days],
|
||||||
|
|
@ -18,7 +20,7 @@ export default function Stats() {
|
||||||
return (
|
return (
|
||||||
<div className="p-4 max-w-4xl mx-auto">
|
<div className="p-4 max-w-4xl mx-auto">
|
||||||
<div className="flex items-center justify-between gap-3 mb-4">
|
<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">
|
<div className="flex items-center gap-1">
|
||||||
{RANGES.map((d) => (
|
{RANGES.map((d) => (
|
||||||
<button
|
<button
|
||||||
|
|
@ -30,30 +32,34 @@ export default function Stats() {
|
||||||
: "bg-card border-border text-muted hover:border-accent"
|
: "bg-card border-border text-muted hover:border-accent"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{d}d
|
{t("stats.rangeDays", { count: d })}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-xs text-muted mb-4 leading-relaxed">
|
<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
|
{t("stats.introBefore")}
|
||||||
shared background work (scheduled backfill, enrichment, resync) that isn't tied to one person.
|
<b className="text-fg/80">{t("stats.introSystem")}</b>
|
||||||
|
{t("stats.introAfter")}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{q.isLoading ? (
|
{q.isLoading ? (
|
||||||
<div className="text-muted py-8">Loading…</div>
|
<div className="text-muted py-8">{t("stats.loading")}</div>
|
||||||
) : !data ? (
|
) : !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) */}
|
{/* Daily totals (instance-wide, Pacific days) */}
|
||||||
<div className="glass-card rounded-xl p-3 mb-4">
|
<div className="glass-card rounded-xl p-3 mb-4">
|
||||||
<div className="text-xs text-muted mb-2">
|
<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>
|
</div>
|
||||||
{data.daily.length === 0 ? (
|
{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">
|
<div className="flex items-end gap-0.5 h-24">
|
||||||
{data.daily.map((d) => (
|
{data.daily.map((d) => (
|
||||||
|
|
@ -61,7 +67,10 @@ export default function Stats() {
|
||||||
key={d.day}
|
key={d.day}
|
||||||
className="flex-1 bg-accent/70 hover:bg-accent rounded-t transition"
|
className="flex-1 bg-accent/70 hover:bg-accent rounded-t transition"
|
||||||
style={{ height: `${Math.max(2, (d.total / maxDaily) * 100)}%` }}
|
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>
|
</div>
|
||||||
|
|
@ -71,7 +80,7 @@ export default function Stats() {
|
||||||
{/* Per-user breakdown */}
|
{/* Per-user breakdown */}
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
{data.rows.length === 0 ? (
|
{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) => (
|
data.rows.map((r) => (
|
||||||
<UserRow key={r.user_id ?? "system"} row={r} max={data.rows[0]?.total || 1} />
|
<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 }) {
|
function UserRow({ row, max }: { row: AdminQuotaRow; max: number }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const actions = Object.entries(row.by_action).sort((a, b) => b[1] - a[1]);
|
const actions = Object.entries(row.by_action).sort((a, b) => b[1] - a[1]);
|
||||||
const isSystem = row.user_id === null;
|
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"
|
className="w-full flex items-center gap-3 text-left"
|
||||||
>
|
>
|
||||||
<span className={`text-sm font-medium truncate flex-1 ${isSystem ? "text-muted" : ""}`}>
|
<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>
|
||||||
<span className="text-sm font-semibold tabular-nums shrink-0">
|
<span className="text-sm font-semibold tabular-nums shrink-0">
|
||||||
{row.total.toLocaleString()}
|
{row.total.toLocaleString()}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { useSyncExternalStore } from "react";
|
import { useSyncExternalStore } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
|
|
@ -26,44 +27,45 @@ export const LEVEL_STYLE: Record<
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function Toaster() {
|
export default function Toaster() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const toasts = useSyncExternalStore(subscribe, getActiveToasts, getActiveToasts);
|
const toasts = useSyncExternalStore(subscribe, getActiveToasts, getActiveToasts);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed top-4 right-4 z-50 flex flex-col gap-2 w-80 max-w-[calc(100vw-2rem)]">
|
<div className="fixed top-4 right-4 z-50 flex flex-col gap-2 w-80 max-w-[calc(100vw-2rem)]">
|
||||||
{toasts.map((t) => {
|
{toasts.map((toast) => {
|
||||||
const { icon: Icon, color, bar } = LEVEL_STYLE[t.level];
|
const { icon: Icon, color, bar } = LEVEL_STYLE[toast.level];
|
||||||
return (
|
return (
|
||||||
<div
|
<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]"
|
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}`} />
|
<Icon className={`w-5 h-5 shrink-0 mt-0.5 ${color}`} />
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
{t.title && <div className="text-sm font-semibold">{t.title}</div>}
|
{toast.title && <div className="text-sm font-semibold">{toast.title}</div>}
|
||||||
<div className="text-sm break-words">{t.message}</div>
|
<div className="text-sm break-words">{toast.message}</div>
|
||||||
{t.action && (
|
{toast.action && (
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
t.action!.onClick();
|
toast.action!.onClick();
|
||||||
dismiss(t.id);
|
dismiss(toast.id);
|
||||||
}}
|
}}
|
||||||
className="mt-1 text-accent text-sm font-semibold hover:underline"
|
className="mt-1 text-accent text-sm font-semibold hover:underline"
|
||||||
>
|
>
|
||||||
{t.action.label}
|
{toast.action.label}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => dismiss(t.id)}
|
onClick={() => dismiss(toast.id)}
|
||||||
className="shrink-0 text-muted hover:text-fg"
|
className="shrink-0 text-muted hover:text-fg"
|
||||||
title="Dismiss"
|
title={t("notifications.dismiss")}
|
||||||
>
|
>
|
||||||
<X className="w-4 h-4" />
|
<X className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
{t.duration && (
|
{toast.duration && (
|
||||||
<div
|
<div
|
||||||
className={`absolute left-0 bottom-0 h-0.5 w-full origin-left ${bar}`}
|
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>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { memo } from "react";
|
import { memo } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
Bookmark,
|
Bookmark,
|
||||||
Check,
|
Check,
|
||||||
|
|
@ -23,6 +24,7 @@ function Actions({
|
||||||
onState: (id: string, status: string) => void;
|
onState: (id: string, status: string) => void;
|
||||||
onChannelFilter?: (channelId: string, channelName: string) => void;
|
onChannelFilter?: (channelId: string, channelName: string) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const act = (status: string) => (e: React.MouseEvent) => {
|
const act = (status: string) => (e: React.MouseEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
@ -32,7 +34,7 @@ function Actions({
|
||||||
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition">
|
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition">
|
||||||
<button
|
<button
|
||||||
onClick={act("watched")}
|
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(
|
className={clsx(
|
||||||
"p-1.5 rounded-md hover:bg-surface text-muted hover:text-fg",
|
"p-1.5 rounded-md hover:bg-surface text-muted hover:text-fg",
|
||||||
video.status === "watched" && "text-accent"
|
video.status === "watched" && "text-accent"
|
||||||
|
|
@ -46,7 +48,7 @@ function Actions({
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={act("saved")}
|
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(
|
className={clsx(
|
||||||
"p-1.5 rounded-md hover:bg-surface text-muted hover:text-fg",
|
"p-1.5 rounded-md hover:bg-surface text-muted hover:text-fg",
|
||||||
video.status === "saved" && "fill-current text-accent"
|
video.status === "saved" && "fill-current text-accent"
|
||||||
|
|
@ -56,7 +58,7 @@ function Actions({
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={act("hidden")}
|
onClick={act("hidden")}
|
||||||
title={video.status === "hidden" ? "Unhide" : "Hide"}
|
title={video.status === "hidden" ? t("card.unhide") : t("card.hide")}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"p-1.5 rounded-md hover:bg-surface text-muted hover:text-fg",
|
"p-1.5 rounded-md hover:bg-surface text-muted hover:text-fg",
|
||||||
video.status === "hidden" && "text-accent"
|
video.status === "hidden" && "text-accent"
|
||||||
|
|
@ -73,9 +75,9 @@ function Actions({
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
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"
|
className="p-1.5 rounded-md hover:bg-surface text-muted hover:text-fg"
|
||||||
>
|
>
|
||||||
<ListFilter className="w-4 h-4" />
|
<ListFilter className="w-4 h-4" />
|
||||||
|
|
@ -106,6 +108,7 @@ function Thumb({
|
||||||
className?: string;
|
className?: string;
|
||||||
onOpen?: (v: Video, startAt?: number | null) => void;
|
onOpen?: (v: Video, startAt?: number | null) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
// A non-zero saved position on an unfinished video = "in progress": show a resume
|
// 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.
|
// progress bar and offer Continue / Restart instead of a plain Play.
|
||||||
const inProgress = video.position_seconds > 0 && video.status !== "watched";
|
const inProgress = video.position_seconds > 0 && video.status !== "watched";
|
||||||
|
|
@ -149,7 +152,7 @@ function Thumb({
|
||||||
)}
|
)}
|
||||||
{video.live_status === "was_live" && (
|
{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">
|
<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>
|
</span>
|
||||||
)}
|
)}
|
||||||
{video.status === "saved" && (
|
{video.status === "saved" && (
|
||||||
|
|
@ -164,25 +167,25 @@ function Thumb({
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
onClick={open(null)}
|
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"
|
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" />
|
<Play className="w-4 h-4 fill-current" />
|
||||||
Continue
|
{t("card.continue")}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={open(0)}
|
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"
|
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" />
|
<RotateCcw className="w-4 h-4" />
|
||||||
Restart
|
{t("card.restart")}
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
onClick={open(null)}
|
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"
|
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]" />
|
<Play className="w-5 h-5 fill-current translate-x-[1px]" />
|
||||||
|
|
@ -213,10 +216,15 @@ function VideoCard({
|
||||||
onChannelFilter?: (channelId: string, channelName: string) => void;
|
onChannelFilter?: (channelId: string, channelName: string) => void;
|
||||||
onOpen?: (v: Video, startAt?: number | null) => void;
|
onOpen?: (v: Video, startAt?: number | null) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const watched = video.status === "watched";
|
const watched = video.status === "watched";
|
||||||
const meta = (
|
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)}
|
{relativeTime(video.published_at)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
17
frontend/src/i18n/locales/de/card.json
Normal file
17
frontend/src/i18n/locales/de/card.json
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
{
|
||||||
|
"watchedUnmark": "Angesehen — zum Aufheben klicken",
|
||||||
|
"markWatched": "Als angesehen markieren",
|
||||||
|
"savedRemove": "Gespeichert — zum Entfernen klicken",
|
||||||
|
"saveForLater": "Für später speichern",
|
||||||
|
"unhide": "Einblenden",
|
||||||
|
"hide": "Ausblenden",
|
||||||
|
"onlyThisChannel": "Nur dieser Kanal",
|
||||||
|
"thisChannel": "Dieser Kanal",
|
||||||
|
"continueTitle": "Dort fortsetzen, wo du aufgehört hast",
|
||||||
|
"continue": "Fortsetzen",
|
||||||
|
"restartTitle": "Von vorne abspielen",
|
||||||
|
"restart": "Von vorne",
|
||||||
|
"play": "Abspielen",
|
||||||
|
"stream": "Stream",
|
||||||
|
"views": "Aufrufe"
|
||||||
|
}
|
||||||
69
frontend/src/i18n/locales/de/channels.json
Normal file
69
frontend/src/i18n/locales/de/channels.json
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
{
|
||||||
|
"intro": "Lege die <0>Priorität</0> eines Kanals fest, um seine Videos nach oben zu schieben, wenn du nach „Kanalpriorität“ sortierst, hänge eigene <1>Tags</1> an, um den Feed zu filtern, oder <2>blende</2> einen Kanal <2>aus</2>, um ihn ohne Abbestellung aus dem Feed zu entfernen.",
|
||||||
|
"filterPlaceholder": "Kanäle filtern…",
|
||||||
|
"syncSubscriptions": "Abos synchronisieren",
|
||||||
|
"syncSubscriptionsHint": "Importiert deine Abo-Liste erneut von YouTube — fügt neu abonnierte Kanäle hinzu und entfernt abbestellte. Die Videos selbst werden weiterhin automatisch im Hintergrund synchronisiert; sie werden hierbei nicht neu geladen.",
|
||||||
|
"backfillEverything": "Alles nachladen",
|
||||||
|
"backfillEverythingHint": "Fordert das vollständige Nachladen des gesamten Katalogs für jeden abonnierten Kanal an. Ältere Videos und die Suche werden vollständig, soweit das gemeinsame Tageskontingent es zulässt — das kann eine Weile dauern.",
|
||||||
|
"filters": {
|
||||||
|
"all": "Alle",
|
||||||
|
"needsFull": "Verlauf unvollständig",
|
||||||
|
"fullySynced": "Vollständig synchronisiert",
|
||||||
|
"hidden": "Ausgeblendet"
|
||||||
|
},
|
||||||
|
"tags": {
|
||||||
|
"yourTags": "Deine Tags",
|
||||||
|
"yourTagsHint": "Deine persönlichen Labels. Hänge sie unten an Kanäle an und filtere den Feed dann über die Seitenleiste nach Tag. (Getrennt von den automatischen Sprach-/Themen-Tags.)",
|
||||||
|
"newTag": "neuer Tag",
|
||||||
|
"createTag": "Tag erstellen"
|
||||||
|
},
|
||||||
|
"loading": "Kanäle werden geladen…",
|
||||||
|
"empty": "Keine Kanäle.",
|
||||||
|
"stats": {
|
||||||
|
"channels": "Kanäle",
|
||||||
|
"channelsHint": "Kanäle, die du abonniert hast.",
|
||||||
|
"recentSynced": "Aktuelle synchronisiert",
|
||||||
|
"recentSyncedHint": "Kanäle, deren neueste Uploads in der lokalen Datenbank sind und in deinem Feed erscheinen.",
|
||||||
|
"fullHistory": "Vollständiger Verlauf",
|
||||||
|
"fullHistoryHint": "Kanäle, deren gesamter Katalog geladen ist, von denen, für die du den vollständigen Verlauf angefordert hast.",
|
||||||
|
"left": "übrig",
|
||||||
|
"leftHint": "Grobe Schätzung zum Abschluss des Nachladens des vollständigen Verlaufs für {{count}} ausstehende(n) Kanal/Kanäle, basierend auf dem gemeinsamen Tageskontingent.",
|
||||||
|
"myVideos": "Meine Videos",
|
||||||
|
"myVideosHint": "Insgesamt verfügbare Videos über all deine Kanäle.",
|
||||||
|
"quotaLeft": "Kontingent übrig",
|
||||||
|
"quotaLeftHint": "Heute noch verbleibendes gemeinsames YouTube-API-Budget (wird um Mitternacht US-Pazifikzeit zurückgesetzt)."
|
||||||
|
},
|
||||||
|
"row": {
|
||||||
|
"stored": "{{formatted}} gespeichert",
|
||||||
|
"subs": "{{formatted}} Abonnenten",
|
||||||
|
"priorityHint": "Deine Einstufung für diesen Kanal. Sortiere den Feed nach „Kanalpriorität“, um Kanäle mit höherer Priorität nach oben zu bringen.",
|
||||||
|
"raisePriority": "Priorität erhöhen",
|
||||||
|
"lowerPriority": "Priorität senken",
|
||||||
|
"recent": "aktuell",
|
||||||
|
"recentSyncedHint": "Neueste Uploads synchronisiert.",
|
||||||
|
"recentNotSyncedHint": "Neueste Uploads noch nicht geladen.",
|
||||||
|
"full": "vollständig",
|
||||||
|
"fullHint": "Vollständiger Verlauf geladen.",
|
||||||
|
"fullHistoryQueued": "vollständiger Verlauf in Warteschlange",
|
||||||
|
"queuedRequestedHint": "Vollständiger Verlauf angefordert — der gesamte Katalog dieses Kanals wird nachgeladen, soweit das gemeinsame Kontingent es zulässt. Klicke, um deine Anforderung abzubrechen.",
|
||||||
|
"queuedByOtherHint": "Ein anderer Abonnent hat den vollständigen Verlauf dieses Kanals bereits angefordert, sein gesamter Katalog ist also für alle unterwegs — hier gibt es nichts zu tun.",
|
||||||
|
"getFullHistory": "vollständigen Verlauf laden",
|
||||||
|
"getFullHistoryHint": "Bisher nur neueste Uploads. Klicke, um den gesamten Katalog dieses Kanals anzufordern (ältere Videos + vollständige Suche).",
|
||||||
|
"hiddenHint": "Ausgeblendet — die Videos dieses Kanals bleiben aus deinem Feed heraus. Klicke, um sie wieder anzuzeigen.",
|
||||||
|
"hideHint": "Blendet die Videos dieses Kanals aus deinem Feed aus. Er bleibt abonniert; dies bestellt nicht bei YouTube ab.",
|
||||||
|
"unhide": "Einblenden",
|
||||||
|
"hideFromFeed": "Aus Feed ausblenden",
|
||||||
|
"unsubscribeHint": "Bestelle diesen Kanal bei YouTube ab (ändert dein echtes Konto). Im schreibgeschützten Modus ist dies ausgeblendet — nutze stattdessen Ausblenden.",
|
||||||
|
"unsubscribeOnYoutube": "Bei YouTube abbestellen",
|
||||||
|
"thisChannel": "Dieser Kanal"
|
||||||
|
},
|
||||||
|
"notify": {
|
||||||
|
"synced": "{{count}} Abos synchronisiert",
|
||||||
|
"syncFailed": "Abo-Synchronisierung fehlgeschlagen",
|
||||||
|
"unsubscribed": "Bei YouTube abbestellt",
|
||||||
|
"unsubscribeFailed": "Abbestellen fehlgeschlagen",
|
||||||
|
"fullHistoryRequested": "Vollständiger Verlauf für {{count}} Kanäle angefordert",
|
||||||
|
"fullHistoryFailed": "Vollständiger Verlauf konnte nicht angefordert werden"
|
||||||
|
},
|
||||||
|
"confirmUnsubscribe": "Kanal „{{name}}“ bei YouTube abbestellen? Dies ändert dein echtes YouTube-Konto. Um ihn nur aus deinem Feed zu entfernen, blende ihn stattdessen aus."
|
||||||
|
}
|
||||||
9
frontend/src/i18n/locales/de/errors.json
Normal file
9
frontend/src/i18n/locales/de/errors.json
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"boundary": {
|
||||||
|
"title": "Etwas ist schiefgelaufen.",
|
||||||
|
"subtitle": "Die App ist auf einen unerwarteten Fehler gestoßen.",
|
||||||
|
"reload": "Neu laden",
|
||||||
|
"notifTitle": "Etwas ist kaputtgegangen",
|
||||||
|
"notifMessage": "Unerwarteter Fehler"
|
||||||
|
}
|
||||||
|
}
|
||||||
17
frontend/src/i18n/locales/de/feed.json
Normal file
17
frontend/src/i18n/locales/de/feed.json
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
{
|
||||||
|
"loading": "Feed wird geladen…",
|
||||||
|
"loadError": "Feed konnte nicht geladen werden.",
|
||||||
|
"emptyTitle": "Dein Feed ist leer",
|
||||||
|
"emptyBody": "Verbinde dein YouTube-Konto, um deine Abos zu importieren und deinen Feed aufzubauen.",
|
||||||
|
"setUp": "Meinen Feed einrichten",
|
||||||
|
"noMatches": "Keine Videos entsprechen diesen Filtern.",
|
||||||
|
"videoCount_one": "{{formattedCount}} Video",
|
||||||
|
"videoCount_other": "{{formattedCount}} Videos",
|
||||||
|
"loadingMore": "Mehr wird geladen…",
|
||||||
|
"hiddenNamed": "Ausgeblendet: „{{title}}”",
|
||||||
|
"hidden": "Video ausgeblendet",
|
||||||
|
"undo": "Rückgängig",
|
||||||
|
"markedWatchedNamed": "Als angesehen markiert: „{{title}}”",
|
||||||
|
"markedWatched": "Als angesehen markiert",
|
||||||
|
"unwatch": "Nicht mehr als angesehen"
|
||||||
|
}
|
||||||
19
frontend/src/i18n/locales/de/notifications.json
Normal file
19
frontend/src/i18n/locales/de/notifications.json
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
{
|
||||||
|
"title": "Benachrichtigungen",
|
||||||
|
"clearAll": "Alle löschen",
|
||||||
|
"clear": "Löschen",
|
||||||
|
"empty": "Noch keine Benachrichtigungen.",
|
||||||
|
"actionNeeded": "Aktion erforderlich",
|
||||||
|
"dismiss": "Schließen",
|
||||||
|
"findInFeed": "Im Feed finden",
|
||||||
|
"unhide": "Einblenden",
|
||||||
|
"unwatch": "Nicht angesehen",
|
||||||
|
"unhidden": "Eingeblendet „{{title}}”",
|
||||||
|
"unwatched": "Als nicht angesehen markiert „{{title}}”",
|
||||||
|
"time": {
|
||||||
|
"justNow": "gerade eben",
|
||||||
|
"minutes": "vor {{count}} Min.",
|
||||||
|
"hours": "vor {{count}} Std.",
|
||||||
|
"days": "vor {{count}} T."
|
||||||
|
}
|
||||||
|
}
|
||||||
29
frontend/src/i18n/locales/de/onboarding.json
Normal file
29
frontend/src/i18n/locales/de/onboarding.json
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
{
|
||||||
|
"close": "Schließen",
|
||||||
|
"consent": "Google zeigt einen <0>„Google hat diese App nicht überprüft“</0>-Bildschirm — das ist zu erwarten, da Siftlode die vollständige Überprüfung von Google nicht durchlaufen hat. Es ist sicher fortzufahren: klicke auf <1>Erweitert</1> → <2>Weiter zu Siftlode</2>. Du kannst den Zugriff jederzeit in deinem <3>Google-Konto</3> widerrufen.",
|
||||||
|
"read": {
|
||||||
|
"title": "Verbinde deine YouTube-Abos",
|
||||||
|
"body": "Du bist angemeldet. Um deinen Feed aufzubauen, benötigt Siftlode <0>schreibgeschützten</0> Zugriff auf die Kanäle, die du auf YouTube abonniert hast. Es postet, löscht oder ändert damit niemals etwas.",
|
||||||
|
"connect": "Verbinden (schreibgeschützt)",
|
||||||
|
"skip": "Vorerst überspringen"
|
||||||
|
},
|
||||||
|
"importing": {
|
||||||
|
"title": "Dein Feed wird aufgebaut…",
|
||||||
|
"body": "Deine YouTube-Abos werden importiert. Bereits in Siftlode vorhandene Kanäle erscheinen sofort; neue werden automatisch im Hintergrund ergänzt."
|
||||||
|
},
|
||||||
|
"write": {
|
||||||
|
"title": "Du bist verbunden",
|
||||||
|
"importFailed": "Dein YouTube-Konto ist verbunden, aber der Import der Abos ist fehlgeschlagen — du kannst es unter Einstellungen → Synchronisierung erneut versuchen. ",
|
||||||
|
"feedReady": "Dein Feed ist bereit{{channels}}. Optional kannst du Siftlode erlauben, ",
|
||||||
|
"feedReadyChannels": " ({{count}} Kanäle)",
|
||||||
|
"rationale": "Kanäle auf YouTube für dich <0>abzubestellen</0> — dafür ist eine zusätzliche Schreibberechtigung nötig. Überspringe es, um schreibgeschützt zu bleiben; du kannst es später jederzeit in den Einstellungen aktivieren.",
|
||||||
|
"retryImport": "Import erneut versuchen",
|
||||||
|
"enableUnsubscribe": "Abbestellen aktivieren",
|
||||||
|
"keepReadOnly": "Nein danke — schreibgeschützt bleiben"
|
||||||
|
},
|
||||||
|
"done": {
|
||||||
|
"title": "Alles erledigt",
|
||||||
|
"body": "Lese- und Abbestell-Zugriff sind beide erteilt. Du kannst beide jederzeit unter Einstellungen → Konto oder in deinem Google-Konto überprüfen oder widerrufen.",
|
||||||
|
"done": "Fertig"
|
||||||
|
}
|
||||||
|
}
|
||||||
17
frontend/src/i18n/locales/de/player.json
Normal file
17
frontend/src/i18n/locales/de/player.json
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
{
|
||||||
|
"loading": "Wird geladen…",
|
||||||
|
"backToOriginal": "Zurück zum ursprünglichen Video",
|
||||||
|
"back": "Zurück",
|
||||||
|
"close": "Schließen",
|
||||||
|
"closeEsc": "Schließen (Esc)",
|
||||||
|
"description": "Beschreibung",
|
||||||
|
"noDescription": "Keine Beschreibung.",
|
||||||
|
"channel": "Kanal",
|
||||||
|
"views": "{{formattedCount}} Aufrufe",
|
||||||
|
"stream": "Stream",
|
||||||
|
"watched": "Angesehen",
|
||||||
|
"markWatched": "Als angesehen markieren",
|
||||||
|
"watchedUnmark": "Angesehen — zum Aufheben klicken",
|
||||||
|
"jumpToTime": "Zu dieser Stelle springen",
|
||||||
|
"playInApp": "Im integrierten Player abspielen"
|
||||||
|
}
|
||||||
101
frontend/src/i18n/locales/de/settings.json
Normal file
101
frontend/src/i18n/locales/de/settings.json
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
{
|
||||||
|
"title": "Einstellungen",
|
||||||
|
"tabs": {
|
||||||
|
"appearance": "Darstellung",
|
||||||
|
"notifications": "Benachrichtigungen",
|
||||||
|
"sync": "Synchronisierung",
|
||||||
|
"account": "Konto"
|
||||||
|
},
|
||||||
|
"appearance": {
|
||||||
|
"colorScheme": "Farbschema",
|
||||||
|
"display": "Anzeige",
|
||||||
|
"darkMode": "Dunkler Modus",
|
||||||
|
"listView": "Listenansicht",
|
||||||
|
"listViewHint": "Zeigt den Feed als kompakte Liste statt als Kartenraster an.",
|
||||||
|
"performanceMode": "Leistungsmodus",
|
||||||
|
"performanceModeHint": "Schaltet den durchscheinenden Glaseffekt und die weichen Schatten aus, für flüssigere Darstellung auf langsameren Geräten.",
|
||||||
|
"showHints": "Hinweise anzeigen",
|
||||||
|
"showHintsHint": "Diese kleinen Hover-Erklärungen. Schalte sie aus, sobald du dich auskennst.",
|
||||||
|
"textSize": "Schriftgröße"
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"title": "Benachrichtigungen",
|
||||||
|
"sound": "Ton bei Hinweisen",
|
||||||
|
"soundHint": "Spielt einen kurzen Ton ab, wenn eine Benachrichtigung deine Aufmerksamkeit braucht (z. B. Fehler, Abfragen).",
|
||||||
|
"autoDismiss": "Toasts automatisch ausblenden",
|
||||||
|
"autoDismissHint": "Ausgeschaltet bleiben Pop-up-Toasts, bis du sie schließt. Eingeschaltet blenden sie sich nach der eingestellten Zeit aus.",
|
||||||
|
"dismissAfter": "Ausblenden nach",
|
||||||
|
"sendTest": "Testbenachrichtigung senden",
|
||||||
|
"testTitle": "Testbenachrichtigung",
|
||||||
|
"testMessage": "So sieht eine Benachrichtigung aus."
|
||||||
|
},
|
||||||
|
"sync": {
|
||||||
|
"myStatus": "Mein Synchronisierungsstatus",
|
||||||
|
"channels": "Kanäle",
|
||||||
|
"channelsHint": "Wie viele Kanäle du abonniert hast.",
|
||||||
|
"recentSynced": "Neueste synchronisiert",
|
||||||
|
"recentSyncedHint": "Kanäle, deren neueste Uploads (~letztes Jahr) bereits in der lokalen Datenbank sind und so in deinem Feed erscheinen.",
|
||||||
|
"fullHistory": "Vollständiger Verlauf",
|
||||||
|
"fullHistoryHint": "Kanäle, deren gesamter Katalog abgerufen wurde, von denen, für die du den vollständigen Verlauf angefordert hast (pro Kanal auf der Kanäle-Seite aktivierbar).",
|
||||||
|
"fullHistoryEta": "Vollständiger Verlauf – Restzeit",
|
||||||
|
"fullHistoryEtaHint": "Grobe Schätzung, um das Nachladen des vollständigen Verlaufs von {{count}} ausstehenden Kanal/Kanälen abzuschließen, basierend auf dem gemeinsamen Tageskontingent.",
|
||||||
|
"myVideos": "Meine Videos",
|
||||||
|
"myVideosHint": "Gesamtzahl der dir verfügbaren Videos über deine abonnierten Kanäle.",
|
||||||
|
"quotaLeft": "Heute verbleibendes Kontingent",
|
||||||
|
"quotaLeftHint": "Heute verbleibendes gemeinsames YouTube-API-Budget (wird um Mitternacht US-Pazifik zurückgesetzt). Das Nachladen ordnet sich diesem unter.",
|
||||||
|
"loading": "Wird geladen…",
|
||||||
|
"apiUsage": "Deine API-Nutzung",
|
||||||
|
"today": "Heute",
|
||||||
|
"todayHint": "YouTube-API-Einheiten, die deine eigenen Aktionen heute verbraucht haben (wird um Mitternacht US-Pazifik zurückgesetzt). Die Hintergrund-Synchronisierung zählt hier nicht.",
|
||||||
|
"last7d": "Letzte 7 Tage",
|
||||||
|
"last30d": "Letzte 30 Tage",
|
||||||
|
"allTime": "Gesamt",
|
||||||
|
"byAction": "Nach Aktion (30 Tage)",
|
||||||
|
"noUsage": "Noch keine Nutzung.",
|
||||||
|
"actions": "Aktionen",
|
||||||
|
"syncSubscriptions": "Abos synchronisieren",
|
||||||
|
"syncSubscriptionsHint": "Importiert deine YouTube-Abos erneut und holt neu gefolgte Kanäle hinzu.",
|
||||||
|
"backfillEverything": "Alles nachladen",
|
||||||
|
"backfillEverythingHint": "Fordert das Nachladen des vollständigen Katalogs für jeden Kanal an, den du abonniert hast. Ältere Videos und die vollständige Suche werden gefüllt, soweit das gemeinsame Tageskontingent es zulässt.",
|
||||||
|
"synced": "{{count}} Abos synchronisiert",
|
||||||
|
"syncFailed": "Synchronisierung fehlgeschlagen",
|
||||||
|
"fullHistoryRequested": "Vollständiger Verlauf für {{count}} Kanäle angefordert",
|
||||||
|
"fullHistoryFailed": "Vollständiger Verlauf konnte nicht angefordert werden",
|
||||||
|
"admin": "Admin",
|
||||||
|
"adminPauseHint": "Pausiert oder setzt die Hintergrund-Synchronisierung für die gesamte App fort (alle Nutzer).",
|
||||||
|
"resumeBackgroundSync": "Hintergrund-Synchronisierung fortsetzen",
|
||||||
|
"pauseBackgroundSync": "Hintergrund-Synchronisierung pausieren"
|
||||||
|
},
|
||||||
|
"account": {
|
||||||
|
"title": "Konto",
|
||||||
|
"youtubeAccess": "YouTube-Zugriff",
|
||||||
|
"youtubeAccessIntro": "Die Anmeldung teilt nur deinen Namen und deine E-Mail-Adresse. Der YouTube-Zugriff wird separat erteilt, unten — jeder ist optional und jederzeit in deinem Google-Konto widerrufbar.",
|
||||||
|
"readTitle": "Abos lesen (dein Feed)",
|
||||||
|
"readGrantedHint": "Erteilt. Siftlode liest die Kanäle, denen du folgst, um deinen Feed aufzubauen. Damit ändert es nie dein YouTube-Konto.",
|
||||||
|
"readEnableHint": "Nur-Lese-Zugriff auf deine Abos — erforderlich, um deinen Feed aufzubauen. Siftlode kann damit nichts ändern.",
|
||||||
|
"writeTitle": "Abbestellen (Schreiben)",
|
||||||
|
"writeGrantedHint": "Erteilt. Du kannst Kanäle auf YouTube von innerhalb Siftlode abbestellen. Es schreibt nur, wenn du es anforderst.",
|
||||||
|
"writeEnableHint": "Ermöglicht Siftlode, Kanäle auf YouTube für dich abzubestellen. Optional — überspringe es, um im Nur-Lese-Modus zu bleiben.",
|
||||||
|
"granted": "Erteilt",
|
||||||
|
"enable": "Aktivieren",
|
||||||
|
"enableHint": "Leitet zu Google weiter, um die Berechtigung zu erteilen. Google zeigt einen Hinweis „nicht verifizierte App“ — das ist zu erwarten; klicke auf Erweitert → Weiter zu Siftlode.",
|
||||||
|
"walkMeThrough": "Führe mich durch die Einrichtung"
|
||||||
|
},
|
||||||
|
"invites": {
|
||||||
|
"title": "Zugriffsanfragen",
|
||||||
|
"noPending": "Keine ausstehenden Anfragen.",
|
||||||
|
"addPlaceholder": "E-Mail direkt hinzufügen…",
|
||||||
|
"add": "Hinzufügen",
|
||||||
|
"decided": "{{count}} entschieden",
|
||||||
|
"approved": "Genehmigt — sie können sich jetzt anmelden",
|
||||||
|
"approveFailed": "Genehmigung fehlgeschlagen",
|
||||||
|
"denyFailed": "Ablehnung fehlgeschlagen",
|
||||||
|
"addedToWhitelist": "Zur Whitelist hinzugefügt",
|
||||||
|
"addFailed": "Diese E-Mail konnte nicht hinzugefügt werden",
|
||||||
|
"requested": "angefragt {{time}}",
|
||||||
|
"approveHint": "Genehmigen — setzt diese E-Mail auf die Whitelist und benachrichtigt sie per E-Mail, dass sie dabei sind.",
|
||||||
|
"approve": "Genehmigen",
|
||||||
|
"denyHint": "Diese Anfrage ablehnen.",
|
||||||
|
"deny": "Ablehnen"
|
||||||
|
}
|
||||||
|
}
|
||||||
65
frontend/src/i18n/locales/de/sidebar.json
Normal file
65
frontend/src/i18n/locales/de/sidebar.json
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
{
|
||||||
|
"filters": "Filter",
|
||||||
|
"activeCount": "{{count}} aktiv",
|
||||||
|
"clearAll": "Alle löschen",
|
||||||
|
"resetDefaults": "Auf Standard zurücksetzen",
|
||||||
|
"customize": "Seitenleiste anpassen",
|
||||||
|
"done": "Fertig",
|
||||||
|
"editHint": "Zum Umordnen ziehen · Auge zum Ein-/Ausblenden",
|
||||||
|
"channel": "Kanal",
|
||||||
|
"loading": "Wird geladen…",
|
||||||
|
"thisChannel": "Dieser Kanal",
|
||||||
|
"channelCount": "{{count}} Kanal",
|
||||||
|
"channelCount_other": "{{count}} Kanäle",
|
||||||
|
"dragToReorder": "Zum Umordnen ziehen",
|
||||||
|
"showWidget": "Widget einblenden",
|
||||||
|
"hideWidget": "Widget ausblenden",
|
||||||
|
"expand": "Ausklappen",
|
||||||
|
"collapse": "Einklappen",
|
||||||
|
"any": "Beliebig",
|
||||||
|
"all": "Alle",
|
||||||
|
"tagModeTooltip": "Beliebige vs. alle ausgewählten Tags treffen",
|
||||||
|
"custom": "Benutzerdefiniert",
|
||||||
|
"from": "Von",
|
||||||
|
"to": "Bis",
|
||||||
|
"clearDates": "Daten löschen",
|
||||||
|
"widget": {
|
||||||
|
"show": "Anzeigen",
|
||||||
|
"sort": "Sortierung",
|
||||||
|
"date": "Upload-Datum",
|
||||||
|
"content": "Inhaltstyp",
|
||||||
|
"language": "Sprache",
|
||||||
|
"topic": "Thema"
|
||||||
|
},
|
||||||
|
"show": {
|
||||||
|
"unwatched": "Ungesehen",
|
||||||
|
"in_progress": "Angefangen",
|
||||||
|
"all": "Alle",
|
||||||
|
"watched": "Angesehen",
|
||||||
|
"saved": "Gespeichert",
|
||||||
|
"hidden": "Ausgeblendet"
|
||||||
|
},
|
||||||
|
"sort": {
|
||||||
|
"newest": "Neueste",
|
||||||
|
"oldest": "Älteste",
|
||||||
|
"views": "Meistgesehen",
|
||||||
|
"duration_desc": "Längste",
|
||||||
|
"duration_asc": "Kürzeste",
|
||||||
|
"title": "Name (A–Z)",
|
||||||
|
"subscribers": "Kanal-Abonnenten",
|
||||||
|
"priority": "Kanal-Priorität",
|
||||||
|
"shuffle": "Überrasch mich"
|
||||||
|
},
|
||||||
|
"content": {
|
||||||
|
"normal": "Normal",
|
||||||
|
"shorts": "Shorts",
|
||||||
|
"live": "Live / Demnächst"
|
||||||
|
},
|
||||||
|
"datePreset": {
|
||||||
|
"24h": "24 Std.",
|
||||||
|
"1week": "1 Woche",
|
||||||
|
"1month": "1 Monat",
|
||||||
|
"6months": "6 Monate",
|
||||||
|
"1year": "1 Jahr"
|
||||||
|
}
|
||||||
|
}
|
||||||
13
frontend/src/i18n/locales/de/stats.json
Normal file
13
frontend/src/i18n/locales/de/stats.json
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
{
|
||||||
|
"title": "API-Kontingentnutzung",
|
||||||
|
"rangeDays": "{{count}} T",
|
||||||
|
"introBefore": "YouTube-Data-API-Einheiten, zugeordnet danach, wer die Nutzung ausgelöst hat. ",
|
||||||
|
"introSystem": "System",
|
||||||
|
"introAfter": " ist gemeinsame Hintergrundarbeit (geplantes Nachladen, Anreicherung, erneute Synchronisierung), die nicht an eine Person gebunden ist.",
|
||||||
|
"loading": "Wird geladen…",
|
||||||
|
"noData": "Keine Daten.",
|
||||||
|
"dailyTotal": "Tagessumme ({{count}} T · {{units}} Einheiten)",
|
||||||
|
"noUsageInRange": "Keine Nutzung in diesem Zeitraum.",
|
||||||
|
"dailyTooltip": "{{day}}: {{units}} Einheiten",
|
||||||
|
"system": "System (Hintergrund)"
|
||||||
|
}
|
||||||
10
frontend/src/i18n/locales/de/time.json
Normal file
10
frontend/src/i18n/locales/de/time.json
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
{
|
||||||
|
"justNow": "gerade eben",
|
||||||
|
"secondsAgo": "vor {{count}} Sek.",
|
||||||
|
"minutesAgo": "vor {{count}} Min.",
|
||||||
|
"hoursAgo": "vor {{count}} Std.",
|
||||||
|
"daysAgo": "vor {{count}} T.",
|
||||||
|
"weeksAgo": "vor {{count}} Wo.",
|
||||||
|
"monthsAgo": "vor {{count}} Mon.",
|
||||||
|
"yearsAgo": "vor {{count}} J."
|
||||||
|
}
|
||||||
17
frontend/src/i18n/locales/en/card.json
Normal file
17
frontend/src/i18n/locales/en/card.json
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
{
|
||||||
|
"watchedUnmark": "Watched — click to unmark",
|
||||||
|
"markWatched": "Mark watched",
|
||||||
|
"savedRemove": "Saved — click to remove",
|
||||||
|
"saveForLater": "Save for later",
|
||||||
|
"unhide": "Unhide",
|
||||||
|
"hide": "Hide",
|
||||||
|
"onlyThisChannel": "Only this channel",
|
||||||
|
"thisChannel": "This channel",
|
||||||
|
"continueTitle": "Continue where you left off",
|
||||||
|
"continue": "Continue",
|
||||||
|
"restartTitle": "Play from the beginning",
|
||||||
|
"restart": "Restart",
|
||||||
|
"play": "Play",
|
||||||
|
"stream": "stream",
|
||||||
|
"views": "views"
|
||||||
|
}
|
||||||
69
frontend/src/i18n/locales/en/channels.json
Normal file
69
frontend/src/i18n/locales/en/channels.json
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
{
|
||||||
|
"intro": "Set a channel's <0>priority</0> to push its videos up when you sort by “Channel priority”, attach your own <1>tags</1> to filter the feed, or <2>hide</2> a channel to drop it from the feed without unsubscribing.",
|
||||||
|
"filterPlaceholder": "Filter channels…",
|
||||||
|
"syncSubscriptions": "Sync subscriptions",
|
||||||
|
"syncSubscriptionsHint": "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.",
|
||||||
|
"backfillEverything": "Backfill everything",
|
||||||
|
"backfillEverythingHint": "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.",
|
||||||
|
"filters": {
|
||||||
|
"all": "All",
|
||||||
|
"needsFull": "Needs full history",
|
||||||
|
"fullySynced": "Fully synced",
|
||||||
|
"hidden": "Hidden"
|
||||||
|
},
|
||||||
|
"tags": {
|
||||||
|
"yourTags": "Your tags",
|
||||||
|
"yourTagsHint": "Your personal labels. Attach them to channels below, then filter the feed by tag from the sidebar. (Separate from the automatic language/topic tags.)",
|
||||||
|
"newTag": "new tag",
|
||||||
|
"createTag": "Create tag"
|
||||||
|
},
|
||||||
|
"loading": "Loading channels…",
|
||||||
|
"empty": "No channels.",
|
||||||
|
"stats": {
|
||||||
|
"channels": "Channels",
|
||||||
|
"channelsHint": "Channels you're subscribed to.",
|
||||||
|
"recentSynced": "Recent synced",
|
||||||
|
"recentSyncedHint": "Channels whose recent uploads are in the local DB and show in your feed.",
|
||||||
|
"fullHistory": "Full history",
|
||||||
|
"fullHistoryHint": "Channels whose entire back-catalog is fetched, out of the ones you've requested full history for.",
|
||||||
|
"left": "left",
|
||||||
|
"leftHint": "Rough estimate to finish full-history backfill of {{count}} pending channel(s), based on the shared daily quota.",
|
||||||
|
"myVideos": "My videos",
|
||||||
|
"myVideosHint": "Total videos available across your channels.",
|
||||||
|
"quotaLeft": "Quota left",
|
||||||
|
"quotaLeftHint": "Shared YouTube API budget left today (resets midnight US Pacific)."
|
||||||
|
},
|
||||||
|
"row": {
|
||||||
|
"stored": "{{formatted}} stored",
|
||||||
|
"subs": "{{formatted}} subs",
|
||||||
|
"priorityHint": "Your ranking for this channel. Sort the feed by “Channel priority” to bring higher-priority channels to the top.",
|
||||||
|
"raisePriority": "Raise priority",
|
||||||
|
"lowerPriority": "Lower priority",
|
||||||
|
"recent": "recent",
|
||||||
|
"recentSyncedHint": "Recent uploads synced.",
|
||||||
|
"recentNotSyncedHint": "Recent uploads not fetched yet.",
|
||||||
|
"full": "full",
|
||||||
|
"fullHint": "Full history fetched.",
|
||||||
|
"fullHistoryQueued": "full history queued",
|
||||||
|
"queuedRequestedHint": "Full history requested — this channel's whole back-catalog will backfill as the shared quota allows. Click to cancel your request.",
|
||||||
|
"queuedByOtherHint": "Another subscriber already requested this channel's full history, so its whole back-catalog is on its way to everyone — nothing to do here.",
|
||||||
|
"getFullHistory": "get full history",
|
||||||
|
"getFullHistoryHint": "Only recent uploads so far. Click to request this channel's full back-catalog (older videos + complete search).",
|
||||||
|
"hiddenHint": "Hidden — this channel's videos are kept out of your feed. Click to show them again.",
|
||||||
|
"hideHint": "Hide this channel's videos from your feed. It stays subscribed; this doesn't unsubscribe on YouTube.",
|
||||||
|
"unhide": "Unhide",
|
||||||
|
"hideFromFeed": "Hide from feed",
|
||||||
|
"unsubscribeHint": "Unsubscribe from this channel on YouTube (changes your real account). Read-only mode hides this — use Hide instead.",
|
||||||
|
"unsubscribeOnYoutube": "Unsubscribe on YouTube",
|
||||||
|
"thisChannel": "This channel"
|
||||||
|
},
|
||||||
|
"notify": {
|
||||||
|
"synced": "Synced {{count}} subscriptions",
|
||||||
|
"syncFailed": "Subscription sync failed",
|
||||||
|
"unsubscribed": "Unsubscribed on YouTube",
|
||||||
|
"unsubscribeFailed": "Unsubscribe failed",
|
||||||
|
"fullHistoryRequested": "Full history requested for {{count}} channels",
|
||||||
|
"fullHistoryFailed": "Couldn't request full history"
|
||||||
|
},
|
||||||
|
"confirmUnsubscribe": "Unsubscribe from \"{{name}}\" on YouTube? This changes your real YouTube account. To just remove it from your feed, hide it instead."
|
||||||
|
}
|
||||||
9
frontend/src/i18n/locales/en/errors.json
Normal file
9
frontend/src/i18n/locales/en/errors.json
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"boundary": {
|
||||||
|
"title": "Something went wrong.",
|
||||||
|
"subtitle": "The app hit an unexpected error.",
|
||||||
|
"reload": "Reload",
|
||||||
|
"notifTitle": "Something broke",
|
||||||
|
"notifMessage": "Unexpected error"
|
||||||
|
}
|
||||||
|
}
|
||||||
17
frontend/src/i18n/locales/en/feed.json
Normal file
17
frontend/src/i18n/locales/en/feed.json
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
{
|
||||||
|
"loading": "Loading feed…",
|
||||||
|
"loadError": "Couldn't load the feed.",
|
||||||
|
"emptyTitle": "Your feed is empty",
|
||||||
|
"emptyBody": "Connect your YouTube account to import your subscriptions and build your feed.",
|
||||||
|
"setUp": "Set up my feed",
|
||||||
|
"noMatches": "No videos match these filters.",
|
||||||
|
"videoCount_one": "{{formattedCount}} video",
|
||||||
|
"videoCount_other": "{{formattedCount}} videos",
|
||||||
|
"loadingMore": "Loading more…",
|
||||||
|
"hiddenNamed": "Hidden “{{title}}”",
|
||||||
|
"hidden": "Video hidden",
|
||||||
|
"undo": "Undo",
|
||||||
|
"markedWatchedNamed": "Marked watched “{{title}}”",
|
||||||
|
"markedWatched": "Marked watched",
|
||||||
|
"unwatch": "Unwatch"
|
||||||
|
}
|
||||||
19
frontend/src/i18n/locales/en/notifications.json
Normal file
19
frontend/src/i18n/locales/en/notifications.json
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
{
|
||||||
|
"title": "Notifications",
|
||||||
|
"clearAll": "Clear all",
|
||||||
|
"clear": "Clear",
|
||||||
|
"empty": "No notifications yet.",
|
||||||
|
"actionNeeded": "Action needed",
|
||||||
|
"dismiss": "Dismiss",
|
||||||
|
"findInFeed": "Find in feed",
|
||||||
|
"unhide": "Unhide",
|
||||||
|
"unwatch": "Unwatch",
|
||||||
|
"unhidden": "Unhidden “{{title}}”",
|
||||||
|
"unwatched": "Unwatched “{{title}}”",
|
||||||
|
"time": {
|
||||||
|
"justNow": "just now",
|
||||||
|
"minutes": "{{count}}m ago",
|
||||||
|
"hours": "{{count}}h ago",
|
||||||
|
"days": "{{count}}d ago"
|
||||||
|
}
|
||||||
|
}
|
||||||
29
frontend/src/i18n/locales/en/onboarding.json
Normal file
29
frontend/src/i18n/locales/en/onboarding.json
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
{
|
||||||
|
"close": "Close",
|
||||||
|
"consent": "Google will show a <0>\"Google hasn't verified this app\"</0> screen — that's expected, because Siftlode hasn't gone through Google's full review. It's safe to continue: click <1>Advanced</1> → <2>Go to Siftlode</2>. You can revoke access anytime from your <3>Google Account</3>.",
|
||||||
|
"read": {
|
||||||
|
"title": "Connect your YouTube subscriptions",
|
||||||
|
"body": "You're signed in. To build your feed, Siftlode needs <0>read-only</0> access to the channels you're subscribed to on YouTube. It never posts, deletes, or changes anything with this.",
|
||||||
|
"connect": "Connect (read-only)",
|
||||||
|
"skip": "Skip for now"
|
||||||
|
},
|
||||||
|
"importing": {
|
||||||
|
"title": "Building your feed…",
|
||||||
|
"body": "Importing your YouTube subscriptions. Channels already in Siftlode show up instantly; any new ones fill in automatically in the background."
|
||||||
|
},
|
||||||
|
"write": {
|
||||||
|
"title": "You're connected",
|
||||||
|
"importFailed": "Your YouTube account is connected, but importing subscriptions failed — you can retry from Settings → Sync. ",
|
||||||
|
"feedReady": "Your feed is ready{{channels}}. Optionally, let Siftlode ",
|
||||||
|
"feedReadyChannels": " ({{count}} channels)",
|
||||||
|
"rationale": "<0>unsubscribe</0> 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.",
|
||||||
|
"retryImport": "Retry import",
|
||||||
|
"enableUnsubscribe": "Enable unsubscribe",
|
||||||
|
"keepReadOnly": "No thanks — keep it read-only"
|
||||||
|
},
|
||||||
|
"done": {
|
||||||
|
"title": "All set",
|
||||||
|
"body": "Read and unsubscribe access are both granted. You can review or revoke either one anytime in Settings → Account, or from your Google Account.",
|
||||||
|
"done": "Done"
|
||||||
|
}
|
||||||
|
}
|
||||||
17
frontend/src/i18n/locales/en/player.json
Normal file
17
frontend/src/i18n/locales/en/player.json
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
{
|
||||||
|
"loading": "Loading…",
|
||||||
|
"backToOriginal": "Back to the original video",
|
||||||
|
"back": "Back",
|
||||||
|
"close": "Close",
|
||||||
|
"closeEsc": "Close (Esc)",
|
||||||
|
"description": "Description",
|
||||||
|
"noDescription": "No description.",
|
||||||
|
"channel": "Channel",
|
||||||
|
"views": "{{formattedCount}} views",
|
||||||
|
"stream": "stream",
|
||||||
|
"watched": "Watched",
|
||||||
|
"markWatched": "Mark watched",
|
||||||
|
"watchedUnmark": "Watched — click to unmark",
|
||||||
|
"jumpToTime": "Jump to this time",
|
||||||
|
"playInApp": "Play in the in-app player"
|
||||||
|
}
|
||||||
101
frontend/src/i18n/locales/en/settings.json
Normal file
101
frontend/src/i18n/locales/en/settings.json
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
{
|
||||||
|
"title": "Settings",
|
||||||
|
"tabs": {
|
||||||
|
"appearance": "Appearance",
|
||||||
|
"notifications": "Notifications",
|
||||||
|
"sync": "Sync",
|
||||||
|
"account": "Account"
|
||||||
|
},
|
||||||
|
"appearance": {
|
||||||
|
"colorScheme": "Color scheme",
|
||||||
|
"display": "Display",
|
||||||
|
"darkMode": "Dark mode",
|
||||||
|
"listView": "List view",
|
||||||
|
"listViewHint": "Show the feed as a compact list instead of a grid of cards.",
|
||||||
|
"performanceMode": "Performance mode",
|
||||||
|
"performanceModeHint": "Turns off the translucent glass blur and soft shadows for snappier rendering on slower machines.",
|
||||||
|
"showHints": "Show hints",
|
||||||
|
"showHintsHint": "These little hover explanations. Turn them off once you know your way around.",
|
||||||
|
"textSize": "Text size"
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"title": "Notifications",
|
||||||
|
"sound": "Sound on alerts",
|
||||||
|
"soundHint": "Play a short tone when a notification needs your attention (e.g. errors, prompts).",
|
||||||
|
"autoDismiss": "Auto-dismiss toasts",
|
||||||
|
"autoDismissHint": "When off, pop-up toasts stay until you close them. When on, they fade after the set time.",
|
||||||
|
"dismissAfter": "Dismiss after",
|
||||||
|
"sendTest": "Send a test notification",
|
||||||
|
"testTitle": "Test notification",
|
||||||
|
"testMessage": "This is what a notification looks like."
|
||||||
|
},
|
||||||
|
"sync": {
|
||||||
|
"myStatus": "My sync status",
|
||||||
|
"channels": "Channels",
|
||||||
|
"channelsHint": "How many channels you're subscribed to.",
|
||||||
|
"recentSynced": "Recent synced",
|
||||||
|
"recentSyncedHint": "Channels whose recent uploads (~last year) are already in the local database, so they show in your feed.",
|
||||||
|
"fullHistory": "Full history",
|
||||||
|
"fullHistoryHint": "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).",
|
||||||
|
"fullHistoryEta": "Full history ETA",
|
||||||
|
"fullHistoryEtaHint": "Rough estimate to finish full-history backfill of {{count}} pending channel(s), based on the shared daily quota.",
|
||||||
|
"myVideos": "My videos",
|
||||||
|
"myVideosHint": "Total videos available to you across your subscribed channels.",
|
||||||
|
"quotaLeft": "Quota left today",
|
||||||
|
"quotaLeftHint": "Shared YouTube API budget remaining today (resets midnight US Pacific). Backfill yields to it.",
|
||||||
|
"loading": "Loading…",
|
||||||
|
"apiUsage": "Your API usage",
|
||||||
|
"today": "Today",
|
||||||
|
"todayHint": "YouTube API units your own actions used today (resets midnight US Pacific). Background sync isn't counted here.",
|
||||||
|
"last7d": "Last 7 days",
|
||||||
|
"last30d": "Last 30 days",
|
||||||
|
"allTime": "All time",
|
||||||
|
"byAction": "By action (30d)",
|
||||||
|
"noUsage": "No usage yet.",
|
||||||
|
"actions": "Actions",
|
||||||
|
"syncSubscriptions": "Sync subscriptions",
|
||||||
|
"syncSubscriptionsHint": "Re-import your YouTube subscriptions and pull in any newly-followed channels.",
|
||||||
|
"backfillEverything": "Backfill everything",
|
||||||
|
"backfillEverythingHint": "Request full back-catalog backfill for every channel you're subscribed to. Older videos + complete search fill in as the shared daily quota allows.",
|
||||||
|
"synced": "Synced {{count}} subscriptions",
|
||||||
|
"syncFailed": "Sync failed",
|
||||||
|
"fullHistoryRequested": "Full history requested for {{count}} channels",
|
||||||
|
"fullHistoryFailed": "Couldn't request full history",
|
||||||
|
"admin": "Admin",
|
||||||
|
"adminPauseHint": "Pause or resume the background sync for the whole app (all users).",
|
||||||
|
"resumeBackgroundSync": "Resume background sync",
|
||||||
|
"pauseBackgroundSync": "Pause background sync"
|
||||||
|
},
|
||||||
|
"account": {
|
||||||
|
"title": "Account",
|
||||||
|
"youtubeAccess": "YouTube access",
|
||||||
|
"youtubeAccessIntro": "Sign-in only shares your name and email. YouTube access is granted separately, below — each is optional and revocable anytime in your Google Account.",
|
||||||
|
"readTitle": "Read subscriptions (your feed)",
|
||||||
|
"readGrantedHint": "Granted. Siftlode reads the channels you follow to build your feed. It never changes your YouTube account with this.",
|
||||||
|
"readEnableHint": "Read-only access to your subscriptions — required to build your feed. Siftlode can't modify anything with it.",
|
||||||
|
"writeTitle": "Unsubscribe (write)",
|
||||||
|
"writeGrantedHint": "Granted. You can unsubscribe from channels on YouTube from inside Siftlode. It only writes when you ask it to.",
|
||||||
|
"writeEnableHint": "Lets Siftlode unsubscribe from channels on YouTube for you. Optional — skip it to stay read-only.",
|
||||||
|
"granted": "Granted",
|
||||||
|
"enable": "Enable",
|
||||||
|
"enableHint": "Redirects to Google to grant the permission. Google shows an 'unverified app' notice — that's expected; click Advanced → Go to Siftlode.",
|
||||||
|
"walkMeThrough": "Walk me through setup"
|
||||||
|
},
|
||||||
|
"invites": {
|
||||||
|
"title": "Access requests",
|
||||||
|
"noPending": "No pending requests.",
|
||||||
|
"addPlaceholder": "Add an email directly…",
|
||||||
|
"add": "Add",
|
||||||
|
"decided": "{{count}} decided",
|
||||||
|
"approved": "Approved — they can sign in now",
|
||||||
|
"approveFailed": "Approve failed",
|
||||||
|
"denyFailed": "Deny failed",
|
||||||
|
"addedToWhitelist": "Added to the whitelist",
|
||||||
|
"addFailed": "Couldn't add that email",
|
||||||
|
"requested": "requested {{time}}",
|
||||||
|
"approveHint": "Approve — whitelist this email and email them they're in.",
|
||||||
|
"approve": "Approve",
|
||||||
|
"denyHint": "Deny this request.",
|
||||||
|
"deny": "Deny"
|
||||||
|
}
|
||||||
|
}
|
||||||
65
frontend/src/i18n/locales/en/sidebar.json
Normal file
65
frontend/src/i18n/locales/en/sidebar.json
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
{
|
||||||
|
"filters": "Filters",
|
||||||
|
"activeCount": "{{count}} active",
|
||||||
|
"clearAll": "Clear all",
|
||||||
|
"resetDefaults": "Reset to defaults",
|
||||||
|
"customize": "Customize sidebar",
|
||||||
|
"done": "Done",
|
||||||
|
"editHint": "Drag to reorder · eye to show/hide",
|
||||||
|
"channel": "Channel",
|
||||||
|
"loading": "Loading…",
|
||||||
|
"thisChannel": "This channel",
|
||||||
|
"channelCount": "{{count}} channel",
|
||||||
|
"channelCount_other": "{{count}} channels",
|
||||||
|
"dragToReorder": "Drag to reorder",
|
||||||
|
"showWidget": "Show widget",
|
||||||
|
"hideWidget": "Hide widget",
|
||||||
|
"expand": "Expand",
|
||||||
|
"collapse": "Collapse",
|
||||||
|
"any": "Any",
|
||||||
|
"all": "All",
|
||||||
|
"tagModeTooltip": "Match any vs all selected tags",
|
||||||
|
"custom": "Custom",
|
||||||
|
"from": "From",
|
||||||
|
"to": "To",
|
||||||
|
"clearDates": "clear dates",
|
||||||
|
"widget": {
|
||||||
|
"show": "Show",
|
||||||
|
"sort": "Sort",
|
||||||
|
"date": "Upload date",
|
||||||
|
"content": "Content type",
|
||||||
|
"language": "Language",
|
||||||
|
"topic": "Topic"
|
||||||
|
},
|
||||||
|
"show": {
|
||||||
|
"unwatched": "Unwatched",
|
||||||
|
"in_progress": "In progress",
|
||||||
|
"all": "All",
|
||||||
|
"watched": "Watched",
|
||||||
|
"saved": "Saved",
|
||||||
|
"hidden": "Hidden"
|
||||||
|
},
|
||||||
|
"sort": {
|
||||||
|
"newest": "Newest",
|
||||||
|
"oldest": "Oldest",
|
||||||
|
"views": "Most viewed",
|
||||||
|
"duration_desc": "Longest",
|
||||||
|
"duration_asc": "Shortest",
|
||||||
|
"title": "Name (A–Z)",
|
||||||
|
"subscribers": "Channel subscribers",
|
||||||
|
"priority": "Channel priority",
|
||||||
|
"shuffle": "Surprise me"
|
||||||
|
},
|
||||||
|
"content": {
|
||||||
|
"normal": "Normal",
|
||||||
|
"shorts": "Shorts",
|
||||||
|
"live": "Live / Upcoming"
|
||||||
|
},
|
||||||
|
"datePreset": {
|
||||||
|
"24h": "24h",
|
||||||
|
"1week": "1 week",
|
||||||
|
"1month": "1 month",
|
||||||
|
"6months": "6 months",
|
||||||
|
"1year": "1 year"
|
||||||
|
}
|
||||||
|
}
|
||||||
13
frontend/src/i18n/locales/en/stats.json
Normal file
13
frontend/src/i18n/locales/en/stats.json
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
{
|
||||||
|
"title": "API quota usage",
|
||||||
|
"rangeDays": "{{count}}d",
|
||||||
|
"introBefore": "YouTube Data API units attributed by who triggered the spend. ",
|
||||||
|
"introSystem": "System",
|
||||||
|
"introAfter": " is shared background work (scheduled backfill, enrichment, resync) that isn't tied to one person.",
|
||||||
|
"loading": "Loading…",
|
||||||
|
"noData": "No data.",
|
||||||
|
"dailyTotal": "Daily total ({{count}}d · {{units}} units)",
|
||||||
|
"noUsageInRange": "No usage in this range.",
|
||||||
|
"dailyTooltip": "{{day}}: {{units}} units",
|
||||||
|
"system": "System (background)"
|
||||||
|
}
|
||||||
10
frontend/src/i18n/locales/en/time.json
Normal file
10
frontend/src/i18n/locales/en/time.json
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
{
|
||||||
|
"justNow": "just now",
|
||||||
|
"secondsAgo": "{{count}}s ago",
|
||||||
|
"minutesAgo": "{{count}} min ago",
|
||||||
|
"hoursAgo": "{{count}}h ago",
|
||||||
|
"daysAgo": "{{count}}d ago",
|
||||||
|
"weeksAgo": "{{count}} wk ago",
|
||||||
|
"monthsAgo": "{{count}} mo ago",
|
||||||
|
"yearsAgo": "{{count}} yr ago"
|
||||||
|
}
|
||||||
17
frontend/src/i18n/locales/hu/card.json
Normal file
17
frontend/src/i18n/locales/hu/card.json
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
{
|
||||||
|
"watchedUnmark": "Megnézve — kattints a visszavonáshoz",
|
||||||
|
"markWatched": "Megnézettnek jelöl",
|
||||||
|
"savedRemove": "Mentve — kattints az eltávolításhoz",
|
||||||
|
"saveForLater": "Mentés későbbre",
|
||||||
|
"unhide": "Megjelenítés",
|
||||||
|
"hide": "Elrejtés",
|
||||||
|
"onlyThisChannel": "Csak ez a csatorna",
|
||||||
|
"thisChannel": "Ez a csatorna",
|
||||||
|
"continueTitle": "Folytatás ott, ahol abbahagytad",
|
||||||
|
"continue": "Folytatás",
|
||||||
|
"restartTitle": "Lejátszás az elejétől",
|
||||||
|
"restart": "Elölről",
|
||||||
|
"play": "Lejátszás",
|
||||||
|
"stream": "stream",
|
||||||
|
"views": "megtekintés"
|
||||||
|
}
|
||||||
69
frontend/src/i18n/locales/hu/channels.json
Normal file
69
frontend/src/i18n/locales/hu/channels.json
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
{
|
||||||
|
"intro": "Állíts be egy csatornának <0>prioritást</0>, hogy a videói előrébb kerüljenek, amikor „Csatorna prioritás” szerint rendezel, csatolj saját <1>címkéket</1> a hírfolyam szűréséhez, vagy <2>rejts el</2> egy csatornát, hogy leiratkozás nélkül kivedd a hírfolyamból.",
|
||||||
|
"filterPlaceholder": "Csatornák szűrése…",
|
||||||
|
"syncSubscriptions": "Feliratkozások szinkronizálása",
|
||||||
|
"syncSubscriptionsHint": "Újraimportálja a feliratkozási listádat a YouTube-ról — hozzáadja az újonnan követett csatornákat, és eltávolítja azokat, amelyekről leiratkoztál. Maguk a videók a háttérben automatikusan tovább szinkronizálódnak; ez nem tölti le őket újra.",
|
||||||
|
"backfillEverything": "Minden letöltése",
|
||||||
|
"backfillEverythingHint": "Teljes archívum letöltését kéri minden csatornához, amelyre feliratkoztál. A régebbi videók és a keresés a megosztott napi kvóta függvényében válik teljessé — ez eltarthat egy ideig.",
|
||||||
|
"filters": {
|
||||||
|
"all": "Mind",
|
||||||
|
"needsFull": "Hiányos előzmény",
|
||||||
|
"fullySynced": "Teljesen szinkronizálva",
|
||||||
|
"hidden": "Elrejtett"
|
||||||
|
},
|
||||||
|
"tags": {
|
||||||
|
"yourTags": "Címkéid",
|
||||||
|
"yourTagsHint": "Saját személyes címkéid. Csatold őket az alábbi csatornákhoz, majd szűrd a hírfolyamot címke szerint az oldalsávból. (Az automatikus nyelvi/téma címkéktől függetlenül.)",
|
||||||
|
"newTag": "új címke",
|
||||||
|
"createTag": "Címke létrehozása"
|
||||||
|
},
|
||||||
|
"loading": "Csatornák betöltése…",
|
||||||
|
"empty": "Nincsenek csatornák.",
|
||||||
|
"stats": {
|
||||||
|
"channels": "Csatorna",
|
||||||
|
"channelsHint": "Csatornák, amelyekre feliratkoztál.",
|
||||||
|
"recentSynced": "Legutóbbi szinkronizálva",
|
||||||
|
"recentSyncedHint": "Csatornák, amelyek legutóbbi feltöltései a helyi adatbázisban vannak, és megjelennek a hírfolyamodban.",
|
||||||
|
"fullHistory": "Teljes előzmény",
|
||||||
|
"fullHistoryHint": "Csatornák, amelyek teljes archívuma le van töltve, azok közül, amelyekhez teljes előzményt kértél.",
|
||||||
|
"left": "hátra",
|
||||||
|
"leftHint": "Hozzávetőleges becslés {{count}} függőben lévő csatorna teljes előzményének letöltésére, a megosztott napi kvóta alapján.",
|
||||||
|
"myVideos": "Videóim",
|
||||||
|
"myVideosHint": "A csatornáidon elérhető videók teljes száma.",
|
||||||
|
"quotaLeft": "Maradék kvóta",
|
||||||
|
"quotaLeftHint": "A ma még hátralévő megosztott YouTube API-keret (csendes-óceáni idő szerint éjfélkor nullázódik)."
|
||||||
|
},
|
||||||
|
"row": {
|
||||||
|
"stored": "{{formatted}} tárolt",
|
||||||
|
"subs": "{{formatted}} feliratkozó",
|
||||||
|
"priorityHint": "A te rangsorod ehhez a csatornához. Rendezd a hírfolyamot „Csatorna prioritás” szerint, hogy a magasabb prioritású csatornák felülre kerüljenek.",
|
||||||
|
"raisePriority": "Prioritás növelése",
|
||||||
|
"lowerPriority": "Prioritás csökkentése",
|
||||||
|
"recent": "legutóbbi",
|
||||||
|
"recentSyncedHint": "Legutóbbi feltöltések szinkronizálva.",
|
||||||
|
"recentNotSyncedHint": "A legutóbbi feltöltések még nincsenek letöltve.",
|
||||||
|
"full": "teljes",
|
||||||
|
"fullHint": "Teljes előzmény letöltve.",
|
||||||
|
"fullHistoryQueued": "teljes előzmény sorban",
|
||||||
|
"queuedRequestedHint": "Teljes előzmény kérve — a csatorna teljes archívuma letöltődik, ahogy a megosztott kvóta engedi. Kattints a kérés visszavonásához.",
|
||||||
|
"queuedByOtherHint": "Egy másik feliratkozó már kérte ennek a csatornának a teljes előzményét, így a teljes archívuma már úton van mindenkihez — itt nincs teendőd.",
|
||||||
|
"getFullHistory": "teljes előzmény lekérése",
|
||||||
|
"getFullHistoryHint": "Egyelőre csak a legutóbbi feltöltések. Kattints a csatorna teljes archívumának lekéréséhez (régebbi videók + teljes keresés).",
|
||||||
|
"hiddenHint": "Elrejtve — a csatorna videói nem jelennek meg a hírfolyamodban. Kattints, hogy újra láthatóak legyenek.",
|
||||||
|
"hideHint": "A csatorna videóinak elrejtése a hírfolyamodból. Feliratkozva marad; ez nem iratkoztat le a YouTube-on.",
|
||||||
|
"unhide": "Megjelenítés",
|
||||||
|
"hideFromFeed": "Elrejtés a hírfolyamból",
|
||||||
|
"unsubscribeHint": "Leiratkozás erről a csatornáról a YouTube-on (megváltoztatja a valódi fiókodat). Csak olvasható módban ez rejtve van — használd helyette az Elrejtést.",
|
||||||
|
"unsubscribeOnYoutube": "Leiratkozás a YouTube-on",
|
||||||
|
"thisChannel": "Ez a csatorna"
|
||||||
|
},
|
||||||
|
"notify": {
|
||||||
|
"synced": "{{count}} feliratkozás szinkronizálva",
|
||||||
|
"syncFailed": "A feliratkozások szinkronizálása sikertelen",
|
||||||
|
"unsubscribed": "Leiratkozva a YouTube-on",
|
||||||
|
"unsubscribeFailed": "A leiratkozás sikertelen",
|
||||||
|
"fullHistoryRequested": "Teljes előzmény kérve {{count}} csatornához",
|
||||||
|
"fullHistoryFailed": "Nem sikerült teljes előzményt kérni"
|
||||||
|
},
|
||||||
|
"confirmUnsubscribe": "Leiratkozol a(z) „{{name}}” csatornáról a YouTube-on? Ez megváltoztatja a valódi YouTube-fiókodat. Ha csak a hírfolyamodból szeretnéd eltávolítani, inkább rejtsd el."
|
||||||
|
}
|
||||||
9
frontend/src/i18n/locales/hu/errors.json
Normal file
9
frontend/src/i18n/locales/hu/errors.json
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"boundary": {
|
||||||
|
"title": "Hiba történt.",
|
||||||
|
"subtitle": "Az alkalmazás váratlan hibába ütközött.",
|
||||||
|
"reload": "Újratöltés",
|
||||||
|
"notifTitle": "Valami elromlott",
|
||||||
|
"notifMessage": "Váratlan hiba"
|
||||||
|
}
|
||||||
|
}
|
||||||
17
frontend/src/i18n/locales/hu/feed.json
Normal file
17
frontend/src/i18n/locales/hu/feed.json
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
{
|
||||||
|
"loading": "Hírfolyam betöltése…",
|
||||||
|
"loadError": "Nem sikerült betölteni a hírfolyamot.",
|
||||||
|
"emptyTitle": "A hírfolyamod üres",
|
||||||
|
"emptyBody": "Kösd össze a YouTube-fiókodat, hogy importáld a feliratkozásaidat, és felépítsd a hírfolyamodat.",
|
||||||
|
"setUp": "Hírfolyam beállítása",
|
||||||
|
"noMatches": "Egy videó sem felel meg ezeknek a szűrőknek.",
|
||||||
|
"videoCount_one": "{{formattedCount}} videó",
|
||||||
|
"videoCount_other": "{{formattedCount}} videó",
|
||||||
|
"loadingMore": "Továbbiak betöltése…",
|
||||||
|
"hiddenNamed": "Elrejtve: „{{title}}”",
|
||||||
|
"hidden": "Videó elrejtve",
|
||||||
|
"undo": "Visszavonás",
|
||||||
|
"markedWatchedNamed": "Megnézettnek jelölve: „{{title}}”",
|
||||||
|
"markedWatched": "Megnézettnek jelölve",
|
||||||
|
"unwatch": "Megnézés visszavonása"
|
||||||
|
}
|
||||||
19
frontend/src/i18n/locales/hu/notifications.json
Normal file
19
frontend/src/i18n/locales/hu/notifications.json
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
{
|
||||||
|
"title": "Értesítések",
|
||||||
|
"clearAll": "Összes törlése",
|
||||||
|
"clear": "Törlés",
|
||||||
|
"empty": "Még nincs értesítés.",
|
||||||
|
"actionNeeded": "Teendő",
|
||||||
|
"dismiss": "Elvetés",
|
||||||
|
"findInFeed": "Keresés a hírfolyamban",
|
||||||
|
"unhide": "Megjelenítés",
|
||||||
|
"unwatch": "Megtekintés visszavonása",
|
||||||
|
"unhidden": "Megjelenítve: „{{title}}”",
|
||||||
|
"unwatched": "Megtekintés visszavonva: „{{title}}”",
|
||||||
|
"time": {
|
||||||
|
"justNow": "az imént",
|
||||||
|
"minutes": "{{count}} perce",
|
||||||
|
"hours": "{{count}} órája",
|
||||||
|
"days": "{{count}} napja"
|
||||||
|
}
|
||||||
|
}
|
||||||
29
frontend/src/i18n/locales/hu/onboarding.json
Normal file
29
frontend/src/i18n/locales/hu/onboarding.json
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
{
|
||||||
|
"close": "Bezárás",
|
||||||
|
"consent": "A Google megjelenít egy <0>„A Google nem ellenőrizte ezt az alkalmazást”</0> képernyőt — ez várható, mert a Siftlode nem ment át a Google teljes ellenőrzésén. Biztonságos folytatni: kattints a <1>Speciális</1> → <2>Ugrás a Siftlode-ra</2> lehetőségre. A hozzáférést bármikor visszavonhatod a <3>Google-fiókodban</3>.",
|
||||||
|
"read": {
|
||||||
|
"title": "Csatlakoztasd a YouTube-feliratkozásaidat",
|
||||||
|
"body": "Be vagy jelentkezve. A hírfolyamod felépítéséhez a Siftlode-nak <0>csak olvasható</0> hozzáférésre van szüksége azokhoz a csatornákhoz, amelyekre a YouTube-on feliratkoztál. Ezzel soha nem tesz közzé, nem töröl és nem módosít semmit.",
|
||||||
|
"connect": "Csatlakozás (csak olvasható)",
|
||||||
|
"skip": "Most kihagyom"
|
||||||
|
},
|
||||||
|
"importing": {
|
||||||
|
"title": "A hírfolyamod felépítése…",
|
||||||
|
"body": "A YouTube-feliratkozásaid importálása folyamatban. A Siftlode-ban már meglévő csatornák azonnal megjelennek; az újak automatikusan feltöltődnek a háttérben."
|
||||||
|
},
|
||||||
|
"write": {
|
||||||
|
"title": "Csatlakoztatva",
|
||||||
|
"importFailed": "A YouTube-fiókod csatlakoztatva, de a feliratkozások importálása sikertelen volt — újrapróbálhatod a Beállítások → Szinkronizálás menüben. ",
|
||||||
|
"feedReady": "A hírfolyamod készen áll{{channels}}. Választhatóan engedélyezheted, hogy a Siftlode ",
|
||||||
|
"feedReadyChannels": " ({{count}} csatorna)",
|
||||||
|
"rationale": "<0>leiratkozzon</0> helyetted a YouTube-csatornákról — ehhez egy plusz írási engedély szükséges. Hagyd ki, ha csak olvasható módban szeretnél maradni; ezt később bármikor engedélyezheted a Beállításokban.",
|
||||||
|
"retryImport": "Importálás újra",
|
||||||
|
"enableUnsubscribe": "Leiratkozás engedélyezése",
|
||||||
|
"keepReadOnly": "Köszönöm, nem — maradjon csak olvasható"
|
||||||
|
},
|
||||||
|
"done": {
|
||||||
|
"title": "Minden kész",
|
||||||
|
"body": "Az olvasási és a leiratkozási hozzáférés is engedélyezve van. Bármelyiket bármikor áttekintheted vagy visszavonhatod a Beállítások → Fiók menüben, vagy a Google-fiókodban.",
|
||||||
|
"done": "Kész"
|
||||||
|
}
|
||||||
|
}
|
||||||
17
frontend/src/i18n/locales/hu/player.json
Normal file
17
frontend/src/i18n/locales/hu/player.json
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
{
|
||||||
|
"loading": "Betöltés…",
|
||||||
|
"backToOriginal": "Vissza az eredeti videóhoz",
|
||||||
|
"back": "Vissza",
|
||||||
|
"close": "Bezárás",
|
||||||
|
"closeEsc": "Bezárás (Esc)",
|
||||||
|
"description": "Leírás",
|
||||||
|
"noDescription": "Nincs leírás.",
|
||||||
|
"channel": "Csatorna",
|
||||||
|
"views": "{{formattedCount}} megtekintés",
|
||||||
|
"stream": "élő adás",
|
||||||
|
"watched": "Megtekintve",
|
||||||
|
"markWatched": "Megtekintettnek jelöl",
|
||||||
|
"watchedUnmark": "Megtekintve — kattints a jelölés visszavonásához",
|
||||||
|
"jumpToTime": "Ugrás erre az időpontra",
|
||||||
|
"playInApp": "Lejátszás a beépített lejátszóban"
|
||||||
|
}
|
||||||
101
frontend/src/i18n/locales/hu/settings.json
Normal file
101
frontend/src/i18n/locales/hu/settings.json
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
{
|
||||||
|
"title": "Beállítások",
|
||||||
|
"tabs": {
|
||||||
|
"appearance": "Megjelenés",
|
||||||
|
"notifications": "Értesítések",
|
||||||
|
"sync": "Szinkronizálás",
|
||||||
|
"account": "Fiók"
|
||||||
|
},
|
||||||
|
"appearance": {
|
||||||
|
"colorScheme": "Színséma",
|
||||||
|
"display": "Megjelenítés",
|
||||||
|
"darkMode": "Sötét mód",
|
||||||
|
"listView": "Listanézet",
|
||||||
|
"listViewHint": "A hírfolyam kompakt listaként jelenik meg a kártyarács helyett.",
|
||||||
|
"performanceMode": "Teljesítmény mód",
|
||||||
|
"performanceModeHint": "Kikapcsolja az áttetsző üvegelmosást és a lágy árnyékokat a gyorsabb megjelenítésért lassabb gépeken.",
|
||||||
|
"showHints": "Tippek megjelenítése",
|
||||||
|
"showHintsHint": "Ezek a kis lebegő magyarázatok. Kapcsold ki őket, ha már kiismerted magad.",
|
||||||
|
"textSize": "Betűméret"
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"title": "Értesítések",
|
||||||
|
"sound": "Hang riasztásoknál",
|
||||||
|
"soundHint": "Rövid hangjelzés, ha egy értesítés a figyelmedet igényli (pl. hibák, kérdések).",
|
||||||
|
"autoDismiss": "Buborékok automatikus eltüntetése",
|
||||||
|
"autoDismissHint": "Kikapcsolva a felugró buborékok addig maradnak, amíg be nem zárod őket. Bekapcsolva a beállított idő után elhalványulnak.",
|
||||||
|
"dismissAfter": "Eltüntetés ennyi után",
|
||||||
|
"sendTest": "Tesztértesítés küldése",
|
||||||
|
"testTitle": "Tesztértesítés",
|
||||||
|
"testMessage": "Így néz ki egy értesítés."
|
||||||
|
},
|
||||||
|
"sync": {
|
||||||
|
"myStatus": "Saját szinkronizálási állapot",
|
||||||
|
"channels": "Csatornák",
|
||||||
|
"channelsHint": "Hány csatornára vagy feliratkozva.",
|
||||||
|
"recentSynced": "Friss szinkronizálva",
|
||||||
|
"recentSyncedHint": "Azok a csatornák, amelyek legutóbbi feltöltései (~az elmúlt év) már a helyi adatbázisban vannak, így megjelennek a hírfolyamodban.",
|
||||||
|
"fullHistory": "Teljes előzmény",
|
||||||
|
"fullHistoryHint": "Azok a csatornák, amelyek teljes archívuma letöltődött, azokból, amelyekhez teljes előzményt kértél (csatornánként választható a Csatornák oldalon).",
|
||||||
|
"fullHistoryEta": "Teljes előzmény becsült ideje",
|
||||||
|
"fullHistoryEtaHint": "Hozzávetőleges becslés {{count}} függőben lévő csatorna teljes előzményének letöltéséhez, a megosztott napi kvóta alapján.",
|
||||||
|
"myVideos": "Saját videók",
|
||||||
|
"myVideosHint": "A számodra elérhető videók összesen a feliratkozott csatornáidon.",
|
||||||
|
"quotaLeft": "Ma maradt kvóta",
|
||||||
|
"quotaLeftHint": "A ma fennmaradó megosztott YouTube API-keret (éjfélkor nullázódik, USA csendes-óceáni idő szerint). A letöltés ennek alárendelődik.",
|
||||||
|
"loading": "Betöltés…",
|
||||||
|
"apiUsage": "Saját API-használat",
|
||||||
|
"today": "Ma",
|
||||||
|
"todayHint": "A saját műveleteid által ma használt YouTube API-egységek (éjfélkor nullázódik, USA csendes-óceáni idő szerint). A háttér-szinkronizálás itt nem számít bele.",
|
||||||
|
"last7d": "Utolsó 7 nap",
|
||||||
|
"last30d": "Utolsó 30 nap",
|
||||||
|
"allTime": "Mindenkori",
|
||||||
|
"byAction": "Művelet szerint (30 nap)",
|
||||||
|
"noUsage": "Még nincs használat.",
|
||||||
|
"actions": "Műveletek",
|
||||||
|
"syncSubscriptions": "Feliratkozások szinkronizálása",
|
||||||
|
"syncSubscriptionsHint": "Újraimportálja a YouTube-feliratkozásaidat és behúzza az újonnan követett csatornákat.",
|
||||||
|
"backfillEverything": "Minden letöltése",
|
||||||
|
"backfillEverythingHint": "Teljes archívum letöltésének kérése minden csatornához, amelyre fel vagy iratkozva. A régebbi videók és a teljes keresés annyira töltődnek fel, amennyit a megosztott napi kvóta enged.",
|
||||||
|
"synced": "{{count}} feliratkozás szinkronizálva",
|
||||||
|
"syncFailed": "A szinkronizálás sikertelen",
|
||||||
|
"fullHistoryRequested": "Teljes előzmény kérve {{count}} csatornához",
|
||||||
|
"fullHistoryFailed": "Nem sikerült teljes előzményt kérni",
|
||||||
|
"admin": "Adminisztrátor",
|
||||||
|
"adminPauseHint": "A háttér-szinkronizálás szüneteltetése vagy folytatása az egész alkalmazásra (minden felhasználóra).",
|
||||||
|
"resumeBackgroundSync": "Háttér-szinkronizálás folytatása",
|
||||||
|
"pauseBackgroundSync": "Háttér-szinkronizálás szüneteltetése"
|
||||||
|
},
|
||||||
|
"account": {
|
||||||
|
"title": "Fiók",
|
||||||
|
"youtubeAccess": "YouTube-hozzáférés",
|
||||||
|
"youtubeAccessIntro": "A bejelentkezés csak a nevedet és e-mail-címedet osztja meg. A YouTube-hozzáférést külön kell megadni, lent — mindegyik opcionális és bármikor visszavonható a Google-fiókodban.",
|
||||||
|
"readTitle": "Feliratkozások olvasása (a hírfolyamod)",
|
||||||
|
"readGrantedHint": "Megadva. A Siftlode beolvassa az általad követett csatornákat, hogy felépítse a hírfolyamodat. Ezzel sosem módosítja a YouTube-fiókodat.",
|
||||||
|
"readEnableHint": "Csak olvasási hozzáférés a feliratkozásaidhoz — a hírfolyam felépítéséhez szükséges. A Siftlode ezzel semmit sem tud módosítani.",
|
||||||
|
"writeTitle": "Leiratkozás (írás)",
|
||||||
|
"writeGrantedHint": "Megadva. A Siftlode-on belülről leiratkozhatsz csatornákról a YouTube-on. Csak akkor ír, amikor te kéred.",
|
||||||
|
"writeEnableHint": "Lehetővé teszi, hogy a Siftlode leiratkozzon helyetted csatornákról a YouTube-on. Opcionális — hagyd ki, ha csak olvasási módban szeretnél maradni.",
|
||||||
|
"granted": "Megadva",
|
||||||
|
"enable": "Engedélyezés",
|
||||||
|
"enableHint": "Átirányít a Google-höz az engedély megadásához. A Google „nem ellenőrzött alkalmazás” figyelmeztetést mutat — ez normális; kattints a Speciális → Tovább a Siftlode-ra lehetőségre.",
|
||||||
|
"walkMeThrough": "Vezess végig a beállításon"
|
||||||
|
},
|
||||||
|
"invites": {
|
||||||
|
"title": "Hozzáférési kérelmek",
|
||||||
|
"noPending": "Nincs függőben lévő kérelem.",
|
||||||
|
"addPlaceholder": "E-mail közvetlen hozzáadása…",
|
||||||
|
"add": "Hozzáadás",
|
||||||
|
"decided": "{{count}} eldöntve",
|
||||||
|
"approved": "Jóváhagyva — most már be tud jelentkezni",
|
||||||
|
"approveFailed": "A jóváhagyás sikertelen",
|
||||||
|
"denyFailed": "Az elutasítás sikertelen",
|
||||||
|
"addedToWhitelist": "Hozzáadva a fehérlistához",
|
||||||
|
"addFailed": "Nem sikerült hozzáadni ezt az e-mailt",
|
||||||
|
"requested": "kérve: {{time}}",
|
||||||
|
"approveHint": "Jóváhagyás — fehérlistára teszi ezt az e-mailt és e-mailben értesíti őket, hogy bekerültek.",
|
||||||
|
"approve": "Jóváhagyás",
|
||||||
|
"denyHint": "Kérelem elutasítása.",
|
||||||
|
"deny": "Elutasítás"
|
||||||
|
}
|
||||||
|
}
|
||||||
65
frontend/src/i18n/locales/hu/sidebar.json
Normal file
65
frontend/src/i18n/locales/hu/sidebar.json
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
{
|
||||||
|
"filters": "Szűrők",
|
||||||
|
"activeCount": "{{count}} aktív",
|
||||||
|
"clearAll": "Összes törlése",
|
||||||
|
"resetDefaults": "Visszaállítás alapértelmezettre",
|
||||||
|
"customize": "Oldalsáv testreszabása",
|
||||||
|
"done": "Kész",
|
||||||
|
"editHint": "Húzd az átrendezéshez · szem a megjelenítéshez/elrejtéshez",
|
||||||
|
"channel": "Csatorna",
|
||||||
|
"loading": "Betöltés…",
|
||||||
|
"thisChannel": "Ez a csatorna",
|
||||||
|
"channelCount": "{{count}} csatorna",
|
||||||
|
"channelCount_other": "{{count}} csatorna",
|
||||||
|
"dragToReorder": "Húzd az átrendezéshez",
|
||||||
|
"showWidget": "Modul megjelenítése",
|
||||||
|
"hideWidget": "Modul elrejtése",
|
||||||
|
"expand": "Kibontás",
|
||||||
|
"collapse": "Összecsukás",
|
||||||
|
"any": "Bármelyik",
|
||||||
|
"all": "Összes",
|
||||||
|
"tagModeTooltip": "Bármelyik vagy az összes kijelölt címke illeszkedjen",
|
||||||
|
"custom": "Egyéni",
|
||||||
|
"from": "Ettől",
|
||||||
|
"to": "Eddig",
|
||||||
|
"clearDates": "dátumok törlése",
|
||||||
|
"widget": {
|
||||||
|
"show": "Megjelenítés",
|
||||||
|
"sort": "Rendezés",
|
||||||
|
"date": "Feltöltés dátuma",
|
||||||
|
"content": "Tartalomtípus",
|
||||||
|
"language": "Nyelv",
|
||||||
|
"topic": "Téma"
|
||||||
|
},
|
||||||
|
"show": {
|
||||||
|
"unwatched": "Nem nézett",
|
||||||
|
"in_progress": "Megkezdett",
|
||||||
|
"all": "Összes",
|
||||||
|
"watched": "Megnézett",
|
||||||
|
"saved": "Mentett",
|
||||||
|
"hidden": "Rejtett"
|
||||||
|
},
|
||||||
|
"sort": {
|
||||||
|
"newest": "Legújabb",
|
||||||
|
"oldest": "Legrégebbi",
|
||||||
|
"views": "Legnézettebb",
|
||||||
|
"duration_desc": "Leghosszabb",
|
||||||
|
"duration_asc": "Legrövidebb",
|
||||||
|
"title": "Név (A–Z)",
|
||||||
|
"subscribers": "Csatorna feliratkozói",
|
||||||
|
"priority": "Csatorna prioritás",
|
||||||
|
"shuffle": "Lepj meg"
|
||||||
|
},
|
||||||
|
"content": {
|
||||||
|
"normal": "Normál",
|
||||||
|
"shorts": "Shorts",
|
||||||
|
"live": "Élő / Hamarosan"
|
||||||
|
},
|
||||||
|
"datePreset": {
|
||||||
|
"24h": "24 óra",
|
||||||
|
"1week": "1 hét",
|
||||||
|
"1month": "1 hónap",
|
||||||
|
"6months": "6 hónap",
|
||||||
|
"1year": "1 év"
|
||||||
|
}
|
||||||
|
}
|
||||||
13
frontend/src/i18n/locales/hu/stats.json
Normal file
13
frontend/src/i18n/locales/hu/stats.json
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
{
|
||||||
|
"title": "API-kvóta használat",
|
||||||
|
"rangeDays": "{{count}} nap",
|
||||||
|
"introBefore": "A YouTube Data API-egységek aszerint, hogy ki váltotta ki a felhasználást. A ",
|
||||||
|
"introSystem": "Rendszer",
|
||||||
|
"introAfter": " a megosztott háttérmunka (ütemezett letöltés, gazdagítás, újraszinkronizálás), amely nem köthető egy személyhez.",
|
||||||
|
"loading": "Betöltés…",
|
||||||
|
"noData": "Nincs adat.",
|
||||||
|
"dailyTotal": "Napi összesen ({{count}} nap · {{units}} egység)",
|
||||||
|
"noUsageInRange": "Nincs használat ebben az időszakban.",
|
||||||
|
"dailyTooltip": "{{day}}: {{units}} egység",
|
||||||
|
"system": "Rendszer (háttér)"
|
||||||
|
}
|
||||||
10
frontend/src/i18n/locales/hu/time.json
Normal file
10
frontend/src/i18n/locales/hu/time.json
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
{
|
||||||
|
"justNow": "épp most",
|
||||||
|
"secondsAgo": "{{count}} mp-e",
|
||||||
|
"minutesAgo": "{{count}} perce",
|
||||||
|
"hoursAgo": "{{count}} órája",
|
||||||
|
"daysAgo": "{{count}} napja",
|
||||||
|
"weeksAgo": "{{count}} hete",
|
||||||
|
"monthsAgo": "{{count}} hónapja",
|
||||||
|
"yearsAgo": "{{count}} éve"
|
||||||
|
}
|
||||||
|
|
@ -1,28 +1,30 @@
|
||||||
|
import i18n from "../i18n";
|
||||||
|
|
||||||
export function relativeTime(iso: string | null): string {
|
export function relativeTime(iso: string | null): string {
|
||||||
if (!iso) return "";
|
if (!iso) return "";
|
||||||
const then = new Date(iso).getTime();
|
const then = new Date(iso).getTime();
|
||||||
const secs = Math.max(0, (Date.now() - then) / 1000);
|
const secs = Math.max(0, (Date.now() - then) / 1000);
|
||||||
const units: [number, string][] = [
|
const units: [number, string][] = [
|
||||||
[60, "s"],
|
[60, "time.secondsAgo"],
|
||||||
[3600, "min"],
|
[3600, "time.minutesAgo"],
|
||||||
[86400, "h"],
|
[86400, "time.hoursAgo"],
|
||||||
[604800, "d"],
|
[604800, "time.daysAgo"],
|
||||||
[2592000, "wk"],
|
[2592000, "time.weeksAgo"],
|
||||||
[31536000, "mo"],
|
[31536000, "time.monthsAgo"],
|
||||||
];
|
];
|
||||||
if (secs < 60) return "just now";
|
if (secs < 60) return i18n.t("time.justNow");
|
||||||
for (let i = 0; i < units.length - 1; i++) {
|
for (let i = 0; i < units.length - 1; i++) {
|
||||||
const [, label] = units[i];
|
const [, key] = units[i];
|
||||||
const next = units[i + 1][0];
|
const next = units[i + 1][0];
|
||||||
if (secs < next) {
|
if (secs < next) {
|
||||||
const v = Math.floor(secs / units[i][0]);
|
const v = Math.floor(secs / units[i][0]);
|
||||||
return `${v} ${label} ago`;
|
return i18n.t(key, { count: v });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const years = Math.floor(secs / 31536000);
|
const years = Math.floor(secs / 31536000);
|
||||||
if (years >= 1) return `${years} yr ago`;
|
if (years >= 1) return i18n.t("time.yearsAgo", { count: years });
|
||||||
const months = Math.floor(secs / 2592000);
|
const months = Math.floor(secs / 2592000);
|
||||||
return `${months} mo ago`;
|
return i18n.t("time.monthsAgo", { count: months });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatDuration(sec: number | null): string {
|
export function formatDuration(sec: number | null): string {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue