Merge bug/channels-tab-focus: reliable channel-manager navigation (tab + stale filter)
This commit is contained in:
commit
1740690a48
3 changed files with 50 additions and 13 deletions
|
|
@ -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" });
|
||||||
|
|
|
||||||
|
|
@ -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")}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue