Merge bug/channels-tab-focus: reliable channel-manager navigation (tab + stale filter)

This commit is contained in:
npeter83 2026-06-19 03:57:44 +02:00
commit 1740690a48
3 changed files with 50 additions and 13 deletions

View file

@ -26,7 +26,7 @@ import Header from "./components/Header";
import NavSidebar from "./components/NavSidebar"; import NavSidebar from "./components/NavSidebar";
import Sidebar from "./components/Sidebar"; import Sidebar from "./components/Sidebar";
import Feed from "./components/Feed"; 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 Playlists from "./components/Playlists";
import Stats from "./components/Stats"; import Stats from "./components/Stats";
import Scheduler from "./components/Scheduler"; import Scheduler from "./components/Scheduler";
@ -136,11 +136,27 @@ export default function App() {
const [aboutOpen, setAboutOpen] = useState(false); const [aboutOpen, setAboutOpen] = useState(false);
const [notesOpen, setNotesOpen] = useState(false); const [notesOpen, setNotesOpen] = useState(false);
const [notesHighlight, setNotesHighlight] = useState<string | undefined>(undefined); const [notesHighlight, setNotesHighlight] = useState<string | undefined>(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<ChannelsView>(() =>
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 // "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. // so the channel is isolated. Cleared when leaving the page so it doesn't re-apply later.
const [focusChannelName, setFocusChannelName] = useState<string | null>(null); const [focusChannelName, setFocusChannelName] = useState<string | null>(null);
const focusChannel = (name: string) => { const focusChannel = (name: string) => {
setFocusChannelName(name); setFocusChannelName(name);
setChannelsView("subscribed"); // the name filter applies to the subscriptions table
setPage("channels"); setPage("channels");
}; };
useEffect(() => { useEffect(() => {
@ -409,6 +425,8 @@ export default function App() {
page={page} page={page}
onGoToFullHistory={() => { onGoToFullHistory={() => {
setChannelFilter("needs_full"); 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"); setPage("channels");
}} }}
/> />
@ -433,6 +451,9 @@ export default function App() {
onFocusChannel={focusChannel} onFocusChannel={focusChannel}
statusFilter={channelFilter} statusFilter={channelFilter}
setStatusFilter={setChannelFilter} setStatusFilter={setChannelFilter}
view={channelsView}
setView={setChannelsView}
filtersResetToken={channelsFilterReset}
onOpenWizard={() => setWizardOpen(true)} onOpenWizard={() => setWizardOpen(true)}
onViewChannel={(id, name) => { onViewChannel={(id, name) => {
setFilters({ ...filters, channelId: id, channelName: name, show: "all" }); setFilters({ ...filters, channelId: id, channelName: name, show: "all" });

View file

@ -23,7 +23,7 @@ import ChannelDiscovery from "./ChannelDiscovery";
import TagManager from "./TagManager"; import TagManager from "./TagManager";
import { useConfirm } from "./ConfirmProvider"; import { useConfirm } from "./ConfirmProvider";
type ChannelsView = "subscribed" | "discovery"; export type ChannelsView = "subscribed" | "discovery";
export type ChannelStatusFilter = "all" | "needs_full" | "fully_synced" | "hidden"; export type ChannelStatusFilter = "all" | "needs_full" | "fully_synced" | "hidden";
@ -50,6 +50,9 @@ export default function Channels({
focusChannelName, focusChannelName,
statusFilter, statusFilter,
setStatusFilter, setStatusFilter,
view,
setView,
filtersResetToken,
onOpenWizard, onOpenWizard,
}: { }: {
canWrite: boolean; canWrite: boolean;
@ -60,23 +63,18 @@ export default function Channels({
focusChannelName: string | null; focusChannelName: string | null;
statusFilter: ChannelStatusFilter; statusFilter: ChannelStatusFilter;
setStatusFilter: (f: ChannelStatusFilter) => void; 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; onOpenWizard: () => void;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const qc = useQueryClient(); const qc = useQueryClient();
const confirm = useConfirm(); 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<ChannelsView>(() =>
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 // 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. // granted the needed scope — surface that with a "Connect" action instead of a vague fail.
const notifyActionError = (err: unknown, fallbackKey: string) => { const notifyActionError = (err: unknown, fallbackKey: string) => {
@ -515,6 +513,7 @@ export default function Channels({
controlsPosition="top" controlsPosition="top"
controlsLeading={statusChips} controlsLeading={statusChips}
externalFilter={focusChannelName ? { key: "channel", value: focusChannelName } : null} externalFilter={focusChannelName ? { key: "channel", value: focusChannelName } : null}
resetFiltersToken={filtersResetToken}
rowClassName={(c) => (c.hidden ? "opacity-60" : "")} rowClassName={(c) => (c.hidden ? "opacity-60" : "")}
emptyText={t("channels.empty")} emptyText={t("channels.empty")}
/> />

View file

@ -67,6 +67,7 @@ export default function DataTable<T>({
controlsPosition = "bottom", controlsPosition = "bottom",
controlsLeading, controlsLeading,
externalFilter, externalFilter,
resetFiltersToken,
}: { }: {
rows: T[]; rows: T[];
columns: Column<T>[]; columns: Column<T>[];
@ -81,6 +82,10 @@ export default function DataTable<T>({
// Set a column's filter from outside (e.g. "focus this channel" deep-links here). Applied // 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. // whenever its value changes; the user can still clear it from the header afterwards.
externalFilter?: { key: string; value: string } | null; 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 { t } = useTranslation();
const initial = loadPersist(persistKey); const initial = loadPersist(persistKey);
@ -104,6 +109,18 @@ export default function DataTable<T>({
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [externalFilter?.key, externalFilter?.value]); }, [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(() => { useEffect(() => {
if (!openFilter) return; if (!openFilter) return;
function onDown(e: MouseEvent) { function onDown(e: MouseEvent) {