mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-19 01:25:06 +00:00
Merge c2daa50bd5 into 6bd42c83f4
This commit is contained in:
commit
5cc6d4eee6
8 changed files with 650 additions and 797 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
66
frontend/src/components/SlidingPanels.jsx
Normal file
66
frontend/src/components/SlidingPanels.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
80
frontend/src/config/settingsNav.js
Normal file
80
frontend/src/config/settingsNav.js
Normal 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 },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue