feat(filters): scope global feed filters in FeedFiltersProvider
Lift the feed's filter god-state (per-account persistence, the account-load effect, share-link capture) out of App into a split-context provider mirroring NavigationProvider. Sidebar/Header/NotificationsPanel read it via hooks instead of prop-drilling; <Feed> keeps its filters prop since ChannelPage renders it with its own channel-scoped state.
This commit is contained in:
+8
-91
@@ -7,7 +7,6 @@ import {
|
|||||||
getActiveAccount,
|
getActiveAccount,
|
||||||
setActiveAccount,
|
setActiveAccount,
|
||||||
setUnauthorizedHandler,
|
setUnauthorizedHandler,
|
||||||
type FeedFilters,
|
|
||||||
type PlexFilters,
|
type PlexFilters,
|
||||||
} from "./lib/api";
|
} from "./lib/api";
|
||||||
import { setLanguage, isSupported, type LangCode } from "./i18n";
|
import { setLanguage, isSupported, type LangCode } from "./i18n";
|
||||||
@@ -18,7 +17,7 @@ import {
|
|||||||
saveLocalTheme,
|
saveLocalTheme,
|
||||||
type ThemePrefs,
|
type ThemePrefs,
|
||||||
} from "./lib/theme";
|
} from "./lib/theme";
|
||||||
import { hasFilterParams, paramsToFilters, stripUrlParams } from "./lib/urlState";
|
import { hasFilterParams, stripUrlParams } from "./lib/urlState";
|
||||||
import {
|
import {
|
||||||
loadLayout,
|
loadLayout,
|
||||||
normalizeLayout,
|
normalizeLayout,
|
||||||
@@ -31,19 +30,16 @@ import { configureNotifications, getNotifSettings, notify, removeByMetaKind, typ
|
|||||||
import { hintsEnabled, setHintsEnabled } from "./lib/hints";
|
import { hintsEnabled, setHintsEnabled } from "./lib/hints";
|
||||||
import { parseFeedView, type FeedView } from "./lib/feedView";
|
import { parseFeedView, type FeedView } from "./lib/feedView";
|
||||||
import {
|
import {
|
||||||
accountKey,
|
|
||||||
getAccountRaw,
|
getAccountRaw,
|
||||||
LS,
|
LS,
|
||||||
readAccount,
|
readAccount,
|
||||||
readJSON,
|
|
||||||
readMerged,
|
|
||||||
setAccountRaw,
|
setAccountRaw,
|
||||||
useAccountPersistedState,
|
useAccountPersistedState,
|
||||||
writeAccount,
|
writeAccount,
|
||||||
writeJSON,
|
|
||||||
} from "./lib/storage";
|
} from "./lib/storage";
|
||||||
import type { PrefsController } from "./components/SettingsPanel";
|
import type { PrefsController } from "./components/SettingsPanel";
|
||||||
import { useNavigation, useNavigationActions } from "./components/NavigationProvider";
|
import { useNavigation, useNavigationActions } from "./components/NavigationProvider";
|
||||||
|
import { useFeedFilters, useFeedFiltersActions } from "./components/FeedFiltersProvider";
|
||||||
import type { ChannelStatusFilter, ChannelsView } from "./components/Channels";
|
import type { ChannelStatusFilter, ChannelsView } from "./components/Channels";
|
||||||
// Persistent shell — always mounted, so eagerly imported.
|
// Persistent shell — always mounted, so eagerly imported.
|
||||||
import Welcome from "./components/Welcome";
|
import Welcome from "./components/Welcome";
|
||||||
@@ -86,20 +82,6 @@ const OnboardingWizard = lazy(() => import("./components/OnboardingWizard"));
|
|||||||
const About = lazy(() => import("./components/About"));
|
const About = lazy(() => import("./components/About"));
|
||||||
const ReleaseNotes = lazy(() => import("./components/ReleaseNotes"));
|
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;
|
const PERF_KEY = LS.perfMode;
|
||||||
|
|
||||||
// The preferences edited on the Settings page. They apply locally for instant preview but
|
// The preferences edited on the Settings page. They apply locally for instant preview but
|
||||||
@@ -112,50 +94,16 @@ type EditablePrefs = {
|
|||||||
notifications: NotifSettings;
|
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<FeedFilters | null>(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() {
|
export default function App() {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const { page, channelView, ytSearch } = useNavigation();
|
const { page, channelView, ytSearch } = useNavigation();
|
||||||
const { setPage, openChannel, enterYtSearch, exitYtSearch } = useNavigationActions();
|
const { setPage, openChannel, enterYtSearch, exitYtSearch } = useNavigationActions();
|
||||||
const [theme, setThemeState] = useState<ThemePrefs>(() => loadLocalTheme());
|
const [theme, setThemeState] = useState<ThemePrefs>(() => loadLocalTheme());
|
||||||
const [filters, setFiltersState] = useState<FeedFilters>(loadInitialFilters);
|
// The global feed filters live in FeedFiltersProvider (per-account persistence + the account-load
|
||||||
// Whether this load arrived via a "Share view" link (captured once, before the URL is stripped)
|
// effect + share-link capture all moved there). App reads them to hand to <Feed> and to the
|
||||||
// — such filters win over the account's stored view and are persisted to the account instead.
|
// handlers that mutate the feed view (onShowInFeed, tag-click, the feed scrollKey).
|
||||||
const [initialHadUrlFilters] = useState(() =>
|
const filters = useFeedFilters();
|
||||||
hasFilterParams(new URLSearchParams(window.location.search))
|
const { setFilters } = useFeedFiltersActions();
|
||||||
);
|
|
||||||
// The feed's view mode is picked from the feed's own toolbar; like every Settings pref it
|
// The feed's view mode is picked from the feed's own toolbar; like every Settings pref it
|
||||||
// persists the moment you choose it.
|
// persists the moment you choose it.
|
||||||
const [view, setViewState] = useState<FeedView>(() => parseFeedView(readAccount(LS.feedView, null)));
|
const [view, setViewState] = useState<FeedView>(() => parseFeedView(readAccount(LS.feedView, null)));
|
||||||
@@ -257,13 +205,6 @@ export default function App() {
|
|||||||
setNotesOpen(true);
|
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) {
|
function setPanelLayout(panel: PanelId, next: PanelLayout) {
|
||||||
setPanelLayouts((prev) => ({ ...prev, [panel]: next }));
|
setPanelLayouts((prev) => ({ ...prev, [panel]: next }));
|
||||||
saveLayoutLocal(panel, next);
|
saveLayoutLocal(panel, next);
|
||||||
@@ -423,26 +364,6 @@ export default function App() {
|
|||||||
if (meQuery.data && getActiveAccount() == null) setActiveAccount(meQuery.data.id);
|
if (meQuery.data && getActiveAccount() == null) setActiveAccount(meQuery.data.id);
|
||||||
}, [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.
|
// 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
|
// ?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.
|
// 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. */}
|
content column. Sits beside the nav rail as its own collapsible column. */}
|
||||||
{page === "feed" && !channelView && (
|
{page === "feed" && !channelView && (
|
||||||
<Sidebar
|
<Sidebar
|
||||||
filters={filters}
|
|
||||||
setFilters={setFilters}
|
|
||||||
layout={panelLayouts.feed}
|
layout={panelLayouts.feed}
|
||||||
setLayout={(l) => setPanelLayout("feed", l)}
|
setLayout={(l) => setPanelLayout("feed", l)}
|
||||||
onFocusChannel={focusChannel}
|
onFocusChannel={focusChannel}
|
||||||
@@ -740,8 +659,6 @@ export default function App() {
|
|||||||
header={
|
header={
|
||||||
<Header
|
<Header
|
||||||
me={meQuery.data!}
|
me={meQuery.data!}
|
||||||
filters={filters}
|
|
||||||
setFilters={setFilters}
|
|
||||||
plexQ={plexQ}
|
plexQ={plexQ}
|
||||||
setPlexQ={setPlexQ}
|
setPlexQ={setPlexQ}
|
||||||
channelsSearch={channelsSearch}
|
channelsSearch={channelsSearch}
|
||||||
@@ -804,7 +721,7 @@ export default function App() {
|
|||||||
setSelectedId={setSelectedPlaylistId}
|
setSelectedId={setSelectedPlaylistId}
|
||||||
/>
|
/>
|
||||||
) : page === "notifications" ? (
|
) : page === "notifications" ? (
|
||||||
<NotificationsPanel filters={filters} setFilters={setFilters} onFocusChannel={focusChannel} />
|
<NotificationsPanel onFocusChannel={focusChannel} />
|
||||||
) : page === "messages" && !meQuery.data!.is_demo ? (
|
) : page === "messages" && !meQuery.data!.is_demo ? (
|
||||||
<div className="p-4 max-w-3xl w-full mx-auto">
|
<div className="p-4 max-w-3xl w-full mx-auto">
|
||||||
<Messages meId={meQuery.data!.id} />
|
<Messages meId={meQuery.data!.id} />
|
||||||
|
|||||||
@@ -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 <App/>.
|
||||||
|
//
|
||||||
|
// 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 <Feed> 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<FeedFilters | null>(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<FeedFilters>(DEFAULT_FILTERS);
|
||||||
|
const ActionsContext = createContext<FeedFiltersActions>({ 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<FeedFilters>(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<FeedFiltersActions>(() => ({ setFilters }), [setFilters]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ActionsContext.Provider value={actions}>
|
||||||
|
<StateContext.Provider value={filters}>{children}</StateContext.Provider>
|
||||||
|
</ActionsContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,20 +1,19 @@
|
|||||||
import { type ReactNode } from "react";
|
import { type ReactNode } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Youtube } from "lucide-react";
|
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 { moduleLabelKey, moduleOrder, stepModule } from "../lib/modules";
|
||||||
import ModuleName from "./ModuleName";
|
import ModuleName from "./ModuleName";
|
||||||
import SearchBar from "./SearchBar";
|
import SearchBar from "./SearchBar";
|
||||||
import SyncStatus from "./SyncStatus";
|
import SyncStatus from "./SyncStatus";
|
||||||
import { useNavigation, useNavigationActions } from "./NavigationProvider";
|
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 +
|
// 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
|
// accent dot + back/forward arrows), the module's SearchBar where it has one, and the per-user
|
||||||
// sync chip at the right end.
|
// sync chip at the right end.
|
||||||
export default function Header({
|
export default function Header({
|
||||||
me,
|
me,
|
||||||
filters,
|
|
||||||
setFilters,
|
|
||||||
plexQ,
|
plexQ,
|
||||||
setPlexQ,
|
setPlexQ,
|
||||||
channelsSearch,
|
channelsSearch,
|
||||||
@@ -25,8 +24,6 @@ export default function Header({
|
|||||||
onGoToFullHistory,
|
onGoToFullHistory,
|
||||||
}: {
|
}: {
|
||||||
me: Me;
|
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.
|
// Plex has its own ephemeral search term (see App) — the box is shared UI, the state is not.
|
||||||
plexQ: string;
|
plexQ: string;
|
||||||
setPlexQ: (q: string) => void;
|
setPlexQ: (q: string) => void;
|
||||||
@@ -43,6 +40,8 @@ export default function Header({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { page } = useNavigation();
|
const { page } = useNavigation();
|
||||||
const { setPage } = useNavigationActions();
|
const { setPage } = useNavigationActions();
|
||||||
|
const filters = useFeedFilters();
|
||||||
|
const { setFilters } = useFeedFiltersActions();
|
||||||
// The ◀/▶ arrows step cyclically through the user's available modules (derived from `me`, so
|
// 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
|
// 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.
|
// dynamic width so the pill is sized to the widest reachable title in the current language.
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useEffect, useSyncExternalStore } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { Activity, Bell, Check, CheckCheck, ExternalLink, Eye, RotateCcw, Search, Trash2, Tv, UserPlus, X } from "lucide-react";
|
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 { useLiveQuery } from "../lib/useLiveQuery";
|
||||||
import { focusAccessRequestsTab } from "../lib/adminUsersTab";
|
import { focusAccessRequestsTab } from "../lib/adminUsersTab";
|
||||||
import {
|
import {
|
||||||
@@ -23,6 +23,7 @@ import { channelYouTubeUrl, relativeFromMs, relativeTime } from "../lib/format";
|
|||||||
import { LEVEL_STYLE } from "./Toaster";
|
import { LEVEL_STYLE } from "./Toaster";
|
||||||
import { PageToolbar } from "./PageShell";
|
import { PageToolbar } from "./PageShell";
|
||||||
import { useNavigationActions } from "./NavigationProvider";
|
import { useNavigationActions } from "./NavigationProvider";
|
||||||
|
import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider";
|
||||||
|
|
||||||
// The single notification center. "System" = durable, server-backed notifications (inbox
|
// The single notification center. "System" = durable, server-backed notifications (inbox
|
||||||
// phase 1). "Activity" = the client-side transient events (action confirmations, errors, with
|
// phase 1). "Activity" = the client-side transient events (action confirmations, errors, with
|
||||||
@@ -31,16 +32,14 @@ import { useNavigationActions } from "./NavigationProvider";
|
|||||||
const POLL_MS = 8000;
|
const POLL_MS = 8000;
|
||||||
|
|
||||||
export default function NotificationsPanel({
|
export default function NotificationsPanel({
|
||||||
filters,
|
|
||||||
setFilters,
|
|
||||||
onFocusChannel,
|
onFocusChannel,
|
||||||
}: {
|
}: {
|
||||||
filters: FeedFilters;
|
|
||||||
setFilters: (f: FeedFilters) => void;
|
|
||||||
onFocusChannel: (name: string) => void;
|
onFocusChannel: (name: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { setPage } = useNavigationActions();
|
const { setPage } = useNavigationActions();
|
||||||
|
const filters = useFeedFilters();
|
||||||
|
const { setFilters } = useFeedFiltersActions();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const q = useLiveQuery<{ items: AppNotification[]; total: number }>(
|
const q = useLiveQuery<{ items: AppNotification[]; total: number }>(
|
||||||
["notifications"],
|
["notifications"],
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import SavedViewsWidget from "./SavedViewsWidget";
|
|||||||
import SidePanel from "./SidePanel";
|
import SidePanel from "./SidePanel";
|
||||||
import PanelGroups, { type PanelItem } from "./PanelGroups";
|
import PanelGroups, { type PanelItem } from "./PanelGroups";
|
||||||
import ChannelPicker from "./ChannelPicker";
|
import ChannelPicker from "./ChannelPicker";
|
||||||
|
import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider";
|
||||||
|
|
||||||
// Filter ids; display labels resolved at render time via t("sidebar.sort.<id>") etc.
|
// Filter ids; display labels resolved at render time via t("sidebar.sort.<id>") etc.
|
||||||
const DATE_PRESETS: { days: number; key: string }[] = [
|
const DATE_PRESETS: { days: number; key: string }[] = [
|
||||||
@@ -60,8 +61,6 @@ function TagChip({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function Sidebar({
|
export default function Sidebar({
|
||||||
filters,
|
|
||||||
setFilters,
|
|
||||||
layout,
|
layout,
|
||||||
setLayout,
|
setLayout,
|
||||||
onFocusChannel,
|
onFocusChannel,
|
||||||
@@ -69,8 +68,6 @@ export default function Sidebar({
|
|||||||
collapsed,
|
collapsed,
|
||||||
onToggleCollapse,
|
onToggleCollapse,
|
||||||
}: {
|
}: {
|
||||||
filters: FeedFilters;
|
|
||||||
setFilters: (f: FeedFilters) => void;
|
|
||||||
layout: PanelLayout;
|
layout: PanelLayout;
|
||||||
setLayout: (l: PanelLayout) => void;
|
setLayout: (l: PanelLayout) => void;
|
||||||
onFocusChannel: (name: string) => void;
|
onFocusChannel: (name: string) => void;
|
||||||
@@ -79,6 +76,8 @@ export default function Sidebar({
|
|||||||
onToggleCollapse: () => void;
|
onToggleCollapse: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const filters = useFeedFilters();
|
||||||
|
const { setFilters } = useFeedFiltersActions();
|
||||||
const tagsQuery = useQuery({ queryKey: ["tags"], queryFn: api.tags });
|
const tagsQuery = useQuery({ queryKey: ["tags"], queryFn: api.tags });
|
||||||
const tags = tagsQuery.data ?? [];
|
const tags = tagsQuery.data ?? [];
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|||||||
import ErrorBoundary from "./components/ErrorBoundary";
|
import ErrorBoundary from "./components/ErrorBoundary";
|
||||||
import { ConfirmProvider } from "./components/ConfirmProvider";
|
import { ConfirmProvider } from "./components/ConfirmProvider";
|
||||||
import { NavigationProvider } from "./components/NavigationProvider";
|
import { NavigationProvider } from "./components/NavigationProvider";
|
||||||
|
import { FeedFiltersProvider } from "./components/FeedFiltersProvider";
|
||||||
import "./i18n";
|
import "./i18n";
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
|
|
||||||
@@ -37,7 +38,9 @@ const root =
|
|||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<ConfirmProvider>
|
<ConfirmProvider>
|
||||||
<NavigationProvider>
|
<NavigationProvider>
|
||||||
<App />
|
<FeedFiltersProvider>
|
||||||
|
<App />
|
||||||
|
</FeedFiltersProvider>
|
||||||
</NavigationProvider>
|
</NavigationProvider>
|
||||||
</ConfirmProvider>
|
</ConfirmProvider>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
|
|||||||
Reference in New Issue
Block a user