This commit is contained in:
ponzischeme89
2026-08-24 09:16:19 +12:00
parent 30155e1c5f
commit 067395a362
38 changed files with 104 additions and 60756 deletions
+7 -30
View File
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from 'react';
import { matchPath, NavLink, Outlet, useLocation } from 'react-router-dom';
import { NavLink, Outlet, useLocation } from 'react-router-dom';
import { Icon } from './Icon';
import { OmniSearch } from './OmniSearch';
import { NotificationBell } from './NotificationBell';
@@ -16,13 +16,12 @@ import { Confirm } from './ui';
function Rail({ open, onNavigate }: { open: boolean; onNavigate: () => void }) {
const { unread } = useNotifications();
const location = useLocation();
// A group holding the current page is open regardless of what was collapsed last time:
// a rail that hides the page you are on is a rail that has lost its place.
// Every category is visible. Children can be folded independently and the choice is
// remembered between visits.
const [collapsed, setCollapsed] = useState<Record<string, boolean>>(() => {
try {
return JSON.parse(localStorage.getItem('memby-admin-nav') ?? '{}') as Record<string, boolean>;
return JSON.parse(localStorage.getItem('memby-admin-nav-v2') ?? '{}') as Record<string, boolean>;
} catch {
return {};
}
@@ -30,37 +29,18 @@ function Rail({ open, onNavigate }: { open: boolean; onNavigate: () => void }) {
const storeCollapsed = (next: Record<string, boolean>) => {
try {
localStorage.setItem('memby-admin-nav', JSON.stringify(next));
localStorage.setItem('memby-admin-nav-v2', JSON.stringify(next));
} catch {
// Private-mode storage refusals cost the memory of which group is open, and
// nothing else.
}
};
// The labelled groups are an accordion. Following a destination opens its group and
// folds the one the operator has just left, including navigation from search or Back.
useEffect(() => {
const activeGroup = nav.find((group) =>
group.items.some((item) => matchPath({ path: item.path, end: true }, location.pathname)),
);
if (!activeGroup) return;
setCollapsed((current) => {
const next = { ...current };
nav.forEach((group) => {
if (group.collapsible !== false) next[group.id] = group.id !== activeGroup.id;
});
storeCollapsed(next);
return next;
});
}, [location.pathname]);
const toggle = (id: string, defaultCollapsed = false) => {
setCollapsed((current) => {
const next = { ...current };
const collapseSelected = !(current[id] ?? defaultCollapsed);
nav.forEach((group) => {
if (group.collapsible !== false) next[group.id] = group.id === id ? collapseSelected : true;
});
next[id] = collapseSelected;
storeCollapsed(next);
return next;
});
@@ -71,11 +51,8 @@ function Rail({ open, onNavigate }: { open: boolean; onNavigate: () => void }) {
{nav.map((group) => {
const items = group.items.filter((item) => !item.hidden);
if (items.length === 0) return null;
const holdsCurrent = items.some((item) =>
matchPath({ path: item.path, end: true }, location.pathname),
);
const collapsible = group.collapsible !== false;
const expanded = holdsCurrent || !collapsible || !(collapsed[group.id] ?? group.defaultCollapsed ?? false);
const expanded = !collapsible || !(collapsed[group.id] ?? group.defaultCollapsed ?? false);
return (
<div className="rail-group" key={group.id}>
{group.label && collapsible ? (
+72 -367
View File
@@ -1,18 +1,8 @@
import type { IconName } from './components/Icon';
/* The console's table of contents, and the only place a page is declared.
*
* The rail, the page search and the router all read it, so a page cannot be in the menu
* and 404, or be reachable and unnamed. It used to have to agree with a matching list in
* Go as well — the gateway rendered the rail and decided which /admin URLs were legal —
* which is a duplication the single-page console removes: the gateway now serves the
* console for any /admin path and the routing is entirely here.
*
* `hidden` marks a destination that is reachable and titled but not in the rail: a page
* about one person or one television belongs to the thing it is about, not to a menu.
* Everyday is deliberately frequency-based; the remaining sections are organised by the
* operator's question and begin folded so the common routes never scroll out of reach. */
/* The console's table of contents. The rail and page search both read this catalogue so
* every destination has one name and one home. Detail pages stay in the catalogue for
* routing and search, but remain out of the rail when they need an id in their URL. */
export interface NavItem {
id: string;
path: string;
@@ -21,406 +11,121 @@ export interface NavItem {
intro: string;
icon?: IconName;
hidden?: boolean;
/** badge names a live count the rail should show beside this item — today only the
* unread activity count. */
badge?: 'notifications';
}
export interface NavGroup {
id: string;
label?: string;
/** Less-frequent sections begin folded, but still open whenever they hold the current
* page. An operator's explicit choice is remembered and takes precedence. */
label: string;
defaultCollapsed?: boolean;
/** A group may opt out of the accordion when its links must remain visible. */
collapsible?: boolean;
items: NavItem[];
}
const item = (
id: string,
path: string,
label: string,
title: string,
intro: string,
icon: IconName,
extra: Pick<NavItem, 'hidden' | 'badge'> = {},
): NavItem => ({ id, path, label, title, intro, icon, ...extra });
export const nav: NavGroup[] = [
{
id: 'everyday',
label: 'Everyday',
defaultCollapsed: false,
id: 'dashboard', label: 'Dashboard', defaultCollapsed: false, collapsible: false,
items: [item('overview', '/admin', 'Overview', 'Overview', 'What the gateway is doing right now.', 'overview')],
},
{
id: 'map', label: 'Map', defaultCollapsed: false, collapsible: false,
items: [
{
id: 'overview',
path: '/admin',
label: 'Overview',
title: 'Overview',
intro: 'What the gateway is doing right now.',
icon: 'overview',
},
{
id: 'activity',
path: '/admin/activity',
label: 'Activity',
title: 'Activity',
intro: 'Every administrative event, newest first.',
icon: 'bell',
badge: 'notifications',
},
{
id: 'accounts',
path: '/admin/accounts',
label: 'Users',
title: 'Users',
intro: 'Who uses Memby, and the devices they are signed in on.',
icon: 'people',
},
{
id: 'requests',
path: '/admin/requests',
label: 'Media requests',
title: 'Media requests',
intro: 'Who can ask for something the library does not have.',
icon: 'inbox',
},
{
/* The record of what Memby sent, which is a different question from the activity
feed above it: that is the operator's own bell, this is every outbound
notification to a viewer or an external service, whichever feature produced it. */
id: 'notifications',
path: '/admin/notifications',
label: 'Notifications',
title: 'Notifications',
intro: 'Everything Memby sent: who it went to, over which channel, and whether it worked.',
icon: 'send',
},
{
id: 'notification-settings',
path: '/admin/notification-settings',
label: 'TV notifications',
title: 'TV notifications',
intro: 'Choose where notifications appear and who receives them.',
icon: 'bell',
},
{
id: 'media-reports',
path: '/admin/media-reports',
label: 'Media reports',
title: 'Media reports',
intro: 'Problems viewers reported with a film or episode.',
icon: 'alert',
},
{
id: 'updates',
path: '/admin/updates',
label: 'App updates',
title: 'App updates',
intro: 'Publish an optional or a required client update.',
icon: 'upload',
},
{
id: 'logs',
path: '/admin/logs',
label: 'Logs',
title: 'Logs',
intro: 'Structured gateway events as they happen.',
icon: 'list',
},
item('journeys', '/admin/journeys', 'Journeys', 'User journeys', 'How viewers move through Memby and complete flows.', 'journey'),
item('journey-viewer', '/admin/journeys/:userId', 'Journey', 'User journey', 'One viewers journey through Memby.', 'journey', { hidden: true }),
],
},
{
id: 'people',
label: 'Devices & access',
defaultCollapsed: true,
id: 'history', label: 'History', defaultCollapsed: false,
items: [
{
id: 'account',
path: '/admin/accounts/:userId',
label: 'User',
title: 'User',
intro: 'Devices, recommendation setup and synced settings for one person.',
icon: 'person',
hidden: true,
},
{
id: 'settings-history',
path: '/admin/accounts/:userId/settings',
label: 'Settings history',
title: 'Settings history',
intro: "Every change to one person's synced settings, and which devices took it.",
icon: 'sliders',
hidden: true,
},
{
id: 'clients',
path: '/admin/clients',
label: 'Devices',
title: 'Devices',
intro: 'Which sets have reported in, what they are running and what their build understands.',
icon: 'tv',
},
{
id: 'logins',
path: '/admin/logins',
label: 'Sign-ins',
title: 'Sign-in history',
intro: 'Every connection attempt: who, which television, from where, and whether it got in.',
icon: 'key',
},
{
id: 'device',
path: '/admin/devices/:deviceId',
label: 'Device',
title: 'Device',
intro: 'One television: how often it connects, at what times, and from which addresses.',
icon: 'tv',
hidden: true,
},
item('activity', '/admin/activity', 'Activity', 'Activity', 'Every administrative event, newest first.', 'bell', { badge: 'notifications' }),
item('logins', '/admin/logins', 'Sign-ins', 'Sign-in history', 'Every connection attempt and whether it got in.', 'key'),
item('notifications', '/admin/notifications', 'Notifications', 'Notifications', 'Everything Memby sent and whether it worked.', 'send'),
item('imports', '/admin/imports', 'Imports', 'Imports', 'Catalogue synchronisation history.', 'database'),
],
},
{
id: 'content',
label: 'Content & discovery',
defaultCollapsed: true,
id: 'stats', label: 'Stats', defaultCollapsed: false,
items: [
{
id: 'library',
path: '/admin/library',
label: 'Library',
title: 'Library',
intro: 'Import and inspect the catalogue Memby ranks.',
icon: 'library',
},
{
id: 'hero',
path: '/admin/hero',
label: 'Home hero',
title: 'Home hero',
intro:
'Choose films or television shows for the launcher spotlight while recent releases fill the remaining places.',
icon: 'star',
},
{
id: 'metadata-hero',
path: '/admin/metadata-hero',
label: 'Metadata hero',
title: 'Metadata hero',
intro: 'Order the focused-title information shown above browse rows on every television.',
icon: 'tv',
},
{
id: 'recommendations',
path: '/admin/recommendations',
label: 'For You',
title: 'For You',
intro: 'The prepared pools personalised rows are drawn from.',
icon: 'sparkle',
},
{
id: 'inspector',
path: '/admin/inspector',
label: 'Score inspector',
title: 'Score inspector',
intro: 'Re-run the ranker for one person and read every component.',
icon: 'search',
},
item('views', '/admin/views', 'Views', 'App views', 'Home-screen visits, viewers and the times Memby is used.', 'overview'),
item('engagement', '/admin/engagement', 'Engagement', 'Row engagement', 'Impressions, focus, dwell and selections per launcher row.', 'chart'),
item('searches', '/admin/searches', 'Searches', 'Searches', 'What viewers have been looking for.', 'search'),
],
},
{
id: 'experience',
label: 'Viewing experience',
defaultCollapsed: true,
id: 'media', label: 'Media', defaultCollapsed: false,
items: [
{
id: 'features',
path: '/admin/features',
label: 'Client configuration',
title: 'Client configuration',
intro: 'Control everything the thin TV client renders, without releasing an APK.',
icon: 'sliders',
},
{
id: 'playback',
path: '/admin/playback',
label: 'Playback',
title: 'Playback',
intro: 'Presentation policy sent with every playback launch.',
icon: 'play',
},
{
id: 'subtitles',
path: '/admin/subtitles',
label: 'Subtitles',
title: 'Subtitles',
intro: 'Which providers a viewer may fetch a missing subtitle from.',
icon: 'captions',
},
{
id: 'credits',
path: '/admin/credits',
label: 'Credits detection',
title: 'Credits detection',
intro: 'Control predictive scanning and review every completed credits scan.',
icon: 'clock',
},
item('library', '/admin/library', 'Library', 'Library', 'Import and inspect the catalogue Memby ranks.', 'library'),
item('requests', '/admin/requests', 'Requests', 'Media requests', 'Who can ask for something the library does not have.', 'inbox'),
item('recommendations', '/admin/recommendations', 'Recommendations', 'For You', 'The prepared pools personalised rows are drawn from.', 'sparkle'),
item('inspector', '/admin/inspector', 'Score inspector', 'Score inspector', 'Re-run the ranker for one person and read every component.', 'search'),
item('hero', '/admin/hero', 'Home hero', 'Home hero', 'Choose films or television shows for the launcher spotlight.', 'star'),
item('metadata-hero', '/admin/metadata-hero', 'Metadata hero', 'Metadata hero', 'Order focused-title information shown above browse rows.', 'tv'),
item('playback', '/admin/playback', 'Playback', 'Playback', 'Presentation policy sent with every playback launch.', 'play'),
item('subtitles', '/admin/subtitles', 'Subtitles', 'Subtitles', 'Which providers a viewer may fetch a missing subtitle from.', 'captions'),
item('credits', '/admin/credits', 'Credits detection', 'Credits detection', 'Control predictive scanning and review completed scans.', 'clock'),
],
},
{
id: 'operations',
label: 'Operations',
defaultCollapsed: true,
id: 'performance', label: 'Performance', defaultCollapsed: false,
items: [
{
id: 'tasks',
path: '/admin/tasks',
label: 'Scheduled tasks',
title: 'Scheduled tasks',
intro: 'What the gateway does in the background, when it last ran and whether it worked.',
icon: 'clock',
},
{
/* One task, addressed by its id — a page about a single job belongs to the job
rather than to the rail, the stance the user and device pages take. */
id: 'task',
path: '/admin/tasks/:taskId',
label: 'Task',
title: 'Task',
intro: 'One scheduled task: its cadence, its switch and its own run history.',
icon: 'clock',
hidden: true,
},
{
id: 'imports',
path: '/admin/imports',
label: 'Imports',
title: 'Imports',
intro: 'Catalogue synchronisation history.',
icon: 'database',
},
{
/* The process, rather than the household. It sits beside Logs and Maintenance
because the question it answers — is the container healthy — is the one an
operator arrives with when something is slow rather than wrong. */
id: 'runtime',
path: '/admin/runtime',
label: 'Runtime',
title: 'Runtime',
intro: 'Goroutines, memory and the background workers inside the gateway process.',
icon: 'chip',
},
{
id: 'maintenance',
path: '/admin/maintenance',
label: 'Maintenance',
title: 'Maintenance',
intro: 'Take Memby offline now or schedule daily quiet time.',
icon: 'wrench',
},
{
/* Hidden because its way in is the account menu in the top bar, not the rail:
these are settings for the server process rather than for the household, and
they belong beside "signed in as". It is still declared here so the page search
can find it and the router has one place a page is named. */
id: 'gateway-settings',
path: '/admin/settings',
label: 'Gateway settings',
title: 'Gateway settings',
intro: 'Timezone, logging and the other server-level settings for this gateway.',
icon: 'sliders',
hidden: true,
},
item('runtime', '/admin/runtime', 'Runtime', 'Runtime', 'Goroutines, memory and background workers.', 'chip'),
item('logs', '/admin/logs', 'Logs', 'Logs', 'Structured gateway events as they happen.', 'list'),
item('updates', '/admin/updates', 'App updates', 'App updates', 'Publish an optional or required client update.', 'upload'),
item('tasks', '/admin/tasks', 'Scheduled tasks', 'Scheduled tasks', 'What the gateway does in the background.', 'clock'),
item('task', '/admin/tasks/:taskId', 'Task', 'Task', 'One scheduled task and its run history.', 'clock', { hidden: true }),
],
},
{
/* External services, and the one place they are configured.
*
* Its own section rather than an entry under Operations, because the question it
* answers — is everything Memby depends on working — is not the same as "what is the
* container doing", and because four services' settings previously lived on four
* unrelated pages: MDBList under Movie ratings, the *arr switches and request
* policies on a page about Discord, and Tracearr nowhere at all. */
id: 'integrations',
label: 'Integrations',
defaultCollapsed: true,
id: 'users', label: 'Users', defaultCollapsed: false,
items: [
{
id: 'integrations',
path: '/admin/integrations',
label: 'Overview',
title: 'Integrations',
intro: 'Every external service Memby depends on: configured, working, and what it last did.',
icon: 'plug',
},
{
/* One service, addressed by its id — a page about a single integration belongs to
the integration rather than to the rail, the stance the user, device and task
pages take. */
id: 'integration',
path: '/admin/integrations/:integrationId',
label: 'Integration',
title: 'Integration',
intro: 'One external service: its switch, its settings, its jobs and its run history.',
icon: 'plug',
hidden: true,
},
{
id: 'webhooks',
path: '/admin/integrations/webhooks',
label: 'Event webhooks',
title: 'Event webhooks',
intro: 'Send administrative events to Discord and, in time, elsewhere.',
icon: 'send',
},
item('accounts', '/admin/accounts', 'Users', 'Users', 'Who uses Memby and the devices they are signed in on.', 'people'),
item('account', '/admin/accounts/:userId', 'User', 'User', 'Devices, recommendation setup and synced settings for one person.', 'person', { hidden: true }),
item('settings-history', '/admin/accounts/:userId/settings', 'Settings history', 'Settings history', 'Changes to one persons synced settings.', 'sliders', { hidden: true }),
item('clients', '/admin/clients', 'Devices', 'Devices', 'Which sets have reported in and what they are running.', 'tv'),
item('device', '/admin/devices/:deviceId', 'Device', 'Device', 'One televisions connections and activity.', 'tv', { hidden: true }),
],
},
{
id: 'insights',
label: 'Insights',
defaultCollapsed: true,
id: 'rules', label: 'Rules', defaultCollapsed: false,
items: [
{
id: 'views',
path: '/admin/views',
label: 'Views',
title: 'App views',
intro: 'Home-screen visits, viewers and the times Memby is used.',
icon: 'overview',
},
{
id: 'searches',
path: '/admin/searches',
label: 'Searches',
title: 'Searches',
intro: 'What viewers have been looking for, and what was searched just now.',
icon: 'search',
},
{
id: 'journeys',
path: '/admin/journeys',
label: 'Journeys',
title: 'User journeys',
intro: 'How viewers move through Memby, use features and complete flows.',
icon: 'journey',
},
{
id: 'engagement',
path: '/admin/engagement',
label: 'Row engagement',
title: 'Row engagement',
intro: 'Impressions, focus, dwell and selections per launcher row.',
icon: 'chart',
},
item('features', '/admin/features', 'Client configuration', 'Client configuration', 'Control everything the thin TV client renders.', 'sliders'),
item('notification-settings', '/admin/notification-settings', 'Notification rules', 'TV notifications', 'Choose where notifications appear and who receives them.', 'bell'),
item('webhooks', '/admin/integrations/webhooks', 'Event webhooks', 'Event webhooks', 'Send administrative events to external services.', 'send'),
],
},
{
id: 'violations', label: 'Violations', defaultCollapsed: false, collapsible: false,
items: [item('media-reports', '/admin/media-reports', 'Media reports', 'Media reports', 'Problems viewers reported with a film or episode.', 'alert')],
},
{
id: 'settings', label: 'Settings', defaultCollapsed: false,
items: [
item('integrations', '/admin/integrations', 'Integrations', 'Integrations', 'External services Memby depends on.', 'plug'),
item('integration', '/admin/integrations/:integrationId', 'Integration', 'Integration', 'One external service, its settings and run history.', 'plug', { hidden: true }),
item('maintenance', '/admin/maintenance', 'Maintenance', 'Maintenance', 'Take Memby offline or schedule quiet time.', 'wrench'),
item('gateway-settings', '/admin/settings', 'Gateway settings', 'Gateway settings', 'Timezone, logging and other server-level settings.', 'sliders', { hidden: true }),
],
},
];
export const allNavItems: NavItem[] = nav.flatMap((group) => group.items);
/** searchableNavItems is what the omni search offers: every page that has a URL of its
* own, which is every page that is not addressed by an id in the path. That test is the
* path rather than `hidden`, because the two answer different questions — a page can be
* off the rail (gateway settings is reached from the account menu) and still be a real
* address somebody would type. A page about one person or one television is the case
* this excludes: without knowing which person, the URL does not exist. */
export const searchableNavItems = nav.flatMap((group) =>
group.items
.filter((item) => !item.path.includes(':'))
.map((item) => ({ ...item, group: group.label ?? '' })),
.filter((navItem) => !navItem.path.includes(':'))
.map((navItem) => ({ ...navItem, group: group.label })),
);
export function navItem(id: string): NavItem | undefined {
return allNavItems.find((item) => item.id === id);
return allNavItems.find((navItem) => navItem.id === id);
}