refactor(frontend): each module defines itself in its folder; rails load lazily and keep their own state
A page used to be listed three times (lib/modules for its label, icon and gate; shell/moduleRegistry for its page and rail; lib/pageMeta for its title), and App held every rail's layout and collapse flag. Now each module's `module.ts` says it all — label, icon, gate, its lazy page and rail, its width — handed out through its index.ts, and features/navigation/moduleRegistry lists them in nav order for the rail, the palette, the tab title, the reachable set and the shell's render. A contract test holds the registry to the page ids and each definition to its own folder, with no static import of a page. Every rail is lazy now, loaded together with its page so a pinned rail never arrives after it and shoves the content aside; the feed's and the playlists' rails took @dnd-kit off the boot path (boot 428.5 → 393.9 KiB gzip, the App chunk 122 → 32 KiB). A rail reads its own layout and collapse flag (lib/useRail) and writes a layout change to the `me` cache too, since it now mounts anew on each visit. App's session effects, its one-shot entry-URL handlers and the admin nudge move into shell hooks; App is layout. The registry's three layer suppressions are gone: none is left.
This commit is contained in:
@@ -1,2 +1,3 @@
|
||||
// The activity page's public face: what another unit may use of it.
|
||||
export { StatusDot, type DotKey } from "./RunStatusDot";
|
||||
export { activityModule } from "./module";
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { ScrollText } from "lucide-react";
|
||||
import { lazyWithPreload } from "../../lib/lazyWithPreload";
|
||||
import type { ModuleDef } from "../../lib/moduleDef";
|
||||
|
||||
// This page as the shell and the navigation know it (lib/moduleDef). Its page load lazily.
|
||||
export const activityModule: ModuleDef = {
|
||||
id: "activity",
|
||||
labelKey: "header.account.activity",
|
||||
railKey: "nav.rail.activity",
|
||||
titleKey: "activity.title",
|
||||
icon: ScrollText,
|
||||
system: true,
|
||||
gate: (me) => me.role === "admin",
|
||||
Page: lazyWithPreload(() => import("./Activity")),
|
||||
};
|
||||
@@ -3,3 +3,4 @@
|
||||
export { ChannelsProvider } from "./ChannelsProvider";
|
||||
export { useChannelsActions } from "./channelsContext";
|
||||
export { default as SyncStatus } from "./SyncStatus";
|
||||
export { channelsModule } from "./module";
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Tv } from "lucide-react";
|
||||
import { lazyWithPreload } from "../../lib/lazyWithPreload";
|
||||
import type { ModuleDef } from "../../lib/moduleDef";
|
||||
|
||||
// This page as the shell and the navigation know it (lib/moduleDef). Its page load lazily.
|
||||
export const channelsModule: ModuleDef = {
|
||||
id: "channels",
|
||||
labelKey: "header.account.channels",
|
||||
railKey: "nav.rail.channels",
|
||||
titleKey: "header.channelManager",
|
||||
icon: Tv,
|
||||
Page: lazyWithPreload(() => import("./Channels")),
|
||||
maxWidth: "96rem",
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
// The config page's public face: how the shell and the navigation know it.
|
||||
export { configModule } from "./module";
|
||||
@@ -0,0 +1,15 @@
|
||||
import { SlidersHorizontal } from "lucide-react";
|
||||
import { lazyWithPreload } from "../../lib/lazyWithPreload";
|
||||
import type { ModuleDef } from "../../lib/moduleDef";
|
||||
|
||||
// This page as the shell and the navigation know it (lib/moduleDef). Its page load lazily.
|
||||
export const configModule: ModuleDef = {
|
||||
id: "config",
|
||||
labelKey: "header.account.config",
|
||||
railKey: "nav.rail.config",
|
||||
titleKey: "header.configuration",
|
||||
icon: SlidersHorizontal,
|
||||
system: true,
|
||||
gate: (me) => me.role === "admin",
|
||||
Page: lazyWithPreload(() => import("./ConfigPanel")),
|
||||
};
|
||||
@@ -1,4 +1,3 @@
|
||||
import { lazyWithPreload } from "../../lib/lazyWithPreload";
|
||||
import { useMe } from "../../lib/useMe";
|
||||
import { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext";
|
||||
import { useFeedView, useFeedViewActions } from "./feedViewContext";
|
||||
@@ -6,16 +5,12 @@ import { useNavigation, useNavigationActions } from "../../features/navigation/n
|
||||
import PersonalViewsModal from "../../features/filters/PersonalViewsModal";
|
||||
import { useSavedViews } from "../../features/filters/savedViewsContext";
|
||||
import FeedHeader from "./FeedHeader";
|
||||
|
||||
// The two module-registry pages that need per-render wiring (see moduleRegistry), split out of it so
|
||||
// that file exports only data and this one only components (Fast Refresh). Their lazy chunks are
|
||||
// exported alongside so the registry can attach each chunk's preload to the wrapper that renders it.
|
||||
export const Feed = lazyWithPreload(() => import("./Feed"));
|
||||
import Feed from "./Feed";
|
||||
|
||||
// The GLOBAL feed page: wires <Feed> to the app-wide feed filters / view / live-search, and fills the
|
||||
// header (the channel page's feed has no header). (The channel
|
||||
// page renders <Feed> itself with its own channel-scoped filters, so those two never share state.)
|
||||
export function FeedPage() {
|
||||
export default function FeedPage() {
|
||||
const me = useMe();
|
||||
const filters = useFeedFilters();
|
||||
const { setFilters } = useFeedFiltersActions();
|
||||
@@ -4,7 +4,7 @@ import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import { SlidersHorizontal } from "lucide-react";
|
||||
import { api, NO_TAG_SENTINEL, type FeedFilters, type Tag, type TagCategory } from "../../lib/api";
|
||||
import { defaultLayout, type PanelLayout } from "../../lib/panelLayout";
|
||||
import { defaultLayout } from "../../lib/panelLayout";
|
||||
import { useDebounced } from "../../lib/useDebounced";
|
||||
import { useActiveFilters } from "../../lib/useActiveFilters";
|
||||
import TagManager from "../../features/channel/TagManager";
|
||||
@@ -23,6 +23,7 @@ import { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext";
|
||||
import { useChannelsActions } from "../channels";
|
||||
import { useTags } from "../../lib/useTags";
|
||||
import { compareText } from "../../lib/collate";
|
||||
import { useRail } from "../../lib/useRail";
|
||||
|
||||
// Filter ids; display labels resolved at render time via t("sidebar.sort.<id>") etc.
|
||||
const DATE_PRESETS: { days: number; key: string }[] = [
|
||||
@@ -37,17 +38,9 @@ const DATE_PRESETS: { days: number; key: string }[] = [
|
||||
{ days: 1825, key: "5years" },
|
||||
];
|
||||
|
||||
export default function Sidebar({
|
||||
layout,
|
||||
setLayout,
|
||||
collapsed,
|
||||
onToggleCollapse,
|
||||
}: {
|
||||
layout: PanelLayout;
|
||||
setLayout: (l: PanelLayout) => void;
|
||||
collapsed: boolean;
|
||||
onToggleCollapse: () => void;
|
||||
}) {
|
||||
export default function Sidebar() {
|
||||
// Its layout and collapse flag are its own (lib/useRail), so the shell only places it.
|
||||
const { layout, setLayout, collapsed, onToggleCollapse } = useRail("feed");
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
const { openModal } = useSavedViews();
|
||||
|
||||
@@ -4,3 +4,4 @@ export { FeedViewProvider } from "./FeedViewProvider";
|
||||
export { SavedViewsProvider } from "./SavedViewsProvider";
|
||||
export { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext";
|
||||
export { useFeedView, useFeedViewActions } from "./feedViewContext";
|
||||
export { ChannelPage, feedModule } from "./module";
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { YouTubeIcon } from "../../lib/brandIcons";
|
||||
import { lazyWithPreload, lazyWithPreloadAlongside } from "../../lib/lazyWithPreload";
|
||||
import type { ModuleDef } from "../../lib/moduleDef";
|
||||
|
||||
// The rail loads with the page (lazyWithPreloadAlongside), so it never arrives after it.
|
||||
const Rail = lazyWithPreload(() => import("./Sidebar"));
|
||||
|
||||
// This page as the shell and the navigation know it (lib/moduleDef). Its page and rail load lazily.
|
||||
export const feedModule: ModuleDef = {
|
||||
id: "feed",
|
||||
labelKey: "header.account.feed",
|
||||
railKey: "nav.rail.feed",
|
||||
icon: YouTubeIcon,
|
||||
Rail,
|
||||
Page: lazyWithPreloadAlongside(() => import("./FeedPage"), Rail),
|
||||
};
|
||||
|
||||
// The channel page: a feed scoped to one channel, opened over any page (App renders it in place of the
|
||||
// page). Lazy like a page, and preloaded with them.
|
||||
export const ChannelPage = lazyWithPreload(() => import("./ChannelPage"));
|
||||
@@ -9,6 +9,15 @@ import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-libra
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
// The rail's own state, pinned open: these tests are about what it lists, not how it folds.
|
||||
vi.mock("../../lib/useRail", () => ({
|
||||
useRail: () => ({
|
||||
layout: { order: ["library", "sources", "own"], collapsed: {}, hidden: {} },
|
||||
setLayout: () => {},
|
||||
collapsed: false,
|
||||
onToggleCollapse: () => {},
|
||||
}),
|
||||
}));
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return { ...actual, useTranslation: () => ({ t: (k: string) => k, i18n: { language: "en" } }) };
|
||||
@@ -33,7 +42,6 @@ vi.mock("../../components/ui/FilterRail", () => ({
|
||||
|
||||
import FilesRail from "./FilesRail";
|
||||
import { api, type FileFolder } from "../../lib/api";
|
||||
import { defaultLayout } from "../../lib/panelLayout";
|
||||
|
||||
// The share dialog is a lazy chunk, and the test that opens it would pay for transforming it inside
|
||||
// its own wait for the dialog — over that on a busy machine. Loaded once, up front.
|
||||
@@ -79,12 +87,7 @@ async function mount(folders: FileFolder[] = [YT, OWN]) {
|
||||
vi.spyOn(api, "filesUsage").mockResolvedValue({ unlimited: true, footprint_bytes: 0 } as never);
|
||||
render(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<FilesRail
|
||||
layout={defaultLayout("files")}
|
||||
setLayout={vi.fn()}
|
||||
collapsed={false}
|
||||
onToggleCollapse={vi.fn()}
|
||||
/>
|
||||
<FilesRail />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
await screen.findByText("files.folder.new");
|
||||
@@ -194,12 +197,7 @@ describe("FilesRail · what it says when a part is empty or missing", () => {
|
||||
<QueryClientProvider
|
||||
client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}
|
||||
>
|
||||
<FilesRail
|
||||
layout={defaultLayout("files")}
|
||||
setLayout={vi.fn()}
|
||||
collapsed={false}
|
||||
onToggleCollapse={vi.fn()}
|
||||
/>
|
||||
<FilesRail />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
expect(await screen.findByText("files.usage.failed")).toBeTruthy();
|
||||
|
||||
@@ -7,7 +7,6 @@ import { FolderOpen, FolderPlus, HardDrive, Link2, Users } from "lucide-react";
|
||||
import { api, type FileFolder } from "../../lib/api";
|
||||
import { compareText } from "../../lib/collate";
|
||||
import { formatBytes } from "../../lib/format";
|
||||
import { type PanelLayout } from "../../lib/panelLayout";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import { useLiveQuery } from "../../lib/useLiveQuery";
|
||||
import FilterRail from "../../components/ui/FilterRail";
|
||||
@@ -19,6 +18,7 @@ import { ContextMenu } from "../../components/ui/ContextMenu";
|
||||
import { Pressable } from "../../components/ui/Pressable";
|
||||
import { type PanelItem } from "../../components/ui/PanelGroups";
|
||||
import { useContextMenu } from "../../components/ui/useContextMenu";
|
||||
import { useRail } from "../../lib/useRail";
|
||||
|
||||
// The Files module's left rail: the library's places — every file, the ones shared with me, the
|
||||
// source folders the library fills by itself, the user's own folders — and the storage meter. Lifted
|
||||
@@ -111,17 +111,9 @@ function StorageMeter() {
|
||||
);
|
||||
}
|
||||
|
||||
export default function FilesRail({
|
||||
layout,
|
||||
setLayout,
|
||||
collapsed,
|
||||
onToggleCollapse,
|
||||
}: {
|
||||
layout: PanelLayout;
|
||||
setLayout: (l: PanelLayout) => void;
|
||||
collapsed: boolean;
|
||||
onToggleCollapse: () => void;
|
||||
}) {
|
||||
export default function FilesRail() {
|
||||
// Its layout and collapse flag are its own (lib/useRail), so the shell only places it.
|
||||
const { layout, setLayout, collapsed, onToggleCollapse } = useRail("files");
|
||||
const { t, i18n } = useTranslation();
|
||||
const { view } = useFiles();
|
||||
const foldersQ = useQuery({ queryKey: qk.fileFolders(), queryFn: api.fileFolders });
|
||||
|
||||
@@ -2,3 +2,4 @@
|
||||
export { FilesProvider } from "./FilesProvider";
|
||||
export { useFilesActions } from "./filesContext";
|
||||
export { folderIcon, folderLabel, sourceLabel, takesUploads } from "./fileFolders";
|
||||
export { filesModule } from "./module";
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { FolderOpen } from "lucide-react";
|
||||
import { lazyWithPreload, lazyWithPreloadAlongside } from "../../lib/lazyWithPreload";
|
||||
import type { ModuleDef } from "../../lib/moduleDef";
|
||||
|
||||
// The rail loads with the page (lazyWithPreloadAlongside), so it never arrives after it.
|
||||
const Rail = lazyWithPreload(() => import("./FilesRail"));
|
||||
|
||||
// This page as the shell and the navigation know it (lib/moduleDef). Its page and rail load lazily.
|
||||
export const filesModule: ModuleDef = {
|
||||
id: "files",
|
||||
labelKey: "files.navLabel",
|
||||
railKey: "nav.rail.files",
|
||||
icon: FolderOpen,
|
||||
Rail,
|
||||
Page: lazyWithPreloadAlongside(() => import("./Files"), Rail),
|
||||
maxWidth: "80rem",
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
// The home page's public face: how the shell and the navigation know it.
|
||||
export { homeModule } from "./module";
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Home } from "lucide-react";
|
||||
import { lazyWithPreload } from "../../lib/lazyWithPreload";
|
||||
import type { ModuleDef } from "../../lib/moduleDef";
|
||||
|
||||
// This page as the shell and the navigation know it (lib/moduleDef). Its page load lazily.
|
||||
export const homeModule: ModuleDef = {
|
||||
id: "home",
|
||||
labelKey: "home.navLabel",
|
||||
railKey: "nav.rail.home",
|
||||
titleKey: "home.title",
|
||||
icon: Home,
|
||||
Page: lazyWithPreload(() => import("./Home")),
|
||||
maxWidth: "80rem",
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
// The notifications page's public face: how the shell and the navigation know it.
|
||||
export { notificationsModule } from "./module";
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Bell } from "lucide-react";
|
||||
import { lazyWithPreload } from "../../lib/lazyWithPreload";
|
||||
import type { ModuleDef } from "../../lib/moduleDef";
|
||||
|
||||
// This page as the shell and the navigation know it (lib/moduleDef). Its page load lazily.
|
||||
export const notificationsModule: ModuleDef = {
|
||||
id: "notifications",
|
||||
labelKey: "inbox.navLabel",
|
||||
railKey: "nav.rail.notifications",
|
||||
icon: Bell,
|
||||
hasBadge: true,
|
||||
Page: lazyWithPreload(() => import("./NotificationsPanel")),
|
||||
maxWidth: "48rem",
|
||||
};
|
||||
@@ -10,6 +10,15 @@ import { FloatingTree } from "@floating-ui/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
// The rail's own state, pinned open: these tests are about what it lists, not how it folds.
|
||||
vi.mock("../../lib/useRail", () => ({
|
||||
useRail: () => ({
|
||||
layout: { order: ["manage", "list"], collapsed: {}, hidden: {} },
|
||||
setLayout: () => {},
|
||||
collapsed: false,
|
||||
onToggleCollapse: () => {},
|
||||
}),
|
||||
}));
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return { ...actual, useTranslation: () => ({ t: (k: string) => k, i18n: { language: "en" } }) };
|
||||
@@ -82,12 +91,7 @@ async function openRename() {
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<FloatingTree>
|
||||
<PlaylistsRail
|
||||
layout={{} as never}
|
||||
setLayout={() => {}}
|
||||
collapsed={false}
|
||||
onToggleCollapse={() => {}}
|
||||
/>
|
||||
<PlaylistsRail />
|
||||
</FloatingTree>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
@@ -130,12 +134,7 @@ describe("PlaylistsRail · the row's '…'", () => {
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<FloatingTree>
|
||||
<PlaylistsRail
|
||||
layout={{} as never}
|
||||
setLayout={() => {}}
|
||||
collapsed={false}
|
||||
onToggleCollapse={() => {}}
|
||||
/>
|
||||
<PlaylistsRail />
|
||||
</FloatingTree>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
@@ -11,7 +11,6 @@ import { notify } from "../../lib/notifications";
|
||||
import { notifyYouTubeActionError } from "../../lib/youtubeErrors";
|
||||
import { LS } from "../../lib/storage";
|
||||
import { usePref } from "../../lib/usePref";
|
||||
import { type PanelLayout } from "../../lib/panelLayout";
|
||||
import FilterRail from "../../components/ui/FilterRail";
|
||||
import { Button } from "../../components/ui/Button";
|
||||
import { type ActionMenuItem } from "../../components/ui/ActionMenu";
|
||||
@@ -31,6 +30,7 @@ import { Pressable } from "../../components/ui/Pressable";
|
||||
import { Input } from "../../components/ui/Input";
|
||||
import { MoreButton } from "../../components/ui/MoreButton";
|
||||
import RemoteImage from "../../components/ui/RemoteImage";
|
||||
import { useRail } from "../../lib/useRail";
|
||||
|
||||
type PlSort = {
|
||||
key: "custom" | "name" | "count" | "duration" | "recent";
|
||||
@@ -47,17 +47,9 @@ const ROW_BASE = "w-full flex items-center gap-2.5 p-2 rounded-lg border transit
|
||||
// shared SidePanel with the same collapse-to-tab behavior. It owns the list query + its own rail
|
||||
// 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({
|
||||
layout,
|
||||
setLayout,
|
||||
collapsed,
|
||||
onToggleCollapse,
|
||||
}: {
|
||||
layout: PanelLayout;
|
||||
setLayout: (l: PanelLayout) => void;
|
||||
collapsed: boolean;
|
||||
onToggleCollapse: () => void;
|
||||
}) {
|
||||
export default function PlaylistsRail() {
|
||||
// Its layout and collapse flag are its own (lib/useRail), so the shell only places it.
|
||||
const { layout, setLayout, collapsed, onToggleCollapse } = useRail("playlists");
|
||||
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.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// The playlists page's public face: its store, mounted at the root and read app-wide.
|
||||
export { PlaylistsProvider } from "./PlaylistsProvider";
|
||||
export { usePlaylistsActions } from "./playlistsContext";
|
||||
export { playlistsModule } from "./module";
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { ListVideo } from "lucide-react";
|
||||
import { lazyWithPreload, lazyWithPreloadAlongside } from "../../lib/lazyWithPreload";
|
||||
import type { ModuleDef } from "../../lib/moduleDef";
|
||||
|
||||
// The rail loads with the page (lazyWithPreloadAlongside), so it never arrives after it.
|
||||
const Rail = lazyWithPreload(() => import("./PlaylistsRail"));
|
||||
|
||||
// This page as the shell and the navigation know it (lib/moduleDef). Its page and rail load lazily.
|
||||
export const playlistsModule: ModuleDef = {
|
||||
id: "playlists",
|
||||
labelKey: "header.account.playlists",
|
||||
railKey: "nav.rail.playlists",
|
||||
icon: ListVideo,
|
||||
Rail,
|
||||
Page: lazyWithPreloadAlongside(() => import("./Playlists"), Rail),
|
||||
maxWidth: "64rem",
|
||||
};
|
||||
@@ -8,7 +8,6 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { ListMusic, Plus, SlidersHorizontal } from "lucide-react";
|
||||
import { api, plexFilterCount, type PlexFilters } from "../../lib/api";
|
||||
import { notifyActionError } from "../../lib/actionErrors";
|
||||
import { type PanelLayout } from "../../lib/panelLayout";
|
||||
import { type MediaSourceKind } from "../../lib/mediaSource";
|
||||
import { useDebounced } from "../../lib/useDebounced";
|
||||
import FilterRail from "../../components/ui/FilterRail";
|
||||
@@ -22,19 +21,13 @@ import { useSavedViews } from "../../features/filters/savedViewsContext";
|
||||
import { usePlex, usePlexActions } from "./plexContext";
|
||||
import { DismissibleChip } from "../../components/ui/Chip";
|
||||
import { Input } from "../../components/ui/Input";
|
||||
import { useRail } from "../../lib/useRail";
|
||||
|
||||
// The Plex module's left filter column — now the shared floating SidePanel with its filter groups
|
||||
// as reorderable "islands" (same system as the feed rail). Movie libraries get the full metadata
|
||||
// set; shows keep library + sort. State is owned by App (per-account persisted); facets come from
|
||||
// set; shows keep library + sort. The filter state lives in PlexProvider; facets come from
|
||||
// the backend so the panel only offers what the library actually contains.
|
||||
|
||||
type Props = {
|
||||
layout: PanelLayout;
|
||||
setLayout: (l: PanelLayout) => void;
|
||||
collapsed: boolean;
|
||||
onToggleCollapse: () => void;
|
||||
};
|
||||
|
||||
const RATING_STEPS = [5, 6, 7, 8, 9];
|
||||
const ADDED_OPTS = ["24h", "1w", "1m", "6m", "1y"] as const;
|
||||
const DURATION_BUCKETS: { key: string; min: number | null; max: number | null }[] = [
|
||||
@@ -43,7 +36,9 @@ const DURATION_BUCKETS: { key: string; min: number | null; max: number | null }[
|
||||
{ key: "long", min: 120 * 60, max: null },
|
||||
];
|
||||
|
||||
export default function PlexSidebar({ layout, setLayout, collapsed, onToggleCollapse }: Props) {
|
||||
export default function PlexSidebar() {
|
||||
// Its layout and collapse flag are its own (lib/useRail), so the shell only places it.
|
||||
const { layout, setLayout, collapsed, onToggleCollapse } = useRail("plex");
|
||||
const { t, i18n } = useTranslation();
|
||||
// Scope / watch-state / sort / expanded filters live in PlexProvider (shared with PlexBrowse and
|
||||
// the header search box); this rail is the primary editor of them. Opening a playlist is a
|
||||
|
||||
@@ -2,3 +2,4 @@
|
||||
export { PlexProvider } from "./PlexProvider";
|
||||
export { usePlex, usePlexActions } from "./plexContext";
|
||||
export { buildPlexActiveFilters } from "./usePlexActiveFilters";
|
||||
export { plexModule } from "./module";
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Clapperboard } from "lucide-react";
|
||||
import { lazyWithPreload, lazyWithPreloadAlongside } from "../../lib/lazyWithPreload";
|
||||
import type { ModuleDef } from "../../lib/moduleDef";
|
||||
|
||||
// The rail loads with the page (lazyWithPreloadAlongside), so it never arrives after it.
|
||||
const Rail = lazyWithPreload(() => import("./PlexSidebar"));
|
||||
|
||||
// This page as the shell and the navigation know it (lib/moduleDef). Its page and rail load lazily.
|
||||
export const plexModule: ModuleDef = {
|
||||
id: "plex",
|
||||
labelKey: "plex.navLabel",
|
||||
railKey: "nav.rail.plex",
|
||||
icon: Clapperboard,
|
||||
gate: (me) => me.plex_enabled,
|
||||
Rail,
|
||||
Page: lazyWithPreloadAlongside(() => import("./PlexBrowse"), Rail),
|
||||
maxWidth: "100rem",
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
// The scheduler page's public face: how the shell and the navigation know it.
|
||||
export { schedulerModule } from "./module";
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Activity } from "lucide-react";
|
||||
import { lazyWithPreload } from "../../lib/lazyWithPreload";
|
||||
import type { ModuleDef } from "../../lib/moduleDef";
|
||||
|
||||
// This page as the shell and the navigation know it (lib/moduleDef). Its page load lazily.
|
||||
export const schedulerModule: ModuleDef = {
|
||||
id: "scheduler",
|
||||
labelKey: "header.account.scheduler",
|
||||
railKey: "nav.rail.scheduler",
|
||||
titleKey: "header.scheduler",
|
||||
icon: Activity,
|
||||
system: true,
|
||||
gate: (me) => me.role === "admin",
|
||||
Page: lazyWithPreload(() => import("./Scheduler")),
|
||||
};
|
||||
@@ -21,7 +21,11 @@ vi.mock("react-i18next", async (importOriginal) => {
|
||||
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
|
||||
};
|
||||
});
|
||||
vi.mock("../../lib/api", () => ({ api: h }));
|
||||
// Partial: the module graph reaches the feed's store, which reads its defaults here.
|
||||
vi.mock("../../lib/api", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../../lib/api")>()),
|
||||
api: h,
|
||||
}));
|
||||
// Partial mock: SettingsPanel's module graph (PrefsProvider) also pulls getNotifSettings from here.
|
||||
vi.mock("../../lib/notifications", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../lib/notifications")>();
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
// The settings page's public face: how the shell and the navigation know it.
|
||||
export { settingsModule } from "./module";
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Settings } from "lucide-react";
|
||||
import { lazyWithPreload } from "../../lib/lazyWithPreload";
|
||||
import type { ModuleDef } from "../../lib/moduleDef";
|
||||
|
||||
// This page as the shell and the navigation know it (lib/moduleDef). Its page load lazily.
|
||||
export const settingsModule: ModuleDef = {
|
||||
id: "settings",
|
||||
labelKey: "header.account.settings",
|
||||
railKey: "nav.rail.settings",
|
||||
titleKey: "settings.title",
|
||||
icon: Settings,
|
||||
Page: lazyWithPreload(() => import("./SettingsPanel")),
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
// The stats page's public face: how the shell and the navigation know it.
|
||||
export { statsModule } from "./module";
|
||||
@@ -0,0 +1,13 @@
|
||||
import { BarChart3 } from "lucide-react";
|
||||
import { lazyWithPreload } from "../../lib/lazyWithPreload";
|
||||
import type { ModuleDef } from "../../lib/moduleDef";
|
||||
|
||||
// This page as the shell and the navigation know it (lib/moduleDef). Its page load lazily.
|
||||
export const statsModule: ModuleDef = {
|
||||
id: "stats",
|
||||
labelKey: "header.account.stats",
|
||||
railKey: "nav.rail.stats",
|
||||
titleKey: "header.usageStats",
|
||||
icon: BarChart3,
|
||||
Page: lazyWithPreload(() => import("./Stats")),
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
// The users page's public face: how the shell and the navigation know it.
|
||||
export { usersModule } from "./module";
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Users } from "lucide-react";
|
||||
import { lazyWithPreload } from "../../lib/lazyWithPreload";
|
||||
import type { ModuleDef } from "../../lib/moduleDef";
|
||||
|
||||
// This page as the shell and the navigation know it (lib/moduleDef). Its page load lazily.
|
||||
export const usersModule: ModuleDef = {
|
||||
id: "users",
|
||||
labelKey: "header.account.users",
|
||||
railKey: "nav.rail.users",
|
||||
titleKey: "header.users",
|
||||
icon: Users,
|
||||
system: true,
|
||||
gate: (me) => me.role === "admin",
|
||||
Page: lazyWithPreload(() => import("./AdminUsers")),
|
||||
maxWidth: "48rem",
|
||||
};
|
||||
Reference in New Issue
Block a user