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:
+7
-148
@@ -3,11 +3,9 @@ import { useTranslation } from "react-i18next";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
api,
|
||||
EMPTY_PLEX_FILTERS,
|
||||
getActiveAccount,
|
||||
setActiveAccount,
|
||||
setUnauthorizedHandler,
|
||||
type PlexFilters,
|
||||
} from "./lib/api";
|
||||
import { setLanguage, isSupported, type LangCode } from "./i18n";
|
||||
import {
|
||||
@@ -34,13 +32,11 @@ import {
|
||||
LS,
|
||||
readAccount,
|
||||
setAccountRaw,
|
||||
useAccountPersistedState,
|
||||
writeAccount,
|
||||
} 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";
|
||||
import SetupWizard from "./components/SetupWizard";
|
||||
@@ -97,7 +93,7 @@ type EditablePrefs = {
|
||||
export default function App() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { page, channelView, ytSearch } = useNavigation();
|
||||
const { setPage, openChannel, enterYtSearch, exitYtSearch } = useNavigationActions();
|
||||
const { setPage, enterYtSearch, exitYtSearch } = useNavigationActions();
|
||||
const [theme, setThemeState] = useState<ThemePrefs>(() => loadLocalTheme());
|
||||
// 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
|
||||
@@ -131,74 +127,10 @@ export default function App() {
|
||||
plex: loadLayout("plex"),
|
||||
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);
|
||||
// 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 [notesOpen, setNotesOpen] = useState(false);
|
||||
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) {
|
||||
setNotesHighlight(highlight);
|
||||
@@ -246,17 +178,6 @@ export default function App() {
|
||||
writeAccount(LS.playlistsCollapsed, next);
|
||||
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]);
|
||||
|
||||
// Apply the draft prefs locally for instant preview (their localStorage mirrors update via
|
||||
@@ -584,7 +505,6 @@ export default function App() {
|
||||
<Sidebar
|
||||
layout={panelLayouts.feed}
|
||||
setLayout={(l) => setPanelLayout("feed", l)}
|
||||
onFocusChannel={focusChannel}
|
||||
isDemo={meQuery.data!.is_demo}
|
||||
collapsed={filterCollapsed}
|
||||
onToggleCollapse={() => setFilterCollapsed(!filterCollapsed)}
|
||||
@@ -593,15 +513,6 @@ export default function App() {
|
||||
{page === "plex" && meQuery.data!.plex_enabled && (
|
||||
<Suspense fallback={null}>
|
||||
<PlexSidebar
|
||||
scope={plexScope}
|
||||
setScope={setPlexScope}
|
||||
show={plexShowFilter}
|
||||
setShow={setPlexShowFilter}
|
||||
sort={plexSort}
|
||||
setSort={setPlexSort}
|
||||
filters={plexFilters}
|
||||
setFilters={setPlexFilters}
|
||||
onOpenPlaylist={setPlexPlaylistOpen}
|
||||
layout={panelLayouts.plex}
|
||||
setLayout={(l) => setPanelLayout("plex", l)}
|
||||
collapsed={filterCollapsed}
|
||||
@@ -611,10 +522,7 @@ export default function App() {
|
||||
)}
|
||||
{page === "playlists" && !channelView && (
|
||||
<PlaylistsRail
|
||||
canWrite={meQuery.data!.can_write}
|
||||
search={playlistsSearch}
|
||||
selectedId={selectedPlaylistId}
|
||||
setSelectedId={setSelectedPlaylistId}
|
||||
me={meQuery.data!}
|
||||
layout={panelLayouts.playlists}
|
||||
setLayout={(l) => setPanelLayout("playlists", l)}
|
||||
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.
|
||||
fadeTop={page !== "channels" && page !== "audit"}
|
||||
header={
|
||||
<Header
|
||||
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");
|
||||
}}
|
||||
/>
|
||||
<Header me={meQuery.data!} onYtSearch={enterYtSearch} />
|
||||
}
|
||||
banners={
|
||||
<>
|
||||
@@ -683,27 +576,7 @@ export default function App() {
|
||||
>
|
||||
<Suspense fallback={pageFallback}>
|
||||
{page === "channels" ? (
|
||||
<Channels
|
||||
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 }) });
|
||||
}}
|
||||
/>
|
||||
<Channels me={meQuery.data!} onOpenWizard={() => setWizardOpen(true)} />
|
||||
) : page === "stats" ? (
|
||||
<Stats me={meQuery.data!} />
|
||||
) : page === "scheduler" && meQuery.data!.role === "admin" ? (
|
||||
@@ -715,13 +588,9 @@ export default function App() {
|
||||
) : page === "audit" && meQuery.data!.role === "admin" ? (
|
||||
<AuditLog />
|
||||
) : page === "playlists" ? (
|
||||
<Playlists
|
||||
canWrite={meQuery.data!.can_write}
|
||||
selectedId={selectedPlaylistId}
|
||||
setSelectedId={setSelectedPlaylistId}
|
||||
/>
|
||||
<Playlists me={meQuery.data!} />
|
||||
) : page === "notifications" ? (
|
||||
<NotificationsPanel onFocusChannel={focusChannel} />
|
||||
<NotificationsPanel />
|
||||
) : page === "messages" && !meQuery.data!.is_demo ? (
|
||||
<div className="p-4 max-w-3xl w-full mx-auto">
|
||||
<Messages meId={meQuery.data!.id} />
|
||||
@@ -735,17 +604,7 @@ export default function App() {
|
||||
onOpenWizard={() => setWizardOpen(true)}
|
||||
/>
|
||||
) : page === "plex" && meQuery.data!.plex_enabled ? (
|
||||
<PlexBrowse
|
||||
q={plexQ}
|
||||
scope={plexScope}
|
||||
setScope={setPlexScope}
|
||||
show={plexShowFilter}
|
||||
sort={plexSort}
|
||||
filters={plexFilters}
|
||||
setFilters={setPlexFilters}
|
||||
openPlaylist={plexPlaylistOpen}
|
||||
onPlaylistOpened={() => setPlexPlaylistOpen(null)}
|
||||
/>
|
||||
<PlexBrowse />
|
||||
) : (
|
||||
<Feed
|
||||
filters={filters}
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
UserMinus,
|
||||
X,
|
||||
} 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 { accountKey, LS } from "../lib/storage";
|
||||
import { useDismiss } from "../lib/useDismiss";
|
||||
@@ -30,6 +30,9 @@ import ChannelLink from "./ChannelLink";
|
||||
import ChannelDiscovery from "./ChannelDiscovery";
|
||||
import TagManager from "./TagManager";
|
||||
import { useConfirm } from "./ConfirmProvider";
|
||||
import { useChannels, useChannelsActions } from "./ChannelsProvider";
|
||||
import { useNavigationActions } from "./NavigationProvider";
|
||||
import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider";
|
||||
|
||||
export type ChannelsView = "subscribed" | "discovery" | "blocked";
|
||||
|
||||
@@ -42,49 +45,36 @@ const STATUS_FILTERS: { id: ChannelStatusFilter; labelKey: string }[] = [
|
||||
{ id: "hidden", labelKey: "channels.filters.hidden" },
|
||||
];
|
||||
|
||||
export default function Channels({
|
||||
canWrite,
|
||||
isAdmin,
|
||||
export default function Channels({ me, onOpenWizard }: { me: Me; onOpenWizard: () => void }) {
|
||||
const canWrite = me.can_write;
|
||||
const isAdmin = me.role === "admin";
|
||||
// The manager's name filter, status chip, active tab, reset token, and focus-channel intent live
|
||||
// in ChannelsProvider (shared with the header search box + the cross-module "focus this channel"
|
||||
// callers); this page is their primary editor.
|
||||
const {
|
||||
search,
|
||||
setSearch,
|
||||
onViewChannel,
|
||||
onFilterByTag,
|
||||
onFocusChannel,
|
||||
focusChannelName,
|
||||
focusChannelToken,
|
||||
statusFilter,
|
||||
setStatusFilter,
|
||||
view,
|
||||
setView,
|
||||
filtersResetToken,
|
||||
onOpenWizard,
|
||||
}: {
|
||||
canWrite: boolean;
|
||||
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;
|
||||
}) {
|
||||
focusName: focusChannelName,
|
||||
focusToken: focusChannelToken,
|
||||
} = useChannels();
|
||||
const { setSearch, setStatusFilter, setView, focusChannel: onFocusChannel } = useChannelsActions();
|
||||
const { openChannel: onViewChannel, setPage } = useNavigationActions();
|
||||
const feedFilters = useFeedFilters();
|
||||
const { setFilters: setFeedFilters } = useFeedFiltersActions();
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
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 tagsQuery = useQuery({ queryKey: ["tags"], queryFn: api.tags });
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -8,40 +8,36 @@ import SearchBar from "./SearchBar";
|
||||
import SyncStatus from "./SyncStatus";
|
||||
import { useNavigation, useNavigationActions } from "./NavigationProvider";
|
||||
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 +
|
||||
// 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,
|
||||
plexQ,
|
||||
setPlexQ,
|
||||
channelsSearch,
|
||||
setChannelsSearch,
|
||||
playlistsSearch,
|
||||
setPlaylistsSearch,
|
||||
onYtSearch,
|
||||
onGoToFullHistory,
|
||||
}: {
|
||||
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,
|
||||
// which can't spend the shared quota).
|
||||
onYtSearch: (q: string) => void;
|
||||
// Jump to the channel manager filtered to channels still missing full history.
|
||||
onGoToFullHistory: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { page } = useNavigation();
|
||||
const { setPage } = useNavigationActions();
|
||||
const filters = useFeedFilters();
|
||||
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
|
||||
// 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.
|
||||
|
||||
@@ -4,32 +4,21 @@ import { useTranslation } from "react-i18next";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useNavigation, useNavigationActions } from "./NavigationProvider";
|
||||
import {
|
||||
Activity,
|
||||
BarChart3,
|
||||
Bell,
|
||||
Clapperboard,
|
||||
Download,
|
||||
Home,
|
||||
Info,
|
||||
ListVideo,
|
||||
LogOut,
|
||||
MessageSquare,
|
||||
Pin,
|
||||
PinOff,
|
||||
ScrollText,
|
||||
Settings,
|
||||
Shield,
|
||||
SlidersHorizontal,
|
||||
Users,
|
||||
Tv,
|
||||
UserPlus,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { api, clearActiveAccount, setActiveAccount, type Me } from "../lib/api";
|
||||
import * as e2ee from "../lib/e2ee";
|
||||
import { useLiveQuery } from "../lib/useLiveQuery";
|
||||
import { getUnreadCount, subscribe } from "../lib/notifications";
|
||||
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 { useHoverFocusWithin } from "../lib/useHoverFocusWithin";
|
||||
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.
|
||||
const listFade = useScrollFade();
|
||||
|
||||
type NavItem = { page: Page; icon: typeof Home; label: string; badge?: number };
|
||||
// Per-page presentation (icon/label/badge). WHICH pages appear and in what order comes from the
|
||||
// shared moduleOrder(me) — the same source the header's ◀/▶ stepper uses — so the two never drift
|
||||
// and a new/gated module updates both at once. Badges live here (nav-only concern).
|
||||
// Icon + badge per page; the label comes from the shared moduleLabelKey so the rail and the
|
||||
// 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,
|
||||
};
|
||||
type NavItem = { page: Page; icon: LucideIcon; label: string; badge?: number };
|
||||
// Per-page presentation (icon/label/badge). WHICH pages appear and in what order — and each
|
||||
// module's icon + label — come from the shared module registry (the same source the header's
|
||||
// ◀/▶ stepper uses), so the two never drift and a new/gated module updates both at once. The
|
||||
// badge NUMBERS are the only nav-only concern (they need the live polling queries above).
|
||||
const BADGE: Partial<Record<Page, number>> = {
|
||||
notifications: unread,
|
||||
messages: msgUnread,
|
||||
downloads: dlActive,
|
||||
};
|
||||
const META = (p: Page): Omit<NavItem, "page"> => ({
|
||||
icon: ICON[p],
|
||||
icon: moduleDef(p).icon,
|
||||
label: t(moduleLabelKey[p]),
|
||||
badge: BADGE[p],
|
||||
});
|
||||
|
||||
@@ -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 { useChannelsActions } from "./ChannelsProvider";
|
||||
import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider";
|
||||
|
||||
// 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.
|
||||
const POLL_MS = 8000;
|
||||
|
||||
export default function NotificationsPanel({
|
||||
onFocusChannel,
|
||||
}: {
|
||||
onFocusChannel: (name: string) => void;
|
||||
}) {
|
||||
export default function NotificationsPanel() {
|
||||
const { t } = useTranslation();
|
||||
const { setPage } = useNavigationActions();
|
||||
const filters = useFeedFilters();
|
||||
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 q = useLiveQuery<{ items: AppNotification[]; total: number }>(
|
||||
["notifications"],
|
||||
|
||||
@@ -31,7 +31,7 @@ import {
|
||||
X,
|
||||
Youtube,
|
||||
} 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 { notify } from "../lib/notifications";
|
||||
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
||||
@@ -40,6 +40,7 @@ const PlayerModal = lazy(() => import("./PlayerModal"));
|
||||
import UndoToolbar from "./UndoToolbar";
|
||||
import { PageToolbar } from "./PageShell";
|
||||
import { useConfirm } from "./ConfirmProvider";
|
||||
import { usePlaylists, usePlaylistsActions } from "./PlaylistsProvider";
|
||||
|
||||
type SortKey = "manual" | "title" | "duration" | "channel";
|
||||
type SortDir = "asc" | "desc";
|
||||
@@ -178,17 +179,12 @@ function Row({
|
||||
);
|
||||
}
|
||||
|
||||
export default function Playlists({
|
||||
canWrite,
|
||||
selectedId,
|
||||
setSelectedId,
|
||||
}: {
|
||||
canWrite: boolean;
|
||||
// The selected playlist is App state, shared with the App-level PlaylistsRail (which owns the
|
||||
export default function Playlists({ me }: { me: Me }) {
|
||||
const canWrite = me.can_write;
|
||||
// The selected playlist lives in PlaylistsProvider, shared with the PlaylistsRail (which owns the
|
||||
// list + its rail controls). This detail pane reacts to the selection.
|
||||
selectedId: number | null;
|
||||
setSelectedId: (id: number | null) => void;
|
||||
}) {
|
||||
const { selectedId } = usePlaylists();
|
||||
const { setSelectedId } = usePlaylistsActions();
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
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 { notify } from "../lib/notifications";
|
||||
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
||||
@@ -10,6 +10,7 @@ import { LS, readAccountMerged, writeAccount } from "../lib/storage";
|
||||
import { defaultLayout, type PanelLayout } from "../lib/panelLayout";
|
||||
import SidePanel from "./SidePanel";
|
||||
import PanelGroups, { type PanelItem } from "./PanelGroups";
|
||||
import { usePlaylists, usePlaylistsActions } from "./PlaylistsProvider";
|
||||
|
||||
type PlSort = { key: "custom" | "name" | "count" | "duration"; dir: "asc" | "desc"; dirtyFirst: boolean };
|
||||
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
|
||||
// (Playlists.tsx) reacts to it. Grouped into two islands: "manage" and the playlist list.
|
||||
export default function PlaylistsRail({
|
||||
canWrite,
|
||||
search,
|
||||
selectedId,
|
||||
setSelectedId,
|
||||
me,
|
||||
layout,
|
||||
setLayout,
|
||||
collapsed,
|
||||
onToggleCollapse,
|
||||
}: {
|
||||
canWrite: boolean;
|
||||
search: string;
|
||||
selectedId: number | null;
|
||||
setSelectedId: (id: number | null) => void;
|
||||
me: Me;
|
||||
layout: PanelLayout;
|
||||
setLayout: (l: PanelLayout) => void;
|
||||
collapsed: boolean;
|
||||
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 qc = useQueryClient();
|
||||
const [plSort, setPlSort] = useState<PlSort>(() => readAccountMerged(LS.plSort, PL_SORT_DEFAULT));
|
||||
|
||||
@@ -34,6 +34,7 @@ import { DetailCustomizeMenu, Filterable, useArtBackdrop, useDetailPrefs } from
|
||||
import PlexPlaylistAdd, { type PlexAddTarget } from "./PlexPlaylistAdd";
|
||||
import PlexCollectionEditor from "./PlexCollectionEditor";
|
||||
import { PageToolbar } from "./PageShell";
|
||||
import { usePlex, usePlexActions } from "./PlexProvider";
|
||||
|
||||
// Lazy: the rich player (pulls in hls.js) loads only when something is first played.
|
||||
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
|
||||
// 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;
|
||||
|
||||
export default function PlexBrowse({
|
||||
q,
|
||||
scope,
|
||||
setScope,
|
||||
show,
|
||||
sort,
|
||||
filters,
|
||||
setFilters,
|
||||
openPlaylist,
|
||||
onPlaylistOpened,
|
||||
}: Props) {
|
||||
export default function PlexBrowse() {
|
||||
const { t } = useTranslation();
|
||||
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 sub = useHistorySubview<Sub>({ kind: "grid" });
|
||||
|
||||
@@ -90,7 +73,7 @@ export default function PlexBrowse({
|
||||
useEffect(() => {
|
||||
if (openPlaylist != null) {
|
||||
sub.open({ kind: "playlist", id: openPlaylist });
|
||||
onPlaylistOpened?.();
|
||||
setPlaylistOpen(null);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [openPlaylist]);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { useDebounced } from "../lib/useDebounced";
|
||||
import { useScrollFade } from "../lib/useScrollFade";
|
||||
import SidePanel from "./SidePanel";
|
||||
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
|
||||
// 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.
|
||||
|
||||
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;
|
||||
setLayout: (l: PanelLayout) => void;
|
||||
collapsed: boolean;
|
||||
@@ -42,21 +34,17 @@ const DURATION_BUCKETS: { key: string; min: number | null; max: number | null }[
|
||||
];
|
||||
|
||||
export default function PlexSidebar({
|
||||
scope,
|
||||
setScope,
|
||||
show,
|
||||
setShow,
|
||||
sort,
|
||||
setSort,
|
||||
filters,
|
||||
setFilters,
|
||||
onOpenPlaylist,
|
||||
layout,
|
||||
setLayout,
|
||||
collapsed,
|
||||
onToggleCollapse,
|
||||
}: Props) {
|
||||
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 playlistsQ = useQuery({ queryKey: ["plex-playlists"], queryFn: () => api.plexPlaylists() });
|
||||
const [newPlaylist, setNewPlaylist] = useState("");
|
||||
|
||||
@@ -14,6 +14,7 @@ import SidePanel from "./SidePanel";
|
||||
import PanelGroups, { type PanelItem } from "./PanelGroups";
|
||||
import ChannelPicker from "./ChannelPicker";
|
||||
import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider";
|
||||
import { useChannelsActions } from "./ChannelsProvider";
|
||||
|
||||
// Filter ids; display labels resolved at render time via t("sidebar.sort.<id>") etc.
|
||||
const DATE_PRESETS: { days: number; key: string }[] = [
|
||||
@@ -63,14 +64,12 @@ function TagChip({
|
||||
export default function Sidebar({
|
||||
layout,
|
||||
setLayout,
|
||||
onFocusChannel,
|
||||
isDemo = false,
|
||||
collapsed,
|
||||
onToggleCollapse,
|
||||
}: {
|
||||
layout: PanelLayout;
|
||||
setLayout: (l: PanelLayout) => void;
|
||||
onFocusChannel: (name: string) => void;
|
||||
isDemo?: boolean;
|
||||
collapsed: boolean;
|
||||
onToggleCollapse: () => void;
|
||||
@@ -78,6 +77,8 @@ export default function Sidebar({
|
||||
const { t } = useTranslation();
|
||||
const filters = useFeedFilters();
|
||||
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 tags = tagsQuery.data ?? [];
|
||||
|
||||
|
||||
+88
-30
@@ -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 { Page } from "./urlState";
|
||||
|
||||
// Admin/system modules — rendered in their own section (below a divider) in the nav rail.
|
||||
export const SYSTEM_PAGES: readonly Page[] = ["scheduler", "config", "users", "audit"];
|
||||
|
||||
// The i18n key for each module's SHORT display name — the same label the nav rail shows. 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> = {
|
||||
feed: "header.account.feed",
|
||||
channels: "header.account.channels",
|
||||
playlists: "header.account.playlists",
|
||||
plex: "plex.navLabel",
|
||||
notifications: "inbox.navLabel",
|
||||
messages: "messages.navLabel",
|
||||
downloads: "downloads.navLabel",
|
||||
stats: "header.account.stats",
|
||||
scheduler: "header.account.scheduler",
|
||||
config: "header.account.config",
|
||||
users: "header.account.users",
|
||||
audit: "header.account.audit",
|
||||
settings: "header.account.settings",
|
||||
// ── The module registry ───────────────────────────────────────────────────────────────────────
|
||||
// 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
|
||||
// ◀/▶ stepper, and (as the registry grows) the page render all read from here, so a new or newly
|
||||
// gated module shows up everywhere at once — nothing hard-codes the sequence or duplicates the icon.
|
||||
export type ModuleDef = {
|
||||
id: Page;
|
||||
// i18n key for the SHORT display name — the same label the nav rail and the header pill show.
|
||||
labelKey: string;
|
||||
icon: LucideIcon;
|
||||
// Admin/system modules render in their own section (below a divider) in the nav rail.
|
||||
system?: boolean;
|
||||
// Carries a live unread/active count in the rail (the number itself is computed in NavSidebar,
|
||||
// which owns the polling queries; this only flags which modules have one).
|
||||
hasBadge?: boolean;
|
||||
// Availability for THIS account, derived from language-independent account state (plex toggle,
|
||||
// demo restrictions, admin role). Absent = always available. Drives both the reachable module
|
||||
// set (nav + stepper) and, later, the render gate.
|
||||
gate?: (me: Me) => boolean;
|
||||
};
|
||||
|
||||
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
|
||||
// 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.
|
||||
// Availability is derived from the account (plex toggle, demo restrictions, admin role), so the
|
||||
// reachable set changes with the language-independent account state, not the UI.
|
||||
// new module (or a newly-gated one) shows up in both at once. Availability is derived from the
|
||||
// account via each module's `gate`, so the reachable set changes with the account state, not the UI.
|
||||
export function moduleOrder(me: Me): Page[] {
|
||||
const pages: Page[] = ["feed", "channels", "playlists"];
|
||||
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;
|
||||
return MODULES.filter((m) => !m.gate || m.gate(me)).map((m) => m.id);
|
||||
}
|
||||
|
||||
// Step to the next/previous module cyclically (wraps at both ends). `dir` = +1 forward, -1 back.
|
||||
|
||||
@@ -5,6 +5,9 @@ import ErrorBoundary from "./components/ErrorBoundary";
|
||||
import { ConfirmProvider } from "./components/ConfirmProvider";
|
||||
import { NavigationProvider } from "./components/NavigationProvider";
|
||||
import { FeedFiltersProvider } from "./components/FeedFiltersProvider";
|
||||
import { PlaylistsProvider } from "./components/PlaylistsProvider";
|
||||
import { PlexProvider } from "./components/PlexProvider";
|
||||
import { ChannelsProvider } from "./components/ChannelsProvider";
|
||||
import "./i18n";
|
||||
import "./index.css";
|
||||
|
||||
@@ -39,7 +42,13 @@ const root =
|
||||
<ConfirmProvider>
|
||||
<NavigationProvider>
|
||||
<FeedFiltersProvider>
|
||||
<PlaylistsProvider>
|
||||
<PlexProvider>
|
||||
<ChannelsProvider>
|
||||
<App />
|
||||
</ChannelsProvider>
|
||||
</PlexProvider>
|
||||
</PlaylistsProvider>
|
||||
</FeedFiltersProvider>
|
||||
</NavigationProvider>
|
||||
</ConfirmProvider>
|
||||
|
||||
Reference in New Issue
Block a user