From 39cd025e793ea1a46924f358de6858a207a29c52 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Fri, 19 Jun 2026 03:38:57 +0200 Subject: [PATCH 1/2] fix(channels): land on the subscriptions tab for channel-targeted navigation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Channel manager's tab (subscriptions vs playlist discovery) was persisted locally, so the header's "N without full history" link and the focus-channel jump (subscribe notice, tag manager) could dump the user on the Discovery tab while quietly applying a status/name filter that only affects the subscriptions table — the targeted channel was there, just on the hidden tab. Lift the tab state to App alongside the status filter it pairs with, and have those navigation intents switch it back to "subscribed". --- frontend/src/App.tsx | 18 +++++++++++++++++- frontend/src/components/Channels.tsx | 19 +++++++------------ 2 files changed, 24 insertions(+), 13 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 306aab0..ea37c48 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -26,7 +26,7 @@ import Header from "./components/Header"; import NavSidebar from "./components/NavSidebar"; import Sidebar from "./components/Sidebar"; import Feed from "./components/Feed"; -import Channels, { type ChannelStatusFilter } from "./components/Channels"; +import Channels, { type ChannelStatusFilter, type ChannelsView } from "./components/Channels"; import Playlists from "./components/Playlists"; import Stats from "./components/Stats"; import Scheduler from "./components/Scheduler"; @@ -136,11 +136,24 @@ export default function App() { const [aboutOpen, setAboutOpen] = useState(false); const [notesOpen, setNotesOpen] = useState(false); const [notesHighlight, setNotesHighlight] = useState(undefined); + // The Channel manager's active tab (subscriptions vs playlist discovery). Lifted here and + // persisted so navigation intents that target the subscriptions table — the header's + // "without full history" link, focus-channel — can force it back to "subscribed" instead + // of dumping the user on whatever tab they last left open. + const CHANNELS_VIEW_KEY = "siftlode.channelsView"; + const [channelsView, setChannelsViewState] = useState(() => + localStorage.getItem(CHANNELS_VIEW_KEY) === "discovery" ? "discovery" : "subscribed" + ); + const setChannelsView = (v: ChannelsView) => { + setChannelsViewState(v); + localStorage.setItem(CHANNELS_VIEW_KEY, v); + }; // "Focus this channel in the manager": jump to the Channels page and seed its name filter // so the channel is isolated. Cleared when leaving the page so it doesn't re-apply later. const [focusChannelName, setFocusChannelName] = useState(null); const focusChannel = (name: string) => { setFocusChannelName(name); + setChannelsView("subscribed"); // the name filter applies to the subscriptions table setPage("channels"); }; useEffect(() => { @@ -409,6 +422,7 @@ export default function App() { page={page} onGoToFullHistory={() => { setChannelFilter("needs_full"); + setChannelsView("subscribed"); // the status filter applies to the subscriptions tab setPage("channels"); }} /> @@ -433,6 +447,8 @@ export default function App() { onFocusChannel={focusChannel} statusFilter={channelFilter} setStatusFilter={setChannelFilter} + view={channelsView} + setView={setChannelsView} onOpenWizard={() => setWizardOpen(true)} onViewChannel={(id, name) => { setFilters({ ...filters, channelId: id, channelName: name, show: "all" }); diff --git a/frontend/src/components/Channels.tsx b/frontend/src/components/Channels.tsx index fc1c817..daf1caa 100644 --- a/frontend/src/components/Channels.tsx +++ b/frontend/src/components/Channels.tsx @@ -23,7 +23,7 @@ import ChannelDiscovery from "./ChannelDiscovery"; import TagManager from "./TagManager"; import { useConfirm } from "./ConfirmProvider"; -type ChannelsView = "subscribed" | "discovery"; +export type ChannelsView = "subscribed" | "discovery"; export type ChannelStatusFilter = "all" | "needs_full" | "fully_synced" | "hidden"; @@ -50,6 +50,8 @@ export default function Channels({ focusChannelName, statusFilter, setStatusFilter, + view, + setView, onOpenWizard, }: { canWrite: boolean; @@ -60,23 +62,16 @@ export default function Channels({ focusChannelName: string | null; statusFilter: ChannelStatusFilter; setStatusFilter: (f: ChannelStatusFilter) => void; + // The active tab lives in App so navigation intents that target the subscriptions table + // (the header's "without full history" link, focus-channel) can force it back here. + view: ChannelsView; + setView: (v: ChannelsView) => void; onOpenWizard: () => void; }) { const { t } = useTranslation(); const qc = useQueryClient(); const confirm = useConfirm(); - // Which tab is showing: the user's subscriptions, or channels discovered from their - // playlists. Persisted so a reload keeps the active tab (see other siftlode.* keys). - const [view, setView] = useState(() => - localStorage.getItem("siftlode.channelsView") === "discovery" - ? "discovery" - : "subscribed" - ); - useEffect(() => { - localStorage.setItem("siftlode.channelsView", view); - }, [view]); - // A YouTube-gated action (sync, backfill, unsubscribe) that 403s means the user hasn't // granted the needed scope — surface that with a "Connect" action instead of a vague fail. const notifyActionError = (err: unknown, fallbackKey: string) => { From 52a5d1fef891c394f099de59f4730449acd52938 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Fri, 19 Jun 2026 03:55:55 +0200 Subject: [PATCH 2/2] fix(channels): drop a stale column filter when sent to "without full history" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A focus-channel deep-link sets (and persists) the channel-name column filter via the DataTable's externalFilter. That persisted value then survived into unrelated visits: clicking the header's "N without full history" link landed on the right tab with the right status chip, but a leftover name filter (e.g. a channel focused in a past session) hid every row — "No channels". Add a resetFiltersToken to DataTable that clears its column filters when it advances, and bump it from the header intent. The token's ref starts at 0 so the clear fires even when the table remounts on navigation, while a plain reload (token still 0) keeps the user's persisted filters. --- frontend/src/App.tsx | 5 +++++ frontend/src/components/Channels.tsx | 4 ++++ frontend/src/components/DataTable.tsx | 17 +++++++++++++++++ 3 files changed, 26 insertions(+) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ea37c48..179cc92 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -148,6 +148,9 @@ export default function App() { setChannelsViewState(v); localStorage.setItem(CHANNELS_VIEW_KEY, v); }; + // Bumped to tell the channel manager to drop a stale column filter when we send the user + // there to see a specific set (the header's "without full history" link). + const [channelsFilterReset, setChannelsFilterReset] = useState(0); // "Focus this channel in the manager": jump to the Channels page and seed its name filter // so the channel is isolated. Cleared when leaving the page so it doesn't re-apply later. const [focusChannelName, setFocusChannelName] = useState(null); @@ -423,6 +426,7 @@ export default function App() { onGoToFullHistory={() => { setChannelFilter("needs_full"); setChannelsView("subscribed"); // the status filter applies to the subscriptions tab + setChannelsFilterReset((n) => n + 1); // drop any stale column filter hiding the rows setPage("channels"); }} /> @@ -449,6 +453,7 @@ export default function App() { setStatusFilter={setChannelFilter} view={channelsView} setView={setChannelsView} + filtersResetToken={channelsFilterReset} onOpenWizard={() => setWizardOpen(true)} onViewChannel={(id, name) => { setFilters({ ...filters, channelId: id, channelName: name, show: "all" }); diff --git a/frontend/src/components/Channels.tsx b/frontend/src/components/Channels.tsx index daf1caa..76447a7 100644 --- a/frontend/src/components/Channels.tsx +++ b/frontend/src/components/Channels.tsx @@ -52,6 +52,7 @@ export default function Channels({ setStatusFilter, view, setView, + filtersResetToken, onOpenWizard, }: { canWrite: boolean; @@ -66,6 +67,8 @@ export default function Channels({ // (the header's "without full history" link, focus-channel) can force it back here. view: ChannelsView; setView: (v: ChannelsView) => void; + // Bumped by the header's "without full history" intent to clear a stale column filter. + filtersResetToken: number; onOpenWizard: () => void; }) { const { t } = useTranslation(); @@ -510,6 +513,7 @@ export default function Channels({ controlsPosition="top" controlsLeading={statusChips} externalFilter={focusChannelName ? { key: "channel", value: focusChannelName } : null} + resetFiltersToken={filtersResetToken} rowClassName={(c) => (c.hidden ? "opacity-60" : "")} emptyText={t("channels.empty")} /> diff --git a/frontend/src/components/DataTable.tsx b/frontend/src/components/DataTable.tsx index f2e729d..cad982d 100644 --- a/frontend/src/components/DataTable.tsx +++ b/frontend/src/components/DataTable.tsx @@ -67,6 +67,7 @@ export default function DataTable({ controlsPosition = "bottom", controlsLeading, externalFilter, + resetFiltersToken, }: { rows: T[]; columns: Column[]; @@ -81,6 +82,10 @@ export default function DataTable({ // Set a column's filter from outside (e.g. "focus this channel" deep-links here). Applied // whenever its value changes; the user can still clear it from the header afterwards. externalFilter?: { key: string; value: string } | null; + // Bump this to clear all column filters — for navigation intents that mean "show me this + // set" (e.g. the header's "without full history" link), so a stale persisted filter can't + // hide the rows the user was just sent to see. + resetFiltersToken?: number; }) { const { t } = useTranslation(); const initial = loadPersist(persistKey); @@ -104,6 +109,18 @@ export default function DataTable({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [externalFilter?.key, externalFilter?.value]); + // Clear column filters when the reset token advances. The ref starts at 0 (not at the prop) + // so a navigation that bumped the token still clears even when it remounts this table fresh + // (e.g. arriving from another page); a plain reload keeps the token at 0 and the effect is a + // no-op, so the user's persisted filters survive. + const resetRef = useRef(0); + useEffect(() => { + if (resetFiltersToken === undefined || resetFiltersToken === resetRef.current) return; + resetRef.current = resetFiltersToken; + setFilters({}); + setPage(0); + }, [resetFiltersToken]); + useEffect(() => { if (!openFilter) return; function onDown(e: MouseEvent) {