From 1d6d3d3063a40c6aa3026829e73d4cb1b8270c41 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 14 Jun 2026 07:08:59 +0200 Subject: [PATCH] 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. --- frontend/src/App.tsx | 9 +++- frontend/src/components/Channels.tsx | 59 ++++++++++++++++++++++++-- frontend/src/components/Header.tsx | 4 +- frontend/src/components/SyncStatus.tsx | 30 +++++++++++-- 4 files changed, 92 insertions(+), 10 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 9e2f382..6d157e0 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -21,7 +21,7 @@ import Login from "./components/Login"; import Header from "./components/Header"; import Sidebar from "./components/Sidebar"; import Feed from "./components/Feed"; -import Channels from "./components/Channels"; +import Channels, { type ChannelStatusFilter } from "./components/Channels"; import Stats from "./components/Stats"; import SettingsPanel from "./components/SettingsPanel"; import OnboardingWizard from "./components/OnboardingWizard"; @@ -64,6 +64,7 @@ export default function App() { const [page, setPageState] = useState(readPage); const [settingsOpen, setSettingsOpen] = useState(false); const [wizardOpen, setWizardOpen] = useState(false); + const [channelFilter, setChannelFilter] = useState("all"); function setFilters(next: FeedFilters) { setFiltersState(next); @@ -157,6 +158,10 @@ export default function App() { page={page} setPage={setPage} onOpenSettings={() => setSettingsOpen(true)} + onGoToFullHistory={() => { + setChannelFilter("needs_full"); + setPage("channels"); + }} />
{page === "feed" && ( @@ -171,6 +176,8 @@ export default function App() { {page === "channels" ? ( { setFilters({ ...filters, channelId: id, channelName: name, show: "all" }); setPage("feed"); diff --git a/frontend/src/components/Channels.tsx b/frontend/src/components/Channels.tsx index f36afef..a9ffc63 100644 --- a/frontend/src/components/Channels.tsx +++ b/frontend/src/components/Channels.tsx @@ -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(["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({
+ {/* Status filter */} +
+ {STATUS_FILTERS.map((f) => ( + + ))} +
+

Set a channel's priority to push its videos up when you sort by “Channel priority”, attach your own tags 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 } }) diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index bdce358..70190dd 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -13,6 +13,7 @@ export default function Header({ page, setPage, onOpenSettings, + onGoToFullHistory, }: { me: Me; filters: FeedFilters; @@ -20,6 +21,7 @@ export default function Header({ page: Page; setPage: (p: Page) => void; onOpenSettings: () => void; + onGoToFullHistory: () => void; }) { async function logout() { await fetch("/auth/logout", { method: "POST", credentials: "include" }); @@ -36,7 +38,7 @@ export default function Header({ Siftlode - + {page === "feed" ? (

diff --git a/frontend/src/components/SyncStatus.tsx b/frontend/src/components/SyncStatus.tsx index 88c4288..a07c65d 100644 --- a/frontend/src/components/SyncStatus.tsx +++ b/frontend/src/components/SyncStatus.tsx @@ -1,12 +1,19 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { Database, Loader2, Pause, Play } from "lucide-react"; +import { Database, History, Loader2, Pause, Play } from "lucide-react"; import { api } from "../lib/api"; import { formatViews } from "../lib/format"; +import Tooltip from "./Tooltip"; // Per-user status (not the global catalog): shows the number of videos available to *this* -// user and how many of *their* channels are still being fetched. The pause control is -// admin-only (it pauses the shared background sync). -export default function SyncStatus({ isAdmin }: { isAdmin: boolean }) { +// user, how many of *their* channels are still being fetched, and how many lack full +// history (clickable -> channel manager filtered to those). The pause control is admin-only. +export default function SyncStatus({ + isAdmin, + onGoToFullHistory, +}: { + isAdmin: boolean; + onGoToFullHistory: () => void; +}) { const qc = useQueryClient(); const { data } = useQuery({ queryKey: ["my-status"], @@ -23,6 +30,7 @@ export default function SyncStatus({ isAdmin }: { isAdmin: boolean }) { if (!data) return null; const syncing = data.channels_recent_pending; + const notFull = data.channels_deep_pending; // your channels without full history yet return (
@@ -39,6 +47,20 @@ export default function SyncStatus({ isAdmin }: { isAdmin: boolean }) { ) : ( all synced )} + {notFull > 0 && ( + <> + · + + + + + )} {isAdmin && (