diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c6a473e..8d0376b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +import { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { @@ -40,36 +40,43 @@ import { } from "./lib/storage"; import { useConfirm } from "./components/ConfirmProvider"; import type { PrefsController } from "./components/SettingsPanel"; +import type { ChannelStatusFilter, ChannelsView } from "./components/Channels"; +// 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 Feed from "./components/Feed"; -import ChannelPage from "./components/ChannelPage"; import BackToTop from "./components/BackToTop"; -import Channels, { type ChannelStatusFilter, type ChannelsView } from "./components/Channels"; -import Playlists from "./components/Playlists"; -import Stats from "./components/Stats"; -import Scheduler from "./components/Scheduler"; -import ConfigPanel from "./components/ConfigPanel"; -import AdminUsers, { focusAccessRequestsTab } from "./components/AdminUsers"; -import SettingsPanel from "./components/SettingsPanel"; -import NotificationsPanel from "./components/NotificationsPanel"; -import Messages from "./components/Messages"; -import DownloadCenter from "./components/DownloadCenter"; -import { setNavigator } from "./lib/nav"; import ChatDock from "./components/ChatDock"; -import OnboardingWizard from "./components/OnboardingWizard"; -import { shouldAutoOpenOnboarding } from "./lib/onboarding"; import Toaster from "./components/Toaster"; import ErrorDialog from "./components/ErrorDialog"; -import About from "./components/About"; -import ReleaseNotes from "./components/ReleaseNotes"; import VersionBanner from "./components/VersionBanner"; import DemoBanner from "./components/DemoBanner"; +import { setNavigator } from "./lib/nav"; +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 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 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 DEFAULT_FILTERS: FeedFilters = { tags: [], tagMode: "or", @@ -656,6 +663,11 @@ export default function App() { ); if (!meQuery.data) return ; + // Shown briefly while a lazy page/module chunk loads (first visit to that page only). + const pageFallback = ( +
{t("common.loading")}
+ ); + return (
{channelView ? ( - + + + ) : ( <>
} openReleaseNotes(CURRENT_VERSION)} />
+ {page === "channels" ? ( )} +
)} @@ -777,21 +793,23 @@ export default function App() {
{meQuery.data && !meQuery.data.is_demo && } - {wizardOpen && meQuery.data && !meQuery.data.is_demo && ( - setWizardOpen(false)} /> - )} - {aboutOpen && ( - setAboutOpen(false)} - onOpenReleaseNotes={() => { - setAboutOpen(false); - openReleaseNotes(); - }} - /> - )} - {notesOpen && ( - setNotesOpen(false)} highlight={notesHighlight} /> - )} + + {wizardOpen && meQuery.data && !meQuery.data.is_demo && ( + setWizardOpen(false)} /> + )} + {aboutOpen && ( + setAboutOpen(false)} + onOpenReleaseNotes={() => { + setAboutOpen(false); + openReleaseNotes(); + }} + /> + )} + {notesOpen && ( + setNotesOpen(false)} highlight={notesHighlight} /> + )} + {/* Re-binds to the active page's scroll container on navigation (page or channel change). */} diff --git a/frontend/src/components/AdminUsers.tsx b/frontend/src/components/AdminUsers.tsx index 9e43dec..20b69af 100644 --- a/frontend/src/components/AdminUsers.tsx +++ b/frontend/src/components/AdminUsers.tsx @@ -4,7 +4,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Ban, Check, RotateCcw, Shield, Trash2, UserPlus, X } from "lucide-react"; import { api, type AdminUserRow, type Invite, type Me } from "../lib/api"; import { notify } from "../lib/notifications"; -import { LS, setAccountRaw } from "../lib/storage"; +import { ADMIN_USERS_TAB_KEY } from "../lib/adminUsersTab"; import Tooltip from "./Tooltip"; import { Section } from "./ui/form"; import { useConfirm } from "./ConfirmProvider"; @@ -13,15 +13,8 @@ import Tabs, { usePersistedTab } from "./Tabs"; // 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 // carries a badge with the count of pending requests so it's visible without switching to it. -// The persisted-tab storage key + the access-requests tab id, exported so a notification's -// "Review" link can pre-select that tab before navigating here (usePersistedTab reads the key -// on mount, and this page mounts fresh on navigation). -export const ADMIN_USERS_TAB_KEY = LS.adminUsersTab; -export const ADMIN_USERS_ACCESS_TAB = "access"; - -export function focusAccessRequestsTab(): void { - setAccountRaw(ADMIN_USERS_TAB_KEY, ADMIN_USERS_ACCESS_TAB); -} +// (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 }) { const { t } = useTranslation(); diff --git a/frontend/src/components/DownloadButton.tsx b/frontend/src/components/DownloadButton.tsx index 82e362e..81cee4e 100644 --- a/frontend/src/components/DownloadButton.tsx +++ b/frontend/src/components/DownloadButton.tsx @@ -1,9 +1,11 @@ -import { useState } from "react"; +import { lazy, Suspense, useState } from "react"; import { useTranslation } from "react-i18next"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { Download } from "lucide-react"; import clsx from "clsx"; -import DownloadDialog from "./DownloadDialog"; +// Lazy: the download dialog (+ its profile editor) loads only when a card's download button is +// clicked, so it never ships in the feed chunk for the majority who just browse. +const DownloadDialog = lazy(() => import("./DownloadDialog")); import { api, type Me } from "../lib/api"; // Self-contained download affordance for a video card / the player. Hidden for the demo account @@ -59,7 +61,9 @@ export default function DownloadButton({ {open && ( - setOpen(false)} /> + + setOpen(false)} /> + )} ); diff --git a/frontend/src/components/DownloadCenter.tsx b/frontend/src/components/DownloadCenter.tsx index 2950b62..85e0834 100644 --- a/frontend/src/components/DownloadCenter.tsx +++ b/frontend/src/components/DownloadCenter.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from "react"; +import { lazy, Suspense, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { @@ -16,10 +16,12 @@ import { import clsx from "clsx"; import Tabs, { usePersistedTab } from "./Tabs"; import Modal from "./Modal"; -import DownloadDialog from "./DownloadDialog"; -import ProfileEditor from "./ProfileEditor"; -import VideoEditor from "./VideoEditor"; -import ShareDialog from "./ShareDialog"; +// Lazy: these dialogs/editors open on demand, so they stay out of the Downloads page chunk until +// used (the video editor in particular is heavy — filmstrip + scrubber). +const DownloadDialog = lazy(() => import("./DownloadDialog")); +const ProfileEditor = lazy(() => import("./ProfileEditor")); +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"; @@ -582,19 +584,21 @@ export default function DownloadCenter({ me }: { me: Me }) { {tab === "system" && me.role === "admin" && } - {addSource && ( - { - setAddSource(null); - setAddUrl(""); - }} - /> - )} - {manage && setManage(false)} />} - {renameJob && setRenameJob(null)} />} - {shareJob && setShareJob(null)} />} - {editJob && setEditJob(null)} />} + + {addSource && ( + { + setAddSource(null); + setAddUrl(""); + }} + /> + )} + {manage && setManage(false)} />} + {renameJob && setRenameJob(null)} />} + {shareJob && setShareJob(null)} />} + {editJob && setEditJob(null)} />} + ); } diff --git a/frontend/src/components/DownloadDialog.tsx b/frontend/src/components/DownloadDialog.tsx index 1f27474..6c9e87b 100644 --- a/frontend/src/components/DownloadDialog.tsx +++ b/frontend/src/components/DownloadDialog.tsx @@ -1,9 +1,11 @@ -import { useState } from "react"; +import { lazy, Suspense, useState } from "react"; import { useTranslation } from "react-i18next"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import Modal from "./Modal"; -import ProfileEditor from "./ProfileEditor"; import { api } from "../lib/api"; + +// Lazy: the full profile editor only opens from the dialog's "manage presets" affordance. +const ProfileEditor = lazy(() => import("./ProfileEditor")); import { notify } from "../lib/notifications"; import { navigateTo } from "../lib/nav"; @@ -105,12 +107,14 @@ export default function DownloadDialog({ {manage && ( - { - setManage(false); - profilesQ.refetch(); - }} - /> + + { + setManage(false); + profilesQ.refetch(); + }} + /> + )} ); diff --git a/frontend/src/components/Feed.tsx b/frontend/src/components/Feed.tsx index d3ade19..8152d59 100644 --- a/frontend/src/components/Feed.tsx +++ b/frontend/src/components/Feed.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +import { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { keepPreviousData, useInfiniteQuery, useQuery, useQueryClient } from "@tanstack/react-query"; import { ArrowDown, ArrowUp, ArrowLeft, Info, RefreshCw, Trash2, Youtube } from "lucide-react"; @@ -7,7 +7,8 @@ import i18n from "../i18n"; import { notify, resolveVideo } from "../lib/notifications"; import { useDebounced } from "../lib/useDebounced"; import VirtualFeed from "./VirtualFeed"; -import PlayerModal from "./PlayerModal"; +// Lazy: the in-app YouTube player (IFrame API) loads only when a video is first opened. +const PlayerModal = lazy(() => import("./PlayerModal")); const PAGE = 60; @@ -441,13 +442,15 @@ export default function Feed({ )} {activeVideo && ( - setActiveVideo(null)} - onState={onState} - onOpenChannel={onOpenChannel} - /> + + setActiveVideo(null)} + onState={onState} + onOpenChannel={onOpenChannel} + /> + )} ); @@ -647,13 +650,15 @@ export default function Feed({ /> )} {activeVideo && ( - setActiveVideo(null)} - onState={onState} - onOpenChannel={onOpenChannel} - /> + + setActiveVideo(null)} + onState={onState} + onOpenChannel={onOpenChannel} + /> + )} {isFetchingNextPage && (
{t("feed.loadingMore")}
diff --git a/frontend/src/components/NotificationsPanel.tsx b/frontend/src/components/NotificationsPanel.tsx index 8844da3..07fcf0e 100644 --- a/frontend/src/components/NotificationsPanel.tsx +++ b/frontend/src/components/NotificationsPanel.tsx @@ -4,7 +4,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { Activity, Bell, Check, CheckCheck, ExternalLink, Eye, RotateCcw, Search, Trash2, Tv, UserPlus, X } from "lucide-react"; import { api, type AppNotification, type FeedFilters } from "../lib/api"; import { useLiveQuery } from "../lib/useLiveQuery"; -import { focusAccessRequestsTab } from "./AdminUsers"; +import { focusAccessRequestsTab } from "../lib/adminUsersTab"; import { clearAll as clearClient, dismiss as dismissClient, diff --git a/frontend/src/components/Playlists.tsx b/frontend/src/components/Playlists.tsx index b866541..cbf846f 100644 --- a/frontend/src/components/Playlists.tsx +++ b/frontend/src/components/Playlists.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState } from "react"; +import { lazy, Suspense, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { @@ -39,7 +39,7 @@ import { formatDuration } from "../lib/format"; import { notify } from "../lib/notifications"; import { getAccountRaw, LS, readAccountMerged, setAccountRaw, writeAccount } from "../lib/storage"; import { useUndoable } from "../lib/useUndoable"; -import PlayerModal from "./PlayerModal"; +const PlayerModal = lazy(() => import("./PlayerModal")); import UndoToolbar from "./UndoToolbar"; import { useConfirm } from "./ConfirmProvider"; @@ -850,14 +850,16 @@ export default function Playlists({ canWrite }: { canWrite: boolean }) { {playingIndex != null && items[playingIndex] && ( - setPlayingIndex(null)} - onState={playerState} - /> + + setPlayingIndex(null)} + onState={playerState} + /> + )} ); diff --git a/frontend/src/lib/adminUsersTab.ts b/frontend/src/lib/adminUsersTab.ts new file mode 100644 index 0000000..6bf817d --- /dev/null +++ b/frontend/src/lib/adminUsersTab.ts @@ -0,0 +1,15 @@ +import { LS, setAccountRaw } from "./storage"; + +// The admin Users page's persisted-tab key + the Access-requests tab id, plus a helper to +// pre-select that tab before navigating there (a notification's "Review" link uses it — +// usePersistedTab reads the key on mount, and the page mounts fresh on navigation). +// +// Kept out of AdminUsers.tsx so callers (App, NotificationsPanel) can pre-select the tab +// WITHOUT statically importing the — now lazy-loaded — admin page, which would otherwise pull +// it back into the main bundle and defeat the code-split. +export const ADMIN_USERS_TAB_KEY = LS.adminUsersTab; +export const ADMIN_USERS_ACCESS_TAB = "access"; + +export function focusAccessRequestsTab(): void { + setAccountRaw(ADMIN_USERS_TAB_KEY, ADMIN_USERS_ACCESS_TAB); +} diff --git a/frontend/src/lib/releaseNotes.ts b/frontend/src/lib/releaseNotes.ts index 5f4a34f..1a96887 100644 --- a/frontend/src/lib/releaseNotes.ts +++ b/frontend/src/lib/releaseNotes.ts @@ -17,9 +17,10 @@ export const RELEASE_NOTES: ReleaseEntry[] = [ { version: "0.22.2", date: "2026-07-04", - summary: "A faster sign-in page and better accessibility across the app.", + summary: "Faster loading everywhere and better accessibility across the app.", fixes: [ "The landing page loads much faster — its preview screenshots are now served in a modern, far smaller image format, and static assets are cached so repeat visits are near-instant.", + "The app now loads in smaller pieces: each section is fetched only when you open it, so the first screen appears sooner and a shared watch link no longer downloads the whole app.", "Accessibility: toggles, colour swatches, sort menus and settings inputs now carry proper labels for screen readers and voice control, across every section of the app.", ], chores: [ diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 8923e7d..6bd188b 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,22 +1,25 @@ -import React from "react"; +import React, { lazy, Suspense } from "react"; import { createRoot } from "react-dom/client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import App from "./App"; import ErrorBoundary from "./components/ErrorBoundary"; import { ConfirmProvider } from "./components/ConfirmProvider"; -import PrivacyPolicy from "./components/legal/PrivacyPolicy"; -import Terms from "./components/legal/Terms"; -import WatchPage from "./components/WatchPage"; import "./i18n"; import "./index.css"; +// Split by top-level route so each entry point is its own chunk: a public /watch share link or a +// legal page never downloads the authenticated app bundle, and the app never carries them either. +// The legal pages live outside the App tree (no /api/me) so Google's consent screen and reviewers +// can fetch them directly; the watch page is a login-free public player. Reached by full-page +// navigation (plain ), so a simple pathname switch is enough — no router needed. +const App = lazy(() => import("./App")); +const PrivacyPolicy = lazy(() => import("./components/legal/PrivacyPolicy")); +const Terms = lazy(() => import("./components/legal/Terms")); +const WatchPage = lazy(() => import("./components/WatchPage")); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: 30_000, refetchOnWindowFocus: false } }, }); -// Public, login-free legal pages. They live outside the authenticated App tree (no /api/me) -// so Google's consent screen and reviewers can fetch them directly. Reached by full-page -// navigation (plain ), so a simple pathname switch is enough — no router needed. const path = window.location.pathname; const root = path === "/privacy" ? : @@ -32,5 +35,7 @@ const root = ); createRoot(document.getElementById("root")!).render( - {root} + + }>{root} + );