diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1db90502..93f003b9 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,4 +1,4 @@ -import { lazy, Suspense, useEffect, useMemo, useRef, useState } from "react"; +import { lazy, Suspense, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { @@ -8,14 +8,8 @@ import { setUnauthorizedHandler, } from "./lib/api"; import { setLanguage, isSupported, type LangCode } from "./i18n"; -import { - applyTheme, - DEFAULT_THEME, - loadLocalTheme, - saveLocalTheme, - type ThemePrefs, -} from "./lib/theme"; import { hasFilterParams, stripUrlParams } from "./lib/urlState"; +import type { Page } from "./lib/urlState"; import { loadLayout, normalizeLayout, @@ -24,26 +18,23 @@ import { type PanelId, type PanelLayout, } from "./lib/panelLayout"; -import { configureNotifications, getNotifSettings, notify, removeByMetaKind, type NotifSettings } from "./lib/notifications"; -import { hintsEnabled, setHintsEnabled } from "./lib/hints"; -import { parseFeedView, type FeedView } from "./lib/feedView"; +import { notify, removeByMetaKind } from "./lib/notifications"; import { - getAccountRaw, LS, readAccount, setAccountRaw, writeAccount, } from "./lib/storage"; -import type { PrefsController } from "./components/SettingsPanel"; import { useNavigation, useNavigationActions } from "./components/NavigationProvider"; import { useFeedFilters, useFeedFiltersActions } from "./components/FeedFiltersProvider"; +import { useFeedView, useFeedViewActions } from "./components/FeedViewProvider"; +import { moduleDef } from "./lib/modules"; +import { PAGE_CONTENT, PAGE_RAIL } from "./components/moduleRegistry"; // Persistent shell — always mounted, so eagerly imported. import Welcome from "./components/Welcome"; import SetupWizard from "./components/SetupWizard"; import Header from "./components/Header"; import NavSidebar from "./components/NavSidebar"; -import Sidebar from "./components/Sidebar"; -import PlaylistsRail from "./components/PlaylistsRail"; import BackToTop from "./components/BackToTop"; import PageShell from "./components/PageShell"; import GlassTuner from "./components/GlassTuner"; @@ -53,73 +44,28 @@ import ErrorDialog from "./components/ErrorDialog"; import VersionBanner from "./components/VersionBanner"; import DemoBanner from "./components/DemoBanner"; import { focusAccessRequestsTab } from "./lib/adminUsersTab"; -import { shouldAutoOpenOnboarding } from "./lib/onboarding"; import { CURRENT_VERSION } from "./lib/releaseNotes"; -// Route-level code splitting: each module page (and the heavier modals) is its own lazy chunk, -// so the initial load — and the logged-out landing — pulls only the shell, and admin-only pages -// never reach non-admins. All are rendered inside a boundary below. -const Feed = lazy(() => import("./components/Feed")); -const PlexBrowse = lazy(() => import("./components/PlexBrowse")); -const PlexSidebar = lazy(() => import("./components/PlexSidebar")); +// The page modules (and their side rails) are code-split per route via the module registry +// (components/moduleRegistry). App keeps only the persistent-shell chunks + the channel page and the +// two modals, which sit outside the registry-driven content column. const ChannelPage = lazy(() => import("./components/ChannelPage")); -const Channels = lazy(() => import("./components/Channels")); -const Playlists = lazy(() => import("./components/Playlists")); -const Stats = lazy(() => import("./components/Stats")); -const Scheduler = lazy(() => import("./components/Scheduler")); -const ConfigPanel = lazy(() => import("./components/ConfigPanel")); -const AdminUsers = lazy(() => import("./components/AdminUsers")); -const AuditLog = lazy(() => import("./components/AuditLog")); -const SettingsPanel = lazy(() => import("./components/SettingsPanel")); -const NotificationsPanel = lazy(() => import("./components/NotificationsPanel")); -const Messages = lazy(() => import("./components/Messages")); -const DownloadCenter = lazy(() => import("./components/DownloadCenter")); -const OnboardingWizard = lazy(() => import("./components/OnboardingWizard")); const About = lazy(() => import("./components/About")); const ReleaseNotes = lazy(() => import("./components/ReleaseNotes")); -const PERF_KEY = LS.perfMode; - -// The preferences edited on the Settings page. They apply locally for instant preview but -// persist to the server only on an explicit Save — so App holds both the live "draft" (the -// individual states below) and the last-saved "baseline" to compute dirty / revert on discard. -type EditablePrefs = { - theme: ThemePrefs; - performanceMode: boolean; - hints: boolean; - notifications: NotifSettings; -}; - export default function App() { const { t, i18n } = useTranslation(); const { page, channelView, ytSearch } = useNavigation(); - const { setPage, enterYtSearch, exitYtSearch } = useNavigationActions(); - const [theme, setThemeState] = useState(() => loadLocalTheme()); + const { setPage, enterYtSearch } = useNavigationActions(); // 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 // handlers that mutate the feed view (onShowInFeed, tag-click, the feed scrollKey). const filters = useFeedFilters(); const { setFilters } = useFeedFiltersActions(); - // The feed's view mode is picked from the feed's own toolbar; like every Settings pref it - // persists the moment you choose it. - const [view, setViewState] = useState(() => parseFeedView(readAccount(LS.feedView, null))); - // Settings-page prefs: applied live by the effects below, and auto-saved (debounced) on change - // — no draft, no Save button (see the auto-save effect). - const [perf, setPerf] = useState(() => getAccountRaw(PERF_KEY) === "1"); - const [hints, setHints] = useState(() => hintsEnabled()); - const [notif, setNotif] = useState(() => getNotifSettings()); - // The last server-known prefs. The same states are ALSO written when we adopt server prefs on - // login/account-switch, so the auto-save effect compares against this baseline to avoid echoing - // a server-adopted value straight back. A ref (drives no UI); updated on adopt + after each save. - const baselineRef = useRef({ - theme: loadLocalTheme(), - performanceMode: getAccountRaw(PERF_KEY) === "1", - hints: hintsEnabled(), - notifications: getNotifSettings(), - }); - const [prefsSaveState, setPrefsSaveState] = useState("idle"); - const saveMsgTimer = useRef(undefined); - const saveTimer = useRef(undefined); + // The feed's view mode (card/row/tile density) lives in FeedViewProvider — server-synced, shared + // by the feed page and the channel page. App reads it only to hand to /. + const view = useFeedView(); + const { setView } = useFeedViewActions(); // Per-panel "customize" layouts (order/collapsed/hidden of each side panel's group cards), // persisted per-account. One record so feed/plex/playlists share the same mechanism. const [panelLayouts, setPanelLayouts] = useState>(() => ({ @@ -127,7 +73,6 @@ export default function App() { plex: loadLayout("plex"), playlists: loadLayout("playlists"), })); - const [wizardOpen, setWizardOpen] = useState(false); const [aboutOpen, setAboutOpen] = useState(false); const [notesOpen, setNotesOpen] = useState(false); const [notesHighlight, setNotesHighlight] = useState(undefined); @@ -163,11 +108,6 @@ export default function App() { writeAccount(LS.navCollapsed, next); api.savePrefs({ navCollapsed: next }).catch(() => {}); } - function setView(next: FeedView) { - setViewState(next); - writeAccount(LS.feedView, next); - api.savePrefs({ view: next }).catch(() => {}); - } function setFilterCollapsed(next: boolean) { setFilterCollapsedState(next); writeAccount(LS.filterCollapsed, next); @@ -178,70 +118,6 @@ export default function App() { writeAccount(LS.playlistsCollapsed, next); api.savePrefs({ playlistsCollapsed: next }).catch(() => {}); } - useEffect(() => applyTheme(theme), [theme]); - - // Apply the draft prefs locally for instant preview (their localStorage mirrors update via - // the stores too); persistence to the server is deferred to an explicit Save. - useEffect(() => { - document.documentElement.dataset.perf = perf ? "1" : ""; - setAccountRaw(PERF_KEY, perf ? "1" : "0"); - }, [perf]); - - // The per-scheme background image (the "glass over image" look) is on when the user hasn't opted - // out and we're not in perf mode. Drives `html[data-backdrop]` (see index.css), which paints the - // theme-appropriate image on (dark or /light/ set) and switches the glass to translucent. - useEffect(() => { - const on = theme.bgImage && !perf; - document.documentElement.dataset.backdrop = on ? "on" : "off"; - }, [theme.bgImage, perf]); - useEffect(() => setHintsEnabled(hints), [hints]); - useEffect(() => configureNotifications(notif), [notif]); - - // Auto-save the Settings prefs on change (debounced ~500ms so a slider drag coalesces into one - // PUT). Live-apply is handled by the effects above; this is the only persistence path — there is - // no draft or Save button. `baselineRef` suppresses the echo when the change came from adopting - // server prefs on login (`/me` merges, so a full payload is fine). - const editablePrefs = useMemo( - () => ({ theme, performanceMode: perf, hints, notifications: notif }), - [theme, perf, hints, notif], - ); - useEffect(() => { - window.clearTimeout(saveTimer.current); - if (JSON.stringify(editablePrefs) === JSON.stringify(baselineRef.current)) { - // Back at the last-saved value (initial load, server adopt, or an edit undone) — nothing to - // save; clear any "Saving…" left from a change that was reverted before it flushed. - setPrefsSaveState((s) => (s === "saving" ? "idle" : s)); - return; - } - setPrefsSaveState("saving"); - window.clearTimeout(saveMsgTimer.current); - const payload = editablePrefs; - saveTimer.current = window.setTimeout(() => { - api - .savePrefs(payload) - .then(() => { - baselineRef.current = payload; - setPrefsSaveState("saved"); - saveMsgTimer.current = window.setTimeout(() => setPrefsSaveState("idle"), 2500); - }) - .catch(() => { - // api.req() already surfaces the failure; flag the indicator so the user sees it didn't take. - setPrefsSaveState("error"); - saveMsgTimer.current = window.setTimeout(() => setPrefsSaveState("idle"), 4000); - }); - }, 500); - return () => window.clearTimeout(saveTimer.current); - }, [editablePrefs]); - // Clear pending pref-save timers on unmount so a debounced PUT or a fade timer never fires - // setState on a dead component. - useEffect( - () => () => { - window.clearTimeout(saveTimer.current); - window.clearTimeout(saveMsgTimer.current); - }, - [], - ); - // Filters live in localStorage, not the address bar. If we arrived via a "Share view" // link, its params have already hydrated the initial state — persist them and strip the // query so the URL stays clean from here on. @@ -337,42 +213,13 @@ export default function App() { stripUrlParams(); }, [adminDeepLink, meQuery.data?.id]); // eslint-disable-line react-hooks/exhaustive-deps - // First-login onboarding: prompt the user to connect YouTube (and resume the flow after - // each consent redirect). Derived from granted scopes + storage flags so it's stable - // across the full-page OAuth round-trip; dismissible and reopenable from Settings. - useEffect(() => { - if (meQuery.data && shouldAutoOpenOnboarding(meQuery.data)) setWizardOpen(true); - }, [meQuery.data?.id, meQuery.data?.can_read, meQuery.data?.can_write]); - - // On login, adopt server-stored preferences. + // On login, adopt the server-stored SHELL preferences (panel layouts, rail collapse flags, + // language). The Settings-page prefs (theme/perf/hints/notifications) are adopted by PrefsProvider + // and the feed view by FeedViewProvider — each via its own passive ["me"] observer. useEffect(() => { const prefs = meQuery.data?.preferences; if (!prefs) return; - // Adopt the server prefs into the live states (the runtime effects above apply them for - // display) AND into the auto-save baseline, so adopting them doesn't echo a save back. - const adopted: EditablePrefs = { - theme: prefs.theme ? { ...DEFAULT_THEME, ...prefs.theme } : loadLocalTheme(), - performanceMode: - typeof prefs.performanceMode === "boolean" - ? prefs.performanceMode - : getAccountRaw(PERF_KEY) === "1", - hints: typeof prefs.hints === "boolean" ? prefs.hints : hintsEnabled(), - notifications: prefs.notifications - ? { ...getNotifSettings(), ...prefs.notifications } - : getNotifSettings(), - }; - setThemeState(adopted.theme); - saveLocalTheme(adopted.theme); - setPerf(adopted.performanceMode); - setHints(adopted.hints); - setNotif(adopted.notifications); - baselineRef.current = adopted; - // Out-of-scope prefs stay instant (edited outside the Settings page). const prefsRec = prefs as Record; - // parseFeedView also maps the pre-0.45 "grid" / "list" values forward. - const adoptedView = parseFeedView(prefs.view); - setViewState(adoptedView); - writeAccount(LS.feedView, adoptedView); (["feed", "plex", "playlists"] as PanelId[]).forEach((p) => { const raw = prefsRec[PREF_KEY[p]]; if (raw) { @@ -428,30 +275,12 @@ export default function App() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [meQuery.data?.role, meQuery.data?.pending_invites]); - // Theme applies live (and caches to localStorage); the auto-save effect persists it to the server. - function setTheme(next: ThemePrefs) { - setThemeState(next); - saveLocalTheme(next); - } // Language is edited outside the Settings page (sidebar/login), so it stays instant. function changeLanguage(code: LangCode) { setLanguage(code); api.savePrefs({ language: code }).catch(() => {}); } - const prefsCtl: PrefsController = { - theme, - setTheme, - perf, - setPerf, - hints, - setHints, - notif, - setNotif, - saveState: prefsSaveState, - }; - - // First-run: a fresh instance is in setup mode (all other routes are locked) → show the wizard. if (setupQuery.isLoading) return ( @@ -489,6 +318,16 @@ export default function App() { ? `yt:${ytSearch}` : `feed:${JSON.stringify({ ...filters, q: "" })}`; + // The module registry drives the render from `page` alone — no per-page chrome conditionals. A + // page the account can't reach (a gated module via stale nav / a deep link) falls back to the feed, + // exactly as the old ternary's fallthrough did. A channel page overlays the content column, so no + // side rail shows behind it. + const me = meQuery.data!; + const pageGate = moduleDef(page).gate; + const activePage: Page = !pageGate || pageGate(me) ? page : "feed"; + const { Component: PageContent, fadeTop } = PAGE_CONTENT[activePage]; + const railDef = channelView ? undefined : PAGE_RAIL[activePage]; + return (
setNavCollapsed(!navCollapsed)} /> - {/* Full-height filter sidebar: feed only, and hidden while a channel page overlays the - content column. Sits beside the nav rail as its own collapsible column. */} - {page === "feed" && !channelView && ( - setPanelLayout("feed", l)} - isDemo={meQuery.data!.is_demo} - collapsed={filterCollapsed} - onToggleCollapse={() => setFilterCollapsed(!filterCollapsed)} - /> - )} - {page === "plex" && meQuery.data!.plex_enabled && ( + {/* The active page's side rail (feed filters / plex filters / playlists list), if it has one. + A floating collapsible column beside the nav rail; hidden while a channel page overlays the + content. feed + plex share the filter-rail collapse flag, playlists has its own. */} + {railDef && ( - setPanelLayout("plex", l)} - collapsed={filterCollapsed} - onToggleCollapse={() => setFilterCollapsed(!filterCollapsed)} + setPanelLayout(railDef.panelId, l)} + collapsed={railDef.collapse === "playlists" ? playlistsCollapsed : filterCollapsed} + onToggleCollapse={() => + railDef.collapse === "playlists" + ? setPlaylistsCollapsed(!playlistsCollapsed) + : setFilterCollapsed(!filterCollapsed) + } /> )} - {page === "playlists" && !channelView && ( - setPanelLayout("playlists", l)} - collapsed={playlistsCollapsed} - onToggleCollapse={() => setPlaylistsCollapsed(!playlistsCollapsed)} - /> - )}
{channelView ? ( @@ -561,12 +387,8 @@ export default function App() { ) : ( - } + fadeTop={fadeTop ?? true} + header={
} banners={ <> {meQuery.data!.is_demo && } @@ -575,50 +397,7 @@ export default function App() { } > - {page === "channels" ? ( - setWizardOpen(true)} /> - ) : page === "stats" ? ( - - ) : page === "scheduler" && meQuery.data!.role === "admin" ? ( - - ) : page === "config" && meQuery.data!.role === "admin" ? ( - - ) : page === "users" && meQuery.data!.role === "admin" ? ( - - ) : page === "audit" && meQuery.data!.role === "admin" ? ( - - ) : page === "playlists" ? ( - - ) : page === "notifications" ? ( - - ) : page === "messages" && !meQuery.data!.is_demo ? ( -
- -
- ) : page === "downloads" && !meQuery.data!.is_demo ? ( - - ) : page === "settings" ? ( - setWizardOpen(true)} - /> - ) : page === "plex" && meQuery.data!.plex_enabled ? ( - - ) : ( - setWizardOpen(true)} - ytSearch={ytSearch} - onYtSearch={enterYtSearch} - onExitYtSearch={exitYtSearch} - /> - )} +
)} @@ -629,9 +408,6 @@ export default function App() {
{meQuery.data && !meQuery.data.is_demo && } - {wizardOpen && meQuery.data && !meQuery.data.is_demo && ( - setWizardOpen(false)} /> - )} {aboutOpen && ( setAboutOpen(false)} @@ -649,7 +425,7 @@ export default function App() { {/* Re-binds to the active page's scroll container on navigation (page or channel change). */} {/* Opt-in live appearance tuner (Settings → Appearance; off by default). */} - +
); } diff --git a/frontend/src/components/AdminUsers.tsx b/frontend/src/components/AdminUsers.tsx index e2fca998..ce618225 100644 --- a/frontend/src/components/AdminUsers.tsx +++ b/frontend/src/components/AdminUsers.tsx @@ -10,6 +10,7 @@ import { Section } from "./ui/form"; import { useConfirm } from "./ConfirmProvider"; import Tabs, { usePersistedTab } from "./Tabs"; import { PageToolbar } from "./PageShell"; +import { useMe } from "../lib/useMe"; // Admin-only user & access management: roles, access requests (the Invite whitelist), and the // demo whitelist + reset. Each section is its own tab (persisted across reloads); the Access tab @@ -17,7 +18,8 @@ import { PageToolbar } from "./PageShell"; // (The tab key + a "focus the Access tab" helper live in lib/adminUsersTab so callers can // pre-select the tab without importing this lazy-loaded page — see that file.) -export default function AdminUsers({ me }: { me: Me }) { +export default function AdminUsers() { + const me = useMe(); const { t } = useTranslation(); const [tab, setTab] = usePersistedTab(ADMIN_USERS_TAB_KEY, "roles"); const tabs = [ diff --git a/frontend/src/components/ChannelPage.tsx b/frontend/src/components/ChannelPage.tsx index 17cc45d0..ad549bc5 100644 --- a/frontend/src/components/ChannelPage.tsx +++ b/frontend/src/components/ChannelPage.tsx @@ -398,7 +398,6 @@ export default function ChannelPage({ setView={setView} canRead={me.can_read} isDemo={me.is_demo} - onOpenWizard={() => {}} ytSearch={null} onYtSearch={() => {}} onExitYtSearch={() => {}} diff --git a/frontend/src/components/Channels.tsx b/frontend/src/components/Channels.tsx index 3a19cdb8..93a69c46 100644 --- a/frontend/src/components/Channels.tsx +++ b/frontend/src/components/Channels.tsx @@ -15,7 +15,7 @@ import { UserMinus, X, } from "lucide-react"; -import { api, type ManagedChannel, type Me, type Tag } from "../lib/api"; +import { api, type ManagedChannel, type Tag } from "../lib/api"; import { notifyYouTubeActionError } from "../lib/youtubeErrors"; import { accountKey, LS } from "../lib/storage"; import { useDismiss } from "../lib/useDismiss"; @@ -33,6 +33,8 @@ import { useConfirm } from "./ConfirmProvider"; import { useChannels, useChannelsActions } from "./ChannelsProvider"; import { useNavigationActions } from "./NavigationProvider"; import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider"; +import { useWizard } from "./WizardProvider"; +import { useMe } from "../lib/useMe"; export type ChannelsView = "subscribed" | "discovery" | "blocked"; @@ -45,9 +47,12 @@ const STATUS_FILTERS: { id: ChannelStatusFilter; labelKey: string }[] = [ { id: "hidden", labelKey: "channels.filters.hidden" }, ]; -export default function Channels({ me, onOpenWizard }: { me: Me; onOpenWizard: () => void }) { +export default function Channels() { + const me = useMe(); const canWrite = me.can_write; const isAdmin = me.role === "admin"; + // "Connect YouTube" onboarding lives in WizardProvider (a notify action / a mutation-error CTA). + const { openWizard: onOpenWizard } = useWizard(); // 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. diff --git a/frontend/src/components/DownloadCenter.tsx b/frontend/src/components/DownloadCenter.tsx index a2c36647..5d734340 100644 --- a/frontend/src/components/DownloadCenter.tsx +++ b/frontend/src/components/DownloadCenter.tsx @@ -27,7 +27,8 @@ const VideoEditor = lazy(() => import("./VideoEditor")); const ShareDialog = lazy(() => import("./ShareDialog")); import { useConfirm } from "./ConfirmProvider"; import { useLiveQuery } from "../lib/useLiveQuery"; -import { api, type DownloadJob, type Me } from "../lib/api"; +import { useMe } from "../lib/useMe"; +import { api, type DownloadJob } from "../lib/api"; import { invalidateDownloads } from "../lib/downloads"; import { notify } from "../lib/notifications"; import { formatBytes, formatDuration, formatEta, formatSpeed } from "../lib/format"; @@ -539,7 +540,8 @@ function AdminSystem() { // --- Main ----------------------------------------------------------------------------------- -export default function DownloadCenter({ me }: { me: Me }) { +export default function DownloadCenter() { + const me = useMe(); const { t } = useTranslation(); const qc = useQueryClient(); const confirm = useConfirm(); diff --git a/frontend/src/components/Feed.tsx b/frontend/src/components/Feed.tsx index c0b10d19..af09312b 100644 --- a/frontend/src/components/Feed.tsx +++ b/frontend/src/components/Feed.tsx @@ -32,6 +32,7 @@ import { FEED_VIEWS, type FeedView } from "../lib/feedView"; import { clearedFilters, useActiveFilters } from "../lib/useActiveFilters"; import ActiveFilterChips from "./ActiveFilterChips"; import { PageToolbar } from "./PageShell"; +import { useWizard } from "./WizardProvider"; import ViewSwitcher from "./ViewSwitcher"; import VirtualFeed from "./VirtualFeed"; // Lazy: the in-app YouTube player (IFrame API) loads only when a video is first opened. @@ -85,7 +86,6 @@ export default function Feed({ setView, canRead, isDemo = false, - onOpenWizard, ytSearch, onYtSearch, onExitYtSearch, @@ -93,13 +93,13 @@ export default function Feed({ }: { filters: FeedFilters; setFilters: (f: FeedFilters) => void; - // The view mode is an account pref, not a filter: App owns it and it persists on pick, so it - // isn't part of FeedFilters (no share-link param, no saved view, no active-filter chip). + // The view mode is an account pref, not a filter: it persists on pick, so it isn't part of + // FeedFilters (no share-link param, no saved view, no active-filter chip). Provided by the caller + // (FeedViewProvider on the feed page; ChannelPage threads its own). view: FeedView; setView: (v: FeedView) => void; canRead: boolean; isDemo?: boolean; - onOpenWizard: () => void; // Live YouTube search: the active search term (null = normal feed). Entering a search and // leaving it both step browser history (the search is a feed sub-view with its own history // entry), so the empty-state CTA enters via onYtSearch and "back to feed" pops via @@ -112,6 +112,8 @@ export default function Feed({ channelScoped?: boolean; }) { const { t } = useTranslation(); + // "Connect YouTube" onboarding lives in WizardProvider (the empty-state CTA when not connected). + const { openWizard: onOpenWizard } = useWizard(); // Same list the panel's badge counts — see lib/useActiveFilters. const activeFilters = useActiveFilters(filters, setFilters); const sortKeys = channelScoped diff --git a/frontend/src/components/FeedViewProvider.tsx b/frontend/src/components/FeedViewProvider.tsx new file mode 100644 index 00000000..785a022b --- /dev/null +++ b/frontend/src/components/FeedViewProvider.tsx @@ -0,0 +1,69 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, + type ReactNode, +} from "react"; +import { useQuery } from "@tanstack/react-query"; +import { api } from "../lib/api"; +import { parseFeedView, type FeedView } from "../lib/feedView"; +import { LS, readAccount, writeAccount } from "../lib/storage"; + +// Owns the feed's view mode (the card/row/tile density switcher), lifted out of App so both the feed +// page and the channel page read it via hooks instead of prop-drilling `view`/`setView` through App. +// Split State + Actions contexts, mirroring the other providers; composed in main.tsx around . +// +// The view mode is an account PREFERENCE, not a filter: it persists to the server on pick (savePrefs) +// and is adopted from server prefs on login — so unlike the ephemeral list filters, this provider +// carries the same server-sync the App god component used to. A localStorage mirror seeds the initial +// render synchronously; the passive ["me"] observer adopts the server value once the account resolves. + +interface FeedViewActions { + setView: (v: FeedView) => void; +} + +const StateContext = createContext("cards"); +const ActionsContext = createContext({ setView: () => {} }); + +export function useFeedView(): FeedView { + return useContext(StateContext); +} +export function useFeedViewActions(): FeedViewActions { + return useContext(ActionsContext); +} + +export function FeedViewProvider({ children }: { children: ReactNode }) { + const [view, setViewState] = useState(() => parseFeedView(readAccount(LS.feedView, null))); + // Passive observer of the account App fetches (enabled:false → reads cache, re-renders on update). + const { data: me } = useQuery({ queryKey: ["me"], queryFn: api.me, enabled: false }); + + // A user pick persists both to the localStorage mirror (instant reload) and to the server. + const setView = useCallback((next: FeedView) => { + setViewState(next); + writeAccount(LS.feedView, next); + api.savePrefs({ view: next }).catch(() => {}); + }, []); + + // Adopt the server-stored view on login / account switch. Writes the mirror but does NOT savePrefs + // (that would echo the just-adopted value straight back). parseFeedView also maps the pre-0.45 + // "grid" / "list" values forward. + useEffect(() => { + const prefs = me?.preferences; + if (!prefs) return; + const adopted = parseFeedView(prefs.view); + setViewState(adopted); + writeAccount(LS.feedView, adopted); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [me?.id]); + + const actions = useMemo(() => ({ setView }), [setView]); + + return ( + + {children} + + ); +} diff --git a/frontend/src/components/GlassTuner.tsx b/frontend/src/components/GlassTuner.tsx index 7620eb7f..4b28d925 100644 --- a/frontend/src/components/GlassTuner.tsx +++ b/frontend/src/components/GlassTuner.tsx @@ -1,5 +1,6 @@ import { useEffect, useMemo, useState } from "react"; import Overlay from "./Overlay"; +import { usePrefs } from "./PrefsProvider"; /** * A floating live appearance tuner. Mounted at the app root so it's reachable on every page; it @@ -91,7 +92,9 @@ function loadSaved(): Saved { } } -export default function GlassTuner({ enabled }: { enabled: boolean }) { +export default function GlassTuner() { + // Opt-in via Settings → Appearance (theme.showTuner, off by default); the flag lives in PrefsProvider. + const enabled = usePrefs().theme.showTuner; const initial = useMemo(loadSaved, []); const [vars, setVars] = useState>(() => { const base: Record = {}; diff --git a/frontend/src/components/Playlists.tsx b/frontend/src/components/Playlists.tsx index cd7575bf..5e2c9fc2 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 Me, type Video } from "../lib/api"; +import { api, type Video } from "../lib/api"; import { formatDuration } from "../lib/format"; import { notify } from "../lib/notifications"; import { notifyYouTubeActionError } from "../lib/youtubeErrors"; @@ -41,6 +41,7 @@ import UndoToolbar from "./UndoToolbar"; import { PageToolbar } from "./PageShell"; import { useConfirm } from "./ConfirmProvider"; import { usePlaylists, usePlaylistsActions } from "./PlaylistsProvider"; +import { useMe } from "../lib/useMe"; type SortKey = "manual" | "title" | "duration" | "channel"; type SortDir = "asc" | "desc"; @@ -179,7 +180,8 @@ function Row({ ); } -export default function Playlists({ me }: { me: Me }) { +export default function Playlists() { + const me = useMe(); 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. diff --git a/frontend/src/components/PlaylistsRail.tsx b/frontend/src/components/PlaylistsRail.tsx index 1dd85c4e..d7f09d21 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 Me, type Playlist } from "../lib/api"; +import { api, type Playlist } from "../lib/api"; import { formatDuration } from "../lib/format"; import { notify } from "../lib/notifications"; import { notifyYouTubeActionError } from "../lib/youtubeErrors"; @@ -11,6 +11,7 @@ import { defaultLayout, type PanelLayout } from "../lib/panelLayout"; import SidePanel from "./SidePanel"; import PanelGroups, { type PanelItem } from "./PanelGroups"; import { usePlaylists, usePlaylistsActions } from "./PlaylistsProvider"; +import { useMe } from "../lib/useMe"; type PlSort = { key: "custom" | "name" | "count" | "duration"; dir: "asc" | "desc"; dirtyFirst: boolean }; const PL_SORT_DEFAULT: PlSort = { key: "custom", dir: "asc", dirtyFirst: false }; @@ -20,19 +21,17 @@ 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({ - me, layout, setLayout, collapsed, onToggleCollapse, }: { - me: Me; layout: PanelLayout; setLayout: (l: PanelLayout) => void; collapsed: boolean; onToggleCollapse: () => void; }) { - const canWrite = me.can_write; + const canWrite = useMe().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(); diff --git a/frontend/src/components/PrefsProvider.tsx b/frontend/src/components/PrefsProvider.tsx new file mode 100644 index 00000000..18f3c5a3 --- /dev/null +++ b/frontend/src/components/PrefsProvider.tsx @@ -0,0 +1,171 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from "react"; +import { useQuery } from "@tanstack/react-query"; +import { api } from "../lib/api"; +import { + applyTheme, + DEFAULT_THEME, + loadLocalTheme, + saveLocalTheme, + type ThemePrefs, +} from "../lib/theme"; +import { configureNotifications, getNotifSettings, type NotifSettings } from "../lib/notifications"; +import { hintsEnabled, setHintsEnabled } from "../lib/hints"; +import { getAccountRaw, LS, setAccountRaw } from "../lib/storage"; +import type { PrefsController } from "./SettingsPanel"; + +const PERF_KEY = LS.perfMode; + +// The four Settings-page preferences. They apply locally for instant preview and auto-save (debounced) +// to the server — there is no draft or Save button. This provider owns them (lifted out of the App god +// component) plus the last-saved "baseline" used to suppress the echo when a change came from adopting +// server prefs on login. Consumers (SettingsPanel, GlassTuner) read the PrefsController via usePrefs(). +type EditablePrefs = { + theme: ThemePrefs; + performanceMode: boolean; + hints: boolean; + notifications: NotifSettings; +}; + +const PrefsContext = createContext({ + theme: DEFAULT_THEME, + setTheme: () => {}, + perf: false, + setPerf: () => {}, + hints: true, + setHints: () => {}, + notif: getNotifSettings(), + setNotif: () => {}, + saveState: "idle", +}); + +export function usePrefs(): PrefsController { + return useContext(PrefsContext); +} + +export function PrefsProvider({ children }: { children: ReactNode }) { + const [theme, setThemeState] = useState(() => loadLocalTheme()); + const [perf, setPerf] = useState(() => getAccountRaw(PERF_KEY) === "1"); + const [hints, setHints] = useState(() => hintsEnabled()); + const [notif, setNotif] = useState(() => getNotifSettings()); + // The last server-known prefs. Written on adopt + after each save so the auto-save effect can + // compare against it and avoid echoing a server-adopted value straight back. A ref (drives no UI). + const baselineRef = useRef({ + theme: loadLocalTheme(), + performanceMode: getAccountRaw(PERF_KEY) === "1", + hints: hintsEnabled(), + notifications: getNotifSettings(), + }); + const [saveState, setSaveState] = useState("idle"); + const saveMsgTimer = useRef(undefined); + const saveTimer = useRef(undefined); + // Passive observer of the account App fetches (enabled:false → reads cache, re-renders on update). + const { data: me } = useQuery({ queryKey: ["me"], queryFn: api.me, enabled: false }); + + // Live-apply the prefs locally for instant preview (their localStorage mirrors update via the + // stores too); persistence to the server is the auto-save effect below. + useEffect(() => applyTheme(theme), [theme]); + useEffect(() => { + document.documentElement.dataset.perf = perf ? "1" : ""; + setAccountRaw(PERF_KEY, perf ? "1" : "0"); + }, [perf]); + // The per-scheme background image (the "glass over image" look) is on when the user hasn't opted + // out and we're not in perf mode. Drives `html[data-backdrop]` (see index.css), which paints the + // theme-appropriate image on and switches the glass to translucent. + useEffect(() => { + const on = theme.bgImage && !perf; + document.documentElement.dataset.backdrop = on ? "on" : "off"; + }, [theme.bgImage, perf]); + useEffect(() => setHintsEnabled(hints), [hints]); + useEffect(() => configureNotifications(notif), [notif]); + + // Auto-save on change (debounced ~500ms so a slider drag coalesces into one PUT). Live-apply is the + // effects above; this is the only persistence path. `baselineRef` suppresses the echo when the + // change came from adopting server prefs on login (`/me` merges, so a full payload is fine). + const editablePrefs = useMemo( + () => ({ theme, performanceMode: perf, hints, notifications: notif }), + [theme, perf, hints, notif], + ); + useEffect(() => { + window.clearTimeout(saveTimer.current); + if (JSON.stringify(editablePrefs) === JSON.stringify(baselineRef.current)) { + // Back at the last-saved value (initial load, server adopt, or an edit undone) — nothing to + // save; clear any "Saving…" left from a change that was reverted before it flushed. + setSaveState((s) => (s === "saving" ? "idle" : s)); + return; + } + setSaveState("saving"); + window.clearTimeout(saveMsgTimer.current); + const payload = editablePrefs; + saveTimer.current = window.setTimeout(() => { + api + .savePrefs(payload) + .then(() => { + baselineRef.current = payload; + setSaveState("saved"); + saveMsgTimer.current = window.setTimeout(() => setSaveState("idle"), 2500); + }) + .catch(() => { + // api.req() already surfaces the failure; flag the indicator so the user sees it didn't take. + setSaveState("error"); + saveMsgTimer.current = window.setTimeout(() => setSaveState("idle"), 4000); + }); + }, 500); + return () => window.clearTimeout(saveTimer.current); + }, [editablePrefs]); + // Clear pending timers on unmount so a debounced PUT or a fade timer never fires setState on a dead + // component. + useEffect( + () => () => { + window.clearTimeout(saveTimer.current); + window.clearTimeout(saveMsgTimer.current); + }, + [], + ); + + // On login / account switch, adopt the server-stored prefs into the live states AND the auto-save + // baseline (so adopting them doesn't echo a save back). + useEffect(() => { + const prefs = me?.preferences; + if (!prefs) return; + const adopted: EditablePrefs = { + theme: prefs.theme ? { ...DEFAULT_THEME, ...prefs.theme } : loadLocalTheme(), + performanceMode: + typeof prefs.performanceMode === "boolean" + ? prefs.performanceMode + : getAccountRaw(PERF_KEY) === "1", + hints: typeof prefs.hints === "boolean" ? prefs.hints : hintsEnabled(), + notifications: prefs.notifications + ? { ...getNotifSettings(), ...prefs.notifications } + : getNotifSettings(), + }; + setThemeState(adopted.theme); + saveLocalTheme(adopted.theme); + setPerf(adopted.performanceMode); + setHints(adopted.hints); + setNotif(adopted.notifications); + baselineRef.current = adopted; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [me?.id]); + + // Theme applies live (and caches to localStorage); the auto-save effect persists it to the server. + const setTheme = useCallback((next: ThemePrefs) => { + setThemeState(next); + saveLocalTheme(next); + }, []); + + const value = useMemo( + () => ({ theme, setTheme, perf, setPerf, hints, setHints, notif, setNotif, saveState }), + [theme, setTheme, perf, hints, notif, saveState], + ); + + return {children}; +} diff --git a/frontend/src/components/SettingsPanel.tsx b/frontend/src/components/SettingsPanel.tsx index dc44ac50..36bb30fb 100644 --- a/frontend/src/components/SettingsPanel.tsx +++ b/frontend/src/components/SettingsPanel.tsx @@ -11,6 +11,9 @@ import Tooltip from "./Tooltip"; import { Section, SettingRow, Switch } from "./ui/form"; import { type SaveState } from "./ui/DraftSaveBar"; import { useConfirm } from "./ConfirmProvider"; +import { useWizard } from "./WizardProvider"; +import { usePrefs } from "./PrefsProvider"; +import { useMe } from "../lib/useMe"; // The Settings page edits server-persisted preferences. Each change applies locally for instant // preview AND auto-saves (debounced) — no draft, no Save button; a small indicator flashes the @@ -39,16 +42,13 @@ const TABS: { id: TabId; icon: typeof Monitor }[] = [ // Settings as a page (Design B): rendered in the main content area, not an overlay. It keeps // the frosted `.glass` surface so it still refracts the ambient backdrop. The page title is // shown by the Header; the tab rail stays as in-page sub-navigation. -export default function SettingsPanel({ - me, - prefs, - onOpenWizard, -}: { - me: Me; - prefs: PrefsController; - onOpenWizard: () => void; -}) { +export default function SettingsPanel() { + const me = useMe(); const { t } = useTranslation(); + // The editable prefs (theme/perf/hints/notifications + save state) live in PrefsProvider. + const prefs = usePrefs(); + // "Connect YouTube" onboarding lives in WizardProvider (the Account tab's reconnect button). + const { openWizard } = useWizard(); const [tabRaw, setTab] = useAccountPersistedState(LS.settingsTab, "appearance"); // Clamp at render so a stale/removed tab id falls back to Appearance. const tab: TabId = TABS.some((tabItem) => tabItem.id === tabRaw) @@ -87,7 +87,7 @@ export default function SettingsPanel({
{tab === "appearance" && } {tab === "notifications" && } - {tab === "account" && } + {tab === "account" && }
diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 11efebc8..126ae058 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -15,6 +15,7 @@ import PanelGroups, { type PanelItem } from "./PanelGroups"; import ChannelPicker from "./ChannelPicker"; import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider"; import { useChannelsActions } from "./ChannelsProvider"; +import { useMe } from "../lib/useMe"; // Filter ids; display labels resolved at render time via t("sidebar.sort.") etc. const DATE_PRESETS: { days: number; key: string }[] = [ @@ -64,17 +65,16 @@ function TagChip({ export default function Sidebar({ layout, setLayout, - isDemo = false, collapsed, onToggleCollapse, }: { layout: PanelLayout; setLayout: (l: PanelLayout) => void; - isDemo?: boolean; collapsed: boolean; onToggleCollapse: () => void; }) { const { t } = useTranslation(); + const isDemo = useMe().is_demo; const filters = useFeedFilters(); const { setFilters } = useFeedFiltersActions(); // "Focus this channel in the manager" (from the tag manager here) lives in ChannelsProvider. diff --git a/frontend/src/components/Stats.tsx b/frontend/src/components/Stats.tsx index 74ff8556..4c22d383 100644 --- a/frontend/src/components/Stats.tsx +++ b/frontend/src/components/Stats.tsx @@ -8,6 +8,7 @@ import { notify } from "../lib/notifications"; import { LS, useAccountPersistedState } from "../lib/storage"; import Tooltip from "./Tooltip"; import { Section, SettingRow } from "./ui/form"; +import { useMe } from "../lib/useMe"; const RANGES = [7, 30, 90] as const; type StatsTab = "overview" | "system"; @@ -15,7 +16,8 @@ type StatsTab = "overview" | "system"; // Usage & stats page. "Overview" is per-user (sync status + your own API usage + actions); // "System" is the admin-only instance-wide quota dashboard + background-sync control. The // per-user content used to live in Settings → Sync; it moved here so Settings stays config-only. -export default function Stats({ me }: { me: Me }) { +export default function Stats() { + const me = useMe(); const { t } = useTranslation(); const isAdmin = me.role === "admin"; const [tabRaw, setTab] = useAccountPersistedState(LS.statsTab, "overview"); diff --git a/frontend/src/components/WizardProvider.tsx b/frontend/src/components/WizardProvider.tsx new file mode 100644 index 00000000..c9cd42c1 --- /dev/null +++ b/frontend/src/components/WizardProvider.tsx @@ -0,0 +1,66 @@ +import { + createContext, + lazy, + Suspense, + useCallback, + useContext, + useEffect, + useMemo, + useState, + type ReactNode, +} from "react"; +import { useQuery } from "@tanstack/react-query"; +import { api } from "../lib/api"; +import { shouldAutoOpenOnboarding } from "../lib/onboarding"; + +const OnboardingWizard = lazy(() => import("./OnboardingWizard")); + +// Owns the onboarding wizard's open state (and the wizard render itself), lifted out of App so any +// module can trigger "connect YouTube" via a hook instead of an onOpenWizard prop threaded through +// the tree. Mirrors the other split-context providers; composed in main.tsx around . +// +// The wizard auto-opens on first login for accounts that still need to connect YouTube (derived from +// granted scopes + storage flags, so it's stable across the OAuth round-trip) and is reopenable from +// Settings / the notify "reconnect" action. It reads `me` as a passive ["me"] observer (App owns the +// fetching query), so it re-runs the auto-open check when the account resolves or its grants change. + +interface WizardActions { + openWizard: () => void; + closeWizard: () => void; +} + +// Only the actions are shared — the open flag stays local to the provider (it renders the wizard +// itself), so no consumer needs to subscribe to it. +const ActionsContext = createContext({ openWizard: () => {}, closeWizard: () => {} }); + +export function useWizard(): WizardActions { + return useContext(ActionsContext); +} + +export function WizardProvider({ children }: { children: ReactNode }) { + const [open, setOpen] = useState(false); + // Passive observer of the account App fetches (enabled:false → reads cache, re-renders on update). + const { data: me } = useQuery({ queryKey: ["me"], queryFn: api.me, enabled: false }); + + const openWizard = useCallback(() => setOpen(true), []); + const closeWizard = useCallback(() => setOpen(false), []); + + // First-login onboarding: prompt the user to connect YouTube (and resume the flow after each + // consent redirect). Re-checked when the account or its grants change. + useEffect(() => { + if (me && shouldAutoOpenOnboarding(me)) setOpen(true); + }, [me?.id, me?.can_read, me?.can_write]); + + const actions = useMemo(() => ({ openWizard, closeWizard }), [openWizard, closeWizard]); + + return ( + + {children} + {/* The wizard is a full-screen overlay; the demo account can't connect YouTube, so it never + opens there. Lazy — its chunk loads only when first opened. */} + + {open && me && !me.is_demo && } + + + ); +} diff --git a/frontend/src/components/moduleRegistry.tsx b/frontend/src/components/moduleRegistry.tsx new file mode 100644 index 00000000..80eecfa1 --- /dev/null +++ b/frontend/src/components/moduleRegistry.tsx @@ -0,0 +1,121 @@ +import { lazy, type ComponentType } from "react"; +import type { Page } from "../lib/urlState"; +import type { PanelId, PanelLayout } from "../lib/panelLayout"; +import { useMe } from "../lib/useMe"; +import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider"; +import { useFeedView, useFeedViewActions } from "./FeedViewProvider"; +import { useNavigation, useNavigationActions } from "./NavigationProvider"; +import Sidebar from "./Sidebar"; +import PlaylistsRail from "./PlaylistsRail"; + +// The RENDER half of the module registry: which component (and side rail) each page renders. The +// metadata half — nav order, label, icon, gate — lives in lib/modules.ts (imported by NavSidebar / +// Header / App's gate check). Together they let App drive the whole shell from `page` alone, with no +// `page === X` chrome conditionals. +// +// Every content entry is a PROP-FREE component: it reads `me` from useMe() and its module-local state +// from the scoped providers, so App renders `` uniformly. The two that need per-render +// wiring get a thin wrapper here (FeedPage supplies the GLOBAL feed scope; the channel page threads +// its own isolated scope to directly — see the scope note in FeedFiltersProvider). + +// Route-level code splitting: each page is its own lazy chunk (admin-only pages never reach +// non-admins; the logged-out landing pulls only the shell). Rendered inside App's . +const Feed = lazy(() => import("./Feed")); +const Channels = lazy(() => import("./Channels")); +const Playlists = lazy(() => import("./Playlists")); +const PlexBrowse = lazy(() => import("./PlexBrowse")); +const PlexSidebar = lazy(() => import("./PlexSidebar")); +const NotificationsPanel = lazy(() => import("./NotificationsPanel")); +const Messages = lazy(() => import("./Messages")); +const DownloadCenter = lazy(() => import("./DownloadCenter")); +const Stats = lazy(() => import("./Stats")); +const Scheduler = lazy(() => import("./Scheduler")); +const ConfigPanel = lazy(() => import("./ConfigPanel")); +const AdminUsers = lazy(() => import("./AdminUsers")); +const AuditLog = lazy(() => import("./AuditLog")); +const SettingsPanel = lazy(() => import("./SettingsPanel")); + +// The GLOBAL feed page: wires to the app-wide feed filters / view / live-search. (The channel +// page renders itself with its own channel-scoped filters, so those two never share state.) +function FeedPage() { + const me = useMe(); + const filters = useFeedFilters(); + const { setFilters } = useFeedFiltersActions(); + const view = useFeedView(); + const { setView } = useFeedViewActions(); + const { ytSearch } = useNavigation(); + const { enterYtSearch, exitYtSearch } = useNavigationActions(); + return ( + + ); +} + +function MessagesPage() { + const me = useMe(); + return ( +
+ +
+ ); +} + +export type PageDef = { + // Prop-free by construction: reads `me` + module state from hooks/providers. + Component: ComponentType; + // Channels + Audit render a table with a sticky header at the scroller top; the shell's top fade + // would sit on it and flip on with the first scroll, so they turn it off. Defaults to on. + fadeTop?: boolean; +}; + +// page id → the component App renders in the content column (+ its shell chrome config). +export const PAGE_CONTENT: Record = { + feed: { Component: FeedPage }, + channels: { Component: Channels, fadeTop: false }, + playlists: { Component: Playlists }, + plex: { Component: PlexBrowse }, + notifications: { Component: NotificationsPanel }, + messages: { Component: MessagesPage }, + downloads: { Component: DownloadCenter }, + stats: { Component: Stats }, + scheduler: { Component: Scheduler }, + config: { Component: ConfigPanel }, + users: { Component: AdminUsers }, + audit: { Component: AuditLog, fadeTop: false }, + settings: { Component: SettingsPanel }, +}; + +// The uniform side-rail contract — shell layout/collapse only; the rail reads its module state from +// its provider and `me` from useMe(). +type RailProps = { + layout: PanelLayout; + setLayout: (l: PanelLayout) => void; + collapsed: boolean; + onToggleCollapse: () => void; +}; + +// Which collapse flag a rail uses (feed + plex share the filter rail's flag; playlists has its own). +type RailCollapseKey = "filter" | "playlists"; + +export type RailDef = { + Rail: ComponentType; + // The persisted panel-layout record this rail edits (App owns the panelLayouts map). + panelId: PanelId; + collapse: RailCollapseKey; +}; + +// page id → its side rail (only the three pages that have one). App renders it in the rail slot. +export const PAGE_RAIL: Partial> = { + feed: { Rail: Sidebar, panelId: "feed", collapse: "filter" }, + plex: { Rail: PlexSidebar, panelId: "plex", collapse: "filter" }, + playlists: { Rail: PlaylistsRail, panelId: "playlists", collapse: "playlists" }, +}; diff --git a/frontend/src/lib/useMe.ts b/frontend/src/lib/useMe.ts new file mode 100644 index 00000000..dd5d7809 --- /dev/null +++ b/frontend/src/lib/useMe.ts @@ -0,0 +1,13 @@ +import { useQuery } from "@tanstack/react-query"; +import { api, type Me } from "./api"; + +// The signed-in account, read from the ["me"] query cache. App owns the FETCHING query (gated on +// setup state) and renders modules only once it has resolved to a real user — so anywhere inside the +// authenticated module tree the cache is always populated. `enabled:false` makes this a passive +// observer (no second fetch, which could 503 in setup mode) that still re-renders when the cache +// updates (login, the 60s refetch, a Google-link round-trip). Lets modules read `me` from a hook +// instead of having it prop-drilled through App. +export function useMe(): Me { + const { data } = useQuery({ queryKey: ["me"], queryFn: api.me, enabled: false }); + return data as Me; +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index a7445ab9..553b4367 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -8,6 +8,9 @@ import { FeedFiltersProvider } from "./components/FeedFiltersProvider"; import { PlaylistsProvider } from "./components/PlaylistsProvider"; import { PlexProvider } from "./components/PlexProvider"; import { ChannelsProvider } from "./components/ChannelsProvider"; +import { FeedViewProvider } from "./components/FeedViewProvider"; +import { PrefsProvider } from "./components/PrefsProvider"; +import { WizardProvider } from "./components/WizardProvider"; import "./i18n"; import "./index.css"; @@ -45,7 +48,13 @@ const root = - + + + + + + +