diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3767d49..273a6f0 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -7,7 +7,6 @@ import { getActiveAccount, setActiveAccount, setUnauthorizedHandler, - type FeedFilters, type PlexFilters, } from "./lib/api"; import { setLanguage, isSupported, type LangCode } from "./i18n"; @@ -18,7 +17,7 @@ import { saveLocalTheme, type ThemePrefs, } from "./lib/theme"; -import { hasFilterParams, paramsToFilters, stripUrlParams } from "./lib/urlState"; +import { hasFilterParams, stripUrlParams } from "./lib/urlState"; import { loadLayout, normalizeLayout, @@ -31,19 +30,16 @@ import { configureNotifications, getNotifSettings, notify, removeByMetaKind, typ import { hintsEnabled, setHintsEnabled } from "./lib/hints"; import { parseFeedView, type FeedView } from "./lib/feedView"; import { - accountKey, getAccountRaw, LS, readAccount, - readJSON, - readMerged, setAccountRaw, useAccountPersistedState, writeAccount, - writeJSON, } from "./lib/storage"; import type { PrefsController } from "./components/SettingsPanel"; import { useNavigation, useNavigationActions } from "./components/NavigationProvider"; +import { useFeedFilters, useFeedFiltersActions } from "./components/FeedFiltersProvider"; import type { ChannelStatusFilter, ChannelsView } from "./components/Channels"; // Persistent shell — always mounted, so eagerly imported. import Welcome from "./components/Welcome"; @@ -86,20 +82,6 @@ const OnboardingWizard = lazy(() => import("./components/OnboardingWizard")); const About = lazy(() => import("./components/About")); const ReleaseNotes = lazy(() => import("./components/ReleaseNotes")); -const DEFAULT_FILTERS: FeedFilters = { - tags: [], - tagMode: "or", - channelIds: [], - q: "", - sort: "newest", - scope: "my", - includeNormal: true, - includeShorts: false, - includeLive: false, - show: "unwatched", -}; - -const FILTERS_KEY = LS.filters; const PERF_KEY = LS.perfMode; // The preferences edited on the Settings page. They apply locally for instant preview but @@ -112,50 +94,16 @@ type EditablePrefs = { notifications: NotifSettings; }; -// Page is navigation state, not a filter, but it's also kept out of the address bar — so -// persist it to localStorage to survive a reload. A share link's ?page= still wins. -// This account's own persisted filters (per-account keys — never another account's). A chosen -// default saved view wins over the last-session filters (SavedViewsWidget mirrors it here). When -// the account isn't known yet (first load, before the tab is pinned), start from defaults; the -// post-login effect loads the real ones once the id arrives. -function loadAccountFilters(id: number | null): FeedFilters { - // Your last-applied filters win, so a reload keeps whatever view you're on (a saved view you - // picked, or manual tweaks) — setFilters persists them under LS.filters on every change. The - // starred default view only SEEDS a fresh account that has never stored filters: it drives the - // very first load, after which that state persists like any other. (Re-apply the default view - // from the sidebar to return to it.) - const fKey = accountKey(LS.filters, id); - let hasStored = false; - try { - hasStored = !!fKey && localStorage.getItem(fKey) != null; - } catch { - /* localStorage may be unavailable */ - } - if (hasStored && fKey) return readMerged(fKey, DEFAULT_FILTERS); - const dvKey = accountKey(LS.defaultViewFilters, id); - const def = dvKey ? readJSON(dvKey, null) : null; - if (def) return { ...DEFAULT_FILTERS, ...def }; - return { ...DEFAULT_FILTERS }; -} - -// URL wins over localStorage so a pasted link reproduces the exact view. -function loadInitialFilters(): FeedFilters { - const params = new URLSearchParams(window.location.search); - if (hasFilterParams(params)) return paramsToFilters(params, DEFAULT_FILTERS); - return loadAccountFilters(getActiveAccount()); -} - export default function App() { const { t, i18n } = useTranslation(); const { page, channelView, ytSearch } = useNavigation(); const { setPage, openChannel, enterYtSearch, exitYtSearch } = useNavigationActions(); const [theme, setThemeState] = useState(() => loadLocalTheme()); - const [filters, setFiltersState] = useState(loadInitialFilters); - // Whether this load arrived via a "Share view" link (captured once, before the URL is stripped) - // — such filters win over the account's stored view and are persisted to the account instead. - const [initialHadUrlFilters] = useState(() => - hasFilterParams(new URLSearchParams(window.location.search)) - ); + // The global feed filters live in FeedFiltersProvider (per-account persistence + the account-load + // effect + share-link capture all moved there). App reads them to hand to and to the + // handlers that mutate the feed view (onShowInFeed, tag-click, the feed scrollKey). + const filters = useFeedFilters(); + const { setFilters } = useFeedFiltersActions(); // The feed's view mode is picked from the feed's own toolbar; like every Settings pref it // persists the moment you choose it. const [view, setViewState] = useState(() => parseFeedView(readAccount(LS.feedView, null))); @@ -257,13 +205,6 @@ export default function App() { setNotesOpen(true); } - function setFilters(next: FeedFilters) { - setFiltersState(next); - // Persist under THIS tab's active account so filters don't bleed between accounts. - const key = accountKey(FILTERS_KEY, getActiveAccount()); - if (key) writeJSON(key, next); - } - function setPanelLayout(panel: PanelId, next: PanelLayout) { setPanelLayouts((prev) => ({ ...prev, [panel]: next })); saveLayoutLocal(panel, next); @@ -423,26 +364,6 @@ export default function App() { if (meQuery.data && getActiveAccount() == null) setActiveAccount(meQuery.data.id); }, [meQuery.data?.id]); - // Load THIS account's own filters when the account first resolves or changes (login / switch / - // add). Filters are per-account localStorage state, so another account's last view (incl. its - // default saved view) must never carry over. A share link's filters (captured at mount) win and - // are persisted to the account instead. Runs on id change only, so a 60s background refetch of - // the same account never clobbers the filters you're editing. - useEffect(() => { - const id = meQuery.data?.id; - if (id == null) return; - if (initialHadUrlFilters) { - const key = accountKey(FILTERS_KEY, id); - if (key) writeJSON(key, filters); - return; - } - let f = loadAccountFilters(id); - // The shared demo account has no subscriptions, so default it to the whole library. - if (meQuery.data?.is_demo) f = { ...f, scope: "all" }; - setFiltersState(f); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [meQuery.data?.id]); - // Once signed in, drop any leftover query string from the pre-login flow (e.g. // ?access=requested, ?verify, ?reset). The logged-in app reads nothing from the URL, so the // address bar stays clean. Gated on auth so the logged-out Welcome page still reads its params. @@ -661,8 +582,6 @@ export default function App() { content column. Sits beside the nav rail as its own collapsible column. */} {page === "feed" && !channelView && ( setPanelLayout("feed", l)} onFocusChannel={focusChannel} @@ -740,8 +659,6 @@ export default function App() { header={
) : page === "notifications" ? ( - + ) : page === "messages" && !meQuery.data!.is_demo ? (
diff --git a/frontend/src/components/FeedFiltersProvider.tsx b/frontend/src/components/FeedFiltersProvider.tsx new file mode 100644 index 0000000..d0622a8 --- /dev/null +++ b/frontend/src/components/FeedFiltersProvider.tsx @@ -0,0 +1,137 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, + type ReactNode, +} from "react"; +import { useQuery } from "@tanstack/react-query"; +import { api, getActiveAccount, type FeedFilters } from "../lib/api"; +import { hasFilterParams, paramsToFilters } from "../lib/urlState"; +import { accountKey, LS, readJSON, readMerged, writeJSON } from "../lib/storage"; + +// Owns the GLOBAL feed-filter state (the feed's scope/tags/content/sort/show), lifted out of the App +// god component so consumers read it via a hook instead of prop-drilling `filters`/`setFilters` +// through the tree. Mirrors NavigationProvider (split contexts + one-line hooks); composed in +// main.tsx inside NavigationProvider, around . +// +// TWO contexts on purpose: a STABLE actions context (setFilters never changes reference) and a +// CHANGING state context (the filters object). Context consumers re-render when their value changes, +// so a component that only mutates filters (e.g. NotificationsPanel) subscribes to actions ONLY and +// doesn't re-render on every filter tweak. +// +// SCOPE NOTE: this owns the FEED's filters only. The channel detail page keeps its OWN isolated +// filter state (ChannelPage) — it renders a channel-scoped catalog, not the global feed — and the +// reusable is parameterized by a `filters` prop so it can render either. Because the two live +// in structurally separate cells, opening a channel can never leak into the global feed (bug 2). + +const DEFAULT_FILTERS: FeedFilters = { + tags: [], + tagMode: "or", + channelIds: [], + q: "", + sort: "newest", + scope: "my", + includeNormal: true, + includeShorts: false, + includeLive: false, + show: "unwatched", +}; + +const FILTERS_KEY = LS.filters; + +// This account's own persisted filters (per-account keys — never another account's). A chosen +// default saved view wins over the last-session filters (SavedViewsWidget mirrors it here). When +// the account isn't known yet (first load, before the tab is pinned), start from defaults; the +// post-login effect loads the real ones once the id arrives. +function loadAccountFilters(id: number | null): FeedFilters { + // Your last-applied filters win, so a reload keeps whatever view you're on (a saved view you + // picked, or manual tweaks) — setFilters persists them under LS.filters on every change. The + // starred default view only SEEDS a fresh account that has never stored filters: it drives the + // very first load, after which that state persists like any other. (Re-apply the default view + // from the sidebar to return to it.) + const fKey = accountKey(FILTERS_KEY, id); + let hasStored = false; + try { + hasStored = !!fKey && localStorage.getItem(fKey) != null; + } catch { + /* localStorage may be unavailable */ + } + if (hasStored && fKey) return readMerged(fKey, DEFAULT_FILTERS); + const dvKey = accountKey(LS.defaultViewFilters, id); + const def = dvKey ? readJSON(dvKey, null) : null; + if (def) return { ...DEFAULT_FILTERS, ...def }; + return { ...DEFAULT_FILTERS }; +} + +// URL wins over localStorage so a pasted link reproduces the exact view. +function loadInitialFilters(): FeedFilters { + const params = new URLSearchParams(window.location.search); + if (hasFilterParams(params)) return paramsToFilters(params, DEFAULT_FILTERS); + return loadAccountFilters(getActiveAccount()); +} + +interface FeedFiltersActions { + setFilters: (next: FeedFilters) => void; +} + +const StateContext = createContext(DEFAULT_FILTERS); +const ActionsContext = createContext({ setFilters: () => {} }); + +export function useFeedFilters(): FeedFilters { + return useContext(StateContext); +} +export function useFeedFiltersActions(): FeedFiltersActions { + return useContext(ActionsContext); +} + +export function FeedFiltersProvider({ children }: { children: ReactNode }) { + const [filters, setFiltersState] = useState(loadInitialFilters); + // Whether this load arrived via a "Share view" link (captured once, before the URL is stripped) + // — such filters win over the account's stored view and are persisted to the account instead. + const [initialHadUrlFilters] = useState(() => + hasFilterParams(new URLSearchParams(window.location.search)), + ); + + // Passive observer: App owns the fetching `["me"]` query (gated on setup state); enabled:false + // here reads its cache and re-renders when it lands, without triggering a second fetch (which + // could 503 in setup mode). We only need id + is_demo, which don't change on background refetch. + const meQuery = useQuery({ queryKey: ["me"], queryFn: api.me, enabled: false }); + + const setFilters = useCallback((next: FeedFilters) => { + setFiltersState(next); + // Persist under THIS tab's active account so filters don't bleed between accounts. + const key = accountKey(FILTERS_KEY, getActiveAccount()); + if (key) writeJSON(key, next); + }, []); + + // Load THIS account's own filters when the account first resolves or changes (login / switch / + // add). Filters are per-account localStorage state, so another account's last view (incl. its + // default saved view) must never carry over. A share link's filters (captured at mount) win and + // are persisted to the account instead. Runs on id change only, so a 60s background refetch of + // the same account never clobbers the filters you're editing. + useEffect(() => { + const id = meQuery.data?.id; + if (id == null) return; + if (initialHadUrlFilters) { + const key = accountKey(FILTERS_KEY, id); + if (key) writeJSON(key, filters); + return; + } + let f = loadAccountFilters(id); + // The shared demo account has no subscriptions, so default it to the whole library. + if (meQuery.data?.is_demo) f = { ...f, scope: "all" }; + setFiltersState(f); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [meQuery.data?.id]); + + const actions = useMemo(() => ({ setFilters }), [setFilters]); + + return ( + + {children} + + ); +} diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index 313dde7..7eadae0 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -1,20 +1,19 @@ import { type ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { Youtube } from "lucide-react"; -import type { FeedFilters, Me } from "../lib/api"; +import type { Me } from "../lib/api"; import { moduleLabelKey, moduleOrder, stepModule } from "../lib/modules"; import ModuleName from "./ModuleName"; import SearchBar from "./SearchBar"; import SyncStatus from "./SyncStatus"; import { useNavigation, useNavigationActions } from "./NavigationProvider"; +import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider"; // The floating top header: a row of glass pills over the content — a ModuleName pill (label + // accent dot + back/forward arrows), the module's SearchBar where it has one, and the per-user // sync chip at the right end. export default function Header({ me, - filters, - setFilters, plexQ, setPlexQ, channelsSearch, @@ -25,8 +24,6 @@ export default function Header({ onGoToFullHistory, }: { me: Me; - filters: FeedFilters; - setFilters: (f: FeedFilters) => void; // Plex has its own ephemeral search term (see App) — the box is shared UI, the state is not. plexQ: string; setPlexQ: (q: string) => void; @@ -43,6 +40,8 @@ export default function Header({ const { t } = useTranslation(); const { page } = useNavigation(); const { setPage } = useNavigationActions(); + const filters = useFeedFilters(); + const { setFilters } = useFeedFiltersActions(); // The ◀/▶ arrows step cyclically through the user's available modules (derived from `me`, so // it stays correct as modules are added/gated). Labels of every active module feed ModuleName's // dynamic width so the pill is sized to the widest reachable title in the current language. diff --git a/frontend/src/components/NotificationsPanel.tsx b/frontend/src/components/NotificationsPanel.tsx index aed27a5..e4aaed4 100644 --- a/frontend/src/components/NotificationsPanel.tsx +++ b/frontend/src/components/NotificationsPanel.tsx @@ -2,7 +2,7 @@ import { useEffect, useSyncExternalStore } from "react"; import { useTranslation } from "react-i18next"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { Activity, Bell, Check, CheckCheck, ExternalLink, Eye, RotateCcw, Search, Trash2, Tv, UserPlus, X } from "lucide-react"; -import { api, type AppNotification, type FeedFilters } from "../lib/api"; +import { api, type AppNotification } from "../lib/api"; import { useLiveQuery } from "../lib/useLiveQuery"; import { focusAccessRequestsTab } from "../lib/adminUsersTab"; import { @@ -23,6 +23,7 @@ import { channelYouTubeUrl, relativeFromMs, relativeTime } from "../lib/format"; import { LEVEL_STYLE } from "./Toaster"; import { PageToolbar } from "./PageShell"; import { useNavigationActions } from "./NavigationProvider"; +import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider"; // The single notification center. "System" = durable, server-backed notifications (inbox // phase 1). "Activity" = the client-side transient events (action confirmations, errors, with @@ -31,16 +32,14 @@ import { useNavigationActions } from "./NavigationProvider"; const POLL_MS = 8000; export default function NotificationsPanel({ - filters, - setFilters, onFocusChannel, }: { - filters: FeedFilters; - setFilters: (f: FeedFilters) => void; onFocusChannel: (name: string) => void; }) { const { t } = useTranslation(); const { setPage } = useNavigationActions(); + const filters = useFeedFilters(); + const { setFilters } = useFeedFiltersActions(); const qc = useQueryClient(); const q = useLiveQuery<{ items: AppNotification[]; total: number }>( ["notifications"], diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 20d7d1c..2da3a3a 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -13,6 +13,7 @@ import SavedViewsWidget from "./SavedViewsWidget"; import SidePanel from "./SidePanel"; import PanelGroups, { type PanelItem } from "./PanelGroups"; import ChannelPicker from "./ChannelPicker"; +import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider"; // Filter ids; display labels resolved at render time via t("sidebar.sort.") etc. const DATE_PRESETS: { days: number; key: string }[] = [ @@ -60,8 +61,6 @@ function TagChip({ } export default function Sidebar({ - filters, - setFilters, layout, setLayout, onFocusChannel, @@ -69,8 +68,6 @@ export default function Sidebar({ collapsed, onToggleCollapse, }: { - filters: FeedFilters; - setFilters: (f: FeedFilters) => void; layout: PanelLayout; setLayout: (l: PanelLayout) => void; onFocusChannel: (name: string) => void; @@ -79,6 +76,8 @@ export default function Sidebar({ onToggleCollapse: () => void; }) { const { t } = useTranslation(); + const filters = useFeedFilters(); + const { setFilters } = useFeedFiltersActions(); const tagsQuery = useQuery({ queryKey: ["tags"], queryFn: api.tags }); const tags = tagsQuery.data ?? []; diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 1b5e791..078b824 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -4,6 +4,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import ErrorBoundary from "./components/ErrorBoundary"; import { ConfirmProvider } from "./components/ConfirmProvider"; import { NavigationProvider } from "./components/NavigationProvider"; +import { FeedFiltersProvider } from "./components/FeedFiltersProvider"; import "./i18n"; import "./index.css"; @@ -37,7 +38,9 @@ const root = - + + +