feat(channels): data-table channel manager

Replace the stacked card rows with the reusable DataTable: sortable columns, in-header
Channel/Tags filters, status chips + page controls in one row, wider layout. Tag toggle
is now optimistic (no full refetch). Sync column collapses to one 'fully synced' chip;
Actions column carries hide/unsubscribe + a backfill control (admin reset / per-user
full-history opt-in). Channel status filter persists across reloads.
This commit is contained in:
npeter83 2026-06-17 19:16:23 +02:00
parent 2941832566
commit cab700bca5
5 changed files with 476 additions and 259 deletions

View file

@ -93,7 +93,18 @@ export default function App() {
const [sidebarLayout, setSidebarLayoutState] = useState<SidebarLayout>(loadLayout); const [sidebarLayout, setSidebarLayoutState] = useState<SidebarLayout>(loadLayout);
const [page, setPageState] = useState<Page>(loadInitialPage); const [page, setPageState] = useState<Page>(loadInitialPage);
const [wizardOpen, setWizardOpen] = useState(false); const [wizardOpen, setWizardOpen] = useState(false);
const [channelFilter, setChannelFilter] = useState<ChannelStatusFilter>("all"); const CHANNEL_FILTER_KEY = "siftlode.channelFilter";
const [channelFilter, setChannelFilterState] = useState<ChannelStatusFilter>(() => {
const v = localStorage.getItem(CHANNEL_FILTER_KEY);
return v === "needs_full" || v === "fully_synced" || v === "hidden" || v === "all"
? v
: "all";
});
// Persist the channel status chip so a reload (F5) keeps it.
const setChannelFilter = (f: ChannelStatusFilter) => {
setChannelFilterState(f);
localStorage.setItem(CHANNEL_FILTER_KEY, f);
};
const [aboutOpen, setAboutOpen] = useState(false); const [aboutOpen, setAboutOpen] = useState(false);
const [notesOpen, setNotesOpen] = useState(false); const [notesOpen, setNotesOpen] = useState(false);
const [notesHighlight, setNotesHighlight] = useState<string | undefined>(undefined); const [notesHighlight, setNotesHighlight] = useState<string | undefined>(undefined);
@ -266,6 +277,7 @@ export default function App() {
{page === "channels" ? ( {page === "channels" ? (
<Channels <Channels
canWrite={meQuery.data!.can_write} canWrite={meQuery.data!.can_write}
isAdmin={meQuery.data!.role === "admin"}
statusFilter={channelFilter} statusFilter={channelFilter}
setStatusFilter={setChannelFilter} setStatusFilter={setChannelFilter}
onOpenWizard={() => setWizardOpen(true)} onOpenWizard={() => setWizardOpen(true)}

View file

@ -11,7 +11,6 @@ import {
History, History,
Plus, Plus,
RefreshCw, RefreshCw,
Search,
UserMinus, UserMinus,
X, X,
} from "lucide-react"; } from "lucide-react";
@ -20,6 +19,7 @@ import { formatEta } from "../lib/format";
import { notify } from "../lib/notifications"; import { notify } from "../lib/notifications";
import Tooltip from "./Tooltip"; import Tooltip from "./Tooltip";
import Avatar from "./Avatar"; import Avatar from "./Avatar";
import DataTable, { type Column } from "./DataTable";
import { useConfirm } from "./ConfirmProvider"; import { useConfirm } from "./ConfirmProvider";
export type ChannelStatusFilter = "all" | "needs_full" | "fully_synced" | "hidden"; export type ChannelStatusFilter = "all" | "needs_full" | "fully_synced" | "hidden";
@ -33,12 +33,14 @@ const STATUS_FILTERS: { id: ChannelStatusFilter; labelKey: string }[] = [
export default function Channels({ export default function Channels({
canWrite, canWrite,
isAdmin,
onViewChannel, onViewChannel,
statusFilter, statusFilter,
setStatusFilter, setStatusFilter,
onOpenWizard, onOpenWizard,
}: { }: {
canWrite: boolean; canWrite: boolean;
isAdmin: boolean;
onViewChannel: (id: string, name: string) => void; onViewChannel: (id: string, name: string) => void;
statusFilter: ChannelStatusFilter; statusFilter: ChannelStatusFilter;
setStatusFilter: (f: ChannelStatusFilter) => void; setStatusFilter: (f: ChannelStatusFilter) => void;
@ -64,7 +66,6 @@ export default function Channels({
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 });
const statusQuery = useQuery({ queryKey: ["my-status"], queryFn: api.myStatus }); const statusQuery = useQuery({ queryKey: ["my-status"], queryFn: api.myStatus });
const [q, setQ] = useState("");
const [newTag, setNewTag] = useState(""); const [newTag, setNewTag] = useState("");
const invalidate = () => { const invalidate = () => {
@ -102,14 +103,26 @@ export default function Channels({
.catch(() => qc.invalidateQueries({ queryKey: ["channels"] })); .catch(() => qc.invalidateQueries({ queryKey: ["channels"] }));
}; };
const attach = useMutation({ // Tagging is updated optimistically in place (no refetch of the whole channel list, which
mutationFn: (v: { id: string; tagId: number }) => api.attachChannelTag(v.id, v.tagId), // made the toggle feel ~2s slow); only revert by refetching if the server call fails.
onSuccess: () => qc.invalidateQueries({ queryKey: ["channels"] }), const toggleTag = (id: string, tagId: number, hasTag: boolean) => {
}); qc.setQueryData<ManagedChannel[]>(["channels"], (old) =>
const detach = useMutation({ (old ?? []).map((ch) =>
mutationFn: (v: { id: string; tagId: number }) => api.detachChannelTag(v.id, v.tagId), ch.id === id
onSuccess: () => qc.invalidateQueries({ queryKey: ["channels"] }), ? {
}); ...ch,
tag_ids: hasTag
? ch.tag_ids.filter((x) => x !== tagId)
: [...ch.tag_ids, tagId],
}
: ch
)
);
const call = hasTag
? api.detachChannelTag(id, tagId)
: api.attachChannelTag(id, tagId);
call.catch(() => qc.invalidateQueries({ queryKey: ["channels"] }));
};
const syncSubs = useMutation({ const syncSubs = useMutation({
mutationFn: () => api.syncSubscriptions(), mutationFn: () => api.syncSubscriptions(),
onSuccess: (r: { subscriptions?: number }) => { onSuccess: (r: { subscriptions?: number }) => {
@ -138,6 +151,15 @@ export default function Channels({
}, },
onError: (e) => notifyActionError(e, "channels.notify.unsubscribeFailed"), onError: (e) => notifyActionError(e, "channels.notify.unsubscribeFailed"),
}); });
const resetBackfill = useMutation({
mutationFn: (id: string) => api.resetChannelBackfill(id),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["channels"] });
qc.invalidateQueries({ queryKey: ["my-status"] });
notify({ level: "success", message: t("channels.notify.resetDone") });
},
onError: (e) => notifyActionError(e, "channels.notify.resetFailed"),
});
const deepAll = useMutation({ const deepAll = useMutation({
mutationFn: () => api.deepAll(true), mutationFn: () => api.deepAll(true),
onSuccess: (r: { updated?: number }) => { onSuccess: (r: { updated?: number }) => {
@ -151,62 +173,184 @@ export default function Channels({
onError: (e) => notifyActionError(e, "channels.notify.fullHistoryFailed"), onError: (e) => notifyActionError(e, "channels.notify.fullHistoryFailed"),
}); });
const channels = (channelsQuery.data ?? []) // The Sync-status filter stays a top-level chip set (not a column filter) because the
.filter((c) => !q || (c.title ?? "").toLowerCase().includes(q.toLowerCase())) // header's "go to full history" deep-link drives it; the DataTable then handles name
.filter((c) => // search, tag filtering, sort and pagination over whatever the status chip leaves.
statusFilter === "needs_full" const channels = (channelsQuery.data ?? []).filter((c) =>
? !c.backfill_done statusFilter === "needs_full"
: statusFilter === "fully_synced" ? !c.backfill_done
? c.backfill_done : statusFilter === "fully_synced"
: statusFilter === "hidden" ? c.backfill_done
? c.hidden : statusFilter === "hidden"
: true ? c.hidden
); : true
);
const s = statusQuery.data; const s = statusQuery.data;
return ( const onView = (c: ManagedChannel) =>
<div className="p-4 max-w-4xl mx-auto"> onViewChannel(c.id, c.title ?? t("channels.row.thisChannel"));
{/* Per-user sync status */} const onUnsub = async (c: ManagedChannel) => {
{s && ( const ok = await confirm({
<div className="flex flex-wrap items-center gap-x-5 gap-y-1 text-sm text-muted mb-4"> title: t("channels.row.unsubscribeOnYoutube"),
<Stat label={t("channels.stats.channels")} value={s.channels_total} hint={t("channels.stats.channelsHint")} /> message: t("channels.confirmUnsubscribe", { name: c.title ?? c.id }),
<Stat confirmLabel: t("channels.row.unsubscribeOnYoutube"),
label={t("channels.stats.recentSynced")} danger: true,
value={`${s.channels_recent_synced}/${s.channels_total}`} });
hint={t("channels.stats.recentSyncedHint")} if (ok) unsubscribe.mutate(c.id);
/> };
<Stat const onReset = async (c: ManagedChannel) => {
label={t("channels.stats.fullHistory")} const ok = await confirm({
value={`${s.channels_deep_done}/${s.channels_deep_requested}`} title: t("channels.row.backfillThis"),
hint={t("channels.stats.fullHistoryHint")} message: t("channels.confirmReset", { name: c.title ?? c.id }),
/> confirmLabel: t("channels.row.backfillThis"),
{s.deep_pending_count > 0 && ( });
<Stat if (ok) resetBackfill.mutate(c.id);
label={t("channels.stats.left")} };
value={formatEta(s.deep_eta_seconds)}
hint={t("channels.stats.leftHint", { count: s.deep_pending_count })}
/>
)}
<Stat label={t("channels.stats.myVideos")} value={s.my_videos.toLocaleString()} hint={t("channels.stats.myVideosHint")} />
<Stat
label={t("channels.stats.quotaLeft")}
value={s.quota_remaining_today.toLocaleString()}
hint={t("channels.stats.quotaLeftHint")}
/>
</div>
)}
{/* Toolbar */} const columns: Column<ManagedChannel>[] = [
<div className="flex items-center gap-2 mb-4"> {
<div className="relative flex-1"> key: "priority",
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-muted" /> header: t("channels.cols.priority"),
<input align: "center",
value={q} width: "56px",
onChange={(e) => setQ(e.target.value)} sortable: true,
placeholder={t("channels.filterPlaceholder")} hideInCard: true,
className="w-full bg-card border border-border rounded-full pl-9 pr-4 py-2 text-sm outline-none focus:border-accent" sortValue: (c) => c.priority,
/> render: (c) => <PriorityCell c={c} onPriority={(d) => bumpPriority(c.id, c.priority, d)} />,
},
{
key: "channel",
header: t("channels.cols.channel"),
sortable: true,
sortValue: (c) => (c.title ?? c.id).toLowerCase(),
filter: { kind: "text", get: (c) => `${c.title ?? ""} ${c.handle ?? ""}` },
cardPrimary: true,
render: (c) => <NameCell c={c} onView={() => onView(c)} />,
},
{
key: "stored",
header: t("channels.cols.stored"),
align: "right",
width: "84px",
sortable: true,
sortValue: (c) => c.stored_videos,
render: (c) => <span className="text-muted tabular-nums">{c.stored_videos.toLocaleString()}</span>,
},
{
key: "subs",
header: t("channels.cols.subs"),
align: "right",
width: "92px",
sortable: true,
sortValue: (c) => c.subscriber_count ?? -1,
render: (c) => (
<span className="text-muted tabular-nums">
{c.subscriber_count != null ? c.subscriber_count.toLocaleString() : "—"}
</span>
),
},
{
key: "sync",
header: t("channels.cols.sync"),
width: "130px",
cardLabel: false,
render: (c) => <SyncCell c={c} />,
},
{
key: "tags",
header: t("channels.cols.tags"),
width: "360px",
cardLabel: false,
filter: {
kind: "multi",
options: userTags.map((tg) => ({ value: String(tg.id), label: tg.name })),
// OR semantics: show channels carrying any of the selected tags.
test: (c, values) => values.some((v) => c.tag_ids.includes(Number(v))),
},
render: (c) => (
<TagsCell
c={c}
userTags={userTags}
onToggleTag={(tagId) => toggleTag(c.id, tagId, c.tag_ids.includes(tagId))}
/>
),
},
{
key: "actions",
header: t("channels.cols.actions"),
align: "right",
width: "104px",
cardLabel: false,
render: (c) => (
<ActionsCell
c={c}
isAdmin={isAdmin}
canWrite={canWrite}
onHide={() => patch.mutate({ id: c.id, body: { hidden: !c.hidden } })}
onDeep={() => patch.mutate({ id: c.id, body: { deep_requested: !c.deep_requested } })}
onReset={() => onReset(c)}
onUnsubscribe={() => onUnsub(c)}
/>
),
},
];
// Status chips sit in the table's controls row (next to paging) — compact and close to
// where they act.
const statusChips = (
<div className="flex flex-wrap items-center gap-1.5">
{STATUS_FILTERS.map((f) => (
<button
key={f.id}
onClick={() => setStatusFilter(f.id)}
className={`text-xs px-2.5 py-1 rounded-full border transition ${
statusFilter === f.id
? "bg-accent text-accent-fg border-accent"
: "bg-card border-border text-muted hover:border-accent"
}`}
>
{t(f.labelKey)}
</button>
))}
</div>
);
return (
<div className="p-4 max-w-7xl mx-auto">
{/* Per-user sync status + catalog-wide actions on one row (search/tags filtering
now lives in the table headers). */}
<div className="flex items-start justify-between gap-4 flex-wrap mb-4">
<div className="flex flex-wrap items-center gap-x-5 gap-y-1 text-sm text-muted">
{s && (
<>
<Stat label={t("channels.stats.channels")} value={s.channels_total} hint={t("channels.stats.channelsHint")} />
<Stat
label={t("channels.stats.recentSynced")}
value={`${s.channels_recent_synced}/${s.channels_total}`}
hint={t("channels.stats.recentSyncedHint")}
/>
<Stat
label={t("channels.stats.fullHistory")}
value={`${s.channels_deep_done}/${s.channels_deep_requested}`}
hint={t("channels.stats.fullHistoryHint")}
/>
{s.deep_pending_count > 0 && (
<Stat
label={t("channels.stats.left")}
value={formatEta(s.deep_eta_seconds)}
hint={t("channels.stats.leftHint", { count: s.deep_pending_count })}
/>
)}
<Stat label={t("channels.stats.myVideos")} value={s.my_videos.toLocaleString()} hint={t("channels.stats.myVideosHint")} />
<Stat
label={t("channels.stats.quotaLeft")}
value={s.quota_remaining_today.toLocaleString()}
hint={t("channels.stats.quotaLeftHint")}
/>
</>
)}
</div> </div>
<div className="flex items-center gap-2 shrink-0">
<Tooltip <Tooltip
side="bottom" side="bottom"
hint={t("channels.syncSubscriptionsHint")} hint={t("channels.syncSubscriptionsHint")}
@ -233,23 +377,7 @@ export default function Channels({
{t("channels.backfillEverything")} {t("channels.backfillEverything")}
</button> </button>
</Tooltip> </Tooltip>
</div> </div>
{/* Status filter */}
<div className="flex flex-wrap items-center gap-1.5 mb-4">
{STATUS_FILTERS.map((f) => (
<button
key={f.id}
onClick={() => setStatusFilter(f.id)}
className={`text-xs px-2.5 py-1 rounded-full border transition ${
statusFilter === f.id
? "bg-accent text-accent-fg border-accent"
: "bg-card border-border text-muted hover:border-accent"
}`}
>
{t(f.labelKey)}
</button>
))}
</div> </div>
<p className="text-xs text-muted mb-4 leading-relaxed"> <p className="text-xs text-muted mb-4 leading-relaxed">
@ -300,42 +428,20 @@ export default function Channels({
</form> </form>
</div> </div>
{/* Channel list */} {/* Channel table */}
{channelsQuery.isLoading ? ( {channelsQuery.isLoading ? (
<div className="text-muted py-8">{t("channels.loading")}</div> <div className="text-muted py-8">{t("channels.loading")}</div>
) : channels.length === 0 ? (
<div className="text-muted py-8">{t("channels.empty")}</div>
) : ( ) : (
<div className="flex flex-col gap-1.5"> <DataTable
{channels.map((c) => ( rows={channels}
<ChannelRow columns={columns}
key={c.id} rowKey={(c) => c.id}
c={c} persistKey="siftlode.channelsTable"
userTags={userTags} controlsPosition="top"
canWrite={canWrite} controlsLeading={statusChips}
onUnsubscribe={async () => { rowClassName={(c) => (c.hidden ? "opacity-60" : "")}
const ok = await confirm({ emptyText={t("channels.empty")}
title: t("channels.row.unsubscribeOnYoutube"), />
message: t("channels.confirmUnsubscribe", { name: c.title ?? c.id }),
confirmLabel: t("channels.row.unsubscribeOnYoutube"),
danger: true,
});
if (ok) unsubscribe.mutate(c.id);
}}
onView={() => onViewChannel(c.id, c.title ?? t("channels.row.thisChannel"))}
onPriority={(d) => bumpPriority(c.id, c.priority, d)}
onHide={() => patch.mutate({ id: c.id, body: { hidden: !c.hidden } })}
onDeep={() =>
patch.mutate({ id: c.id, body: { deep_requested: !c.deep_requested } })
}
onToggleTag={(tagId) =>
c.tag_ids.includes(tagId)
? detach.mutate({ id: c.id, tagId })
: attach.mutate({ id: c.id, tagId })
}
/>
))}
</div>
)} )}
</div> </div>
); );
@ -374,153 +480,180 @@ function SyncBadge({ ok, label, hint }: { ok: boolean; label: string; hint?: str
); );
} }
function ChannelRow({ function PriorityCell({ c, onPriority }: { c: ManagedChannel; onPriority: (delta: number) => void }) {
c, const { t } = useTranslation();
userTags, return (
canWrite, <Tooltip hint={t("channels.row.priorityHint")}>
onUnsubscribe, <div className="inline-flex flex-col items-center cursor-help">
onView, <button onClick={() => onPriority(1)} className="text-muted hover:text-accent" aria-label={t("channels.row.raisePriority")}>
onPriority, <ArrowUp className="w-3.5 h-3.5" />
onHide, </button>
onDeep, <span className="text-xs text-muted tabular-nums">{c.priority}</span>
onToggleTag, <button onClick={() => onPriority(-1)} className="text-muted hover:text-accent" aria-label={t("channels.row.lowerPriority")}>
}: { <ArrowDown className="w-3.5 h-3.5" />
c: ManagedChannel; </button>
userTags: Tag[]; </div>
canWrite: boolean; </Tooltip>
onUnsubscribe: () => void; );
onView: () => void; }
onPriority: (delta: number) => void;
onHide: () => void; function NameCell({ c, onView }: { c: ManagedChannel; onView: () => void }) {
onDeep: () => void;
onToggleTag: (tagId: number) => void;
}) {
const { t } = useTranslation(); const { t } = useTranslation();
const ytUrl = c.handle const ytUrl = c.handle
? `https://www.youtube.com/@${c.handle.replace(/^@/, "")}` ? `https://www.youtube.com/@${c.handle.replace(/^@/, "")}`
: `https://www.youtube.com/channel/${c.id}`; : `https://www.youtube.com/channel/${c.id}`;
return ( return (
<div <div className="flex items-center gap-2 min-w-0">
className={`glass-card glass-hover flex items-center gap-3 p-2.5 rounded-xl transition ${ <Avatar src={c.thumbnail_url} fallback={c.title ?? ""} className="w-8 h-8 rounded-full shrink-0" />
c.hidden ? "opacity-60" : "" <button
}`} onClick={onView}
> onAuxClick={(e) => {
<Tooltip hint={t("channels.row.priorityHint")}> // Middle-click opens the channel's YouTube page in a new tab; left-click
<div className="flex flex-col items-center cursor-help"> // keeps opening the in-app channel detail.
<button onClick={() => onPriority(1)} className="text-muted hover:text-accent" aria-label={t("channels.row.raisePriority")}> if (e.button === 1) {
<ArrowUp className="w-3.5 h-3.5" /> e.preventDefault();
</button> window.open(ytUrl, "_blank", "noopener,noreferrer");
<span className="text-xs text-muted tabular-nums">{c.priority}</span> }
<button onClick={() => onPriority(-1)} className="text-muted hover:text-accent" aria-label={t("channels.row.lowerPriority")}> }}
<ArrowDown className="w-3.5 h-3.5" /> onMouseDown={(e) => {
</button> if (e.button === 1) e.preventDefault(); // suppress middle-click autoscroll
</div> }}
</Tooltip> className="text-sm font-medium truncate hover:text-accent text-left min-w-0"
<Avatar
src={c.thumbnail_url}
fallback={c.title ?? ""}
className="w-10 h-10 rounded-full shrink-0"
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1 max-w-full">
<button
onClick={onView}
onAuxClick={(e) => {
// Middle-click opens the channel's YouTube page in a new tab; left-click
// keeps opening the in-app channel detail.
if (e.button === 1) {
e.preventDefault();
window.open(ytUrl, "_blank", "noopener,noreferrer");
}
}}
onMouseDown={(e) => {
if (e.button === 1) e.preventDefault(); // suppress middle-click autoscroll
}}
className="text-sm font-semibold truncate hover:text-accent text-left min-w-0"
>
{c.title ?? c.id}
</button>
<Tooltip hint={t("channels.row.openOnYouTube")}>
<a
href={ytUrl}
target="_blank"
rel="noopener noreferrer"
className="text-muted hover:text-accent shrink-0"
aria-label={t("channels.row.openOnYouTube")}
>
<ExternalLink className="w-3.5 h-3.5" />
</a>
</Tooltip>
</div>
<div className="flex items-center gap-2 text-[11px] text-muted">
<span>{t("channels.row.stored", { count: c.stored_videos, formatted: c.stored_videos.toLocaleString() })}</span>
{c.subscriber_count != null && <span>· {t("channels.row.subs", { count: c.subscriber_count, formatted: c.subscriber_count.toLocaleString() })}</span>}
</div>
<div className="flex flex-wrap items-center gap-1 mt-1">
<SyncBadge
ok={c.recent_synced}
label={t("channels.row.recent")}
hint={c.recent_synced ? t("channels.row.recentSyncedHint") : t("channels.row.recentNotSyncedHint")}
/>
{c.backfill_done ? (
<SyncBadge ok label={t("channels.row.full")} hint={t("channels.row.fullHint")} />
) : c.deep_requested ? (
<Tooltip hint={t("channels.row.queuedRequestedHint")}>
<button
onClick={onDeep}
className="inline-flex items-center gap-1 text-[10px] px-1.5 py-0.5 rounded-full border border-accent bg-accent text-accent-fg transition"
>
<History className="w-3 h-3" />
{t("channels.row.fullHistoryQueued")}
</button>
</Tooltip>
) : c.deep_in_queue ? (
<Tooltip hint={t("channels.row.queuedByOtherHint")}>
<span className="inline-flex items-center gap-1 text-[10px] px-1.5 py-0.5 rounded-full border border-accent/40 text-accent">
<History className="w-3 h-3" />
{t("channels.row.fullHistoryComing")}
</span>
</Tooltip>
) : (
<Tooltip hint={t("channels.row.getFullHistoryHint")}>
<button
onClick={onDeep}
className="inline-flex items-center gap-1 text-[10px] px-1.5 py-0.5 rounded-full border bg-card border-border text-muted hover:border-accent transition"
>
<History className="w-3 h-3" />
{t("channels.row.getFullHistory")}
</button>
</Tooltip>
)}
{userTags.map((t) => {
const on = c.tag_ids.includes(t.id);
return (
<button
key={t.id}
onClick={() => onToggleTag(t.id)}
className={`text-[10px] px-1.5 py-0.5 rounded-full border transition ${
on
? "bg-accent text-accent-fg border-accent"
: "bg-card border-border text-muted hover:border-accent"
}`}
>
{t.name}
</button>
);
})}
</div>
</div>
<Tooltip
hint={c.hidden ? t("channels.row.hiddenHint") : t("channels.row.hideHint")}
> >
{c.title ?? c.id}
</button>
<Tooltip hint={t("channels.row.openOnYouTube")}>
<a
href={ytUrl}
target="_blank"
rel="noopener noreferrer"
className="text-muted hover:text-accent shrink-0"
aria-label={t("channels.row.openOnYouTube")}
>
<ExternalLink className="w-3.5 h-3.5" />
</a>
</Tooltip>
</div>
);
}
// Status only — the backfill *action* lives in the Actions column. When both recent and
// full history are in, collapse to a single "fully synced" chip; otherwise show what's
// present plus the missing/queued state.
function SyncCell({ c }: { c: ManagedChannel }) {
const { t } = useTranslation();
if (c.recent_synced && c.backfill_done) {
return <SyncBadge ok label={t("channels.row.fullySynced")} hint={t("channels.row.fullySyncedHint")} />;
}
return (
<div className="flex flex-wrap items-center gap-1">
<SyncBadge
ok={c.recent_synced}
label={t("channels.row.recent")}
hint={c.recent_synced ? t("channels.row.recentSyncedHint") : t("channels.row.recentNotSyncedHint")}
/>
{c.backfill_done ? (
<SyncBadge ok label={t("channels.row.full")} hint={t("channels.row.fullHint")} />
) : c.deep_requested ? (
<SyncBadge ok={false} label={t("channels.row.fullHistoryQueued")} hint={t("channels.row.queuedRequestedHint")} />
) : c.deep_in_queue ? (
<SyncBadge ok={false} label={t("channels.row.fullHistoryComing")} hint={t("channels.row.queuedByOtherHint")} />
) : (
<SyncBadge ok={false} label={t("channels.row.full")} hint={t("channels.row.fullNotFetchedHint")} />
)}
</div>
);
}
function TagsCell({
c,
userTags,
onToggleTag,
}: {
c: ManagedChannel;
userTags: Tag[];
onToggleTag: (tagId: number) => void;
}) {
if (userTags.length === 0) return null;
return (
<div className="flex flex-wrap items-center gap-1">
{userTags.map((tg) => {
const on = c.tag_ids.includes(tg.id);
return (
<button
key={tg.id}
onClick={() => onToggleTag(tg.id)}
className={`text-[10px] px-1.5 py-0.5 rounded-full border transition ${
on
? "bg-accent text-accent-fg border-accent"
: "bg-card border-border text-muted hover:border-accent"
}`}
>
{tg.name}
</button>
);
})}
</div>
);
}
function ActionsCell({
c,
isAdmin,
canWrite,
onHide,
onDeep,
onReset,
onUnsubscribe,
}: {
c: ManagedChannel;
isAdmin: boolean;
canWrite: boolean;
onHide: () => void;
onDeep: () => void;
onReset: () => void;
onUnsubscribe: () => void;
}) {
const { t } = useTranslation();
// Admins get a "reset" trigger that re-fetches the channel from scratch regardless of
// state — always actionable. Regular users get the per-user "request full history" opt-in,
// which only applies until the back-catalog is in (or already coming for everyone).
const done = c.backfill_done;
const requested = !done && c.deep_requested;
const optInActionable = !done && !c.deep_in_queue;
const backfillHint = isAdmin
? t("channels.row.resetHint")
: c.deep_in_queue
? t("channels.row.queuedByOtherHint")
: requested
? t("channels.row.queuedRequestedHint")
: done
? t("channels.row.fullHint")
: t("channels.row.backfillThisHint");
const enabled = isAdmin || optInActionable;
return (
<div className="inline-flex items-center gap-2">
<Tooltip hint={backfillHint}>
<button
onClick={!enabled ? undefined : isAdmin ? onReset : onDeep}
disabled={!enabled}
className={`shrink-0 transition ${
requested
? "text-accent"
: enabled
? "text-muted hover:text-fg"
: "text-muted/40 cursor-default"
}`}
aria-label={isAdmin ? t("channels.row.resetBackfill") : t("channels.row.backfillThis")}
>
<History className="w-4 h-4" />
</button>
</Tooltip>
<Tooltip hint={c.hidden ? t("channels.row.hiddenHint") : t("channels.row.hideHint")}>
<button onClick={onHide} className="text-muted hover:text-fg shrink-0" aria-label={c.hidden ? t("channels.row.unhide") : t("channels.row.hideFromFeed")}> <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={t("channels.row.unsubscribeHint")}> <Tooltip hint={t("channels.row.unsubscribeHint")}>
<button <button

View file

@ -1,7 +1,7 @@
{ {
"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.", "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…", "filterPlaceholder": "Kanäle filtern…",
"syncSubscriptions": "Abos synchronisieren", "syncSubscriptions": "Abos von YouTube lesen",
"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.", "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", "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.", "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.",
@ -19,6 +19,20 @@
}, },
"loading": "Kanäle werden geladen…", "loading": "Kanäle werden geladen…",
"empty": "Keine Kanäle.", "empty": "Keine Kanäle.",
"pager": {
"prev": "Zurück",
"next": "Weiter",
"page": "Seite {{page}} von {{total}}"
},
"cols": {
"priority": "Prio",
"channel": "Kanal",
"stored": "Gespeichert",
"subs": "Abonnenten",
"sync": "Sync",
"tags": "Tags",
"actions": "Aktionen"
},
"stats": { "stats": {
"channels": "Kanäle", "channels": "Kanäle",
"channelsHint": "Kanäle, die du abonniert hast.", "channelsHint": "Kanäle, die du abonniert hast.",
@ -45,6 +59,13 @@
"recentNotSyncedHint": "Neueste Uploads noch nicht geladen.", "recentNotSyncedHint": "Neueste Uploads noch nicht geladen.",
"full": "vollständig", "full": "vollständig",
"fullHint": "Vollständiger Verlauf geladen.", "fullHint": "Vollständiger Verlauf geladen.",
"fullNotFetchedHint": "Das vollständige Archiv ist noch nicht geladen — nutze die Backfill-Aktion in der Spalte Aktionen.",
"fullySynced": "vollständig synchron",
"fullySyncedHint": "Aktuelle Uploads und das vollständige Archiv sind beide vorhanden.",
"backfillThis": "Diesen Kanal vollständig laden",
"backfillThisHint": "Das vollständige Archiv dieses Kanals laden (ältere Videos + komplette Suche). Erneut klicken, um die Anfrage zurückzuziehen.",
"resetBackfill": "Kanal zurücksetzen & neu laden",
"resetHint": "Admin: diesen Kanal von Grund auf neu laden (aktuelle + vollständiges Archiv), unabhängig vom aktuellen Sync-Status.",
"fullHistoryQueued": "vollständiger Verlauf in Warteschlange", "fullHistoryQueued": "vollständiger Verlauf in Warteschlange",
"fullHistoryComing": "vollständiger Verlauf unterwegs", "fullHistoryComing": "vollständiger Verlauf unterwegs",
"queuedRequestedHint": "Vollständiger Verlauf angefordert — der gesamte Katalog dieses Kanals wird nachgeladen, soweit das gemeinsame Kontingent es zulässt. Klicke, um deine Anforderung abzubrechen.", "queuedRequestedHint": "Vollständiger Verlauf angefordert — der gesamte Katalog dieses Kanals wird nachgeladen, soweit das gemeinsame Kontingent es zulässt. Klicke, um deine Anforderung abzubrechen.",
@ -67,7 +88,10 @@
"fullHistoryRequested": "Vollständiger Verlauf für {{count}} Kanäle angefordert", "fullHistoryRequested": "Vollständiger Verlauf für {{count}} Kanäle angefordert",
"fullHistoryFailed": "Vollständiger Verlauf konnte nicht angefordert werden", "fullHistoryFailed": "Vollständiger Verlauf konnte nicht angefordert werden",
"needYouTube": "Verbinde dein YouTube-Konto, um dies zu tun.", "needYouTube": "Verbinde dein YouTube-Konto, um dies zu tun.",
"connect": "Verbinden" "connect": "Verbinden",
"resetDone": "Kanal zurückgesetzt — wird neu geladen",
"resetFailed": "Kanal konnte nicht zurückgesetzt 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." "confirmUnsubscribe": "Kanal „{{name}}“ bei YouTube abbestellen? Dies ändert dein echtes YouTube-Konto. Um ihn nur aus deinem Feed zu entfernen, blende ihn stattdessen aus.",
"confirmReset": "Kanal „{{name}}“ von Grund auf neu laden? Dies startet den Backfill neu (aktuell + vollständiger Verlauf) und verbraucht etwas API-Kontingent."
} }

View file

@ -1,7 +1,7 @@
{ {
"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.", "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…", "filterPlaceholder": "Filter channels…",
"syncSubscriptions": "Sync subscriptions", "syncSubscriptions": "Read subscriptions from YouTube",
"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.", "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", "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.", "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.",
@ -19,6 +19,20 @@
}, },
"loading": "Loading channels…", "loading": "Loading channels…",
"empty": "No channels.", "empty": "No channels.",
"pager": {
"prev": "Previous",
"next": "Next",
"page": "Page {{page}} of {{total}}"
},
"cols": {
"priority": "Prio",
"channel": "Channel",
"stored": "Stored",
"subs": "Subscribers",
"sync": "Sync",
"tags": "Tags",
"actions": "Actions"
},
"stats": { "stats": {
"channels": "Channels", "channels": "Channels",
"channelsHint": "Channels you're subscribed to.", "channelsHint": "Channels you're subscribed to.",
@ -45,6 +59,13 @@
"recentNotSyncedHint": "Recent uploads not fetched yet.", "recentNotSyncedHint": "Recent uploads not fetched yet.",
"full": "full", "full": "full",
"fullHint": "Full history fetched.", "fullHint": "Full history fetched.",
"fullNotFetchedHint": "Full back-catalog not fetched yet — use the backfill action in the Actions column.",
"fullySynced": "fully synced",
"fullySyncedHint": "Recent uploads and the full back-catalog are both in.",
"backfillThis": "Backfill this channel",
"backfillThisHint": "Fetch this channel's full back-catalog (older videos + complete search). Click again to cancel the request.",
"resetBackfill": "Reset & re-fetch this channel",
"resetHint": "Admin: re-fetch this channel from scratch (recent + full back-catalog), regardless of its current sync state.",
"fullHistoryQueued": "full history queued", "fullHistoryQueued": "full history queued",
"fullHistoryComing": "full history coming", "fullHistoryComing": "full history coming",
"queuedRequestedHint": "Full history requested — this channel's whole back-catalog will backfill as the shared quota allows. Click to cancel your request.", "queuedRequestedHint": "Full history requested — this channel's whole back-catalog will backfill as the shared quota allows. Click to cancel your request.",
@ -67,7 +88,10 @@
"fullHistoryRequested": "Full history requested for {{count}} channels", "fullHistoryRequested": "Full history requested for {{count}} channels",
"fullHistoryFailed": "Couldn't request full history", "fullHistoryFailed": "Couldn't request full history",
"needYouTube": "Connect your YouTube account to do this.", "needYouTube": "Connect your YouTube account to do this.",
"connect": "Connect" "connect": "Connect",
"resetDone": "Channel reset — re-fetching now",
"resetFailed": "Couldn't reset this channel"
}, },
"confirmUnsubscribe": "Unsubscribe from \"{{name}}\" on YouTube? This changes your real YouTube account. To just remove it from your feed, hide it instead." "confirmUnsubscribe": "Unsubscribe from \"{{name}}\" on YouTube? This changes your real YouTube account. To just remove it from your feed, hide it instead.",
"confirmReset": "Re-fetch \"{{name}}\" from scratch? This re-runs its backfill (recent + full history) and spends some API quota."
} }

View file

@ -1,7 +1,7 @@
{ {
"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.", "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…", "filterPlaceholder": "Csatornák szűrése…",
"syncSubscriptions": "Feliratkozások szinkronizálása", "syncSubscriptions": "Feliratkozások beolvasása a YouTube-ról",
"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.", "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", "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.", "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.",
@ -19,6 +19,20 @@
}, },
"loading": "Csatornák betöltése…", "loading": "Csatornák betöltése…",
"empty": "Nincsenek csatornák.", "empty": "Nincsenek csatornák.",
"pager": {
"prev": "Előző",
"next": "Következő",
"page": "{{page}}. oldal / {{total}}"
},
"cols": {
"priority": "Prio",
"channel": "Csatorna",
"stored": "Tárolt",
"subs": "Feliratkozók",
"sync": "Szinkron",
"tags": "Címkék",
"actions": "Műveletek"
},
"stats": { "stats": {
"channels": "Csatorna", "channels": "Csatorna",
"channelsHint": "Csatornák, amelyekre feliratkoztál.", "channelsHint": "Csatornák, amelyekre feliratkoztál.",
@ -45,6 +59,13 @@
"recentNotSyncedHint": "A legutóbbi feltöltések még nincsenek letöltve.", "recentNotSyncedHint": "A legutóbbi feltöltések még nincsenek letöltve.",
"full": "teljes", "full": "teljes",
"fullHint": "Teljes előzmény letöltve.", "fullHint": "Teljes előzmény letöltve.",
"fullNotFetchedHint": "A teljes archívum még nincs letöltve — használd a Műveletek oszlop backfill gombját.",
"fullySynced": "teljesen szinkronban",
"fullySyncedHint": "A legutóbbi feltöltések és a teljes archívum is megvan.",
"backfillThis": "Csatorna teljes letöltése",
"backfillThisHint": "A csatorna teljes archívumának letöltése (régebbi videók + teljes keresés). Újra kattintva visszavonod a kérést.",
"resetBackfill": "Csatorna resetelése és újraletöltése",
"resetHint": "Admin: a csatorna teljes újraletöltése a nulláról (legutóbbi + teljes archívum), a jelenlegi szinkron-állapottól függetlenül.",
"fullHistoryQueued": "teljes előzmény sorban", "fullHistoryQueued": "teljes előzmény sorban",
"fullHistoryComing": "teljes előzmény úton", "fullHistoryComing": "teljes előzmény úton",
"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.", "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.",
@ -67,7 +88,10 @@
"fullHistoryRequested": "Teljes előzmény kérve {{count}} csatornához", "fullHistoryRequested": "Teljes előzmény kérve {{count}} csatornához",
"fullHistoryFailed": "Nem sikerült teljes előzményt kérni", "fullHistoryFailed": "Nem sikerült teljes előzményt kérni",
"needYouTube": "Ehhez csatlakoztasd a YouTube-fiókod.", "needYouTube": "Ehhez csatlakoztasd a YouTube-fiókod.",
"connect": "Csatlakoztatás" "connect": "Csatlakoztatás",
"resetDone": "Csatorna resetelve — újraletöltés folyamatban",
"resetFailed": "Nem sikerült resetelni a csatornát"
}, },
"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." "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.",
"confirmReset": "Újraletöltöd a(z) „{{name}}” csatornát a nulláról? Ez újrafuttatja a backfillt (legutóbbi + teljes előzmény), és némi API-kvótát fogyaszt."
} }