diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 273a6f06..1db90502 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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(() => 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 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(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(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(() => { - 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(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(() => { - 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() { 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 && ( setPanelLayout("plex", l)} collapsed={filterCollapsed} @@ -611,10 +522,7 @@ export default function App() { )} {page === "playlists" && !channelView && ( 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={ -
{ - 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={ <> @@ -683,27 +576,7 @@ export default function App() { > {page === "channels" ? ( - 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 }) }); - }} - /> + setWizardOpen(true)} /> ) : page === "stats" ? ( ) : page === "scheduler" && meQuery.data!.role === "admin" ? ( @@ -715,13 +588,9 @@ export default function App() { ) : page === "audit" && meQuery.data!.role === "admin" ? ( ) : page === "playlists" ? ( - + ) : page === "notifications" ? ( - + ) : page === "messages" && !meQuery.data!.is_demo ? (
@@ -735,17 +604,7 @@ export default function App() { onOpenWizard={() => setWizardOpen(true)} /> ) : page === "plex" && meQuery.data!.plex_enabled ? ( - setPlexPlaylistOpen(null)} - /> + ) : ( 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; -}) { +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, + statusFilter, + view, + filtersResetToken, + 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 }); diff --git a/frontend/src/components/ChannelsProvider.tsx b/frontend/src/components/ChannelsProvider.tsx new file mode 100644 index 00000000..0ccdb6b1 --- /dev/null +++ b/frontend/src/components/ChannelsProvider.tsx @@ -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 ). +// +// 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({ + search: "", + statusFilter: "all", + view: "subscribed", + filtersResetToken: 0, + focusName: null, + focusToken: 0, +}); +const ActionsContext = createContext({ + 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(() => + clampStatus(getAccountRaw(LS.channelFilter)), + ); + const [view, setViewState] = useState(() => + clampView(getAccountRaw(LS.channelsView)), + ); + const [filtersResetToken, setFiltersResetToken] = useState(0); + const [focusName, setFocusName] = useState(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( + () => ({ search, statusFilter, view, filtersResetToken, focusName, focusToken }), + [search, statusFilter, view, filtersResetToken, focusName, focusToken], + ); + const actions = useMemo( + () => ({ setSearch, setStatusFilter, setView, focusChannel, goToFullHistory }), + [setSearch, setStatusFilter, setView, focusChannel, goToFullHistory], + ); + + return ( + + {children} + + ); +} diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index 857ec4db..14de9e6c 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -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. diff --git a/frontend/src/components/NavSidebar.tsx b/frontend/src/components/NavSidebar.tsx index 7028eacb..3fc139e0 100644 --- a/frontend/src/components/NavSidebar.tsx +++ b/frontend/src/components/NavSidebar.tsx @@ -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 = { - 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> = { notifications: unread, messages: msgUnread, downloads: dlActive, }; const META = (p: Page): Omit => ({ - icon: ICON[p], + icon: moduleDef(p).icon, label: t(moduleLabelKey[p]), badge: BADGE[p], }); diff --git a/frontend/src/components/NotificationsPanel.tsx b/frontend/src/components/NotificationsPanel.tsx index e4aaed4c..6486ab76 100644 --- a/frontend/src/components/NotificationsPanel.tsx +++ b/frontend/src/components/NotificationsPanel.tsx @@ -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"], diff --git a/frontend/src/components/Playlists.tsx b/frontend/src/components/Playlists.tsx index 881458b7..cd7575bf 100644 --- a/frontend/src/components/Playlists.tsx +++ b/frontend/src/components/Playlists.tsx @@ -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(); diff --git a/frontend/src/components/PlaylistsProvider.tsx b/frontend/src/components/PlaylistsProvider.tsx new file mode 100644 index 00000000..4960d7bb --- /dev/null +++ b/frontend/src/components/PlaylistsProvider.tsx @@ -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 ). +// +// 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({ search: "", selectedId: null }); +const ActionsContext = createContext({ + 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(() => { + 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(() => ({ search, selectedId }), [search, selectedId]); + const actions = useMemo( + () => ({ setSearch, setSelectedId }), + [setSearch, setSelectedId], + ); + + return ( + + {children} + + ); +} diff --git a/frontend/src/components/PlaylistsRail.tsx b/frontend/src/components/PlaylistsRail.tsx index 534f35ed..1dd85c4e 100644 --- a/frontend/src/components/PlaylistsRail.tsx +++ b/frontend/src/components/PlaylistsRail.tsx @@ -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(() => readAccountMerged(LS.plSort, PL_SORT_DEFAULT)); diff --git a/frontend/src/components/PlexBrowse.tsx b/frontend/src/components/PlexBrowse.tsx index 96a611e5..03782890 100644 --- a/frontend/src/components/PlexBrowse.tsx +++ b/frontend/src/components/PlexBrowse.tsx @@ -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({ 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]); diff --git a/frontend/src/components/PlexProvider.tsx b/frontend/src/components/PlexProvider.tsx new file mode 100644 index 00000000..dc37f357 --- /dev/null +++ b/frontend/src/components/PlexProvider.tsx @@ -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 ). +// +// 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({ + scope: "both", + show: "all", + sort: "title", + filters: EMPTY_PLEX_FILTERS, + q: "", + playlistOpen: null, +}); +const ActionsContext = createContext({ + 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(() => { + const v = getAccountRaw(LS.plexLibrary) ?? "both"; + return ["movie", "show", "both"].includes(v) ? v : "both"; + }); + const [show, setShowState] = useState(() => getAccountRaw(LS.plexShow) ?? "all"); + const [sort, setSortState] = useState(() => getAccountRaw(LS.plexSort) ?? "title"); + // The expanded Plex filters live as one JSON blob (the string-only account store serializes it). + const [filtersRaw, setFiltersRaw] = useState(() => getAccountRaw(LS.plexFilters) ?? "{}"); + const filters = useMemo(() => { + 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(() => getAccountRaw(LS.plexQ) ?? ""); + const [playlistOpen, setPlaylistOpen] = useState(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( + () => ({ scope, show, sort, filters, q, playlistOpen }), + [scope, show, sort, filters, q, playlistOpen], + ); + const actions = useMemo( + () => ({ setScope, setShow, setSort, setFilters, setQ, setPlaylistOpen }), + [setScope, setShow, setSort, setFilters, setQ], + ); + + return ( + + {children} + + ); +} diff --git a/frontend/src/components/PlexSidebar.tsx b/frontend/src/components/PlexSidebar.tsx index 7cbbc528..d4639a69 100644 --- a/frontend/src/components/PlexSidebar.tsx +++ b/frontend/src/components/PlexSidebar.tsx @@ -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(""); diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 2da3a3a1..11efebc8 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -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.") 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 ?? []; diff --git a/frontend/src/lib/modules.ts b/frontend/src/lib/modules.ts index 05ef1019..ef4f43aa 100644 --- a/frontend/src/lib/modules.ts +++ b/frontend/src/lib/modules.ts @@ -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 = { - 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; + +// 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 = Object.fromEntries( + [...MODULES, SETTINGS_MODULE].map((m) => [m.id, m.labelKey]), +) as Record; + +// 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. diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 078b824a..a7445ab9 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -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 = - + + + + + + +