feat(channels): status filter + header full-history link + stable priority

- header: per-user "N without full history" count (channels_deep_pending),
  clickable with a hint -> opens the channel manager filtered to those.
- channel manager: status filter chips (All / Needs full history / Fully synced
  / Hidden); the header link deep-links to "Needs full history".
- fix: priority up/down is now an optimistic in-place cache update (no refetch /
  re-sort), so the list no longer jumps to the top and loses your scroll position;
  the new order applies on the next page load.
This commit is contained in:
npeter83 2026-06-14 07:08:59 +02:00
parent c949630b89
commit aa38e972fd
4 changed files with 92 additions and 10 deletions

View file

@ -18,12 +18,25 @@ import { notify } from "../lib/notifications";
import Tooltip from "./Tooltip";
import Avatar from "./Avatar";
export type ChannelStatusFilter = "all" | "needs_full" | "fully_synced" | "hidden";
const STATUS_FILTERS: { id: ChannelStatusFilter; label: string }[] = [
{ id: "all", label: "All" },
{ id: "needs_full", label: "Needs full history" },
{ id: "fully_synced", label: "Fully synced" },
{ id: "hidden", label: "Hidden" },
];
export default function Channels({
canWrite,
onViewChannel,
statusFilter,
setStatusFilter,
}: {
canWrite: boolean;
onViewChannel: (id: string, name: string) => void;
statusFilter: ChannelStatusFilter;
setStatusFilter: (f: ChannelStatusFilter) => void;
}) {
const qc = useQueryClient();
const channelsQuery = useQuery({ queryKey: ["channels"], queryFn: api.channels });
@ -54,6 +67,19 @@ export default function Channels({
qc.invalidateQueries({ queryKey: ["feed-count"] });
},
});
// Priority is updated optimistically in place and NOT re-sorted/refetched, so the list
// doesn't jump (the server list is priority-sorted; refetching would reorder rows and
// lose your scroll position). The new order takes effect next time the page loads.
const bumpPriority = (id: string, current: number, delta: number) => {
const next = current + delta;
qc.setQueryData<ManagedChannel[]>(["channels"], (old) =>
(old ?? []).map((ch) => (ch.id === id ? { ...ch, priority: next } : ch))
);
api
.updateChannel(id, { priority: next })
.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"] }),
@ -103,9 +129,17 @@ export default function Channels({
onError: () => notify({ level: "error", message: "Couldn't request full history" }),
});
const channels = (channelsQuery.data ?? []).filter(
(c) => !q || (c.title ?? "").toLowerCase().includes(q.toLowerCase())
);
const channels = (channelsQuery.data ?? [])
.filter((c) => !q || (c.title ?? "").toLowerCase().includes(q.toLowerCase()))
.filter((c) =>
statusFilter === "needs_full"
? !c.backfill_done
: statusFilter === "fully_synced"
? c.backfill_done
: statusFilter === "hidden"
? c.hidden
: true
);
const s = statusQuery.data;
return (
@ -179,6 +213,23 @@ export default function Channels({
</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"
}`}
>
{f.label}
</button>
))}
</div>
<p className="text-xs text-muted mb-4 leading-relaxed">
Set a channel's <b className="text-fg/80">priority</b> to push its videos up when you sort
by Channel priority, attach your own <b className="text-fg/80">tags</b> to filter the feed,
@ -244,7 +295,7 @@ export default function Channels({
unsubscribe.mutate(c.id);
}}
onView={() => onViewChannel(c.id, c.title ?? "This channel")}
onPriority={(d) => patch.mutate({ id: c.id, body: { priority: c.priority + d } })}
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 } })