Correctness / behaviour:
- Move the Personal Views modal from inside the collapsible filter rail to the
feed page (moduleRegistry FeedPage), so opening it from the always-visible
toolbar view chip works while the rail is unpinned (its default) instead of
no-op'ing and latching modalOpen into a later stale pop-open.
- Migrate a persisted legacy global `tagMode` into per-category `tagModes` on the
localStorage seed paths too (loadAccountFilters), matching the saved-view and
URL paths — an upgrading user's topic any/all no longer silently reverts to 'or'.
- DateFilterSelect: reset the custom reveal on an external clear while the popover
is closed (guard on !open, not maxAgeDays), so the trigger reverts to "Any time"
instead of lingering on "Custom range" with empty fields.
- PersonalViewsModal delete: only advance the detail selection when the delete
actually happened (remove() now resolves a boolean) — cancelling the confirm no
longer switches the selected view.
- SavedViewsProvider: prefer the explicitly-applied (sticky) view among several
that capture identical filters, so apply(B) isn't re-attributed to an earlier
duplicate A (wrong "modified" chip name).
A11y:
- Give the clickable panel title a descriptive accessible name (titleActionLabel,
wired to "Personal views") instead of announcing only "Filters".
- Give the "+N" overflow pill an aria-label ("N more filters").
- Use t("views.reorder") for the saved-view drag handle instead of a hardcoded
English "reorder" literal.
Also extract the chip-fit decision into a pure, testable lib/chipOverflow.ts and
measure the inter-chip gap live (rem-safe) instead of a frozen 6px constant.
135 lines
6.0 KiB
TypeScript
135 lines
6.0 KiB
TypeScript
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";
|
|
import PersonalViewsModal from "./PersonalViewsModal";
|
|
import { useSavedViews } from "./SavedViewsProvider";
|
|
|
|
// 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();
|
|
// The Personal Views modal lives at the feed-page level (always mounted while on the feed, unmounts
|
|
// on navigation) rather than inside the collapsible filter rail — otherwise opening it from the
|
|
// always-visible toolbar view chip while the rail is unpinned would render nothing and latch open.
|
|
const { modalOpen } = useSavedViews();
|
|
return (
|
|
<>
|
|
<Feed
|
|
filters={filters}
|
|
setFilters={setFilters}
|
|
view={view}
|
|
setView={setView}
|
|
canRead={me.can_read}
|
|
isDemo={me.is_demo}
|
|
ytSearch={ytSearch}
|
|
onYtSearch={enterYtSearch}
|
|
onExitYtSearch={exitYtSearch}
|
|
/>
|
|
{modalOpen && !me.is_demo && <PersonalViewsModal />}
|
|
</>
|
|
);
|
|
}
|
|
|
|
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;
|
|
// The content column's max width. The header + toolbar centre on this same width (via --content-mw)
|
|
// so the floating chrome lines up with the centred content instead of spanning the whole column.
|
|
// MUST match the max-w the page's own content wrapper uses. Omit for a full-width page (feed).
|
|
maxWidth?: string;
|
|
};
|
|
|
|
// 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, maxWidth: "96rem" },
|
|
playlists: { Component: Playlists, maxWidth: "64rem" },
|
|
plex: { Component: PlexBrowse, maxWidth: "1600px" },
|
|
notifications: { Component: NotificationsPanel, maxWidth: "48rem" },
|
|
messages: { Component: MessagesPage },
|
|
downloads: { Component: DownloadCenter, maxWidth: "56rem" },
|
|
stats: { Component: Stats },
|
|
scheduler: { Component: Scheduler },
|
|
config: { Component: ConfigPanel },
|
|
users: { Component: AdminUsers, maxWidth: "48rem" },
|
|
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" },
|
|
};
|