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:
parent
0210b84166
commit
5610dba5eb
5 changed files with 476 additions and 259 deletions
|
|
@ -93,7 +93,18 @@ export default function App() {
|
|||
const [sidebarLayout, setSidebarLayoutState] = useState<SidebarLayout>(loadLayout);
|
||||
const [page, setPageState] = useState<Page>(loadInitialPage);
|
||||
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 [notesOpen, setNotesOpen] = useState(false);
|
||||
const [notesHighlight, setNotesHighlight] = useState<string | undefined>(undefined);
|
||||
|
|
@ -266,6 +277,7 @@ export default function App() {
|
|||
{page === "channels" ? (
|
||||
<Channels
|
||||
canWrite={meQuery.data!.can_write}
|
||||
isAdmin={meQuery.data!.role === "admin"}
|
||||
statusFilter={channelFilter}
|
||||
setStatusFilter={setChannelFilter}
|
||||
onOpenWizard={() => setWizardOpen(true)}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ import {
|
|||
History,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Search,
|
||||
UserMinus,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
|
|
@ -20,6 +19,7 @@ import { formatEta } from "../lib/format";
|
|||
import { notify } from "../lib/notifications";
|
||||
import Tooltip from "./Tooltip";
|
||||
import Avatar from "./Avatar";
|
||||
import DataTable, { type Column } from "./DataTable";
|
||||
import { useConfirm } from "./ConfirmProvider";
|
||||
|
||||
export type ChannelStatusFilter = "all" | "needs_full" | "fully_synced" | "hidden";
|
||||
|
|
@ -33,12 +33,14 @@ const STATUS_FILTERS: { id: ChannelStatusFilter; labelKey: string }[] = [
|
|||
|
||||
export default function Channels({
|
||||
canWrite,
|
||||
isAdmin,
|
||||
onViewChannel,
|
||||
statusFilter,
|
||||
setStatusFilter,
|
||||
onOpenWizard,
|
||||
}: {
|
||||
canWrite: boolean;
|
||||
isAdmin: boolean;
|
||||
onViewChannel: (id: string, name: string) => void;
|
||||
statusFilter: ChannelStatusFilter;
|
||||
setStatusFilter: (f: ChannelStatusFilter) => void;
|
||||
|
|
@ -64,7 +66,6 @@ export default function Channels({
|
|||
const channelsQuery = useQuery({ queryKey: ["channels"], queryFn: api.channels });
|
||||
const tagsQuery = useQuery({ queryKey: ["tags"], queryFn: api.tags });
|
||||
const statusQuery = useQuery({ queryKey: ["my-status"], queryFn: api.myStatus });
|
||||
const [q, setQ] = useState("");
|
||||
const [newTag, setNewTag] = useState("");
|
||||
|
||||
const invalidate = () => {
|
||||
|
|
@ -102,14 +103,26 @@ export default function Channels({
|
|||
.catch(() => qc.invalidateQueries({ queryKey: ["channels"] }));
|
||||
};
|
||||
|
||||
const attach = useMutation({
|
||||
mutationFn: (v: { id: string; tagId: number }) => api.attachChannelTag(v.id, v.tagId),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["channels"] }),
|
||||
});
|
||||
const detach = useMutation({
|
||||
mutationFn: (v: { id: string; tagId: number }) => api.detachChannelTag(v.id, v.tagId),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["channels"] }),
|
||||
});
|
||||
// Tagging is updated optimistically in place (no refetch of the whole channel list, which
|
||||
// made the toggle feel ~2s slow); only revert by refetching if the server call fails.
|
||||
const toggleTag = (id: string, tagId: number, hasTag: boolean) => {
|
||||
qc.setQueryData<ManagedChannel[]>(["channels"], (old) =>
|
||||
(old ?? []).map((ch) =>
|
||||
ch.id === id
|
||||
? {
|
||||
...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({
|
||||
mutationFn: () => api.syncSubscriptions(),
|
||||
onSuccess: (r: { subscriptions?: number }) => {
|
||||
|
|
@ -138,6 +151,15 @@ export default function Channels({
|
|||
},
|
||||
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({
|
||||
mutationFn: () => api.deepAll(true),
|
||||
onSuccess: (r: { updated?: number }) => {
|
||||
|
|
@ -151,9 +173,10 @@ export default function Channels({
|
|||
onError: (e) => notifyActionError(e, "channels.notify.fullHistoryFailed"),
|
||||
});
|
||||
|
||||
const channels = (channelsQuery.data ?? [])
|
||||
.filter((c) => !q || (c.title ?? "").toLowerCase().includes(q.toLowerCase()))
|
||||
.filter((c) =>
|
||||
// The Sync-status filter stays a top-level chip set (not a column filter) because the
|
||||
// header's "go to full history" deep-link drives it; the DataTable then handles name
|
||||
// search, tag filtering, sort and pagination over whatever the status chip leaves.
|
||||
const channels = (channelsQuery.data ?? []).filter((c) =>
|
||||
statusFilter === "needs_full"
|
||||
? !c.backfill_done
|
||||
: statusFilter === "fully_synced"
|
||||
|
|
@ -164,11 +187,142 @@ export default function Channels({
|
|||
);
|
||||
const s = statusQuery.data;
|
||||
|
||||
const onView = (c: ManagedChannel) =>
|
||||
onViewChannel(c.id, c.title ?? t("channels.row.thisChannel"));
|
||||
const onUnsub = async (c: ManagedChannel) => {
|
||||
const ok = await confirm({
|
||||
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);
|
||||
};
|
||||
const onReset = async (c: ManagedChannel) => {
|
||||
const ok = await confirm({
|
||||
title: t("channels.row.backfillThis"),
|
||||
message: t("channels.confirmReset", { name: c.title ?? c.id }),
|
||||
confirmLabel: t("channels.row.backfillThis"),
|
||||
});
|
||||
if (ok) resetBackfill.mutate(c.id);
|
||||
};
|
||||
|
||||
const columns: Column<ManagedChannel>[] = [
|
||||
{
|
||||
key: "priority",
|
||||
header: t("channels.cols.priority"),
|
||||
align: "center",
|
||||
width: "56px",
|
||||
sortable: true,
|
||||
hideInCard: true,
|
||||
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-4xl mx-auto">
|
||||
{/* Per-user sync status */}
|
||||
<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 && (
|
||||
<div className="flex flex-wrap items-center gap-x-5 gap-y-1 text-sm text-muted mb-4">
|
||||
<>
|
||||
<Stat label={t("channels.stats.channels")} value={s.channels_total} hint={t("channels.stats.channelsHint")} />
|
||||
<Stat
|
||||
label={t("channels.stats.recentSynced")}
|
||||
|
|
@ -193,20 +347,10 @@ export default function Channels({
|
|||
value={s.quota_remaining_today.toLocaleString()}
|
||||
hint={t("channels.stats.quotaLeftHint")}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<div className="relative flex-1">
|
||||
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-muted" />
|
||||
<input
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
placeholder={t("channels.filterPlaceholder")}
|
||||
className="w-full bg-card border border-border rounded-full pl-9 pr-4 py-2 text-sm outline-none focus:border-accent"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Tooltip
|
||||
side="bottom"
|
||||
hint={t("channels.syncSubscriptionsHint")}
|
||||
|
|
@ -234,22 +378,6 @@ export default function Channels({
|
|||
</button>
|
||||
</Tooltip>
|
||||
</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>
|
||||
|
||||
<p className="text-xs text-muted mb-4 leading-relaxed">
|
||||
|
|
@ -300,42 +428,20 @@ export default function Channels({
|
|||
</form>
|
||||
</div>
|
||||
|
||||
{/* Channel list */}
|
||||
{/* Channel table */}
|
||||
{channelsQuery.isLoading ? (
|
||||
<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">
|
||||
{channels.map((c) => (
|
||||
<ChannelRow
|
||||
key={c.id}
|
||||
c={c}
|
||||
userTags={userTags}
|
||||
canWrite={canWrite}
|
||||
onUnsubscribe={async () => {
|
||||
const ok = await confirm({
|
||||
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 })
|
||||
}
|
||||
<DataTable
|
||||
rows={channels}
|
||||
columns={columns}
|
||||
rowKey={(c) => c.id}
|
||||
persistKey="siftlode.channelsTable"
|
||||
controlsPosition="top"
|
||||
controlsLeading={statusChips}
|
||||
rowClassName={(c) => (c.hidden ? "opacity-60" : "")}
|
||||
emptyText={t("channels.empty")}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
@ -374,39 +480,11 @@ function SyncBadge({ ok, label, hint }: { ok: boolean; label: string; hint?: str
|
|||
);
|
||||
}
|
||||
|
||||
function ChannelRow({
|
||||
c,
|
||||
userTags,
|
||||
canWrite,
|
||||
onUnsubscribe,
|
||||
onView,
|
||||
onPriority,
|
||||
onHide,
|
||||
onDeep,
|
||||
onToggleTag,
|
||||
}: {
|
||||
c: ManagedChannel;
|
||||
userTags: Tag[];
|
||||
canWrite: boolean;
|
||||
onUnsubscribe: () => void;
|
||||
onView: () => void;
|
||||
onPriority: (delta: number) => void;
|
||||
onHide: () => void;
|
||||
onDeep: () => void;
|
||||
onToggleTag: (tagId: number) => void;
|
||||
}) {
|
||||
function PriorityCell({ c, onPriority }: { c: ManagedChannel; onPriority: (delta: number) => void }) {
|
||||
const { t } = useTranslation();
|
||||
const ytUrl = c.handle
|
||||
? `https://www.youtube.com/@${c.handle.replace(/^@/, "")}`
|
||||
: `https://www.youtube.com/channel/${c.id}`;
|
||||
return (
|
||||
<div
|
||||
className={`glass-card glass-hover flex items-center gap-3 p-2.5 rounded-xl transition ${
|
||||
c.hidden ? "opacity-60" : ""
|
||||
}`}
|
||||
>
|
||||
<Tooltip hint={t("channels.row.priorityHint")}>
|
||||
<div className="flex flex-col items-center cursor-help">
|
||||
<div className="inline-flex flex-col items-center cursor-help">
|
||||
<button onClick={() => onPriority(1)} className="text-muted hover:text-accent" aria-label={t("channels.row.raisePriority")}>
|
||||
<ArrowUp className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
|
|
@ -416,15 +494,17 @@ function ChannelRow({
|
|||
</button>
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
<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">
|
||||
function NameCell({ c, onView }: { c: ManagedChannel; onView: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const ytUrl = c.handle
|
||||
? `https://www.youtube.com/@${c.handle.replace(/^@/, "")}`
|
||||
: `https://www.youtube.com/channel/${c.id}`;
|
||||
return (
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Avatar src={c.thumbnail_url} fallback={c.title ?? ""} className="w-8 h-8 rounded-full shrink-0" />
|
||||
<button
|
||||
onClick={onView}
|
||||
onAuxClick={(e) => {
|
||||
|
|
@ -438,7 +518,7 @@ function ChannelRow({
|
|||
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"
|
||||
className="text-sm font-medium truncate hover:text-accent text-left min-w-0"
|
||||
>
|
||||
{c.title ?? c.id}
|
||||
</button>
|
||||
|
|
@ -454,11 +534,19 @@ function ChannelRow({
|
|||
</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">
|
||||
);
|
||||
}
|
||||
|
||||
// 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")}
|
||||
|
|
@ -467,60 +555,105 @@ function ChannelRow({
|
|||
{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>
|
||||
<SyncBadge ok={false} label={t("channels.row.fullHistoryQueued")} hint={t("channels.row.queuedRequestedHint")} />
|
||||
) : 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>
|
||||
<SyncBadge ok={false} label={t("channels.row.fullHistoryComing")} hint={t("channels.row.queuedByOtherHint")} />
|
||||
) : (
|
||||
<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>
|
||||
<SyncBadge ok={false} label={t("channels.row.full")} hint={t("channels.row.fullNotFetchedHint")} />
|
||||
)}
|
||||
{userTags.map((t) => {
|
||||
const on = c.tag_ids.includes(t.id);
|
||||
</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={t.id}
|
||||
onClick={() => onToggleTag(t.id)}
|
||||
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"
|
||||
}`}
|
||||
>
|
||||
{t.name}
|
||||
{tg.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
<Tooltip
|
||||
hint={c.hidden ? t("channels.row.hiddenHint") : t("channels.row.hideHint")}
|
||||
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")}>
|
||||
{c.hidden ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
{canWrite && (
|
||||
<Tooltip hint={t("channels.row.unsubscribeHint")}>
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
"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.",
|
||||
"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.",
|
||||
|
|
@ -19,6 +19,20 @@
|
|||
},
|
||||
"loading": "Kanäle werden geladen…",
|
||||
"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": {
|
||||
"channels": "Kanäle",
|
||||
"channelsHint": "Kanäle, die du abonniert hast.",
|
||||
|
|
@ -45,6 +59,13 @@
|
|||
"recentNotSyncedHint": "Neueste Uploads noch nicht geladen.",
|
||||
"full": "vollständig",
|
||||
"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",
|
||||
"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.",
|
||||
|
|
@ -67,7 +88,10 @@
|
|||
"fullHistoryRequested": "Vollständiger Verlauf für {{count}} Kanäle angefordert",
|
||||
"fullHistoryFailed": "Vollständiger Verlauf konnte nicht angefordert werden",
|
||||
"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."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
"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.",
|
||||
"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.",
|
||||
|
|
@ -19,6 +19,20 @@
|
|||
},
|
||||
"loading": "Loading 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": {
|
||||
"channels": "Channels",
|
||||
"channelsHint": "Channels you're subscribed to.",
|
||||
|
|
@ -45,6 +59,13 @@
|
|||
"recentNotSyncedHint": "Recent uploads not fetched yet.",
|
||||
"full": "full",
|
||||
"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",
|
||||
"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.",
|
||||
|
|
@ -67,7 +88,10 @@
|
|||
"fullHistoryRequested": "Full history requested for {{count}} channels",
|
||||
"fullHistoryFailed": "Couldn't request full history",
|
||||
"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."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
"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.",
|
||||
"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.",
|
||||
|
|
@ -19,6 +19,20 @@
|
|||
},
|
||||
"loading": "Csatornák betöltése…",
|
||||
"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": {
|
||||
"channels": "Csatorna",
|
||||
"channelsHint": "Csatornák, amelyekre feliratkoztál.",
|
||||
|
|
@ -45,6 +59,13 @@
|
|||
"recentNotSyncedHint": "A legutóbbi feltöltések még nincsenek letöltve.",
|
||||
"full": "teljes",
|
||||
"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",
|
||||
"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.",
|
||||
|
|
@ -67,7 +88,10 @@
|
|||
"fullHistoryRequested": "Teljes előzmény kérve {{count}} csatornához",
|
||||
"fullHistoryFailed": "Nem sikerült teljes előzményt kérni",
|
||||
"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."
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue