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 { 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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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"],
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|||||||
@@ -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 { 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("");
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user