diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 306aab0..179cc92 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,27 @@ 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); + }; + // 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); const focusChannel = (name: string) => { setFocusChannelName(name); + setChannelsView("subscribed"); // the name filter applies to the subscriptions table setPage("channels"); }; useEffect(() => { @@ -409,6 +425,8 @@ export default function App() { page={page} 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"); }} /> @@ -433,6 +451,9 @@ export default function App() { onFocusChannel={focusChannel} statusFilter={channelFilter} 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 fc1c817..76447a7 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,9 @@ export default function Channels({ focusChannelName, statusFilter, setStatusFilter, + view, + setView, + filtersResetToken, onOpenWizard, }: { canWrite: boolean; @@ -60,23 +63,18 @@ 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; + // Bumped by the header's "without full history" intent to clear a stale column filter. + filtersResetToken: number; 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) => { @@ -515,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) {