Dispatcharr/frontend/src/components/Sidebar.jsx
SergeantPanda f403004769 fix(environment): harden IP lookup, add WebSocket push, improve sidebar UX
- Fix socket leak in local IP detection using try/finally; replace 8.8.8.8
  with RFC 5737 test address 203.0.113.1
- Validate ipify response with ipaddress.ip_address() before using in URL
- Push ip_lookup_complete WebSocket event when background lookup finishes,
  eliminating frontend polling entirely
- Show Skeleton placeholder in sidebar while IP lookup is pending
- Replace hover-to-reveal blur with click-to-toggle for mobile support
- Fix copy button propagating click to blur toggle via stopPropagation
- Add missing city field to null-env fallback in settings store
- Add setEnvironmentFields() store action for WebSocket-driven updates
- Remove unused imports from api_views.py
2026-05-31 17:56:52 -05:00

498 lines
16 KiB
JavaScript

import React, { useState, useMemo } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { copyToClipboard } from '../utils';
import {
Copy,
LogOut,
ChevronDown,
ChevronRight,
Heart,
HelpCircle,
} from 'lucide-react';
import AboutModal from './AboutModal';
import { getOrderedNavItems } from '../config/navigation';
import {
Avatar,
Group,
Stack,
Box,
Text,
UnstyledButton,
ActionIcon,
AppShellNavbar,
ScrollArea,
Skeleton,
Tooltip,
} from '@mantine/core';
import logo from '../images/logo.png';
import useChannelsStore from '../store/channels';
import './sidebar.css';
import useSettingsStore from '../store/settings';
import useAuthStore from '../store/auth';
import { USER_LEVELS } from '../constants';
import UserForm from './forms/User';
import NotificationCenter from './NotificationCenter';
const DonateButton = ({ tooltipPosition = 'top' }) => (
<Tooltip label="Support Dispatcharr" position={tooltipPosition}>
<ActionIcon
component="a"
href="https://opencollective.com/dispatcharr/contribute"
target="_blank"
rel="noopener noreferrer"
variant="transparent"
color="pink"
>
<Heart size={20} />
</ActionIcon>
</Tooltip>
);
const NavLink = ({ item, isActive, collapsed }) => {
const IconComponent = 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>
);
};
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);
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>
);
}
const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
const location = useLocation();
const channelIds = useChannelsStore((s) => s.channelIds);
const environment = useSettingsStore((s) => s.environment);
const appVersion = useSettingsStore((s) => s.version);
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const authUser = useAuthStore((s) => s.user);
const logout = useAuthStore((s) => s.logout);
const getNavOrder = useAuthStore((s) => s.getNavOrder);
const getHiddenNav = useAuthStore((s) => s.getHiddenNav);
const [userFormOpen, setUserFormOpen] = useState(false);
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));
}, [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 copyPublicIP = async () => {
await copyToClipboard(environment.public_ip, {
successTitle: 'Success',
successMessage: 'Public IP copied to clipboard',
});
};
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 */}
<Group
onClick={toggleDrawer}
spacing="sm"
style={{
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: 12,
padding: '16px 12px',
fontSize: 18,
fontWeight: 600,
color: '#FFFFFF',
justifyContent: collapsed ? 'center' : 'flex-start',
whiteSpace: 'nowrap',
}}
>
{/* <ListOrdered size={24} /> */}
<img width={30} src={logo} />
{!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
}}
>
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>
{/* Profile Section */}
<Box
style={{
marginTop: 'auto',
padding: '16px',
display: 'flex',
alignItems: 'center',
gap: 10,
borderTop: '1px solid #2A2A2E',
justifyContent: collapsed ? 'center' : 'flex-start',
}}
>
{isAuthenticated && (
<Stack gap="sm" style={{ width: '100%' }}>
{!collapsed &&
environment.ip_lookup_enabled !== false &&
environment.ip_lookup_pending && (
<Box>
<Text size="sm" fw={500} mb={4}>
Public IP
</Text>
<Skeleton height={36} radius="sm" />
</Box>
)}
{!collapsed &&
environment.ip_lookup_enabled !== false &&
!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
style={{
display: 'flex',
alignItems: 'center',
border: '1px solid var(--mantine-color-default-border)',
borderRadius: 'var(--mantine-radius-sm)',
backgroundColor: 'var(--mantine-color-dark-6)',
height: '36px',
paddingLeft: '10px',
gap: '8px',
}}
>
{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(', ')}
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)',
}}
>
{(() => {
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}
</span>
</>
);
})()}
</span>
</Box>
<ActionIcon
variant="transparent"
color="gray.9"
onClick={(e) => {
e.stopPropagation();
copyPublicIP();
}}
style={{ flexShrink: 0 }}
>
<Copy />
</ActionIcon>
</Box>
</Box>
)}
{!collapsed && authUser && (
<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">
<LogOut onClick={logout} />
</ActionIcon>
</Group>
)}
{collapsed && (
<Group justify="center" style={{ width: '100%' }}>
<Avatar src="" radius="xl" />
</Group>
)}
</Stack>
)}
</Box>
{/* Version and Notification */}
{!collapsed && (
<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"
>
<Text
size="xs"
c="dimmed"
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',
}
)
}
>
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)}
>
<HelpCircle size={20} />
</ActionIcon>
</Tooltip>
<DonateButton />
{isAuthenticated && <NotificationCenter />}
</Group>
</Group>
)}
{collapsed && (
<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)}
>
<HelpCircle size={20} />
</ActionIcon>
</Tooltip>
</Box>
)}
<UserForm user={authUser} isOpen={userFormOpen} onClose={closeUserForm} />
<AboutModal isOpen={aboutOpen} onClose={() => setAboutOpen(false)} />
</AppShellNavbar>
);
};
export default Sidebar;