This commit is contained in:
sethwv 2026-07-15 22:16:47 -04:00 committed by GitHub
commit 5cc6d4eee6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 650 additions and 797 deletions

View file

@ -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.

View file

@ -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' }) => (
<Tooltip label="Support Dispatcharr" position={tooltipPosition}>
<ActionIcon
@ -48,109 +52,104 @@ const DonateButton = ({ tooltipPosition = 'top' }) => (
</Tooltip>
);
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 (
<UnstyledButton
key={item.path}
component={Link}
to={item.path}
className={`navlink ${isActive ? 'navlink-active' : ''} ${collapsed ? 'navlink-collapsed' : ''}`}
>
{IconComponent && <IconComponent size={20} />}
{!collapsed && (
<Text
sx={{
opacity: collapsed ? 0 : 1,
transition: 'opacity 0.2s ease-in-out',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
minWidth: collapsed ? 0 : 150,
}}
>
{item.label}
</Text>
)}
{!collapsed && item.badge && (
<Text size="sm" style={{ color: '#D4D4D8', whiteSpace: 'nowrap' }}>
{item.badge}
</Text>
)}
</UnstyledButton>
<Tooltip label={item.label} position="right" withArrow disabled={!collapsed}>
<UnstyledButton
component={Link}
to={item.path}
className={`navlink${isActive ? ' navlink-active' : ''}${collapsed ? ' navlink-collapsed' : ''}`}
>
{Icon && <Icon size={18} />}
{!collapsed && (
<Text size="xs" style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', flex: 1, minWidth: 0 }}>
{item.label}
</Text>
)}
{!collapsed && item.badge && (
<Text size="xs" style={{ color: '#D4D4D8', whiteSpace: 'nowrap' }}>
{item.badge}
</Text>
)}
</UnstyledButton>
</Tooltip>
);
};
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 (
<Box
style={{ width: '100%', paddingRight: 2 }}
className={open ? 'navgroup-open' : ''}
>
<UnstyledButton
onClick={() => setOpen((o) => !o)}
className={`navlink ${parentActive ? 'navlink-parent-active' : ''} ${open ? 'navlink-collapsed' : ''}`}
style={{ width: '100%' }}
>
{IconComponent && <IconComponent size={20} />}
{!collapsed && (
<Group justify="space-between" style={{ width: '100%' }}>
<Text
sx={{
opacity: open ? 0 : 1,
transition: 'opacity 0.2s ease-in-out',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
minWidth: open ? 0 : 150,
}}
>
{label}
</Text>
<Box alignItems="center" style={{ display: 'flex' }}>
{open ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
</Box>
</Group>
)}
</UnstyledButton>
{open && (
<Box style={{ paddingTop: 10 }}>
<Stack gap="xs" pl={open ? 0 : 'lg'}>
{paths.map((child) => {
const active = location.pathname === child.path;
return (
<Box
style={{ paddingLeft: collapsed ? 0 : 35 }}
key={child.path}
>
<NavLink
key={child.path}
item={child}
isActive={active}
collapsed={collapsed}
/>
</Box>
);
})}
</Stack>
</Box>
<Box style={{ width: '100%' }}>
{!collapsed && (
<Text
size="xs"
fw={700}
tt="uppercase"
c="dimmed"
style={{ padding: '2px 10px 4px', letterSpacing: '0.05em', whiteSpace: 'nowrap' }}
>
{label}
</Text>
)}
<Stack gap={4}>
{paths.map((child) => {
if (child.path === '/settings' && onSettingsClick) {
const Icon = child.icon;
const isActive = settingsPanelOpen || location.pathname.startsWith('/settings');
return (
<Tooltip key={child.path} label={child.label} position="right" withArrow disabled={!collapsed}>
<UnstyledButton
onClick={onSettingsClick}
className={`navlink${isActive ? ' navlink-active' : ''}${collapsed ? ' navlink-collapsed' : ''}`}
style={{ width: '100%' }}
>
{Icon && <Icon size={18} />}
{!collapsed && (
<>
<Text size="xs" style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', flex: 1, minWidth: 0 }}>
{child.label}
</Text>
<ChevronRight size={14} style={{ flexShrink: 0, opacity: 0.5 }} />
</>
)}
</UnstyledButton>
</Tooltip>
);
}
return (
<NavItem key={child.path} item={child} isActive={location.pathname === child.path} collapsed={collapsed} />
);
})}
</Stack>
</Box>
);
}
/** Back button shown at the top of every sub-panel. */
const BackButton = ({ label, collapsed, onClick }) => (
<Tooltip label="Back" position="right" withArrow disabled={!collapsed}>
<UnstyledButton
onClick={onClick}
className={`navlink${collapsed ? ' navlink-collapsed' : ''}`}
style={{ width: '100%' }}
>
<ArrowLeft size={18} />
{!collapsed && (
<Text size="xs" fw={500} style={{ whiteSpace: 'nowrap' }}>
{label}
</Text>
)}
</UnstyledButton>
</Tooltip>
);
// 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 = (
<ScrollArea h="100%" type="scroll" scrollbars="y">
<Stack gap={4} mt="lg" style={{ minHeight: 0, overflowX: 'hidden', paddingRight: 2 }}>
{navItems.flatMap((item, idx) => {
const els = [];
if (item.paths) {
if (idx > 0) {
els.push(
<Box key={`div-${item.id}`} style={{ borderTop: '1px solid #2A2A2E', margin: '4px 4px 6px' }} />
);
}
els.push(
<NavGroup
key={item.id}
label={item.label}
paths={item.paths}
location={location}
collapsed={collapsed}
onSettingsClick={() => 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(
<Tooltip key={item.path} label={item.label} position="right" withArrow disabled={!collapsed}>
<UnstyledButton
onClick={() => nav.push({ type: 'settings' })}
className={`navlink${isActive ? ' navlink-active' : ''}${collapsed ? ' navlink-collapsed' : ''}`}
style={{ width: '100%' }}
>
{Icon && <Icon size={18} />}
{!collapsed && (
<>
<Text style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', flex: 1, minWidth: 0 }}>
{item.label}
</Text>
<ChevronRight size={14} style={{ flexShrink: 0, opacity: 0.5 }} />
</>
)}
</UnstyledButton>
</Tooltip>
);
return els;
}
els.push(
<NavItem
key={item.path}
item={item}
isActive={location.pathname === item.path}
collapsed={collapsed}
/>
);
return els;
})}
</Stack>
</ScrollArea>
);
const secondaryPanel = (
<Box style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<ScrollArea style={{ flex: 1, minHeight: 0 }} type="scroll" scrollbars="y">
<Stack gap={4} mt="xs" style={{ overflowX: 'hidden', paddingRight: 2 }}>
{/* Settings sub-panel */}
{nav.displayed?.type === 'settings' &&
visibleSettingsGroups.map((group, gi) => (
<Box key={group.id}>
{gi > 0 && <Box style={{ borderTop: '1px solid #2A2A2E', margin: '4px 4px 6px' }} />}
{!collapsed && (
<Text
size="xs"
fw={700}
tt="uppercase"
c="dimmed"
style={{ padding: '2px 10px 2px', letterSpacing: '0.05em', whiteSpace: 'nowrap' }}
>
{group.label}
</Text>
)}
<Stack gap={4}>
{group.sections.map((section) => {
const Icon = section.icon;
return (
<Tooltip
key={section.id}
label={section.label}
position="right"
withArrow
disabled={!collapsed}
>
<UnstyledButton
onClick={() => navigate(`/settings#${section.id}`, { replace: true })}
className={`navlink${activeSettingsId === section.id ? ' navlink-active' : ''}${collapsed ? ' navlink-collapsed' : ''}`}
style={{ width: '100%' }}
>
<Icon size={18} />
{!collapsed && (
<Text size="xs" style={{ whiteSpace: 'nowrap' }}>{section.label}</Text>
)}
</UnstyledButton>
</Tooltip>
);
})}
</Stack>
</Box>
))}
</Stack>
</ScrollArea>
<Stack gap={4} style={{ padding: '4px 0 8px' }}>
<BackButton label="Back" collapsed={collapsed} onClick={handleBack} />
</Stack>
</Box>
);
// Render
return (
<AppShellNavbar
width={{ base: collapsed ? miniDrawerWidth : drawerWidth }}
p="xs"
style={{
backgroundColor: '#1A1A1E',
// transition: 'width 0.3s ease',
borderRight: '1px solid #2A2A2E',
minHeight: '100vh',
display: 'flex',
flexDirection: 'column',
}}
>
{/* Brand - Click to Toggle */}
{/* Brand: click to toggle collapse */}
<Group
onClick={toggleDrawer}
spacing="sm"
style={{
cursor: 'pointer',
display: 'flex',
@ -217,63 +357,19 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
whiteSpace: 'nowrap',
}}
>
{/* <ListOrdered size={24} /> */}
<img width={30} src={logo} />
<img width={30} src={logo} alt="Dispatcharr" />
{!collapsed && (
<Text
sx={{
opacity: collapsed ? 0 : 1,
transition: 'opacity 0.2s ease-in-out',
whiteSpace: 'nowrap', // Ensures text never wraps
overflow: 'hidden',
textOverflow: 'ellipsis',
minWidth: collapsed ? 0 : 150, // Prevents reflow
}}
>
<Text style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', minWidth: 150 }}>
Dispatcharr
</Text>
)}
</Group>
{/* Navigation Links */}
<ScrollArea h="100%" type="scroll" scrollbars="y">
<Stack
gap="xs"
mt="lg"
style={{
flex: 1,
minHeight: 0,
overflowY: 'auto',
overflowX: 'hidden',
}}
>
{navItems.map((item) => {
if (item.paths) {
return (
<NavGroup
key={item.label}
label={item.label}
paths={item.paths}
location={location}
collapsed={collapsed}
icon={item.icon}
/>
);
}
const isActive = location.pathname === item.path;
return (
<NavLink
key={item.path}
item={item}
collapsed={collapsed}
isActive={isActive}
/>
);
})}
</Stack>
</ScrollArea>
<SlidingPanels
isOpen={nav.isOpen}
primary={primaryPanel}
secondary={secondaryPanel}
/>
{/* Profile Section */}
<Box
@ -293,9 +389,7 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
environment.ip_lookup_enabled !== false &&
environment.ip_lookup_pending && (
<Box>
<Text size="sm" fw={500} mb={4}>
Public IP
</Text>
<Text size="xs" fw={500} mb={4}>Public IP</Text>
<Skeleton height={36} radius="sm" />
</Box>
)}
@ -305,13 +399,8 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
!environment.ip_lookup_pending &&
environment.public_ip &&
!environment.public_ip.startsWith('Error') && (
<Box
onClick={() => setIpRevealed((v) => !v)}
style={{ cursor: 'pointer' }}
>
<Text size="sm" fw={500} mb={4}>
Public IP
</Text>
<Box onClick={() => setIpRevealed((v) => !v)} style={{ cursor: 'pointer' }}>
<Text size="xs" fw={500} mb={4}>Public IP</Text>
<Box
style={{
display: 'flex',
@ -327,62 +416,32 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
{environment.country_code && (
<img
src={`https://flagcdn.com/16x12/${environment.country_code.toLowerCase()}.png`}
alt={
environment.country_name || environment.country_code
}
title={[
environment.country_name || environment.country_code,
environment.city,
]
.filter(Boolean)
.join(', ')}
alt={environment.country_name || environment.country_code}
title={[environment.country_name || environment.country_code, environment.city]
.filter(Boolean).join(', ')}
style={{ flexShrink: 0 }}
/>
)}
<Box style={{ flex: 1, overflow: 'hidden' }}>
<span
style={{
display: 'block',
whiteSpace: 'nowrap',
fontSize: 'var(--mantine-font-size-sm)',
color: 'var(--mantine-color-text)',
}}
>
<span style={{ display: 'block', whiteSpace: 'nowrap', fontSize: 'var(--mantine-font-size-sm)', color: 'var(--mantine-color-text)' }}>
{(() => {
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}
<span
style={{
filter: ipRevealed ? 'none' : 'blur(5px)',
transition: 'filter 0.15s',
userSelect: ipRevealed ? 'text' : 'none',
}}
>
{hidden}
{parts.slice(0, splitAt).join(sep) + sep}
<span style={{ filter: ipRevealed ? 'none' : 'blur(5px)', transition: 'filter 0.15s', userSelect: ipRevealed ? 'text' : 'none' }}>
{parts.slice(splitAt).join(sep)}
</span>
</>
);
})()}
</span>
</Box>
<ActionIcon
variant="transparent"
color="gray.9"
onClick={(e) => {
e.stopPropagation();
copyPublicIP();
}}
style={{ flexShrink: 0 }}
>
<ActionIcon variant="transparent" color="gray.9" onClick={(e) => { e.stopPropagation(); copyPublicIP(); }} style={{ flexShrink: 0 }}>
<Copy />
</ActionIcon>
</Box>
@ -390,17 +449,14 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
)}
{!collapsed && authUser && (
<Group
gap="xs"
style={{ justifyContent: 'space-between', width: '100%' }}
>
<Group gap="xs" style={{ justifyContent: 'space-between', width: '100%' }}>
<Group gap="xs">
<Avatar src="" radius="xl" />
<UnstyledButton onClick={() => setUserFormOpen(true)}>
{authUser.first_name || authUser.username}
</UnstyledButton>
</Group>
<ActionIcon variant="transparent" color="white" size="sm">
<ActionIcon variant="transparent" color="white" size="xs">
<LogOut onClick={logout} />
</ActionIcon>
</Group>
@ -414,13 +470,9 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
)}
</Box>
{/* Version and Notification */}
{/* Version and Notifications */}
{!collapsed && (
<Group
gap="xs"
wrap="nowrap"
style={{ padding: '0 16px 16px', justifyContent: 'space-between' }}
>
<Group gap="xs" wrap="nowrap" style={{ padding: '0 16px 16px', justifyContent: 'space-between' }}>
<Tooltip
label={`v${appVersion?.version || '0.0.0'}${appVersion?.timestamp ? `-${appVersion.timestamp}` : ''}`}
position="top"
@ -428,35 +480,20 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
<Text
size="xs"
c="dimmed"
style={{
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
minWidth: 0,
flex: 1,
cursor: 'pointer',
}}
style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', minWidth: 0, flex: 1, cursor: 'pointer' }}
onClick={() =>
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}` : ''}
</Text>
</Tooltip>
<Group gap="xs" wrap="nowrap">
<Tooltip label="About" position="top">
<ActionIcon
variant="transparent"
color="gray"
onClick={() => setAboutOpen(true)}
>
<ActionIcon variant="transparent" color="gray" onClick={() => setAboutOpen(true)}>
<HelpCircle size={20} />
</ActionIcon>
</Tooltip>
@ -466,30 +503,18 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
</Group>
)}
{collapsed && (
<Box
style={{
padding: '0 16px 16px',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 8,
}}
>
<Box style={{ padding: '0 16px 16px', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 8 }}>
{isAuthenticated && <NotificationCenter />}
<DonateButton tooltipPosition="right" />
<Tooltip label="About" position="right">
<ActionIcon
variant="transparent"
color="gray"
onClick={() => setAboutOpen(true)}
>
<ActionIcon variant="transparent" color="gray" onClick={() => setAboutOpen(true)}>
<HelpCircle size={20} />
</ActionIcon>
</Tooltip>
</Box>
)}
<UserForm user={authUser} isOpen={userFormOpen} onClose={closeUserForm} />
<UserForm user={authUser} isOpen={userFormOpen} onClose={() => setUserFormOpen(false)} />
<AboutModal isOpen={aboutOpen} onClose={() => setAboutOpen(false)} />
</AppShellNavbar>
);

View file

@ -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 (
<Box style={{ flex: 1, position: 'relative', overflow: 'hidden', minHeight: 0 }}>
<Box
style={{
position: 'absolute',
inset: 0,
transform: isOpen ? 'translateX(-100%)' : 'translateX(0%)',
transition: 'transform 0.22s ease',
}}
>
{primary}
</Box>
<Box
style={{
position: 'absolute',
inset: 0,
transform: isOpen ? 'translateX(0%)' : 'translateX(100%)',
transition: 'transform 0.22s ease',
}}
>
{secondary}
</Box>
</Box>
);
}

View file

@ -26,45 +26,50 @@ vi.mock('../AboutModal', () => ({
default: () => null,
}));
// Mock lucide-react icons
vi.mock('lucide-react', () => ({
ListOrdered: ({ onClick }) => (
<div data-testid="list-ordered-icon" onClick={onClick} />
),
Play: ({ onClick }) => <div data-testid="play-icon" onClick={onClick} />,
Database: ({ onClick }) => (
<div data-testid="database-icon" onClick={onClick} />
),
LayoutGrid: ({ onClick }) => (
<div data-testid="layout-grid-icon" onClick={onClick} />
),
Settings: ({ onClick }) => (
<div data-testid="settings-icon" onClick={onClick} />
),
Copy: ({ onClick }) => <div data-testid="copy-icon" onClick={onClick} />,
ChartLine: ({ onClick }) => (
<div data-testid="chart-line-icon" onClick={onClick} />
),
Video: ({ onClick }) => <div data-testid="video-icon" onClick={onClick} />,
PlugZap: ({ onClick }) => (
<div data-testid="plug-zap-icon" onClick={onClick} />
),
LogOut: ({ onClick }) => <div data-testid="logout-icon" onClick={onClick} />,
User: ({ onClick }) => <div data-testid="user-icon" onClick={onClick} />,
FileImage: ({ onClick }) => (
<div data-testid="file-image-icon" onClick={onClick} />
),
Webhook: () => <div data-testid="webhook-icon" />,
Logs: () => <div data-testid="logs-icon" />,
ChevronDown: () => <div data-testid="chevron-down-icon" />,
ChevronRight: () => <div data-testid="chevron-right-icon" />,
MonitorCog: () => <div data-testid="monitor-cog-icon" />,
Blocks: () => <div data-testid="blocks-icon" />,
Heart: () => <div data-testid="heart-icon" />,
Package: () => <div data-testid="package-icon" />,
Download: () => <div data-testid="download-icon" />,
HelpCircle: () => <div data-testid="help-circle-icon" />,
}));
// 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 }) => (
<div data-testid="list-ordered-icon" onClick={onClick} />
),
Play: ({ onClick }) => <div data-testid="play-icon" onClick={onClick} />,
Database: ({ onClick }) => (
<div data-testid="database-icon" onClick={onClick} />
),
LayoutGrid: ({ onClick }) => (
<div data-testid="layout-grid-icon" onClick={onClick} />
),
Settings: ({ onClick }) => (
<div data-testid="settings-icon" onClick={onClick} />
),
Copy: ({ onClick }) => <div data-testid="copy-icon" onClick={onClick} />,
ChartLine: ({ onClick }) => (
<div data-testid="chart-line-icon" onClick={onClick} />
),
Video: ({ onClick }) => <div data-testid="video-icon" onClick={onClick} />,
PlugZap: ({ onClick }) => (
<div data-testid="plug-zap-icon" onClick={onClick} />
),
LogOut: ({ onClick }) => <div data-testid="logout-icon" onClick={onClick} />,
User: ({ onClick }) => <div data-testid="user-icon" onClick={onClick} />,
FileImage: ({ onClick }) => (
<div data-testid="file-image-icon" onClick={onClick} />
),
Webhook: () => <div data-testid="webhook-icon" />,
Logs: () => <div data-testid="logs-icon" />,
ChevronRight: () => <div data-testid="chevron-right-icon" />,
MonitorCog: () => <div data-testid="monitor-cog-icon" />,
Blocks: () => <div data-testid="blocks-icon" />,
Heart: () => <div data-testid="heart-icon" />,
Package: () => <div data-testid="package-icon" />,
Download: () => <div data-testid="download-icon" />,
HelpCircle: () => <div data-testid="help-circle-icon" />,
ArrowLeft: () => <div data-testid="arrow-left-icon" />,
};
});
// 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', () => {

View file

@ -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;
}

View file

@ -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 },
],
},
];

View file

@ -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 (
<Center p={10}>
<Box w={'100%'} maw={800}>
<Accordion
variant="separated"
value={accordianValue}
onChange={setAccordianValue}
miw={400}
<Box p={10} maw={900} mx="auto">
{ActiveComponent ? (
<Paper withBorder p="md" radius="md">
<Text size="lg" fw={600} mb={6}>
{activeSectionConfig.label}
</Text>
<Divider mb="md" />
<ErrorBoundary>
<Suspense fallback={<Loader />}>
<ActiveComponent active={true} />
</Suspense>
</ErrorBoundary>
</Paper>
) : (
<Box
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
minHeight: 200,
}}
>
<AccordionItem value="ui-settings">
<AccordionControl>UI Settings</AccordionControl>
<AccordionPanel>
<UiSettingsForm active={accordianValue === 'ui-settings'} />
<Divider my="md" />
<Accordion variant="contained">
<AccordionItem value="nav-order">
<AccordionControl>Navigation</AccordionControl>
<AccordionPanel>
<ErrorBoundary>
<Suspense fallback={<Loader />}>
<NavOrderForm
active={accordianValue === 'ui-settings'}
/>
</Suspense>
</ErrorBoundary>
</AccordionPanel>
</AccordionItem>
</Accordion>
</AccordionPanel>
</AccordionItem>
{authUser.user_level >= USER_LEVELS.ADMIN && (
<>
<AccordionItem value="dvr-settings">
<AccordionControl>DVR</AccordionControl>
<AccordionPanel>
<ErrorBoundary>
<Suspense fallback={<Loader />}>
<DvrSettingsForm
active={accordianValue === 'dvr-settings'}
/>
</Suspense>
</ErrorBoundary>
</AccordionPanel>
</AccordionItem>
<AccordionItem value="stream-settings">
<AccordionControl>Stream Settings</AccordionControl>
<AccordionPanel>
<ErrorBoundary>
<Suspense fallback={<Loader />}>
<StreamSettingsForm
active={accordianValue === 'stream-settings'}
/>
</Suspense>
</ErrorBoundary>
</AccordionPanel>
</AccordionItem>
<AccordionItem value="epg-settings">
<AccordionControl>EPG</AccordionControl>
<AccordionPanel>
<ErrorBoundary>
<Suspense fallback={<Loader />}>
<EpgSettingsForm
active={accordianValue === 'epg-settings'}
/>
</Suspense>
</ErrorBoundary>
</AccordionPanel>
</AccordionItem>
<AccordionItem value="system-settings">
<AccordionControl>System Settings</AccordionControl>
<AccordionPanel>
<ErrorBoundary>
<Suspense fallback={<Loader />}>
<SystemSettingsForm
active={accordianValue === 'system-settings'}
/>
</Suspense>
</ErrorBoundary>
</AccordionPanel>
</AccordionItem>
<AccordionItem value="user-agents">
<AccordionControl>User-Agents</AccordionControl>
<AccordionPanel>
<ErrorBoundary>
<Suspense fallback={<Loader />}>
<UserAgentsTable
active={accordianValue === 'user-agents'}
/>
</Suspense>
</ErrorBoundary>
</AccordionPanel>
</AccordionItem>
<AccordionItem value="stream-profiles">
<AccordionControl>Stream Profiles</AccordionControl>
<AccordionPanel>
<ErrorBoundary>
<Suspense fallback={<Loader />}>
<StreamProfilesTable
active={accordianValue === 'stream-profiles'}
/>
</Suspense>
</ErrorBoundary>
</AccordionPanel>
</AccordionItem>
<AccordionItem value="output-profiles">
<AccordionControl>Output Profiles</AccordionControl>
<AccordionPanel>
<ErrorBoundary>
<Suspense fallback={<Loader />}>
<OutputProfilesTable
active={accordianValue === 'output-profiles'}
/>
</Suspense>
</ErrorBoundary>
</AccordionPanel>
</AccordionItem>
<AccordionItem value="network-access">
<AccordionControl>
<Box>Network Access</Box>
{accordianValue === 'network-access' && (
<Box>
<Text size="sm">Comma-Delimited CIDR ranges</Text>
</Box>
)}
</AccordionControl>
<AccordionPanel>
<ErrorBoundary>
<Suspense fallback={<Loader />}>
<NetworkAccessForm
active={accordianValue === 'network-access'}
/>
</Suspense>
</ErrorBoundary>
</AccordionPanel>
</AccordionItem>
<AccordionItem value="proxy-settings">
<AccordionControl>
<Box>Proxy Settings</Box>
</AccordionControl>
<AccordionPanel>
<ErrorBoundary>
<Suspense fallback={<Loader />}>
<ProxySettingsForm
active={accordianValue === 'proxy-settings'}
/>
</Suspense>
</ErrorBoundary>
</AccordionPanel>
</AccordionItem>
<AccordionItem value="backups">
<AccordionControl>Backup & Restore</AccordionControl>
<AccordionPanel>
<ErrorBoundary>
<Suspense fallback={<Loader />}>
<BackupManager active={accordianValue === 'backups'} />
</Suspense>
</ErrorBoundary>
</AccordionPanel>
</AccordionItem>
<AccordionItem value="user-limits">
<AccordionControl>User Limits</AccordionControl>
<AccordionPanel>
<ErrorBoundary>
<Suspense fallback={<Loader />}>
<UserLimitsForm
active={accordianValue === 'user-limits'}
/>
</Suspense>
</ErrorBoundary>
</AccordionPanel>
</AccordionItem>
</>
)}
</Accordion>
</Box>
</Center>
<Text c="dimmed" size="sm">
Select a setting from the sidebar
</Text>
</Box>
)}
</Box>
);
};

View file

@ -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 }) => (
<div data-testid="user-agents-table">
UserAgentsTable {active ? 'active' : 'inactive'}
</div>
<div data-testid="user-agents-table">UserAgentsTable {active ? 'active' : 'inactive'}</div>
),
}));
vi.mock('../../components/tables/StreamProfilesTable', () => ({
default: ({ active }) => (
<div data-testid="stream-profiles-table">
StreamProfilesTable {active ? 'active' : 'inactive'}
</div>
<div data-testid="stream-profiles-table">StreamProfilesTable {active ? 'active' : 'inactive'}</div>
),
}));
vi.mock('../../components/backups/BackupManager', () => ({
default: ({ active }) => (
<div data-testid="backup-manager">
BackupManager {active ? 'active' : 'inactive'}
</div>
<div data-testid="backup-manager">BackupManager {active ? 'active' : 'inactive'}</div>
),
}));
vi.mock('../../components/forms/settings/UiSettingsForm', () => ({
default: ({ active }) => (
<div data-testid="ui-settings-form">
UiSettingsForm {active ? 'active' : 'inactive'}
</div>
<div data-testid="ui-settings-form">UiSettingsForm {active ? 'active' : 'inactive'}</div>
),
}));
vi.mock('../../components/forms/settings/NetworkAccessForm', () => ({
default: ({ active }) => (
<div data-testid="network-access-form">
NetworkAccessForm {active ? 'active' : 'inactive'}
</div>
<div data-testid="network-access-form">NetworkAccessForm {active ? 'active' : 'inactive'}</div>
),
}));
vi.mock('../../components/forms/settings/ProxySettingsForm', () => ({
default: ({ active }) => (
<div data-testid="proxy-settings-form">
ProxySettingsForm {active ? 'active' : 'inactive'}
</div>
<div data-testid="proxy-settings-form">ProxySettingsForm {active ? 'active' : 'inactive'}</div>
),
}));
vi.mock('../../components/forms/settings/StreamSettingsForm', () => ({
default: ({ active }) => (
<div data-testid="stream-settings-form">
StreamSettingsForm {active ? 'active' : 'inactive'}
</div>
<div data-testid="stream-settings-form">StreamSettingsForm {active ? 'active' : 'inactive'}</div>
),
}));
vi.mock('../../components/forms/settings/DvrSettingsForm', () => ({
default: ({ active }) => (
<div data-testid="dvr-settings-form">
DvrSettingsForm {active ? 'active' : 'inactive'}
</div>
<div data-testid="dvr-settings-form">DvrSettingsForm {active ? 'active' : 'inactive'}</div>
),
}));
vi.mock('../../components/forms/settings/SystemSettingsForm', () => ({
default: ({ active }) => (
<div data-testid="system-settings-form">
SystemSettingsForm {active ? 'active' : 'inactive'}
</div>
<div data-testid="system-settings-form">SystemSettingsForm {active ? 'active' : 'inactive'}</div>
),
}));
vi.mock('../../components/forms/settings/NavOrderForm', () => ({
default: ({ active }) => (
<div data-testid="nav-order-form">
NavOrderForm {active ? 'active' : 'inactive'}
</div>
<div data-testid="nav-order-form">NavOrderForm {active ? 'active' : 'inactive'}</div>
),
}));
vi.mock('../../components/forms/settings/UserLimitsForm', () => ({
default: ({ active }) => (
<div data-testid="user-limits-form">
UserLimitsForm {active ? 'active' : 'inactive'}
</div>
<div data-testid="user-limits-form">UserLimitsForm {active ? 'active' : 'inactive'}</div>
),
}));
vi.mock('../../components/ErrorBoundary', () => ({
default: ({ children }) => <div data-testid="error-boundary">{children}</div>,
}));
vi.mock('@mantine/core', async () => {
const accordionComponent = ({ children, onChange, defaultValue }) => (
<div data-testid="accordion">{children}</div>
);
accordionComponent.Item = ({ children, value }) => (
<div data-testid={`accordion-item-${value}`}>{children}</div>
);
accordionComponent.Control = ({ children }) => (
<button data-testid="accordion-control">{children}</button>
);
accordionComponent.Panel = ({ children }) => (
<div data-testid="accordion-panel">{children}</div>
);
vi.mock('@mantine/core', async () => ({
Box: ({ children }) => <div>{children}</div>,
Divider: () => <hr />,
Loader: () => <div data-testid="loader">Loading...</div>,
Paper: ({ children }) => <div>{children}</div>,
Text: ({ children }) => <span>{children}</span>,
}));
return {
Accordion: accordionComponent,
AccordionItem: accordionComponent.Item,
AccordionControl: accordionComponent.Control,
AccordionPanel: accordionComponent.Panel,
Box: ({ children }) => <div>{children}</div>,
Center: ({ children }) => <div>{children}</div>,
Divider: () => <hr />,
Loader: () => <div data-testid="loader">Loading...</div>,
Text: ({ children }) => <span>{children}</span>,
};
});
// Helper function to render with router context
const renderWithRouter = (
component,
{ initialEntries = ['/settings'] } = {}
) => {
return render(
<MemoryRouter initialEntries={initialEntries}>{component}</MemoryRouter>
);
};
const renderWithRouter = (component, { initialEntries = ['/settings'] } = {}) =>
render(<MemoryRouter initialEntries={initialEntries}>{component}</MemoryRouter>);
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(<SettingsPage />);
expect(screen.getAllByTestId('accordion').length).toBeGreaterThan(0);
});
it('renders UI Settings accordion item', () => {
renderWithRouter(<SettingsPage />);
expect(
screen.getByTestId('accordion-item-ui-settings')
).toBeInTheDocument();
expect(screen.getByText('UI Settings')).toBeInTheDocument();
});
it('opens UI Settings panel by default', () => {
renderWithRouter(<SettingsPage />);
expect(screen.getByTestId('ui-settings-form')).toBeInTheDocument();
});
it('does not render admin-only sections for regular users', () => {
renderWithRouter(<SettingsPage />);
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(<SettingsPage />);
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(<SettingsPage />);
expect(screen.getByText('UI Settings')).toBeInTheDocument();
it('renders ui-settings section via hash', async () => {
renderWithRouter(<SettingsPage />, { 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(<SettingsPage />, { initialEntries: ['/settings#nav-order'] });
await waitFor(() => {
expect(screen.getByTestId('nav-order-form')).toBeInTheDocument();
});
});
it('renders DVR settings accordion item', () => {
renderWithRouter(<SettingsPage />);
expect(
screen.getByTestId('accordion-item-dvr-settings')
).toBeInTheDocument();
});
it('renders Stream Settings accordion item', () => {
renderWithRouter(<SettingsPage />);
expect(
screen.getByTestId('accordion-item-stream-settings')
).toBeInTheDocument();
});
it('renders System Settings accordion item', () => {
renderWithRouter(<SettingsPage />);
expect(
screen.getByTestId('accordion-item-system-settings')
).toBeInTheDocument();
});
it('renders User-Agents accordion item', () => {
renderWithRouter(<SettingsPage />);
expect(
screen.getByTestId('accordion-item-user-agents')
).toBeInTheDocument();
});
it('renders Stream Profiles accordion item', () => {
renderWithRouter(<SettingsPage />);
expect(
screen.getByTestId('accordion-item-stream-profiles')
).toBeInTheDocument();
});
it('renders Network Access accordion item', () => {
renderWithRouter(<SettingsPage />);
expect(
screen.getByTestId('accordion-item-network-access')
).toBeInTheDocument();
});
it('renders Proxy Settings accordion item', () => {
renderWithRouter(<SettingsPage />);
expect(
screen.getByTestId('accordion-item-proxy-settings')
).toBeInTheDocument();
});
it('renders Backup & Restore accordion item', () => {
renderWithRouter(<SettingsPage />);
expect(screen.getByTestId('accordion-item-backups')).toBeInTheDocument();
});
it('renders Navigation accordion item', () => {
renderWithRouter(<SettingsPage />);
expect(
screen.getByTestId('accordion-item-nav-order')
).toBeInTheDocument();
it('shows placeholder for admin-only section hash', () => {
renderWithRouter(<SettingsPage />, { 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(<SettingsPage />, { 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(<SettingsPage />, { 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(<SettingsPage />);
it('renders stream-profiles section via hash', async () => {
renderWithRouter(<SettingsPage />, { 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(<SettingsPage />, { 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(<SettingsPage />, { initialEntries: ['/settings#proxy-settings'] });
await waitFor(() => {
expect(screen.getByTestId('proxy-settings-form')).toBeInTheDocument();
});
});
it('renders backups section via hash', async () => {
renderWithRouter(<SettingsPage />, { initialEntries: ['/settings#backups'] });
await waitFor(() => {
expect(screen.getByTestId('backup-manager')).toBeInTheDocument();
});
});
it('renders system-settings section via hash', async () => {
renderWithRouter(<SettingsPage />, { initialEntries: ['/settings#system-settings'] });
await waitFor(() => {
expect(screen.getByTestId('system-settings-form')).toBeInTheDocument();
});
});
it('passes active=true to rendered component', async () => {
renderWithRouter(<SettingsPage />, { initialEntries: ['/settings#dvr-settings'] });
await waitFor(() => {
expect(screen.getByText(/active/)).toBeInTheDocument();
});
expect(screen.getByText('DvrSettingsForm active')).toBeInTheDocument();
});
});
});