Merge Platform S6b.1+S6b.2: module registry + scoped state providers

S6b.1: lib/modules.ts is an ordered ModuleDef registry (single source for nav
label/icon/order/gate), absorbing NavSidebar's ICON map.
S6b.2: module-local state moved out of App into PlaylistsProvider / PlexProvider /
ChannelsProvider (split-context, mirroring FeedFiltersProvider). Header, rails,
PlexBrowse, Channels, NotificationsPanel, Sidebar read from providers; modules take
a uniform `me` prop. App.tsx 796->655 lines. Render still on the ternary (S6b.3
flips it). E2E 17/17; /code-review clean; UAT passed.
This commit is contained in:
2026-07-19 00:46:07 +02:00
15 changed files with 533 additions and 340 deletions
+7 -148
View File
@@ -3,11 +3,9 @@ import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { import {
api, api,
EMPTY_PLEX_FILTERS,
getActiveAccount, getActiveAccount,
setActiveAccount, setActiveAccount,
setUnauthorizedHandler, setUnauthorizedHandler,
type PlexFilters,
} from "./lib/api"; } from "./lib/api";
import { setLanguage, isSupported, type LangCode } from "./i18n"; import { setLanguage, isSupported, type LangCode } from "./i18n";
import { import {
@@ -34,13 +32,11 @@ import {
LS, LS,
readAccount, readAccount,
setAccountRaw, setAccountRaw,
useAccountPersistedState,
writeAccount, writeAccount,
} 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 { useFeedFilters, useFeedFiltersActions } from "./components/FeedFiltersProvider";
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";
import SetupWizard from "./components/SetupWizard"; import SetupWizard from "./components/SetupWizard";
@@ -97,7 +93,7 @@ type EditablePrefs = {
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, enterYtSearch, exitYtSearch } = useNavigationActions();
const [theme, setThemeState] = useState<ThemePrefs>(() => loadLocalTheme()); const [theme, setThemeState] = useState<ThemePrefs>(() => loadLocalTheme());
// The global feed filters live in FeedFiltersProvider (per-account persistence + the account-load // 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 <Feed> and to the // effect + share-link capture all moved there). App reads them to hand to <Feed> and to the
@@ -131,74 +127,10 @@ export default function App() {
plex: loadLayout("plex"), plex: loadLayout("plex"),
playlists: loadLayout("playlists"), playlists: loadLayout("playlists"),
})); }));
// Client-side name filters for the Channels + Playlists lists — lifted here so the shared top
// SearchBar can drive them (the modules read/seed them via props).
const [channelsSearch, setChannelsSearch] = useState("");
const [playlistsSearch, setPlaylistsSearch] = useState("");
const [wizardOpen, setWizardOpen] = useState(false); const [wizardOpen, setWizardOpen] = useState(false);
// Channel status chip, persisted so a reload (F5) keeps it; clamp a stale value at render.
const [channelFilterRaw, setChannelFilter] = useAccountPersistedState(LS.channelFilter, "all");
const channelFilter: ChannelStatusFilter = (
["needs_full", "fully_synced", "hidden", "all"] as const
).includes(channelFilterRaw as ChannelStatusFilter)
? (channelFilterRaw as ChannelStatusFilter)
: "all";
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 [channelsViewRaw, setChannelsView] = useAccountPersistedState(LS.channelsView, "subscribed");
const channelsView: ChannelsView =
channelsViewRaw === "discovery"
? "discovery"
: channelsViewRaw === "blocked"
? "blocked"
: "subscribed";
// Plex module filters (its own left-sidebar filter section) — per-account persisted.
// The former per-library picker is now a cross-library SCOPE: movie | show | both (unified library).
// (Reuses the old LS.plexLibrary key; an old stored library-id value falls back to "both".)
const [plexScopeRaw, setPlexScope] = useAccountPersistedState(LS.plexLibrary, "both");
const plexScope = ["movie", "show", "both"].includes(plexScopeRaw) ? plexScopeRaw : "both";
const [plexShowFilter, setPlexShowFilter] = useAccountPersistedState(LS.plexShow, "all");
const [plexSort, setPlexSort] = useAccountPersistedState(LS.plexSort, "title");
const [plexPlaylistOpen, setPlexPlaylistOpen] = useState<number | null>(null); // sidebar → open a playlist
// The expanded Plex filters live as one JSON blob (the string-only account store serializes it).
const [plexFiltersRaw, setPlexFiltersRaw] = useAccountPersistedState(LS.plexFilters, "{}");
const plexFilters = useMemo<PlexFilters>(() => {
try {
return { ...EMPTY_PLEX_FILTERS, ...JSON.parse(plexFiltersRaw) };
} catch {
return EMPTY_PLEX_FILTERS;
}
}, [plexFiltersRaw]);
const setPlexFilters = (f: PlexFilters) => setPlexFiltersRaw(JSON.stringify(f));
// The Plex library search term is its OWN ephemeral state — deliberately NOT the persisted feed
// `filters.q`. Sharing that box meant a Plex search leaked into the feed and, being persisted,
// greeted you with a stale query (colliding with a persisted collection filter → confusing
// "0 matches") after a reload. Kept in App so it survives page switches within a session, but
// resets on reload.
const [plexQ, setPlexQ] = useAccountPersistedState(LS.plexQ, ""); // survives F5, unlike the feed search
// 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<string | null>(null);
const [focusChannelToken, setFocusChannelToken] = useState(0);
const focusChannel = (name: string) => {
setFocusChannelName(name);
setFocusChannelToken((n) => n + 1); // re-seed even when the same channel is re-focused
setChannelsView("subscribed"); // the name filter applies to the subscriptions table
setPage("channels");
};
useEffect(() => {
// Clear the channel-manager focus when leaving the Channels page so it doesn't re-apply later.
// (Leaving the feed clearing the live search is now the NavigationProvider's job.)
if (page !== "channels") setFocusChannelName(null);
}, [page]);
function openReleaseNotes(highlight?: string) { function openReleaseNotes(highlight?: string) {
setNotesHighlight(highlight); setNotesHighlight(highlight);
@@ -246,17 +178,6 @@ export default function App() {
writeAccount(LS.playlistsCollapsed, next); writeAccount(LS.playlistsCollapsed, next);
api.savePrefs({ playlistsCollapsed: next }).catch(() => {}); api.savePrefs({ playlistsCollapsed: next }).catch(() => {});
} }
// The selected playlist — App state so the App-level PlaylistsRail and the module's detail pane
// (Playlists) stay in sync. Persisted so a reload keeps it (it's not in the URL).
const [selectedPlaylistId, setSelectedPlaylistIdState] = useState<number | null>(() => {
const s = getAccountRaw(LS.playlist);
return s ? Number(s) : null;
});
const setSelectedPlaylistId = (id: number | null) => {
setSelectedPlaylistIdState(id);
if (id != null) setAccountRaw(LS.playlist, String(id));
};
useEffect(() => applyTheme(theme), [theme]); useEffect(() => applyTheme(theme), [theme]);
// Apply the draft prefs locally for instant preview (their localStorage mirrors update via // Apply the draft prefs locally for instant preview (their localStorage mirrors update via
@@ -584,7 +505,6 @@ export default function App() {
<Sidebar <Sidebar
layout={panelLayouts.feed} layout={panelLayouts.feed}
setLayout={(l) => setPanelLayout("feed", l)} setLayout={(l) => setPanelLayout("feed", l)}
onFocusChannel={focusChannel}
isDemo={meQuery.data!.is_demo} isDemo={meQuery.data!.is_demo}
collapsed={filterCollapsed} collapsed={filterCollapsed}
onToggleCollapse={() => setFilterCollapsed(!filterCollapsed)} onToggleCollapse={() => setFilterCollapsed(!filterCollapsed)}
@@ -593,15 +513,6 @@ export default function App() {
{page === "plex" && meQuery.data!.plex_enabled && ( {page === "plex" && meQuery.data!.plex_enabled && (
<Suspense fallback={null}> <Suspense fallback={null}>
<PlexSidebar <PlexSidebar
scope={plexScope}
setScope={setPlexScope}
show={plexShowFilter}
setShow={setPlexShowFilter}
sort={plexSort}
setSort={setPlexSort}
filters={plexFilters}
setFilters={setPlexFilters}
onOpenPlaylist={setPlexPlaylistOpen}
layout={panelLayouts.plex} layout={panelLayouts.plex}
setLayout={(l) => setPanelLayout("plex", l)} setLayout={(l) => setPanelLayout("plex", l)}
collapsed={filterCollapsed} collapsed={filterCollapsed}
@@ -611,10 +522,7 @@ export default function App() {
)} )}
{page === "playlists" && !channelView && ( {page === "playlists" && !channelView && (
<PlaylistsRail <PlaylistsRail
canWrite={meQuery.data!.can_write} me={meQuery.data!}
search={playlistsSearch}
selectedId={selectedPlaylistId}
setSelectedId={setSelectedPlaylistId}
layout={panelLayouts.playlists} layout={panelLayouts.playlists}
setLayout={(l) => setPanelLayout("playlists", l)} setLayout={(l) => setPanelLayout("playlists", l)}
collapsed={playlistsCollapsed} collapsed={playlistsCollapsed}
@@ -657,22 +565,7 @@ export default function App() {
// fade would sit on it and flip on with the first scroll, so turn it off there. // fade would sit on it and flip on with the first scroll, so turn it off there.
fadeTop={page !== "channels" && page !== "audit"} fadeTop={page !== "channels" && page !== "audit"}
header={ header={
<Header <Header me={meQuery.data!} onYtSearch={enterYtSearch} />
me={meQuery.data!}
plexQ={plexQ}
setPlexQ={setPlexQ}
channelsSearch={channelsSearch}
setChannelsSearch={setChannelsSearch}
playlistsSearch={playlistsSearch}
setPlaylistsSearch={setPlaylistsSearch}
onYtSearch={enterYtSearch}
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");
}}
/>
} }
banners={ banners={
<> <>
@@ -683,27 +576,7 @@ export default function App() {
> >
<Suspense fallback={pageFallback}> <Suspense fallback={pageFallback}>
{page === "channels" ? ( {page === "channels" ? (
<Channels <Channels me={meQuery.data!} onOpenWizard={() => setWizardOpen(true)} />
canWrite={meQuery.data!.can_write}
isAdmin={meQuery.data!.role === "admin"}
search={channelsSearch}
setSearch={setChannelsSearch}
focusChannelName={focusChannelName}
focusChannelToken={focusChannelToken}
onFocusChannel={focusChannel}
statusFilter={channelFilter}
setStatusFilter={setChannelFilter}
view={channelsView}
setView={setChannelsView}
filtersResetToken={channelsFilterReset}
onOpenWizard={() => setWizardOpen(true)}
onViewChannel={(id, name) => openChannel(id, name)}
onFilterByTag={(tagId, name) => {
setFilters({ ...filters, tags: [tagId], channelIds: [] });
setPage("feed");
notify({ level: "info", message: t("channels.notify.filteredByTag", { name }) });
}}
/>
) : page === "stats" ? ( ) : page === "stats" ? (
<Stats me={meQuery.data!} /> <Stats me={meQuery.data!} />
) : page === "scheduler" && meQuery.data!.role === "admin" ? ( ) : page === "scheduler" && meQuery.data!.role === "admin" ? (
@@ -715,13 +588,9 @@ export default function App() {
) : page === "audit" && meQuery.data!.role === "admin" ? ( ) : page === "audit" && meQuery.data!.role === "admin" ? (
<AuditLog /> <AuditLog />
) : page === "playlists" ? ( ) : page === "playlists" ? (
<Playlists <Playlists me={meQuery.data!} />
canWrite={meQuery.data!.can_write}
selectedId={selectedPlaylistId}
setSelectedId={setSelectedPlaylistId}
/>
) : page === "notifications" ? ( ) : page === "notifications" ? (
<NotificationsPanel onFocusChannel={focusChannel} /> <NotificationsPanel />
) : 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} />
@@ -735,17 +604,7 @@ export default function App() {
onOpenWizard={() => setWizardOpen(true)} onOpenWizard={() => setWizardOpen(true)}
/> />
) : page === "plex" && meQuery.data!.plex_enabled ? ( ) : page === "plex" && meQuery.data!.plex_enabled ? (
<PlexBrowse <PlexBrowse />
q={plexQ}
scope={plexScope}
setScope={setPlexScope}
show={plexShowFilter}
sort={plexSort}
filters={plexFilters}
setFilters={setPlexFilters}
openPlaylist={plexPlaylistOpen}
onPlaylistOpened={() => setPlexPlaylistOpen(null)}
/>
) : ( ) : (
<Feed <Feed
filters={filters} filters={filters}
+30 -40
View File
@@ -15,7 +15,7 @@ import {
UserMinus, UserMinus,
X, X,
} from "lucide-react"; } from "lucide-react";
import { api, type ManagedChannel, type Tag } from "../lib/api"; import { api, type ManagedChannel, type Me, type Tag } from "../lib/api";
import { notifyYouTubeActionError } from "../lib/youtubeErrors"; import { notifyYouTubeActionError } from "../lib/youtubeErrors";
import { accountKey, LS } from "../lib/storage"; import { accountKey, LS } from "../lib/storage";
import { useDismiss } from "../lib/useDismiss"; import { useDismiss } from "../lib/useDismiss";
@@ -30,6 +30,9 @@ import ChannelLink from "./ChannelLink";
import ChannelDiscovery from "./ChannelDiscovery"; import ChannelDiscovery from "./ChannelDiscovery";
import TagManager from "./TagManager"; import TagManager from "./TagManager";
import { useConfirm } from "./ConfirmProvider"; import { useConfirm } from "./ConfirmProvider";
import { useChannels, useChannelsActions } from "./ChannelsProvider";
import { useNavigationActions } from "./NavigationProvider";
import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider";
export type ChannelsView = "subscribed" | "discovery" | "blocked"; export type ChannelsView = "subscribed" | "discovery" | "blocked";
@@ -42,49 +45,36 @@ const STATUS_FILTERS: { id: ChannelStatusFilter; labelKey: string }[] = [
{ id: "hidden", labelKey: "channels.filters.hidden" }, { id: "hidden", labelKey: "channels.filters.hidden" },
]; ];
export default function Channels({ export default function Channels({ me, onOpenWizard }: { me: Me; onOpenWizard: () => void }) {
canWrite, const canWrite = me.can_write;
isAdmin, const isAdmin = me.role === "admin";
search, // The manager's name filter, status chip, active tab, reset token, and focus-channel intent live
setSearch, // in ChannelsProvider (shared with the header search box + the cross-module "focus this channel"
onViewChannel, // callers); this page is their primary editor.
onFilterByTag, const {
onFocusChannel, search,
focusChannelName, statusFilter,
focusChannelToken, view,
statusFilter, filtersResetToken,
setStatusFilter, focusName: focusChannelName,
view, focusToken: focusChannelToken,
setView, } = useChannels();
filtersResetToken, const { setSearch, setStatusFilter, setView, focusChannel: onFocusChannel } = useChannelsActions();
onOpenWizard, const { openChannel: onViewChannel, setPage } = useNavigationActions();
}: { const feedFilters = useFeedFilters();
canWrite: boolean; const { setFilters: setFeedFilters } = useFeedFiltersActions();
isAdmin: boolean;
// The channel-name filter, lifted to App so the shared top SearchBar drives it.
search: string;
setSearch: (q: string) => void;
onViewChannel: (id: string, name: string) => void;
onFilterByTag: (tagId: number, name: string) => void;
onFocusChannel: (name: string) => void;
focusChannelName: string | null;
// Bumped every time a focus-channel intent is (re-)issued, so re-clicking the same channel
// re-seeds the search box even when the name is unchanged.
focusChannelToken: number;
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 { t } = useTranslation();
const qc = useQueryClient(); const qc = useQueryClient();
const confirm = useConfirm(); const confirm = useConfirm();
// Clicking a tag chip filters the feed by that tag and jumps there. Internalised (was an App
// callback) now that Channels reads the feed filters + navigation straight from their providers.
const onFilterByTag = (tagId: number, name: string) => {
setFeedFilters({ ...feedFilters, tags: [tagId], channelIds: [] });
setPage("feed");
notify({ level: "info", message: t("channels.notify.filteredByTag", { name }) });
};
const channelsQuery = useQuery({ queryKey: ["channels"], queryFn: api.channels }); const channelsQuery = useQuery({ queryKey: ["channels"], queryFn: api.channels });
const tagsQuery = useQuery({ queryKey: ["tags"], queryFn: api.tags }); const tagsQuery = useQuery({ queryKey: ["tags"], queryFn: api.tags });
const statusQuery = useQuery({ queryKey: ["my-status"], queryFn: api.myStatus }); const statusQuery = useQuery({ queryKey: ["my-status"], queryFn: api.myStatus });
@@ -0,0 +1,147 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import { getAccountRaw, LS, setAccountRaw } from "../lib/storage";
import { useNavigation, useNavigationActions } from "./NavigationProvider";
import type { ChannelStatusFilter, ChannelsView } from "./Channels";
// Owns the Channels module's shared local state — the rail-less manager's name filter, status chip,
// active tab, the "drop a stale column filter" token, and the focus-a-channel intent — lifted out of
// App so the Channels page, the header search box, and the cross-module callers (the feed filter
// sidebar and the notifications inbox both "focus this channel in the manager") read it via hooks
// instead of prop-drilling through App. Mirrors FeedFiltersProvider (split State + Actions contexts,
// stable useCallback setters, one-line hooks; composed in main.tsx around <App/>).
//
// Nested INSIDE NavigationProvider so its actions can drive navigation: focusChannel and
// goToFullHistory both jump to the Channels page (and force the subscriptions tab). status / view are
// per-account persisted (localStorage); search / focus / the reset token are ephemeral. An account
// switch does a full reload, so init-from-storage is all the hydration the persisted ones need.
const STATUS_FILTERS: readonly ChannelStatusFilter[] = [
"needs_full",
"fully_synced",
"hidden",
"all",
];
function clampStatus(v: string | null): ChannelStatusFilter {
return STATUS_FILTERS.includes(v as ChannelStatusFilter) ? (v as ChannelStatusFilter) : "all";
}
function clampView(v: string | null): ChannelsView {
return v === "discovery" ? "discovery" : v === "blocked" ? "blocked" : "subscribed";
}
type ChannelsState = {
search: string;
statusFilter: ChannelStatusFilter;
view: ChannelsView;
// Bumped to tell the manager to drop a stale column filter when an intent sends the user to a
// specific set (the header's "without full history" link).
filtersResetToken: number;
focusName: string | null;
// Bumped every time a focus intent is (re-)issued, so re-focusing the same channel re-seeds the
// manager's name filter even when the name is unchanged.
focusToken: number;
};
interface ChannelsActions {
setSearch: (q: string) => void;
setStatusFilter: (f: ChannelStatusFilter) => void;
setView: (v: ChannelsView) => void;
// "Focus this channel in the manager": jump to Channels (subscriptions tab) and seed its name
// filter so the channel is isolated. Called from the feed sidebar and the notifications inbox.
focusChannel: (name: string) => void;
// Jump to the manager filtered to channels still missing full history (the header sync chip).
goToFullHistory: () => void;
}
const StateContext = createContext<ChannelsState>({
search: "",
statusFilter: "all",
view: "subscribed",
filtersResetToken: 0,
focusName: null,
focusToken: 0,
});
const ActionsContext = createContext<ChannelsActions>({
setSearch: () => {},
setStatusFilter: () => {},
setView: () => {},
focusChannel: () => {},
goToFullHistory: () => {},
});
export function useChannels(): ChannelsState {
return useContext(StateContext);
}
export function useChannelsActions(): ChannelsActions {
return useContext(ActionsContext);
}
export function ChannelsProvider({ children }: { children: ReactNode }) {
const { page } = useNavigation();
const { setPage } = useNavigationActions();
const [search, setSearchState] = useState("");
const [statusFilter, setStatusFilterState] = useState<ChannelStatusFilter>(() =>
clampStatus(getAccountRaw(LS.channelFilter)),
);
const [view, setViewState] = useState<ChannelsView>(() =>
clampView(getAccountRaw(LS.channelsView)),
);
const [filtersResetToken, setFiltersResetToken] = useState(0);
const [focusName, setFocusName] = useState<string | null>(null);
const [focusToken, setFocusToken] = useState(0);
const setSearch = useCallback((q: string) => setSearchState(q), []);
const setStatusFilter = useCallback((f: ChannelStatusFilter) => {
setStatusFilterState(f);
setAccountRaw(LS.channelFilter, f);
}, []);
const setView = useCallback((v: ChannelsView) => {
setViewState(v);
setAccountRaw(LS.channelsView, v);
}, []);
const focusChannel = useCallback(
(name: string) => {
setFocusName(name);
setFocusToken((n) => n + 1); // re-seed even when the same channel is re-focused
setView("subscribed"); // the name filter applies to the subscriptions table
setPage("channels");
},
[setView, setPage],
);
const goToFullHistory = useCallback(() => {
setStatusFilter("needs_full");
setView("subscribed"); // the status filter applies to the subscriptions tab
setFiltersResetToken((n) => n + 1); // drop any stale column filter hiding the rows
setPage("channels");
}, [setStatusFilter, setView, setPage]);
// Clear the manager focus when leaving the Channels page so it doesn't re-apply on a later visit.
useEffect(() => {
if (page !== "channels") setFocusName(null);
}, [page]);
const state = useMemo<ChannelsState>(
() => ({ search, statusFilter, view, filtersResetToken, focusName, focusToken }),
[search, statusFilter, view, filtersResetToken, focusName, focusToken],
);
const actions = useMemo<ChannelsActions>(
() => ({ setSearch, setStatusFilter, setView, focusChannel, goToFullHistory }),
[setSearch, setStatusFilter, setView, focusChannel, goToFullHistory],
);
return (
<ActionsContext.Provider value={actions}>
<StateContext.Provider value={state}>{children}</StateContext.Provider>
</ActionsContext.Provider>
);
}
+12 -16
View File
@@ -8,40 +8,36 @@ 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"; import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider";
import { usePlaylists, usePlaylistsActions } from "./PlaylistsProvider";
import { usePlex, usePlexActions } from "./PlexProvider";
import { useChannels, useChannelsActions } from "./ChannelsProvider";
// 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,
plexQ,
setPlexQ,
channelsSearch,
setChannelsSearch,
playlistsSearch,
setPlaylistsSearch,
onYtSearch, onYtSearch,
onGoToFullHistory,
}: { }: {
me: Me; me: Me;
// Plex has its own ephemeral search term (see App) — the box is shared UI, the state is not.
plexQ: string;
setPlexQ: (q: string) => void;
channelsSearch: string;
setChannelsSearch: (q: string) => void;
playlistsSearch: string;
setPlaylistsSearch: (q: string) => void;
// Trigger a live YouTube search for the current term (feed only; hidden for the demo account, // Trigger a live YouTube search for the current term (feed only; hidden for the demo account,
// which can't spend the shared quota). // which can't spend the shared quota).
onYtSearch: (q: string) => void; onYtSearch: (q: string) => void;
// Jump to the channel manager filtered to channels still missing full history.
onGoToFullHistory: () => void;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const { page } = useNavigation(); const { page } = useNavigation();
const { setPage } = useNavigationActions(); const { setPage } = useNavigationActions();
const filters = useFeedFilters(); const filters = useFeedFilters();
const { setFilters } = useFeedFiltersActions(); const { setFilters } = useFeedFiltersActions();
// Each module's search term lives in its own provider (shared with that module's rail/content):
// playlists name filter, Plex search term, channels name filter. The channel sync chip's
// "go to full history" jump is a ChannelsProvider action.
const { search: playlistsSearch } = usePlaylists();
const { setSearch: setPlaylistsSearch } = usePlaylistsActions();
const { q: plexQ } = usePlex();
const { setQ: setPlexQ } = usePlexActions();
const { search: channelsSearch } = useChannels();
const { setSearch: setChannelsSearch, goToFullHistory: onGoToFullHistory } = useChannelsActions();
// 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.
+8 -35
View File
@@ -4,32 +4,21 @@ import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useNavigation, useNavigationActions } from "./NavigationProvider"; import { useNavigation, useNavigationActions } from "./NavigationProvider";
import { import {
Activity,
BarChart3,
Bell,
Clapperboard,
Download,
Home,
Info, Info,
ListVideo,
LogOut, LogOut,
MessageSquare,
Pin, Pin,
PinOff, PinOff,
ScrollText,
Settings, Settings,
Shield, Shield,
SlidersHorizontal,
Users,
Tv,
UserPlus, UserPlus,
type LucideIcon,
} from "lucide-react"; } from "lucide-react";
import { api, clearActiveAccount, setActiveAccount, type Me } from "../lib/api"; import { api, clearActiveAccount, setActiveAccount, type Me } from "../lib/api";
import * as e2ee from "../lib/e2ee"; import * as e2ee from "../lib/e2ee";
import { useLiveQuery } from "../lib/useLiveQuery"; import { useLiveQuery } from "../lib/useLiveQuery";
import { getUnreadCount, subscribe } from "../lib/notifications"; import { getUnreadCount, subscribe } from "../lib/notifications";
import type { Page } from "../lib/urlState"; import type { Page } from "../lib/urlState";
import { moduleLabelKey, moduleOrder, SYSTEM_PAGES } from "../lib/modules"; import { moduleDef, moduleLabelKey, moduleOrder, SYSTEM_PAGES } from "../lib/modules";
import { useScrollFade } from "../lib/useScrollFade"; import { useScrollFade } from "../lib/useScrollFade";
import { useHoverFocusWithin } from "../lib/useHoverFocusWithin"; import { useHoverFocusWithin } from "../lib/useHoverFocusWithin";
import { type LangCode } from "../i18n"; import { type LangCode } from "../i18n";
@@ -177,34 +166,18 @@ export default function NavSidebar({
// also used by the side panels) so a high-zoom overflow scrolls without a scrollbar. // also used by the side panels) so a high-zoom overflow scrolls without a scrollbar.
const listFade = useScrollFade(); const listFade = useScrollFade();
type NavItem = { page: Page; icon: typeof Home; label: string; badge?: number }; type NavItem = { page: Page; icon: LucideIcon; label: string; badge?: number };
// Per-page presentation (icon/label/badge). WHICH pages appear and in what order comes from the // Per-page presentation (icon/label/badge). WHICH pages appear and in what order — and each
// shared moduleOrder(me) — the same source the header's ◀/▶ stepper uses — so the two never drift // module's icon + label — come from the shared module registry (the same source the header's
// and a new/gated module updates both at once. Badges live here (nav-only concern). // ◀/▶ stepper uses), so the two never drift and a new/gated module updates both at once. The
// Icon + badge per page; the label comes from the shared moduleLabelKey so the rail and the // badge NUMBERS are the only nav-only concern (they need the live polling queries above).
// header's ModuleName pill always read the same name.
const ICON: Record<Page, typeof Home> = {
feed: Home,
channels: Tv,
playlists: ListVideo,
plex: Clapperboard,
notifications: Bell,
messages: MessageSquare,
downloads: Download,
stats: BarChart3,
scheduler: Activity,
config: SlidersHorizontal,
users: Users,
audit: ScrollText,
settings: Settings,
};
const BADGE: Partial<Record<Page, number>> = { const BADGE: Partial<Record<Page, number>> = {
notifications: unread, notifications: unread,
messages: msgUnread, messages: msgUnread,
downloads: dlActive, downloads: dlActive,
}; };
const META = (p: Page): Omit<NavItem, "page"> => ({ const META = (p: Page): Omit<NavItem, "page"> => ({
icon: ICON[p], icon: moduleDef(p).icon,
label: t(moduleLabelKey[p]), label: t(moduleLabelKey[p]),
badge: BADGE[p], badge: BADGE[p],
}); });
@@ -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 { useChannelsActions } from "./ChannelsProvider";
import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider"; 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
@@ -31,15 +32,14 @@ import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider";
// there's one indicator and one place to look. Transient toasts still pop via the Toaster. // there's one indicator and one place to look. Transient toasts still pop via the Toaster.
const POLL_MS = 8000; const POLL_MS = 8000;
export default function NotificationsPanel({ export default function NotificationsPanel() {
onFocusChannel,
}: {
onFocusChannel: (name: string) => void;
}) {
const { t } = useTranslation(); const { t } = useTranslation();
const { setPage } = useNavigationActions(); const { setPage } = useNavigationActions();
const filters = useFeedFilters(); const filters = useFeedFilters();
const { setFilters } = useFeedFiltersActions(); const { setFilters } = useFeedFiltersActions();
// "Focus this channel in the manager" (from a new-subscription notification) lives in
// ChannelsProvider — read it here and pass it down to the notification rows.
const { focusChannel: onFocusChannel } = useChannelsActions();
const qc = useQueryClient(); const qc = useQueryClient();
const q = useLiveQuery<{ items: AppNotification[]; total: number }>( const q = useLiveQuery<{ items: AppNotification[]; total: number }>(
["notifications"], ["notifications"],
+7 -11
View File
@@ -31,7 +31,7 @@ import {
X, X,
Youtube, Youtube,
} from "lucide-react"; } from "lucide-react";
import { api, type Video } from "../lib/api"; import { api, type Me, type Video } from "../lib/api";
import { formatDuration } from "../lib/format"; import { formatDuration } from "../lib/format";
import { notify } from "../lib/notifications"; import { notify } from "../lib/notifications";
import { notifyYouTubeActionError } from "../lib/youtubeErrors"; import { notifyYouTubeActionError } from "../lib/youtubeErrors";
@@ -40,6 +40,7 @@ const PlayerModal = lazy(() => import("./PlayerModal"));
import UndoToolbar from "./UndoToolbar"; import UndoToolbar from "./UndoToolbar";
import { PageToolbar } from "./PageShell"; import { PageToolbar } from "./PageShell";
import { useConfirm } from "./ConfirmProvider"; import { useConfirm } from "./ConfirmProvider";
import { usePlaylists, usePlaylistsActions } from "./PlaylistsProvider";
type SortKey = "manual" | "title" | "duration" | "channel"; type SortKey = "manual" | "title" | "duration" | "channel";
type SortDir = "asc" | "desc"; type SortDir = "asc" | "desc";
@@ -178,17 +179,12 @@ function Row({
); );
} }
export default function Playlists({ export default function Playlists({ me }: { me: Me }) {
canWrite, const canWrite = me.can_write;
selectedId, // The selected playlist lives in PlaylistsProvider, shared with the PlaylistsRail (which owns the
setSelectedId,
}: {
canWrite: boolean;
// The selected playlist is App state, shared with the App-level PlaylistsRail (which owns the
// list + its rail controls). This detail pane reacts to the selection. // list + its rail controls). This detail pane reacts to the selection.
selectedId: number | null; const { selectedId } = usePlaylists();
setSelectedId: (id: number | null) => void; const { setSelectedId } = usePlaylistsActions();
}) {
const { t } = useTranslation(); const { t } = useTranslation();
const qc = useQueryClient(); const qc = useQueryClient();
const confirm = useConfirm(); const confirm = useConfirm();
@@ -0,0 +1,69 @@
import {
createContext,
useCallback,
useContext,
useMemo,
useState,
type ReactNode,
} from "react";
import { getAccountRaw, LS, setAccountRaw } from "../lib/storage";
// Owns the Playlists module's shared local state — the rail's name filter and the selected playlist
// — lifted out of the App god component so the header search box, the PlaylistsRail, and the
// Playlists detail pane read it via hooks instead of prop-drilling through App. Mirrors
// FeedFiltersProvider / NavigationProvider (split State + Actions contexts, one-line hooks; composed
// in main.tsx around <App/>).
//
// The `search` term is ephemeral (resets on reload, like the other list filters); the selected
// playlist is per-account persisted (LS.playlist) so a reload returns you to it. Neither is
// server-synced, and an account switch does a full reload, so init-from-storage is all the
// hydration these need — no per-account load effect (unlike the server-backed feed filters).
type PlaylistsState = {
search: string;
selectedId: number | null;
};
interface PlaylistsActions {
setSearch: (q: string) => void;
setSelectedId: (id: number | null) => void;
}
const StateContext = createContext<PlaylistsState>({ search: "", selectedId: null });
const ActionsContext = createContext<PlaylistsActions>({
setSearch: () => {},
setSelectedId: () => {},
});
export function usePlaylists(): PlaylistsState {
return useContext(StateContext);
}
export function usePlaylistsActions(): PlaylistsActions {
return useContext(ActionsContext);
}
export function PlaylistsProvider({ children }: { children: ReactNode }) {
const [search, setSearchState] = useState("");
const [selectedId, setSelectedIdState] = useState<number | null>(() => {
const s = getAccountRaw(LS.playlist);
return s ? Number(s) : null;
});
const setSearch = useCallback((q: string) => setSearchState(q), []);
const setSelectedId = useCallback((id: number | null) => {
setSelectedIdState(id);
if (id != null) setAccountRaw(LS.playlist, String(id));
}, []);
const state = useMemo<PlaylistsState>(() => ({ search, selectedId }), [search, selectedId]);
const actions = useMemo<PlaylistsActions>(
() => ({ setSearch, setSelectedId }),
[setSearch, setSelectedId],
);
return (
<ActionsContext.Provider value={actions}>
<StateContext.Provider value={state}>{children}</StateContext.Provider>
</ActionsContext.Provider>
);
}
+9 -9
View File
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ArrowDown, ArrowUp, ListVideo, Pin, Plus, RefreshCw, Youtube } from "lucide-react"; import { ArrowDown, ArrowUp, ListVideo, Pin, Plus, RefreshCw, Youtube } from "lucide-react";
import { api, type Playlist } from "../lib/api"; import { api, type Me, type Playlist } from "../lib/api";
import { formatDuration } from "../lib/format"; import { formatDuration } from "../lib/format";
import { notify } from "../lib/notifications"; import { notify } from "../lib/notifications";
import { notifyYouTubeActionError } from "../lib/youtubeErrors"; import { notifyYouTubeActionError } from "../lib/youtubeErrors";
@@ -10,6 +10,7 @@ import { LS, readAccountMerged, writeAccount } from "../lib/storage";
import { defaultLayout, type PanelLayout } from "../lib/panelLayout"; import { defaultLayout, type PanelLayout } from "../lib/panelLayout";
import SidePanel from "./SidePanel"; import SidePanel from "./SidePanel";
import PanelGroups, { type PanelItem } from "./PanelGroups"; import PanelGroups, { type PanelItem } from "./PanelGroups";
import { usePlaylists, usePlaylistsActions } from "./PlaylistsProvider";
type PlSort = { key: "custom" | "name" | "count" | "duration"; dir: "asc" | "desc"; dirtyFirst: boolean }; type PlSort = { key: "custom" | "name" | "count" | "duration"; dir: "asc" | "desc"; dirtyFirst: boolean };
const PL_SORT_DEFAULT: PlSort = { key: "custom", dir: "asc", dirtyFirst: false }; const PL_SORT_DEFAULT: PlSort = { key: "custom", dir: "asc", dirtyFirst: false };
@@ -19,24 +20,23 @@ const PL_SORT_DEFAULT: PlSort = { key: "custom", dir: "asc", dirtyFirst: false }
// controls (sort / new / sync); the selected playlist is App state so the module's detail pane // controls (sort / new / sync); the selected playlist is App state so the module's detail pane
// (Playlists.tsx) reacts to it. Grouped into two islands: "manage" and the playlist list. // (Playlists.tsx) reacts to it. Grouped into two islands: "manage" and the playlist list.
export default function PlaylistsRail({ export default function PlaylistsRail({
canWrite, me,
search,
selectedId,
setSelectedId,
layout, layout,
setLayout, setLayout,
collapsed, collapsed,
onToggleCollapse, onToggleCollapse,
}: { }: {
canWrite: boolean; me: Me;
search: string;
selectedId: number | null;
setSelectedId: (id: number | null) => void;
layout: PanelLayout; layout: PanelLayout;
setLayout: (l: PanelLayout) => void; setLayout: (l: PanelLayout) => void;
collapsed: boolean; collapsed: boolean;
onToggleCollapse: () => void; onToggleCollapse: () => void;
}) { }) {
const canWrite = me.can_write;
// The name filter + the selected playlist live in PlaylistsProvider (shared with the header search
// box and the Playlists detail pane); this rail owns the list query + its own controls.
const { search, selectedId } = usePlaylists();
const { setSelectedId } = usePlaylistsActions();
const { t } = useTranslation(); const { t } = useTranslation();
const qc = useQueryClient(); const qc = useQueryClient();
const [plSort, setPlSort] = useState<PlSort>(() => readAccountMerged(LS.plSort, PL_SORT_DEFAULT)); const [plSort, setPlSort] = useState<PlSort>(() => readAccountMerged(LS.plSort, PL_SORT_DEFAULT));
+8 -25
View File
@@ -34,6 +34,7 @@ import { DetailCustomizeMenu, Filterable, useArtBackdrop, useDetailPrefs } from
import PlexPlaylistAdd, { type PlexAddTarget } from "./PlexPlaylistAdd"; import PlexPlaylistAdd, { type PlexAddTarget } from "./PlexPlaylistAdd";
import PlexCollectionEditor from "./PlexCollectionEditor"; import PlexCollectionEditor from "./PlexCollectionEditor";
import { PageToolbar } from "./PageShell"; import { PageToolbar } from "./PageShell";
import { usePlex, usePlexActions } from "./PlexProvider";
// Lazy: the rich player (pulls in hls.js) loads only when something is first played. // Lazy: the rich player (pulls in hls.js) loads only when something is first played.
const PlexPlayer = lazy(() => import("./PlexPlayer")); const PlexPlayer = lazy(() => import("./PlexPlayer"));
@@ -54,34 +55,16 @@ type Sub =
// (useHistorySubview) so browser Back returns to the grid. Playback lands in P2 — a card announces // (useHistorySubview) so browser Back returns to the grid. Playback lands in P2 — a card announces
// for now. Rendered by App for page==="plex" (its own nav module). // for now. Rendered by App for page==="plex" (its own nav module).
type Props = {
q: string;
scope: string; // movie | show | both (unified cross-library scope)
setScope: (v: string) => void;
show: string;
sort: string;
filters: PlexFilters;
setFilters: (f: PlexFilters) => void;
// The sidebar opens a playlist by setting this; PlexBrowse turns it into a history subview + clears it.
openPlaylist?: number | null;
onPlaylistOpened?: () => void;
};
const PAGE = 40; const PAGE = 40;
export default function PlexBrowse({ export default function PlexBrowse() {
q,
scope,
setScope,
show,
sort,
filters,
setFilters,
openPlaylist,
onPlaylistOpened,
}: Props) {
const { t } = useTranslation(); const { t } = useTranslation();
const qc = useQueryClient(); const qc = useQueryClient();
// Scope / watch-state / sort / filters live in PlexProvider (edited mainly from PlexSidebar); the
// search term (q) comes from the shared header box; the rail hands off a playlist to open via the
// shared `playlistOpen` slot, which we consume into a history subview then clear.
const { q, scope, show, sort, filters, playlistOpen: openPlaylist } = usePlex();
const { setScope, setFilters, setPlaylistOpen } = usePlexActions();
const dq = useDebounced(q.trim(), 350); const dq = useDebounced(q.trim(), 350);
const sub = useHistorySubview<Sub>({ kind: "grid" }); const sub = useHistorySubview<Sub>({ kind: "grid" });
@@ -90,7 +73,7 @@ export default function PlexBrowse({
useEffect(() => { useEffect(() => {
if (openPlaylist != null) { if (openPlaylist != null) {
sub.open({ kind: "playlist", id: openPlaylist }); sub.open({ kind: "playlist", id: openPlaylist });
onPlaylistOpened?.(); setPlaylistOpen(null);
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [openPlaylist]); }, [openPlaylist]);
+124
View File
@@ -0,0 +1,124 @@
import {
createContext,
useCallback,
useContext,
useMemo,
useState,
type ReactNode,
} from "react";
import { EMPTY_PLEX_FILTERS, type PlexFilters } from "../lib/api";
import { getAccountRaw, LS, setAccountRaw } from "../lib/storage";
// Owns the Plex module's shared local state — the left-rail library scope / watch-state / sort /
// expanded filters, the header search term, and the "open this playlist" hand-off — lifted out of
// App so the PlexSidebar (rail), PlexBrowse (content), and the header search box read it via hooks
// instead of prop-drilling through App. Mirrors FeedFiltersProvider (split State + Actions contexts,
// stable useCallback setters, one-line hooks; composed in main.tsx around <App/>).
//
// scope / show / sort / filters / q are per-account persisted (localStorage); the account switch does
// a full reload, so init-from-storage is all the hydration they need. `playlistOpen` is an ephemeral
// hand-off (the rail sets it, PlexBrowse consumes it into a history subview then clears it). The Plex
// search term is deliberately its OWN state, NOT the feed's `filters.q`, so a Plex search never leaks
// into (and is never greeted by a stale value from) the feed.
type PlexState = {
scope: string; // movie | show | both (unified cross-library scope)
show: string;
sort: string;
filters: PlexFilters;
q: string;
playlistOpen: number | null;
};
interface PlexActions {
setScope: (v: string) => void;
setShow: (v: string) => void;
setSort: (v: string) => void;
setFilters: (f: PlexFilters) => void;
setQ: (q: string) => void;
setPlaylistOpen: (id: number | null) => void;
}
const StateContext = createContext<PlexState>({
scope: "both",
show: "all",
sort: "title",
filters: EMPTY_PLEX_FILTERS,
q: "",
playlistOpen: null,
});
const ActionsContext = createContext<PlexActions>({
setScope: () => {},
setShow: () => {},
setSort: () => {},
setFilters: () => {},
setQ: () => {},
setPlaylistOpen: () => {},
});
export function usePlex(): PlexState {
return useContext(StateContext);
}
export function usePlexActions(): PlexActions {
return useContext(ActionsContext);
}
export function PlexProvider({ children }: { children: ReactNode }) {
// The former per-library picker is now a cross-library SCOPE: movie | show | both (unified library).
// (Reuses the old LS.plexLibrary key; an old stored library-id value falls back to "both".)
const [scope, setScopeState] = useState<string>(() => {
const v = getAccountRaw(LS.plexLibrary) ?? "both";
return ["movie", "show", "both"].includes(v) ? v : "both";
});
const [show, setShowState] = useState<string>(() => getAccountRaw(LS.plexShow) ?? "all");
const [sort, setSortState] = useState<string>(() => getAccountRaw(LS.plexSort) ?? "title");
// The expanded Plex filters live as one JSON blob (the string-only account store serializes it).
const [filtersRaw, setFiltersRaw] = useState<string>(() => getAccountRaw(LS.plexFilters) ?? "{}");
const filters = useMemo<PlexFilters>(() => {
try {
return { ...EMPTY_PLEX_FILTERS, ...JSON.parse(filtersRaw) };
} catch {
return EMPTY_PLEX_FILTERS;
}
}, [filtersRaw]);
// Survives F5 (persisted), unlike the feed search — see the scope note above.
const [q, setQState] = useState<string>(() => getAccountRaw(LS.plexQ) ?? "");
const [playlistOpen, setPlaylistOpen] = useState<number | null>(null);
const setScope = useCallback((v: string) => {
setScopeState(v);
setAccountRaw(LS.plexLibrary, v);
}, []);
const setShow = useCallback((v: string) => {
setShowState(v);
setAccountRaw(LS.plexShow, v);
}, []);
const setSort = useCallback((v: string) => {
setSortState(v);
setAccountRaw(LS.plexSort, v);
}, []);
const setFilters = useCallback((f: PlexFilters) => {
const s = JSON.stringify(f);
setFiltersRaw(s);
setAccountRaw(LS.plexFilters, s);
}, []);
const setQ = useCallback((v: string) => {
setQState(v);
setAccountRaw(LS.plexQ, v);
}, []);
const state = useMemo<PlexState>(
() => ({ scope, show, sort, filters, q, playlistOpen }),
[scope, show, sort, filters, q, playlistOpen],
);
const actions = useMemo<PlexActions>(
() => ({ setScope, setShow, setSort, setFilters, setQ, setPlaylistOpen }),
[setScope, setShow, setSort, setFilters, setQ],
);
return (
<ActionsContext.Provider value={actions}>
<StateContext.Provider value={state}>{children}</StateContext.Provider>
</ActionsContext.Provider>
);
}
+6 -18
View File
@@ -8,6 +8,7 @@ import { useDebounced } from "../lib/useDebounced";
import { useScrollFade } from "../lib/useScrollFade"; import { useScrollFade } from "../lib/useScrollFade";
import SidePanel from "./SidePanel"; import SidePanel from "./SidePanel";
import PanelGroups, { type PanelItem } from "./PanelGroups"; import PanelGroups, { type PanelItem } from "./PanelGroups";
import { usePlex, usePlexActions } from "./PlexProvider";
// The Plex module's left filter column — now the shared floating SidePanel with its filter groups // The Plex module's left filter column — now the shared floating SidePanel with its filter groups
// as reorderable "islands" (same system as the feed rail). Movie libraries get the full metadata // as reorderable "islands" (same system as the feed rail). Movie libraries get the full metadata
@@ -15,15 +16,6 @@ import PanelGroups, { type PanelItem } from "./PanelGroups";
// the backend so the panel only offers what the library actually contains. // the backend so the panel only offers what the library actually contains.
type Props = { type Props = {
scope: string; // movie | show | both (unified cross-library scope)
setScope: (v: string) => void;
show: string;
setShow: (v: string) => void;
sort: string;
setSort: (v: string) => void;
filters: PlexFilters;
setFilters: (f: PlexFilters) => void;
onOpenPlaylist: (id: number) => void;
layout: PanelLayout; layout: PanelLayout;
setLayout: (l: PanelLayout) => void; setLayout: (l: PanelLayout) => void;
collapsed: boolean; collapsed: boolean;
@@ -42,21 +34,17 @@ const DURATION_BUCKETS: { key: string; min: number | null; max: number | null }[
]; ];
export default function PlexSidebar({ export default function PlexSidebar({
scope,
setScope,
show,
setShow,
sort,
setSort,
filters,
setFilters,
onOpenPlaylist,
layout, layout,
setLayout, setLayout,
collapsed, collapsed,
onToggleCollapse, onToggleCollapse,
}: Props) { }: Props) {
const { t } = useTranslation(); const { t } = useTranslation();
// Scope / watch-state / sort / expanded filters live in PlexProvider (shared with PlexBrowse and
// the header search box); this rail is the primary editor of them. Opening a playlist is a
// hand-off to PlexBrowse via the shared `playlistOpen` slot.
const { scope, show, sort, filters } = usePlex();
const { setScope, setShow, setSort, setFilters, setPlaylistOpen: onOpenPlaylist } = usePlexActions();
const collFade = useScrollFade(); const collFade = useScrollFade();
const playlistsQ = useQuery({ queryKey: ["plex-playlists"], queryFn: () => api.plexPlaylists() }); const playlistsQ = useQuery({ queryKey: ["plex-playlists"], queryFn: () => api.plexPlaylists() });
const [newPlaylist, setNewPlaylist] = useState(""); const [newPlaylist, setNewPlaylist] = useState("");
+3 -2
View File
@@ -14,6 +14,7 @@ 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"; import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider";
import { useChannelsActions } from "./ChannelsProvider";
// 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 }[] = [
@@ -63,14 +64,12 @@ function TagChip({
export default function Sidebar({ export default function Sidebar({
layout, layout,
setLayout, setLayout,
onFocusChannel,
isDemo = false, isDemo = false,
collapsed, collapsed,
onToggleCollapse, onToggleCollapse,
}: { }: {
layout: PanelLayout; layout: PanelLayout;
setLayout: (l: PanelLayout) => void; setLayout: (l: PanelLayout) => void;
onFocusChannel: (name: string) => void;
isDemo?: boolean; isDemo?: boolean;
collapsed: boolean; collapsed: boolean;
onToggleCollapse: () => void; onToggleCollapse: () => void;
@@ -78,6 +77,8 @@ export default function Sidebar({
const { t } = useTranslation(); const { t } = useTranslation();
const filters = useFeedFilters(); const filters = useFeedFilters();
const { setFilters } = useFeedFiltersActions(); const { setFilters } = useFeedFiltersActions();
// "Focus this channel in the manager" (from the tag manager here) lives in ChannelsProvider.
const { focusChannel: onFocusChannel } = useChannelsActions();
const tagsQuery = useQuery({ queryKey: ["tags"], queryFn: api.tags }); const tagsQuery = useQuery({ queryKey: ["tags"], queryFn: api.tags });
const tags = tagsQuery.data ?? []; const tags = tagsQuery.data ?? [];
+88 -30
View File
@@ -1,41 +1,99 @@
import {
Activity,
BarChart3,
Bell,
Clapperboard,
Download,
Home,
ListVideo,
MessageSquare,
ScrollText,
Settings,
SlidersHorizontal,
Tv,
Users,
type LucideIcon,
} from "lucide-react";
import type { Me } from "./api"; import type { Me } from "./api";
import type { Page } from "./urlState"; import type { Page } from "./urlState";
// Admin/system modules — rendered in their own section (below a divider) in the nav rail. // ── The module registry ───────────────────────────────────────────────────────────────────────
export const SYSTEM_PAGES: readonly Page[] = ["scheduler", "config", "users", "audit"]; // One ordered source of truth for every navigable module: its nav label, icon, admin/system
// placement, whether it carries a live badge, and its availability gate. The nav rail, the header's
// The i18n key for each module's SHORT display name — the same label the nav rail shows. Single // ◀/▶ stepper, and (as the registry grows) the page render all read from here, so a new or newly
// source so the nav rail and the header's ModuleName pill always read identically (the longer, // gated module shows up everywhere at once — nothing hard-codes the sequence or duplicates the icon.
// descriptive `pageTitleKey` names are kept only for the browser tab title). export type ModuleDef = {
export const moduleLabelKey: Record<Page, string> = { id: Page;
feed: "header.account.feed", // i18n key for the SHORT display name — the same label the nav rail and the header pill show.
channels: "header.account.channels", labelKey: string;
playlists: "header.account.playlists", icon: LucideIcon;
plex: "plex.navLabel", // Admin/system modules render in their own section (below a divider) in the nav rail.
notifications: "inbox.navLabel", system?: boolean;
messages: "messages.navLabel", // Carries a live unread/active count in the rail (the number itself is computed in NavSidebar,
downloads: "downloads.navLabel", // which owns the polling queries; this only flags which modules have one).
stats: "header.account.stats", hasBadge?: boolean;
scheduler: "header.account.scheduler", // Availability for THIS account, derived from language-independent account state (plex toggle,
config: "header.account.config", // demo restrictions, admin role). Absent = always available. Drives both the reachable module
users: "header.account.users", // set (nav + stepper) and, later, the render gate.
audit: "header.account.audit", gate?: (me: Me) => boolean;
settings: "header.account.settings",
}; };
const isAdmin = (me: Me) => me.role === "admin";
const notDemo = (me: Me) => !me.is_demo;
// Ordered in nav-rail order — this array IS the sequence the rail and the stepper walk.
const MODULES: readonly ModuleDef[] = [
{ id: "feed", labelKey: "header.account.feed", icon: Home },
{ id: "channels", labelKey: "header.account.channels", icon: Tv },
{ id: "playlists", labelKey: "header.account.playlists", icon: ListVideo },
{ id: "plex", labelKey: "plex.navLabel", icon: Clapperboard, gate: (me) => me.plex_enabled },
{ id: "notifications", labelKey: "inbox.navLabel", icon: Bell, hasBadge: true },
{ id: "messages", labelKey: "messages.navLabel", icon: MessageSquare, hasBadge: true, gate: notDemo },
{ id: "downloads", labelKey: "downloads.navLabel", icon: Download, hasBadge: true, gate: notDemo },
{ id: "stats", labelKey: "header.account.stats", icon: BarChart3 },
{ id: "scheduler", labelKey: "header.account.scheduler", icon: Activity, system: true, gate: isAdmin },
{ id: "config", labelKey: "header.account.config", icon: SlidersHorizontal, system: true, gate: isAdmin },
{ id: "users", labelKey: "header.account.users", icon: Users, system: true, gate: isAdmin },
{ id: "audit", labelKey: "header.account.audit", icon: ScrollText, system: true, gate: isAdmin },
];
// Settings is reachable (its own rail button + the ◀/▶ wrap target) but is not a primary module in
// the ordered nav list, so it lives beside the array rather than in it.
const SETTINGS_MODULE: ModuleDef = {
id: "settings",
labelKey: "header.account.settings",
icon: Settings,
};
// Registering a NEW Page means adding it to MODULES (or SETTINGS_MODULE) above — that's the single
// place. If a Page is left unregistered, moduleDef()/moduleLabelKey[] return undefined and NavSidebar
// throws on `.icon`; the coverage isn't compiler-enforced here because the ordered array can't also
// be an exhaustive Page map, so treat the array as the checklist when the Page union grows.
const BY_ID = Object.fromEntries(
[...MODULES, SETTINGS_MODULE].map((m) => [m.id, m]),
) as Record<Page, ModuleDef>;
// Look up a module's definition by page id (every Page is covered, incl. settings).
export function moduleDef(id: Page): ModuleDef {
return BY_ID[id];
}
// The i18n key for each module's SHORT display name. Single source so the nav rail and the header's
// ModuleName pill always read identically (the longer, descriptive `pageTitleKey` names are kept
// only for the browser tab title).
export const moduleLabelKey: Record<Page, string> = Object.fromEntries(
[...MODULES, SETTINGS_MODULE].map((m) => [m.id, m.labelKey]),
) as Record<Page, string>;
// Admin/system modules — rendered in their own section (below a divider) in the nav rail.
export const SYSTEM_PAGES: readonly Page[] = MODULES.filter((m) => m.system).map((m) => m.id);
// The ordered list of primary module pages available to THIS user, in nav-rail order. Single // The ordered list of primary module pages available to THIS user, in nav-rail order. Single
// source of truth for both the nav rail (NavSidebar) and the header's ◀/▶ module stepper, so a // source of truth for both the nav rail (NavSidebar) and the header's ◀/▶ module stepper, so a
// new module (or a newly-gated one) shows up in both at once — nothing hard-codes the sequence. // new module (or a newly-gated one) shows up in both at once. Availability is derived from the
// Availability is derived from the account (plex toggle, demo restrictions, admin role), so the // account via each module's `gate`, so the reachable set changes with the account state, not the UI.
// reachable set changes with the language-independent account state, not the UI.
export function moduleOrder(me: Me): Page[] { export function moduleOrder(me: Me): Page[] {
const pages: Page[] = ["feed", "channels", "playlists"]; return MODULES.filter((m) => !m.gate || m.gate(me)).map((m) => m.id);
if (me.plex_enabled) pages.push("plex");
pages.push("notifications");
if (!me.is_demo) pages.push("messages", "downloads");
pages.push("stats");
if (me.role === "admin") pages.push(...SYSTEM_PAGES);
return pages;
} }
// Step to the next/previous module cyclically (wraps at both ends). `dir` = +1 forward, -1 back. // Step to the next/previous module cyclically (wraps at both ends). `dir` = +1 forward, -1 back.
+10 -1
View File
@@ -5,6 +5,9 @@ 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 { FeedFiltersProvider } from "./components/FeedFiltersProvider";
import { PlaylistsProvider } from "./components/PlaylistsProvider";
import { PlexProvider } from "./components/PlexProvider";
import { ChannelsProvider } from "./components/ChannelsProvider";
import "./i18n"; import "./i18n";
import "./index.css"; import "./index.css";
@@ -39,7 +42,13 @@ const root =
<ConfirmProvider> <ConfirmProvider>
<NavigationProvider> <NavigationProvider>
<FeedFiltersProvider> <FeedFiltersProvider>
<App /> <PlaylistsProvider>
<PlexProvider>
<ChannelsProvider>
<App />
</ChannelsProvider>
</PlexProvider>
</PlaylistsProvider>
</FeedFiltersProvider> </FeedFiltersProvider>
</NavigationProvider> </NavigationProvider>
</ConfirmProvider> </ConfirmProvider>