diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 659d8070..8d66a56c 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -40,7 +40,10 @@ const miniDrawerWidth = 60;
const defaultRoute = '/channels';
const App = () => {
- const [open, setOpen] = useState(true);
+ const [open, setOpen] = useState(() => {
+ const stored = localStorage.getItem('dispatcharr_sidebar_open');
+ return stored === null ? true : stored === 'true';
+ });
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const isInitialized = useAuthStore((s) => s.isInitialized);
const setIsAuthenticated = useAuthStore((s) => s.setIsAuthenticated);
@@ -53,7 +56,11 @@ const App = () => {
const superuserCheckStarted = useRef(false);
const toggleDrawer = () => {
- setOpen(!open);
+ setOpen((prev) => {
+ const next = !prev;
+ localStorage.setItem('dispatcharr_sidebar_open', String(next));
+ return next;
+ });
};
// Check if a superuser exists on first load.
diff --git a/frontend/src/components/Sidebar.jsx b/frontend/src/components/Sidebar.jsx
index 636db2e3..22cb7489 100644
--- a/frontend/src/components/Sidebar.jsx
+++ b/frontend/src/components/Sidebar.jsx
@@ -1,21 +1,23 @@
-import React, { useState, useMemo } from 'react';
-import { Link, useLocation } from 'react-router-dom';
+import React, { useState, useMemo, useEffect } from 'react';
+import { Link, useLocation, useNavigate } from 'react-router-dom';
import { copyToClipboard } from '../utils';
import {
- Copy,
- LogOut,
- ChevronDown,
+ ArrowLeft,
ChevronRight,
+ Copy,
Heart,
HelpCircle,
+ LogOut,
} from 'lucide-react';
+import { SETTINGS_GROUPS } from '../config/settingsNav';
+import { SlidingPanels, usePanelNav } from './SlidingPanels';
import AboutModal from './AboutModal';
import { getOrderedNavItems } from '../config/navigation';
import {
Avatar,
+ Box,
Group,
Stack,
- Box,
Text,
UnstyledButton,
ActionIcon,
@@ -33,6 +35,8 @@ import { USER_LEVELS } from '../constants';
import UserForm from './forms/User';
import NotificationCenter from './NotificationCenter';
+// ─── Small shared components ─────────────────────────────────────────────────
+
const DonateButton = ({ tooltipPosition = 'top' }) => (
(
);
-const NavLink = ({ item, isActive, collapsed }) => {
- const IconComponent = item.icon;
+/** A single leaf nav item that navigates to item.path. */
+const NavItem = ({ item, isActive, collapsed }) => {
+ const Icon = item.icon;
return (
-
- {IconComponent && }
- {!collapsed && (
-
- {item.label}
-
- )}
- {!collapsed && item.badge && (
-
- {item.badge}
-
- )}
-
+
+
+ {Icon && }
+ {!collapsed && (
+
+ {item.label}
+
+ )}
+ {!collapsed && item.badge && (
+
+ {item.badge}
+
+ )}
+
+
);
};
-function NavGroup({ label, icon: IconComponent, paths, location, collapsed }) {
- const [open, setOpen] = useState(() =>
- paths.some((p) => location.pathname.startsWith(p.path))
- );
-
- const parentActive = paths
- .map((path) => path.path)
- .includes(location.pathname);
-
+/** Flat group with a decorative heading, matches the settings sub-panel design language. */
+function NavGroup({ label, paths, location, collapsed, onSettingsClick, settingsPanelOpen }) {
return (
-
- setOpen((o) => !o)}
- className={`navlink ${parentActive ? 'navlink-parent-active' : ''} ${open ? 'navlink-collapsed' : ''}`}
- style={{ width: '100%' }}
- >
- {IconComponent && }
- {!collapsed && (
-
-
- {label}
-
-
-
- {open ? : }
-
-
- )}
-
-
- {open && (
-
-
- {paths.map((child) => {
- const active = location.pathname === child.path;
- return (
-
-
-
- );
- })}
-
-
+
+ {!collapsed && (
+
+ {label}
+
)}
+
+ {paths.map((child) => {
+ if (child.path === '/settings' && onSettingsClick) {
+ const Icon = child.icon;
+ const isActive = settingsPanelOpen || location.pathname.startsWith('/settings');
+ return (
+
+
+ {Icon && }
+ {!collapsed && (
+ <>
+
+ {child.label}
+
+
+ >
+ )}
+
+
+ );
+ }
+ return (
+
+ );
+ })}
+
);
}
+/** Back button shown at the top of every sub-panel. */
+const BackButton = ({ label, collapsed, onClick }) => (
+
+
+
+ {!collapsed && (
+
+ {label}
+
+ )}
+
+
+);
+
+// ─── Sidebar ─────────────────────────────────────────────────────────────────
+
const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
const location = useLocation();
+ const navigate = useNavigate();
const channelIds = useChannelsStore((s) => s.channelIds);
const environment = useSettingsStore((s) => s.environment);
@@ -165,20 +164,33 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
const [aboutOpen, setAboutOpen] = useState(false);
const [ipRevealed, setIpRevealed] = useState(false);
- const closeUserForm = () => setUserFormOpen(false);
-
const isAdmin = authUser && authUser.user_level >= USER_LEVELS.ADMIN;
- // Navigation Items - computed from user's saved order, filtered by visibility
const navOrder = getNavOrder();
const hiddenNav = getHiddenNav();
const navItems = useMemo(() => {
- const orderedItems = getOrderedNavItems(navOrder, isAdmin, channelIds);
- return orderedItems.filter((item) => !hiddenNav.includes(item.id));
+ const ordered = getOrderedNavItems(navOrder, isAdmin, channelIds);
+ return ordered.filter((item) => !hiddenNav.includes(item.id));
}, [navOrder, hiddenNav, isAdmin, channelIds]);
- // Environment settings and version are loaded by the settings store during initData()
- // No need to fetch them again here - just use the store values
+ const isSettingsPage = location.pathname.startsWith('/settings');
+ const activeSettingsId = location.hash.replace('#', '');
+ const visibleSettingsGroups = SETTINGS_GROUPS.filter((g) => !g.adminOnly || isAdmin);
+
+ // Panel navigation state, drives the SlidingPanels component
+ const nav = usePanelNav();
+ // Destructure push so the stable setPanel reference appears in the dep array,
+ // not the recreated nav object.
+ const { push: pushPanel } = nav;
+
+ // Sync settings route → panel state without causing loops.
+ useEffect(() => {
+ pushPanel((curr) => {
+ if (isSettingsPage && curr?.type !== 'settings') return { type: 'settings' };
+ if (!isSettingsPage && curr?.type === 'settings') return null;
+ return curr;
+ });
+ }, [isSettingsPage, pushPanel]);
const copyPublicIP = async () => {
await copyToClipboard(environment.public_ip, {
@@ -187,23 +199,151 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
});
};
+ const handleBack = () => {
+ nav.close(); // close panel only, content stays on whatever is currently rendered
+ };
+
+ // ── Panel content ────────────────────────────────────────────────────────
+
+ const primaryPanel = (
+
+
+ {navItems.flatMap((item, idx) => {
+ const els = [];
+
+ if (item.paths) {
+ if (idx > 0) {
+ els.push(
+
+ );
+ }
+ els.push(
+ nav.push({ type: 'settings' })}
+ settingsPanelOpen={nav.isOpen}
+ />
+ );
+ return els;
+ }
+
+ // Settings leaf item: open sidebar sub-panel instead of navigating
+ if (item.path === '/settings') {
+ const Icon = item.icon;
+ const isActive = nav.isOpen || location.pathname.startsWith('/settings');
+ els.push(
+
+ nav.push({ type: 'settings' })}
+ className={`navlink${isActive ? ' navlink-active' : ''}${collapsed ? ' navlink-collapsed' : ''}`}
+ style={{ width: '100%' }}
+ >
+ {Icon && }
+ {!collapsed && (
+ <>
+
+ {item.label}
+
+
+ >
+ )}
+
+
+ );
+ return els;
+ }
+
+ els.push(
+
+ );
+ return els;
+ })}
+
+
+ );
+
+ const secondaryPanel = (
+
+
+
+ {/* Settings sub-panel */}
+ {nav.displayed?.type === 'settings' &&
+ visibleSettingsGroups.map((group, gi) => (
+
+ {gi > 0 && }
+ {!collapsed && (
+
+ {group.label}
+
+ )}
+
+ {group.sections.map((section) => {
+ const Icon = section.icon;
+ return (
+
+ navigate(`/settings#${section.id}`, { replace: true })}
+ className={`navlink${activeSettingsId === section.id ? ' navlink-active' : ''}${collapsed ? ' navlink-collapsed' : ''}`}
+ style={{ width: '100%' }}
+ >
+
+ {!collapsed && (
+ {section.label}
+ )}
+
+
+ );
+ })}
+
+
+ ))}
+
+
+
+
+
+
+
+ );
+
+ // ── Render ───────────────────────────────────────────────────────────────
+
return (
- {/* Brand - Click to Toggle */}
+ {/* Brand: click to toggle collapse */}
{
whiteSpace: 'nowrap',
}}
>
- {/* */}
-
+
{!collapsed && (
-
+
Dispatcharr
)}
- {/* Navigation Links */}
-
-
- {navItems.map((item) => {
- if (item.paths) {
- return (
-
- );
- }
-
- const isActive = location.pathname === item.path;
-
- return (
-
- );
- })}
-
-
+
{/* Profile Section */}
{
environment.ip_lookup_enabled !== false &&
environment.ip_lookup_pending && (
-
- Public IP
-
+ Public IP
)}
@@ -305,13 +399,8 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
!environment.ip_lookup_pending &&
environment.public_ip &&
!environment.public_ip.startsWith('Error') && (
- setIpRevealed((v) => !v)}
- style={{ cursor: 'pointer' }}
- >
-
- Public IP
-
+ setIpRevealed((v) => !v)} style={{ cursor: 'pointer' }}>
+ Public IP
{
{environment.country_code && (
)}
-
+
{(() => {
const ip = environment.public_ip;
const isIPv6 = ip.includes(':');
const sep = isIPv6 ? ':' : '.';
const parts = ip.split(sep);
const splitAt = isIPv6 ? 4 : 2;
- const visible =
- parts.slice(0, splitAt).join(sep) + sep;
- const hidden = parts.slice(splitAt).join(sep);
return (
<>
- {visible}
-
- {hidden}
+ {parts.slice(0, splitAt).join(sep) + sep}
+
+ {parts.slice(splitAt).join(sep)}
>
);
})()}
- {
- e.stopPropagation();
- copyPublicIP();
- }}
- style={{ flexShrink: 0 }}
- >
+ { e.stopPropagation(); copyPublicIP(); }} style={{ flexShrink: 0 }}>
@@ -390,17 +449,14 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
)}
{!collapsed && authUser && (
-
+
setUserFormOpen(true)}>
{authUser.first_name || authUser.username}
-
+
@@ -414,13 +470,9 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
)}
- {/* Version and Notification */}
+ {/* Version and Notifications */}
{!collapsed && (
-
+
{
copyToClipboard(
`v${appVersion?.version || '0.0.0'}${appVersion?.timestamp ? `-${appVersion.timestamp}` : ''}`,
- {
- successTitle: 'Copied',
- successMessage: 'Version copied to clipboard',
- }
+ { successTitle: 'Copied', successMessage: 'Version copied to clipboard' }
)
}
>
- v{appVersion?.version || '0.0.0'}
- {appVersion?.timestamp ? `-${appVersion.timestamp}` : ''}
+ v{appVersion?.version || '0.0.0'}{appVersion?.timestamp ? `-${appVersion.timestamp}` : ''}
- setAboutOpen(true)}
- >
+ setAboutOpen(true)}>
@@ -466,30 +503,18 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
)}
{collapsed && (
-
+
{isAuthenticated && }
- setAboutOpen(true)}
- >
+ setAboutOpen(true)}>
)}
-
+ setUserFormOpen(false)} />
setAboutOpen(false)} />
);
diff --git a/frontend/src/components/SlidingPanels.jsx b/frontend/src/components/SlidingPanels.jsx
new file mode 100644
index 00000000..9296b727
--- /dev/null
+++ b/frontend/src/components/SlidingPanels.jsx
@@ -0,0 +1,66 @@
+import { useState, useRef } from 'react';
+import { Box } from '@mantine/core';
+
+/**
+ * Manages drill-down panel navigation for a two-level sidebar.
+ *
+ * Usage:
+ * const nav = usePanelNav();
+ * nav.push({ type: 'settings' }) // open secondary panel
+ * nav.close() // return to primary
+ * nav.displayed // safe to read during exit animation
+ */
+export function usePanelNav() {
+ const [panel, setPanel] = useState(null);
+
+ // Retain last non-null value so secondary content stays visible
+ // during the slide-out exit animation (when panel is already null).
+ const lastRef = useRef(null);
+ if (panel !== null) lastRef.current = panel;
+
+ return {
+ panel, // null = primary visible
+ displayed: panel ?? lastRef.current,
+ isOpen: panel !== null,
+ push: setPanel, // React's setPanel is stable, safe in deps
+ close: () => setPanel(null),
+ };
+}
+
+/**
+ * Animated two-panel container.
+ *
+ * When isOpen is false: primary is visible (translateX 0), secondary is off-screen right.
+ * When isOpen is true: secondary slides in from the right, primary slides out left.
+ *
+ * Props:
+ * isOpen {boolean} drives the transition
+ * primary {ReactNode} root panel content
+ * secondary {ReactNode} drill-down panel content
+ */
+export function SlidingPanels({ isOpen, primary, secondary }) {
+ return (
+
+
+ {primary}
+
+
+ {secondary}
+
+
+ );
+}
diff --git a/frontend/src/components/__tests__/Sidebar.test.jsx b/frontend/src/components/__tests__/Sidebar.test.jsx
index a4e99f7f..a4f0ae2d 100644
--- a/frontend/src/components/__tests__/Sidebar.test.jsx
+++ b/frontend/src/components/__tests__/Sidebar.test.jsx
@@ -26,45 +26,50 @@ vi.mock('../AboutModal', () => ({
default: () => null,
}));
-// Mock lucide-react icons
-vi.mock('lucide-react', () => ({
- ListOrdered: ({ onClick }) => (
-
- ),
- Play: ({ onClick }) => ,
- Database: ({ onClick }) => (
-
- ),
- LayoutGrid: ({ onClick }) => (
-
- ),
- Settings: ({ onClick }) => (
-
- ),
- Copy: ({ onClick }) => ,
- ChartLine: ({ onClick }) => (
-
- ),
- Video: ({ onClick }) => ,
- PlugZap: ({ onClick }) => (
-
- ),
- LogOut: ({ onClick }) => ,
- User: ({ onClick }) => ,
- FileImage: ({ onClick }) => (
-
- ),
- Webhook: () => ,
- Logs: () => ,
- ChevronDown: () => ,
- ChevronRight: () => ,
- MonitorCog: () => ,
- Blocks: () => ,
- Heart: () => ,
- Package: () => ,
- Download: () => ,
- HelpCircle: () => ,
-}));
+// Mock lucide-react icons: spread actual module so settingsNav icons are available,
+// override specific ones with testid-bearing stubs for assertions.
+vi.mock('lucide-react', async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ ListOrdered: ({ onClick }) => (
+
+ ),
+ Play: ({ onClick }) => ,
+ Database: ({ onClick }) => (
+
+ ),
+ LayoutGrid: ({ onClick }) => (
+
+ ),
+ Settings: ({ onClick }) => (
+
+ ),
+ Copy: ({ onClick }) => ,
+ ChartLine: ({ onClick }) => (
+
+ ),
+ Video: ({ onClick }) => ,
+ PlugZap: ({ onClick }) => (
+
+ ),
+ LogOut: ({ onClick }) => ,
+ User: ({ onClick }) => ,
+ FileImage: ({ onClick }) => (
+
+ ),
+ Webhook: () => ,
+ Logs: () => ,
+ ChevronRight: () => ,
+ MonitorCog: () => ,
+ Blocks: () => ,
+ Heart: () => ,
+ Package: () => ,
+ Download: () => ,
+ HelpCircle: () => ,
+ ArrowLeft: () => ,
+ };
+});
// Mock UserForm component
vi.mock('../forms/User', () => ({
@@ -509,98 +514,35 @@ describe('Sidebar', () => {
});
describe('NavGroup Component', () => {
- it('should render Integrations group with children collapsed by default', () => {
+ it('renders Integrations group heading and children always visible', () => {
renderSidebar();
expect(screen.getByText('Integrations')).toBeInTheDocument();
- expect(screen.queryByText('Connections')).not.toBeInTheDocument();
- expect(screen.queryByText('Logs')).not.toBeInTheDocument();
+ expect(screen.getByText('Connections')).toBeInTheDocument();
+ expect(screen.getByText('Logs')).toBeInTheDocument();
});
- it('should expand Integrations group when clicked', async () => {
- renderSidebar();
-
- const integrationsGroup = screen
- .getByText('Integrations')
- .closest('button');
- fireEvent.click(integrationsGroup);
-
- await waitFor(() => {
- expect(screen.getByText('Connections')).toBeInTheDocument();
- expect(screen.getByText('Logs')).toBeInTheDocument();
- });
- });
-
- it('should collapse Integrations group when clicked again', async () => {
- renderSidebar();
-
- const integrationsGroup = screen
- .getByText('Integrations')
- .closest('button');
-
- // Expand
- fireEvent.click(integrationsGroup);
- await waitFor(() => {
- expect(screen.getByText('Connections')).toBeInTheDocument();
- });
-
- // Collapse
- fireEvent.click(integrationsGroup);
- await waitFor(() => {
- expect(screen.queryByText('Connections')).not.toBeInTheDocument();
- expect(screen.queryByText('Logs')).not.toBeInTheDocument();
- });
- });
-
- it('should render System group with children collapsed by default', () => {
+ it('renders System group heading and children always visible', () => {
renderSidebar();
expect(screen.getByText('System')).toBeInTheDocument();
- expect(screen.queryByText('Users')).not.toBeInTheDocument();
- expect(screen.queryByText('Logo Manager')).not.toBeInTheDocument();
+ expect(screen.getByText('Users')).toBeInTheDocument();
+ expect(screen.getByText('Logo Manager')).toBeInTheDocument();
});
- it('should expand System group when clicked', async () => {
+ it('renders both groups simultaneously', () => {
renderSidebar();
- const systemGroup = screen.getByText('System').closest('button');
- fireEvent.click(systemGroup);
-
- await waitFor(() => {
- expect(screen.getByText('Users')).toBeInTheDocument();
- expect(screen.getByText('Logo Manager')).toBeInTheDocument();
- expect(screen.getByText('Settings')).toBeInTheDocument();
- });
+ expect(screen.getByText('Connections')).toBeInTheDocument();
+ expect(screen.getByText('Users')).toBeInTheDocument();
});
- it('should hide group label when collapsed sidebar', () => {
+ it('hides group headings when sidebar is collapsed', () => {
renderSidebar({ collapsed: true });
expect(screen.queryByText('Integrations')).not.toBeInTheDocument();
expect(screen.queryByText('System')).not.toBeInTheDocument();
});
-
- it('should not show multiple groups collapsed when both expanded', async () => {
- renderSidebar();
-
- const integrationsGroup = screen
- .getByText('Integrations')
- .closest('button');
- const systemGroup = screen.getByText('System').closest('button');
-
- // Expand Integrations
- fireEvent.click(integrationsGroup);
- await waitFor(() => {
- expect(screen.getByText('Connections')).toBeInTheDocument();
- });
-
- // Expand System (Integrations should remain expanded)
- fireEvent.click(systemGroup);
- await waitFor(() => {
- expect(screen.getByText('Users')).toBeInTheDocument();
- expect(screen.getByText('Connections')).toBeInTheDocument();
- });
- });
});
describe('NotificationCenter Integration', () => {
diff --git a/frontend/src/components/sidebar.css b/frontend/src/components/sidebar.css
index ccbf88e9..356913cf 100644
--- a/frontend/src/components/sidebar.css
+++ b/frontend/src/components/sidebar.css
@@ -4,11 +4,11 @@
flex-wrap: nowrap;
align-items: center;
gap: 12px;
- padding: 5px 8px !important;
+ padding: 4px 8px !important;
border-radius: 6px;
background-color: transparent; /* Default background when not active */
border: 1px solid transparent;
- transition: all 0.3s ease;
+ transition: background-color 0.3s ease, border-color 0.3s ease, color 0.3s ease;
margin-left: 2px;
}
@@ -20,8 +20,8 @@
}
/* Hover effect */
-.navlink:hover, .navlink-parent-active {
- background-color: #2A2F34; /* Gray hover effect when not active */
+.navlink:hover {
+ background-color: #2A2F34;
border: 1px solid #3D3D42;
}
@@ -34,25 +34,11 @@
/* Collapse condition for justifyContent */
.navlink.navlink-collapsed {
justify-content: center;
+ padding: 4px !important;
}
.navlink:not(.navlink-collapsed) {
justify-content: flex-start;
}
-/* Left indicator for open nav groups rendered outside buttons */
-.navgroup-open {
- position: relative;
- overflow: visible;
-}
-.navgroup-open::before {
- content: '';
- position: absolute;
- left: 0; /* avoid horizontal overflow; sits at container edge */
- top: 0;
- bottom: 0;
- width: 2px;
- background: #3BA882;
- pointer-events: none;
- z-index: 2;
-}
+
diff --git a/frontend/src/config/settingsNav.js b/frontend/src/config/settingsNav.js
new file mode 100644
index 00000000..4e7c9311
--- /dev/null
+++ b/frontend/src/config/settingsNav.js
@@ -0,0 +1,80 @@
+import {
+ ArrowLeftRight,
+ CalendarDays,
+ DatabaseBackup,
+ FileOutput,
+ Menu,
+ Monitor,
+ Network,
+ Palette,
+ Settings2,
+ SlidersHorizontal,
+ Tv,
+ Users,
+ Video,
+} from 'lucide-react';
+
+export const SETTINGS_GROUPS = [
+ {
+ id: 'interface',
+ label: 'Interface',
+ adminOnly: false,
+ sections: [
+ { id: 'ui-settings', label: 'UI Settings', icon: Palette },
+ { id: 'nav-order', label: 'Navigation', icon: Menu },
+ ],
+ },
+ {
+ id: 'streaming',
+ label: 'Streaming',
+ adminOnly: true,
+ sections: [
+ { id: 'stream-settings', label: 'Stream Settings', icon: Video },
+ { id: 'proxy-settings', label: 'Proxy Settings', icon: ArrowLeftRight },
+ { id: 'stream-profiles', label: 'Stream Profiles', icon: SlidersHorizontal },
+ { id: 'output-profiles', label: 'Output Profiles', icon: FileOutput },
+ ],
+ },
+ {
+ id: 'dvr',
+ label: 'DVR',
+ adminOnly: true,
+ sections: [
+ { id: 'dvr-settings', label: 'DVR Settings', icon: Tv },
+ ],
+ },
+ {
+ id: 'epg',
+ label: 'EPG',
+ adminOnly: true,
+ sections: [
+ { id: 'epg-settings', label: 'EPG', icon: CalendarDays },
+ ],
+ },
+ {
+ id: 'network',
+ label: 'Network',
+ adminOnly: true,
+ sections: [
+ { id: 'user-agents', label: 'User-Agents', icon: Monitor },
+ { id: 'network-access', label: 'Network Access', icon: Network },
+ ],
+ },
+ {
+ id: 'system',
+ label: 'System',
+ adminOnly: true,
+ sections: [
+ { id: 'system-settings', label: 'System Settings', icon: Settings2 },
+ { id: 'user-limits', label: 'User Limits', icon: Users },
+ ],
+ },
+ {
+ id: 'backup',
+ label: 'Backup',
+ adminOnly: true,
+ sections: [
+ { id: 'backups', label: 'Backup & Restore', icon: DatabaseBackup },
+ ],
+ },
+];
diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx
index 1c7964cc..6af7013d 100644
--- a/frontend/src/pages/Settings.jsx
+++ b/frontend/src/pages/Settings.jsx
@@ -1,16 +1,18 @@
import React, { Suspense, useState, useEffect } from 'react';
import { useLocation } from 'react-router-dom';
import {
- Accordion,
- AccordionControl,
- AccordionItem,
- AccordionPanel,
Box,
- Center,
Divider,
- Text,
Loader,
+ Paper,
+ Text,
} from '@mantine/core';
+import { SETTINGS_GROUPS } from '../config/settingsNav';
+import useAuthStore from '../store/auth';
+import { USER_LEVELS } from '../constants';
+import UiSettingsForm from '../components/forms/settings/UiSettingsForm.jsx';
+import ErrorBoundary from '../components/ErrorBoundary.jsx';
+
const UserAgentsTable = React.lazy(
() => import('../components/tables/UserAgentsTable.jsx')
);
@@ -23,10 +25,6 @@ const OutputProfilesTable = React.lazy(
const BackupManager = React.lazy(
() => import('../components/backups/BackupManager.jsx')
);
-import useAuthStore from '../store/auth';
-import { USER_LEVELS } from '../constants';
-import UiSettingsForm from '../components/forms/settings/UiSettingsForm.jsx';
-import ErrorBoundary from '../components/ErrorBoundary.jsx';
const UserLimitsForm = React.lazy(
() => import('../components/forms/settings/UserLimitsForm.jsx')
);
@@ -52,207 +50,73 @@ const NavOrderForm = React.lazy(
() => import('../components/forms/settings/NavOrderForm.jsx')
);
+const COMPONENT_MAP = {
+ 'ui-settings': UiSettingsForm,
+ 'nav-order': NavOrderForm,
+ 'stream-settings': StreamSettingsForm,
+ 'stream-profiles': StreamProfilesTable,
+ 'output-profiles': OutputProfilesTable,
+ 'dvr-settings': DvrSettingsForm,
+ 'epg-settings': EpgSettingsForm,
+ 'user-agents': UserAgentsTable,
+ 'network-access': NetworkAccessForm,
+ 'proxy-settings': ProxySettingsForm,
+ 'system-settings': SystemSettingsForm,
+ 'user-limits': UserLimitsForm,
+ backups: BackupManager,
+};
+
const SettingsPage = () => {
const authUser = useAuthStore((s) => s.user);
const location = useLocation();
+ const isAdmin = authUser.user_level >= USER_LEVELS.ADMIN;
- const [accordianValue, setAccordianValue] = useState('ui-settings');
+ const [activeSection, setActiveSection] = useState(
+ () => location.hash.replace('#', '') || null
+ );
- // Handle hash navigation to open specific accordion
useEffect(() => {
const hash = location.hash.replace('#', '');
- if (hash) {
- setAccordianValue(hash);
- }
+ if (hash) setActiveSection(hash);
}, [location.hash]);
+ const visibleGroups = SETTINGS_GROUPS.filter((g) => !g.adminOnly || isAdmin);
+ const allSections = visibleGroups.flatMap((g) => g.sections);
+ const activeSectionConfig = activeSection
+ ? (allSections.find((s) => s.id === activeSection) ?? null)
+ : null;
+ const ActiveComponent = activeSectionConfig ? COMPONENT_MAP[activeSectionConfig.id] : null;
+
return (
-
-
-
+ {ActiveComponent ? (
+
+
+ {activeSectionConfig.label}
+
+
+
+ }>
+
+
+
+
+ ) : (
+
-
- UI Settings
-
-
-
-
-
- Navigation
-
-
- }>
-
-
-
-
-
-
-
-
-
- {authUser.user_level >= USER_LEVELS.ADMIN && (
- <>
-
- DVR
-
-
- }>
-
-
-
-
-
-
-
- Stream Settings
-
-
- }>
-
-
-
-
-
-
-
- EPG
-
-
- }>
-
-
-
-
-
-
-
- System Settings
-
-
- }>
-
-
-
-
-
-
-
- User-Agents
-
-
- }>
-
-
-
-
-
-
-
- Stream Profiles
-
-
- }>
-
-
-
-
-
-
-
- Output Profiles
-
-
- }>
-
-
-
-
-
-
-
-
- Network Access
- {accordianValue === 'network-access' && (
-
- Comma-Delimited CIDR ranges
-
- )}
-
-
-
- }>
-
-
-
-
-
-
-
-
- Proxy Settings
-
-
-
- }>
-
-
-
-
-
-
-
- Backup & Restore
-
-
- }>
-
-
-
-
-
-
-
- User Limits
-
-
- }>
-
-
-
-
-
- >
- )}
-
-
-
+
+ Select a setting from the sidebar
+
+
+ )}
+
);
};
diff --git a/frontend/src/pages/__tests__/Settings.test.jsx b/frontend/src/pages/__tests__/Settings.test.jsx
index 7a01b95a..27c38347 100644
--- a/frontend/src/pages/__tests__/Settings.test.jsx
+++ b/frontend/src/pages/__tests__/Settings.test.jsx
@@ -4,298 +4,181 @@ import { MemoryRouter } from 'react-router-dom';
import SettingsPage from '../Settings';
import useAuthStore from '../../store/auth';
import { USER_LEVELS } from '../../constants';
-import userEvent from '@testing-library/user-event';
-// Mock all dependencies
vi.mock('../../store/auth');
vi.mock('../../components/tables/UserAgentsTable', () => ({
default: ({ active }) => (
-
- UserAgentsTable {active ? 'active' : 'inactive'}
-
+ UserAgentsTable {active ? 'active' : 'inactive'}
),
}));
vi.mock('../../components/tables/StreamProfilesTable', () => ({
default: ({ active }) => (
-
- StreamProfilesTable {active ? 'active' : 'inactive'}
-
+ StreamProfilesTable {active ? 'active' : 'inactive'}
),
}));
vi.mock('../../components/backups/BackupManager', () => ({
default: ({ active }) => (
-
- BackupManager {active ? 'active' : 'inactive'}
-
+ BackupManager {active ? 'active' : 'inactive'}
),
}));
vi.mock('../../components/forms/settings/UiSettingsForm', () => ({
default: ({ active }) => (
-
- UiSettingsForm {active ? 'active' : 'inactive'}
-
+ UiSettingsForm {active ? 'active' : 'inactive'}
),
}));
vi.mock('../../components/forms/settings/NetworkAccessForm', () => ({
default: ({ active }) => (
-
- NetworkAccessForm {active ? 'active' : 'inactive'}
-
+ NetworkAccessForm {active ? 'active' : 'inactive'}
),
}));
vi.mock('../../components/forms/settings/ProxySettingsForm', () => ({
default: ({ active }) => (
-
- ProxySettingsForm {active ? 'active' : 'inactive'}
-
+ ProxySettingsForm {active ? 'active' : 'inactive'}
),
}));
vi.mock('../../components/forms/settings/StreamSettingsForm', () => ({
default: ({ active }) => (
-
- StreamSettingsForm {active ? 'active' : 'inactive'}
-
+ StreamSettingsForm {active ? 'active' : 'inactive'}
),
}));
vi.mock('../../components/forms/settings/DvrSettingsForm', () => ({
default: ({ active }) => (
-
- DvrSettingsForm {active ? 'active' : 'inactive'}
-
+ DvrSettingsForm {active ? 'active' : 'inactive'}
),
}));
vi.mock('../../components/forms/settings/SystemSettingsForm', () => ({
default: ({ active }) => (
-
- SystemSettingsForm {active ? 'active' : 'inactive'}
-
+ SystemSettingsForm {active ? 'active' : 'inactive'}
),
}));
vi.mock('../../components/forms/settings/NavOrderForm', () => ({
default: ({ active }) => (
-
- NavOrderForm {active ? 'active' : 'inactive'}
-
+ NavOrderForm {active ? 'active' : 'inactive'}
),
}));
vi.mock('../../components/forms/settings/UserLimitsForm', () => ({
default: ({ active }) => (
-
- UserLimitsForm {active ? 'active' : 'inactive'}
-
+ UserLimitsForm {active ? 'active' : 'inactive'}
),
}));
vi.mock('../../components/ErrorBoundary', () => ({
default: ({ children }) => {children}
,
}));
-vi.mock('@mantine/core', async () => {
- const accordionComponent = ({ children, onChange, defaultValue }) => (
- {children}
- );
- accordionComponent.Item = ({ children, value }) => (
- {children}
- );
- accordionComponent.Control = ({ children }) => (
-
- );
- accordionComponent.Panel = ({ children }) => (
- {children}
- );
+vi.mock('@mantine/core', async () => ({
+ Box: ({ children }) => {children}
,
+ Divider: () =>
,
+ Loader: () => Loading...
,
+ Paper: ({ children }) => {children}
,
+ Text: ({ children }) => {children},
+}));
- return {
- Accordion: accordionComponent,
- AccordionItem: accordionComponent.Item,
- AccordionControl: accordionComponent.Control,
- AccordionPanel: accordionComponent.Panel,
- Box: ({ children }) => {children}
,
- Center: ({ children }) => {children}
,
- Divider: () =>
,
- Loader: () => Loading...
,
- Text: ({ children }) => {children},
- };
-});
-
-// Helper function to render with router context
-const renderWithRouter = (
- component,
- { initialEntries = ['/settings'] } = {}
-) => {
- return render(
- {component}
- );
-};
+const renderWithRouter = (component, { initialEntries = ['/settings'] } = {}) =>
+ render({component});
describe('SettingsPage', () => {
beforeEach(() => {
vi.clearAllMocks();
});
- describe('Rendering for Regular User', () => {
+ describe('no section selected', () => {
beforeEach(() => {
- useAuthStore.mockReturnValue({
- user_level: USER_LEVELS.USER,
- username: 'testuser',
- });
+ useAuthStore.mockReturnValue({ user_level: USER_LEVELS.USER, username: 'testuser' });
});
- it('renders the settings page', () => {
+ it('shows placeholder when no hash', () => {
renderWithRouter();
-
- expect(screen.getAllByTestId('accordion').length).toBeGreaterThan(0);
- });
-
- it('renders UI Settings accordion item', () => {
- renderWithRouter();
-
- expect(
- screen.getByTestId('accordion-item-ui-settings')
- ).toBeInTheDocument();
- expect(screen.getByText('UI Settings')).toBeInTheDocument();
- });
-
- it('opens UI Settings panel by default', () => {
- renderWithRouter();
-
- expect(screen.getByTestId('ui-settings-form')).toBeInTheDocument();
- });
-
- it('does not render admin-only sections for regular users', () => {
- renderWithRouter();
-
- expect(screen.queryByText('DVR')).not.toBeInTheDocument();
- expect(screen.queryByText('Stream Settings')).not.toBeInTheDocument();
- expect(screen.queryByText('System Settings')).not.toBeInTheDocument();
- expect(screen.queryByText('User-Agents')).not.toBeInTheDocument();
- expect(screen.queryByText('Stream Profiles')).not.toBeInTheDocument();
- expect(screen.queryByText('Network Access')).not.toBeInTheDocument();
- expect(screen.queryByText('Proxy Settings')).not.toBeInTheDocument();
- expect(screen.queryByText('Backup & Restore')).not.toBeInTheDocument();
- });
-
- it('renders Navigation accordion item for regular users', () => {
- renderWithRouter();
-
- expect(
- screen.getByTestId('accordion-item-nav-order')
- ).toBeInTheDocument();
- expect(screen.getByText('Navigation')).toBeInTheDocument();
+ expect(screen.getByText('Select a setting from the sidebar')).toBeInTheDocument();
});
});
- describe('Rendering for Admin User', () => {
+ describe('regular user', () => {
beforeEach(() => {
- useAuthStore.mockReturnValue({
- user_level: USER_LEVELS.ADMIN,
- username: 'admin',
- });
+ useAuthStore.mockReturnValue({ user_level: USER_LEVELS.USER, username: 'testuser' });
});
- it('renders all accordion items for admin', async () => {
- renderWithRouter();
-
- expect(screen.getByText('UI Settings')).toBeInTheDocument();
-
+ it('renders ui-settings section via hash', async () => {
+ renderWithRouter(, { initialEntries: ['/settings#ui-settings'] });
await waitFor(() => {
- expect(screen.getByText('DVR')).toBeInTheDocument();
- expect(screen.getByText('Stream Settings')).toBeInTheDocument();
- expect(screen.getByText('System Settings')).toBeInTheDocument();
- expect(screen.getByText('User-Agents')).toBeInTheDocument();
- expect(screen.getByText('Stream Profiles')).toBeInTheDocument();
- expect(screen.getByText('Network Access')).toBeInTheDocument();
- expect(screen.getByText('Proxy Settings')).toBeInTheDocument();
- expect(screen.getByText('Backup & Restore')).toBeInTheDocument();
- expect(screen.getByText('Navigation')).toBeInTheDocument();
+ expect(screen.getByTestId('ui-settings-form')).toBeInTheDocument();
+ });
+ expect(screen.getByText('UI Settings')).toBeInTheDocument();
+ });
+
+ it('renders nav-order section via hash', async () => {
+ renderWithRouter(, { initialEntries: ['/settings#nav-order'] });
+ await waitFor(() => {
+ expect(screen.getByTestId('nav-order-form')).toBeInTheDocument();
});
});
- it('renders DVR settings accordion item', () => {
- renderWithRouter();
-
- expect(
- screen.getByTestId('accordion-item-dvr-settings')
- ).toBeInTheDocument();
- });
-
- it('renders Stream Settings accordion item', () => {
- renderWithRouter();
-
- expect(
- screen.getByTestId('accordion-item-stream-settings')
- ).toBeInTheDocument();
- });
-
- it('renders System Settings accordion item', () => {
- renderWithRouter();
-
- expect(
- screen.getByTestId('accordion-item-system-settings')
- ).toBeInTheDocument();
- });
-
- it('renders User-Agents accordion item', () => {
- renderWithRouter();
-
- expect(
- screen.getByTestId('accordion-item-user-agents')
- ).toBeInTheDocument();
- });
-
- it('renders Stream Profiles accordion item', () => {
- renderWithRouter();
-
- expect(
- screen.getByTestId('accordion-item-stream-profiles')
- ).toBeInTheDocument();
- });
-
- it('renders Network Access accordion item', () => {
- renderWithRouter();
-
- expect(
- screen.getByTestId('accordion-item-network-access')
- ).toBeInTheDocument();
- });
-
- it('renders Proxy Settings accordion item', () => {
- renderWithRouter();
-
- expect(
- screen.getByTestId('accordion-item-proxy-settings')
- ).toBeInTheDocument();
- });
-
- it('renders Backup & Restore accordion item', () => {
- renderWithRouter();
-
- expect(screen.getByTestId('accordion-item-backups')).toBeInTheDocument();
- });
-
- it('renders Navigation accordion item', () => {
- renderWithRouter();
-
- expect(
- screen.getByTestId('accordion-item-nav-order')
- ).toBeInTheDocument();
+ it('shows placeholder for admin-only section hash', () => {
+ renderWithRouter(, { initialEntries: ['/settings#dvr-settings'] });
+ expect(screen.getByText('Select a setting from the sidebar')).toBeInTheDocument();
});
});
- describe('Accordion Interactions', () => {
+ describe('admin user', () => {
beforeEach(() => {
- useAuthStore.mockReturnValue({
- user_level: USER_LEVELS.ADMIN,
- username: 'admin',
+ useAuthStore.mockReturnValue({ user_level: USER_LEVELS.ADMIN, username: 'admin' });
+ });
+
+ it('renders dvr-settings section via hash', async () => {
+ renderWithRouter(, { initialEntries: ['/settings#dvr-settings'] });
+ await waitFor(() => {
+ expect(screen.getByTestId('dvr-settings-form')).toBeInTheDocument();
+ });
+ expect(screen.getByText('DVR Settings')).toBeInTheDocument();
+ });
+
+ it('renders stream-settings section via hash', async () => {
+ renderWithRouter(, { initialEntries: ['/settings#stream-settings'] });
+ await waitFor(() => {
+ expect(screen.getByTestId('stream-settings-form')).toBeInTheDocument();
});
});
- it('opens DVR settings when clicked', async () => {
- const user = userEvent.setup();
- renderWithRouter();
+ it('renders stream-profiles section via hash', async () => {
+ renderWithRouter(, { initialEntries: ['/settings#stream-profiles'] });
+ await waitFor(() => {
+ expect(screen.getByTestId('stream-profiles-table')).toBeInTheDocument();
+ });
+ });
- const streamSettingsButton = screen.getByText('DVR');
- await user.click(streamSettingsButton);
+ it('renders network-access section via hash', async () => {
+ renderWithRouter(, { initialEntries: ['/settings#network-access'] });
+ await waitFor(() => {
+ expect(screen.getByTestId('network-access-form')).toBeInTheDocument();
+ });
+ });
- await screen.findByTestId('dvr-settings-form');
+ it('renders proxy-settings section via hash', async () => {
+ renderWithRouter(, { initialEntries: ['/settings#proxy-settings'] });
+ await waitFor(() => {
+ expect(screen.getByTestId('proxy-settings-form')).toBeInTheDocument();
+ });
+ });
+
+ it('renders backups section via hash', async () => {
+ renderWithRouter(, { initialEntries: ['/settings#backups'] });
+ await waitFor(() => {
+ expect(screen.getByTestId('backup-manager')).toBeInTheDocument();
+ });
+ });
+
+ it('renders system-settings section via hash', async () => {
+ renderWithRouter(, { initialEntries: ['/settings#system-settings'] });
+ await waitFor(() => {
+ expect(screen.getByTestId('system-settings-form')).toBeInTheDocument();
+ });
+ });
+
+ it('passes active=true to rendered component', async () => {
+ renderWithRouter(, { initialEntries: ['/settings#dvr-settings'] });
+ await waitFor(() => {
+ expect(screen.getByText(/active/)).toBeInTheDocument();
+ });
+ expect(screen.getByText('DvrSettingsForm active')).toBeInTheDocument();
});
});
});