Merge Platform S6b.3: module registry drives the render (epic DoD)
Closes the Platform epic DoD. Three cross-cutting App states move into scoped providers (WizardProvider, FeedViewProvider, PrefsProvider) and the module registry (components/moduleRegistry) drives the render: App looks up PAGE_CONTENT[page]/PAGE_RAIL[page] gated by moduleDef(page).gate — the page ternary and the three side-rail page conditionals are gone. Content modules and rails are prop-free (read me via useMe(), module state from providers). App.tsx 796->431 lines, zero page===X chrome conditionals. E2E 17/17; /code-review clean; UAT passed.
This commit is contained in:
+44
-268
@@ -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 <Suspense> 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<ThemePrefs>(() => 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 <Feed> 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<FeedView>(() => 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<NotifSettings>(() => 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<EditablePrefs>({
|
||||
theme: loadLocalTheme(),
|
||||
performanceMode: getAccountRaw(PERF_KEY) === "1",
|
||||
hints: hintsEnabled(),
|
||||
notifications: getNotifSettings(),
|
||||
});
|
||||
const [prefsSaveState, setPrefsSaveState] = useState<PrefsController["saveState"]>("idle");
|
||||
const saveMsgTimer = useRef<number | undefined>(undefined);
|
||||
const saveTimer = useRef<number | undefined>(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 <Feed>/<ChannelPage>.
|
||||
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<Record<PanelId, PanelLayout>>(() => ({
|
||||
@@ -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<string | undefined>(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 <body> (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<EditablePrefs>(
|
||||
() => ({ 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<string, unknown>;
|
||||
// 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 (
|
||||
<div className="h-screen flex text-fg">
|
||||
<NavSidebar
|
||||
@@ -499,36 +338,23 @@ export default function App() {
|
||||
collapsed={navCollapsed}
|
||||
onToggleCollapse={() => 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 && (
|
||||
<Sidebar
|
||||
layout={panelLayouts.feed}
|
||||
setLayout={(l) => 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 && (
|
||||
<Suspense fallback={null}>
|
||||
<PlexSidebar
|
||||
layout={panelLayouts.plex}
|
||||
setLayout={(l) => setPanelLayout("plex", l)}
|
||||
collapsed={filterCollapsed}
|
||||
onToggleCollapse={() => setFilterCollapsed(!filterCollapsed)}
|
||||
<railDef.Rail
|
||||
layout={panelLayouts[railDef.panelId]}
|
||||
setLayout={(l) => setPanelLayout(railDef.panelId, l)}
|
||||
collapsed={railDef.collapse === "playlists" ? playlistsCollapsed : filterCollapsed}
|
||||
onToggleCollapse={() =>
|
||||
railDef.collapse === "playlists"
|
||||
? setPlaylistsCollapsed(!playlistsCollapsed)
|
||||
: setFilterCollapsed(!filterCollapsed)
|
||||
}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
{page === "playlists" && !channelView && (
|
||||
<PlaylistsRail
|
||||
me={meQuery.data!}
|
||||
layout={panelLayouts.playlists}
|
||||
setLayout={(l) => setPanelLayout("playlists", l)}
|
||||
collapsed={playlistsCollapsed}
|
||||
onToggleCollapse={() => setPlaylistsCollapsed(!playlistsCollapsed)}
|
||||
/>
|
||||
)}
|
||||
<div className="relative flex-1 min-w-0 flex flex-col">
|
||||
{channelView ? (
|
||||
<PageShell scrollKey={`channel:${channelView.id}`}>
|
||||
@@ -561,12 +387,8 @@ export default function App() {
|
||||
) : (
|
||||
<PageShell
|
||||
scrollKey={normalScrollKey}
|
||||
// Channels + Audit render a table with a sticky header at the scroller top; the top
|
||||
// fade would sit on it and flip on with the first scroll, so turn it off there.
|
||||
fadeTop={page !== "channels" && page !== "audit"}
|
||||
header={
|
||||
<Header me={meQuery.data!} onYtSearch={enterYtSearch} />
|
||||
}
|
||||
fadeTop={fadeTop ?? true}
|
||||
header={<Header me={meQuery.data!} onYtSearch={enterYtSearch} />}
|
||||
banners={
|
||||
<>
|
||||
{meQuery.data!.is_demo && <DemoBanner />}
|
||||
@@ -575,50 +397,7 @@ export default function App() {
|
||||
}
|
||||
>
|
||||
<Suspense fallback={pageFallback}>
|
||||
{page === "channels" ? (
|
||||
<Channels me={meQuery.data!} onOpenWizard={() => setWizardOpen(true)} />
|
||||
) : page === "stats" ? (
|
||||
<Stats me={meQuery.data!} />
|
||||
) : page === "scheduler" && meQuery.data!.role === "admin" ? (
|
||||
<Scheduler />
|
||||
) : page === "config" && meQuery.data!.role === "admin" ? (
|
||||
<ConfigPanel />
|
||||
) : page === "users" && meQuery.data!.role === "admin" ? (
|
||||
<AdminUsers me={meQuery.data!} />
|
||||
) : page === "audit" && meQuery.data!.role === "admin" ? (
|
||||
<AuditLog />
|
||||
) : page === "playlists" ? (
|
||||
<Playlists me={meQuery.data!} />
|
||||
) : page === "notifications" ? (
|
||||
<NotificationsPanel />
|
||||
) : page === "messages" && !meQuery.data!.is_demo ? (
|
||||
<div className="p-4 max-w-3xl w-full mx-auto">
|
||||
<Messages meId={meQuery.data!.id} />
|
||||
</div>
|
||||
) : page === "downloads" && !meQuery.data!.is_demo ? (
|
||||
<DownloadCenter me={meQuery.data!} />
|
||||
) : page === "settings" ? (
|
||||
<SettingsPanel
|
||||
me={meQuery.data!}
|
||||
prefs={prefsCtl}
|
||||
onOpenWizard={() => setWizardOpen(true)}
|
||||
/>
|
||||
) : page === "plex" && meQuery.data!.plex_enabled ? (
|
||||
<PlexBrowse />
|
||||
) : (
|
||||
<Feed
|
||||
filters={filters}
|
||||
setFilters={setFilters}
|
||||
view={view}
|
||||
setView={setView}
|
||||
canRead={meQuery.data!.can_read}
|
||||
isDemo={meQuery.data!.is_demo}
|
||||
onOpenWizard={() => setWizardOpen(true)}
|
||||
ytSearch={ytSearch}
|
||||
onYtSearch={enterYtSearch}
|
||||
onExitYtSearch={exitYtSearch}
|
||||
/>
|
||||
)}
|
||||
<PageContent />
|
||||
</Suspense>
|
||||
</PageShell>
|
||||
)}
|
||||
@@ -629,9 +408,6 @@ export default function App() {
|
||||
</div>
|
||||
{meQuery.data && !meQuery.data.is_demo && <ChatDock meId={meQuery.data.id} />}
|
||||
<Suspense fallback={null}>
|
||||
{wizardOpen && meQuery.data && !meQuery.data.is_demo && (
|
||||
<OnboardingWizard me={meQuery.data} onClose={() => setWizardOpen(false)} />
|
||||
)}
|
||||
{aboutOpen && (
|
||||
<About
|
||||
onClose={() => 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). */}
|
||||
<BackToTop dep={channelView ? `chan:${channelView.id}` : page} />
|
||||
{/* Opt-in live appearance tuner (Settings → Appearance; off by default). */}
|
||||
<GlassTuner enabled={theme.showTuner} />
|
||||
<GlassTuner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -398,7 +398,6 @@ export default function ChannelPage({
|
||||
setView={setView}
|
||||
canRead={me.can_read}
|
||||
isDemo={me.is_demo}
|
||||
onOpenWizard={() => {}}
|
||||
ytSearch={null}
|
||||
onYtSearch={() => {}}
|
||||
onExitYtSearch={() => {}}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 <App/>.
|
||||
//
|
||||
// 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<FeedView>("cards");
|
||||
const ActionsContext = createContext<FeedViewActions>({ 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<FeedView>(() => 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<FeedViewActions>(() => ({ setView }), [setView]);
|
||||
|
||||
return (
|
||||
<ActionsContext.Provider value={actions}>
|
||||
<StateContext.Provider value={view}>{children}</StateContext.Provider>
|
||||
</ActionsContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -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<Record<string, number>>(() => {
|
||||
const base: Record<string, number> = {};
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<PrefsController>({
|
||||
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<ThemePrefs>(() => loadLocalTheme());
|
||||
const [perf, setPerf] = useState(() => getAccountRaw(PERF_KEY) === "1");
|
||||
const [hints, setHints] = useState(() => hintsEnabled());
|
||||
const [notif, setNotif] = useState<NotifSettings>(() => 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<EditablePrefs>({
|
||||
theme: loadLocalTheme(),
|
||||
performanceMode: getAccountRaw(PERF_KEY) === "1",
|
||||
hints: hintsEnabled(),
|
||||
notifications: getNotifSettings(),
|
||||
});
|
||||
const [saveState, setSaveState] = useState<PrefsController["saveState"]>("idle");
|
||||
const saveMsgTimer = useRef<number | undefined>(undefined);
|
||||
const saveTimer = useRef<number | undefined>(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 <body> 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<EditablePrefs>(
|
||||
() => ({ 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<PrefsController>(
|
||||
() => ({ theme, setTheme, perf, setPerf, hints, setHints, notif, setNotif, saveState }),
|
||||
[theme, setTheme, perf, hints, notif, saveState],
|
||||
);
|
||||
|
||||
return <PrefsContext.Provider value={value}>{children}</PrefsContext.Provider>;
|
||||
}
|
||||
@@ -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({
|
||||
<div className="flex-1 min-w-0 p-4">
|
||||
{tab === "appearance" && <Appearance prefs={prefs} />}
|
||||
{tab === "notifications" && <Notifications prefs={prefs} />}
|
||||
{tab === "account" && <Account me={me} onOpenWizard={onOpenWizard} />}
|
||||
{tab === "account" && <Account me={me} onOpenWizard={openWizard} />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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.<id>") 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.
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 <App/>.
|
||||
//
|
||||
// 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<WizardActions>({ 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<WizardActions>(() => ({ openWizard, closeWizard }), [openWizard, closeWizard]);
|
||||
|
||||
return (
|
||||
<ActionsContext.Provider value={actions}>
|
||||
{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. */}
|
||||
<Suspense fallback={null}>
|
||||
{open && me && !me.is_demo && <OnboardingWizard me={me} onClose={closeWizard} />}
|
||||
</Suspense>
|
||||
</ActionsContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -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 `<Content />` 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 <Feed> 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 <Suspense>.
|
||||
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 <Feed> to the app-wide feed filters / view / live-search. (The channel
|
||||
// page renders <Feed> 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 (
|
||||
<Feed
|
||||
filters={filters}
|
||||
setFilters={setFilters}
|
||||
view={view}
|
||||
setView={setView}
|
||||
canRead={me.can_read}
|
||||
isDemo={me.is_demo}
|
||||
ytSearch={ytSearch}
|
||||
onYtSearch={enterYtSearch}
|
||||
onExitYtSearch={exitYtSearch}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function MessagesPage() {
|
||||
const me = useMe();
|
||||
return (
|
||||
<div className="p-4 max-w-3xl w-full mx-auto">
|
||||
<Messages meId={me.id} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<Page, PageDef> = {
|
||||
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<RailProps>;
|
||||
// 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<Record<Page, RailDef>> = {
|
||||
feed: { Rail: Sidebar, panelId: "feed", collapse: "filter" },
|
||||
plex: { Rail: PlexSidebar, panelId: "plex", collapse: "filter" },
|
||||
playlists: { Rail: PlaylistsRail, panelId: "playlists", collapse: "playlists" },
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
+10
-1
@@ -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 =
|
||||
<PlaylistsProvider>
|
||||
<PlexProvider>
|
||||
<ChannelsProvider>
|
||||
<App />
|
||||
<FeedViewProvider>
|
||||
<PrefsProvider>
|
||||
<WizardProvider>
|
||||
<App />
|
||||
</WizardProvider>
|
||||
</PrefsProvider>
|
||||
</FeedViewProvider>
|
||||
</ChannelsProvider>
|
||||
</PlexProvider>
|
||||
</PlaylistsProvider>
|
||||
|
||||
Reference in New Issue
Block a user