Files
MyClub/frontend/src/components/admin/AdminSidebar.tsx
T
2025-10-24 19:41:55 +02:00

634 lines
18 KiB
TypeScript

import { Box, VStack, Text, useColorModeValue, Icon, Link as ChakraLink, Divider, Image, Flex, Spinner } from '@chakra-ui/react';
import { Link as RouterLink, useLocation } from 'react-router-dom';
import { useEffect, useRef, useCallback, useState } from 'react';
import {
FaTachometerAlt,
FaUsers,
FaFutbol,
FaCalendarAlt,
FaNewspaper,
FaHandshake,
FaImage,
FaEnvelope,
FaCog,
FaPalette,
FaHome,
FaSignOutAlt,
FaPaperPlane,
FaAward,
FaSyncAlt,
FaBook,
FaMobileAlt,
FaChartBar,
FaFolder,
FaAddressBook,
FaBars,
FaPoll,
FaPaintBrush,
FaVideo,
FaCamera,
FaTshirt,
FaBullhorn,
FaUserShield,
FaFileAlt
} from 'react-icons/fa';
import { useAuth } from '../../contexts/AuthContext';
import { useQuery } from '@tanstack/react-query';
import { getUpcomingEvents } from '../../services/eventService';
import { getAllNavigationItems, NavigationItem, seedDefaultNavigation } from '../../services/navigation';
import { usePublicSettings } from '../../hooks/usePublicSettings';
interface NavItemProps {
icon: any;
to?: string;
children: React.ReactNode;
onClick?: (e?: React.MouseEvent) => void;
}
const NavItem = ({ icon, to, children, onClick }: NavItemProps) => {
const location = useLocation();
const isActive = to ? location.pathname.startsWith(to) : false;
const activeBg = useColorModeValue('blue.50', 'blue.900');
const activeColor = useColorModeValue('blue.600', 'blue.300');
const handleClick = (e: React.MouseEvent) => {
// Call the onClick handler first
if (onClick) {
onClick(e);
// If onClick called preventDefault, respect it
if (e.isDefaultPrevented()) {
return;
}
}
// Allow RouterLink to handle navigation normally
};
// If onClick is provided without `to`, render as a button-like link
const LinkComponent = to ? RouterLink : 'a';
const linkProps = to ? { to } : { href: '#' };
return (
<ChakraLink
as={LinkComponent}
{...linkProps}
display="flex"
alignItems="center"
px={3}
py={2.5}
borderRadius="lg"
bg={isActive ? activeBg : 'transparent'}
color={isActive ? activeColor : 'inherit'}
fontWeight={isActive ? 'semibold' : 'medium'}
fontSize="sm"
_hover={{
textDecoration: 'none',
bg: isActive ? activeBg : useColorModeValue('gray.100', 'gray.700'),
transform: 'translateX(2px)',
}}
transition="all 0.2s ease"
onClick={handleClick}
data-navitem="true"
data-active={isActive ? 'true' : undefined}
position="relative"
_before={isActive ? {
content: '""',
position: 'absolute',
left: 0,
top: '50%',
transform: 'translateY(-50%)',
width: '3px',
height: '60%',
bg: activeColor,
borderRadius: 'full',
} : {}}
>
<Icon as={icon} mr={3} boxSize={4} />
<Text flex={1}>{children}</Text>
</ChakraLink>
);
};
interface AdminSidebarProps {
isOpen: boolean;
onClose: () => void;
bg?: string;
borderRight?: string;
borderColor?: string;
}
// Icon mapping for navigation items
const getIconForPageType = (pageType?: string): any => {
const iconMap: Record<string, any> = {
dashboard: FaTachometerAlt,
analytics: FaChartBar,
teams: FaUsers,
matches: FaCalendarAlt,
activities: FaCalendarAlt,
players: FaFutbol,
articles: FaNewspaper,
categories: FaFileAlt,
about: FaBook,
videos: FaVideo,
gallery: FaImage,
scoreboard: FaTachometerAlt,
scoreboard_remote: FaMobileAlt,
clothing: FaTshirt,
sponsors: FaHandshake,
banners: FaBullhorn,
messages: FaEnvelope,
contacts: FaAddressBook,
newsletter: FaPaperPlane,
polls: FaPoll,
navigation: FaBars,
competition_aliases: FaAward,
prefetch: FaSyncAlt,
users: FaUserShield,
settings: FaPalette,
files: FaFolder,
docs: FaBook,
};
return iconMap[pageType || ''] || FaFileAlt;
};
const AdminSidebar = ({
isOpen,
onClose,
bg: bgProp,
borderRight = '1px',
borderColor: borderColorProp
}: AdminSidebarProps) => {
const { logout, user } = useAuth();
const { data: publicSettings } = usePublicSettings();
const isAdmin = (user as any)?.role === 'admin';
const defaultBg = useColorModeValue('white', '#1a1d29');
const defaultBorderColor = useColorModeValue('gray.200', 'rgba(255, 255, 255, 0.12)');
const textColor = useColorModeValue('gray.800', '#e2e8f0');
const bg = bgProp || defaultBg;
const borderColor = borderColorProp || defaultBorderColor;
// Upcoming events count for badge
const { data: upcomingEvents } = useQuery({ queryKey: ['admin-sidebar-upcoming-events'], queryFn: getUpcomingEvents });
const upcomingCount = Array.isArray(upcomingEvents) ? upcomingEvents.length : 0;
const scrollRef = useRef<HTMLDivElement | null>(null);
const location = useLocation();
const STORAGE_KEY = 'admin-sidebar-scroll';
// Dynamic navigation state
const [navItems, setNavItems] = useState<NavigationItem[]>([]);
const [navLoading, setNavLoading] = useState(true);
// Restore scroll on mount
useEffect(() => {
const node = scrollRef.current;
if (!node) return;
const saved = sessionStorage.getItem(STORAGE_KEY);
if (saved) {
try {
const top = parseInt(saved, 10);
if (!Number.isNaN(top)) {
node.scrollTop = top;
}
} catch {}
}
}, []);
// Save scroll on scroll
const handleScroll = useCallback(() => {
const node = scrollRef.current;
if (!node) return;
sessionStorage.setItem(STORAGE_KEY, String(node.scrollTop));
}, []);
// Load dynamic navigation from API
useEffect(() => {
let active = true;
(async () => {
try {
const items = await getAllNavigationItems();
if (active && Array.isArray(items)) {
// Filter only admin navigation items
const adminItems = items.filter(item => item.requires_admin);
// Auto-seed if admin navigation is empty and user is admin
if (adminItems.length === 0 && isAdmin) {
try {
console.log('Admin navigation empty, auto-seeding...');
await seedDefaultNavigation();
const newItems = await getAllNavigationItems();
if (active && Array.isArray(newItems)) {
const newAdminItems = newItems.filter(item => item.requires_admin);
setNavItems(newAdminItems);
}
} catch (seedError) {
console.error('Auto-seed failed:', seedError);
// Continue with empty navigation (will show fallback)
setNavItems(adminItems);
}
} else {
setNavItems(adminItems);
}
}
} catch (error) {
console.error('Failed to load admin navigation:', error);
} finally {
if (active) setNavLoading(false);
}
})();
return () => { active = false };
}, [isAdmin]);
// Keep active item in view upon route change - but only if it's not visible
useEffect(() => {
const node = scrollRef.current;
if (!node) return;
const active = node.querySelector('[data-navitem][data-active="true"]') as HTMLElement | null;
if (active) {
// Check if the active item is already visible in the viewport
const containerRect = node.getBoundingClientRect();
const activeRect = active.getBoundingClientRect();
const isVisible = (
activeRect.top >= containerRect.top &&
activeRect.bottom <= containerRect.bottom
);
// Only scroll if the active item is not fully visible
if (!isVisible) {
active.scrollIntoView({ block: 'nearest', inline: 'nearest', behavior: 'smooth' });
}
}
}, [location.pathname]);
return (
<Box
as="nav"
position="fixed"
left={0}
top={0}
bottom={0}
width="260px"
bg={bg}
borderRightWidth={borderRight}
borderColor={borderColor}
pt={5}
display={{ base: isOpen ? 'block' : 'none', md: 'block' }}
zIndex={10}
overflowY="auto"
overflowX="hidden"
boxShadow="lg"
transition="all 0.3s cubic-bezier(0.4, 0, 0.2, 1)"
ref={scrollRef}
onScroll={handleScroll}
css={{
'&::-webkit-scrollbar': { width: '4px' },
'&::-webkit-scrollbar-track': { background: 'transparent' },
'&::-webkit-scrollbar-thumb': { background: useColorModeValue('gray.300', 'gray.600'), borderRadius: '2px' },
'&::-webkit-scrollbar-thumb:hover': { background: useColorModeValue('gray.400', 'gray.500') },
}}
>
<VStack align="stretch" spacing={1} px={3} pb={6}>
<Box px={3} mb={8}>
<Flex align="center" gap={3} mb={2}>
<Image
src={publicSettings?.club_logo_url || '/dist/img/logo-club-empty.svg'}
alt="Club Logo"
boxSize="48px"
objectFit="contain"
fallbackSrc="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Crect width='100' height='100' fill='%23e2e8f0'/%3E%3Ctext x='50' y='55' text-anchor='middle' font-size='40' fill='%23718096'%3EMC%3C/text%3E%3C/svg%3E"
borderRadius="md"
/>
<VStack align="start" spacing={0}>
<Text
fontSize="xl"
fontWeight="extrabold"
color={useColorModeValue('gray.800', 'white')}
letterSpacing="tight"
>
My Club
</Text>
<Text fontSize="xs" color={useColorModeValue('gray.500', 'gray.400')} fontWeight="semibold" textTransform="uppercase" letterSpacing="wider">
Admin Panel
</Text>
</VStack>
</Flex>
</Box>
<NavItem
icon={FaHome}
to="/"
onClick={onClose}
>
Zpět na web
</NavItem>
<Divider my={2} />
{/* Dynamic Navigation */}
{navLoading ? (
<Flex justify="center" py={8}>
<Spinner size="sm" />
</Flex>
) : navItems.length > 0 ? (
// Render dynamic navigation
<>
{navItems.filter(item => item.visible).map((item, index) => {
const itemIcon = getIconForPageType(item.page_type);
const itemUrl = item.url || '#';
// Add badge for activities showing upcoming count
const isActivities = item.page_type === 'activities';
const showBadge = isActivities && upcomingCount > 0;
return (
<NavItem
key={item.id || index}
icon={itemIcon}
to={itemUrl}
onClick={onClose}
>
<Text as="span">
{item.label}
{showBadge && (
<Text as="span" ml={2} fontSize="10px" px={2} py={0.5} borderRadius="full" bg={useColorModeValue('green.100','green.900')} color={useColorModeValue('green.700','green.200')} borderWidth="1px" borderColor={useColorModeValue('green.200','green.700')}>
{upcomingCount}
</Text>
)}
</Text>
</NavItem>
);
})}
{/* MyUIbrix Editor - Special item */}
<NavItem
icon={FaPaintBrush}
onClick={(e) => {
e?.preventDefault();
window.open('/?myuibrix=edit', '_blank');
}}
>
MyUIbrix Editor
</NavItem>
</>
) : (
// Fallback to hardcoded navigation
<>
<Text fontSize="xs" fontWeight="bold" px={4} py={2} color={useColorModeValue('gray.500', 'gray.400')} textTransform="uppercase" letterSpacing="wider">
Hlavní
</Text>
<NavItem
icon={FaTachometerAlt}
to="/admin"
onClick={onClose}
>
Nástěnka
</NavItem>
{isAdmin && (
<NavItem
icon={FaChartBar}
to="/admin/analytika"
onClick={onClose}
>
Analytika
</NavItem>
)}
<Text fontSize="xs" fontWeight="bold" px={4} py={2} color={useColorModeValue('gray.500', 'gray.400')} textTransform="uppercase" letterSpacing="wider" mt={4}>
Obsah
</Text>
{/* Core sports entities first */}
<NavItem
icon={FaUsers}
to="/admin/tymy"
onClick={onClose}
>
Týmy
</NavItem>
<NavItem
icon={FaCalendarAlt}
to="/admin/zapasy"
onClick={onClose}
>
{/* Add subtle scroller hint */}
<Text as="span">
Zápasy
<Text as="span" ml={2} fontSize="10px" px={2} py={0.5} borderRadius="full" bg={useColorModeValue('gray.100','whiteAlpha.200')} color={useColorModeValue('gray.700','gray.300')} borderWidth="1px" borderColor={useColorModeValue('gray.200','whiteAlpha.300')}>
scroller
</Text>
</Text>
</NavItem>
<NavItem
icon={FaCalendarAlt}
to="/admin/aktivity"
onClick={onClose}
>
<Text as="span">
Aktivity
{upcomingCount > 0 && (
<Text as="span" ml={2} fontSize="10px" px={2} py={0.5} borderRadius="full" bg={useColorModeValue('green.100','green.900')} color={useColorModeValue('green.700','green.200')} borderWidth="1px" borderColor={useColorModeValue('green.200','green.700')}>
{upcomingCount}
</Text>
)}
</Text>
</NavItem>
<NavItem
icon={FaFutbol}
to="/admin/hraci"
onClick={onClose}
>
Hráči
</NavItem>
{/* Other content */}
<NavItem
icon={FaNewspaper}
to="/admin/clanky"
onClick={onClose}
>
Články
</NavItem>
<NavItem
icon={FaFileAlt}
to="/admin/kategorie"
onClick={onClose}
>
Kategorie
</NavItem>
<NavItem
icon={FaBook}
to="/admin/o-klubu"
onClick={onClose}
>
O klubu
</NavItem>
<NavItem
icon={FaImage}
to="/admin/videa"
onClick={onClose}
>
Videa
</NavItem>
<NavItem
icon={FaImage}
to="/admin/galerie"
onClick={onClose}
>
Galerie (Zonerama)
</NavItem>
<NavItem
icon={FaTachometerAlt}
to="/admin/scoreboard"
onClick={onClose}
>
Tabule (Scoreboard)
</NavItem>
<NavItem
icon={FaMobileAlt}
to="/admin/scoreboard/remote"
onClick={onClose}
>
Scoreboard Remote
</NavItem>
<NavItem
icon={FaPalette}
to="/admin/obleceni"
onClick={onClose}
>
Oblečení
</NavItem>
<NavItem
icon={FaHandshake}
to="/admin/sponzori"
onClick={onClose}
>
Sponzoři
</NavItem>
<NavItem
icon={FaImage}
to="/admin/bannery"
onClick={onClose}
>
Bannery
</NavItem>
<NavItem
icon={FaEnvelope}
to="/admin/zpravy"
onClick={onClose}
>
Zprávy
</NavItem>
<NavItem
icon={FaAddressBook}
to="/admin/kontakty"
onClick={onClose}
>
Kontakty
</NavItem>
<NavItem
icon={FaPaperPlane}
to="/admin/newsletter"
onClick={onClose}
>
Zpravodaj
</NavItem>
<NavItem
icon={FaPoll}
to="/admin/ankety"
onClick={onClose}
>
Ankety
</NavItem>
<Divider my={2} />
{isAdmin && (
<>
<Text fontSize="xs" fontWeight="bold" px={4} py={2} color={useColorModeValue('gray.500', 'gray.400')} textTransform="uppercase" letterSpacing="wider" mt={4}>
Nastavení
</Text>
<NavItem
icon={FaPaintBrush}
onClick={(e) => {
e?.preventDefault();
window.open('/?myuibrix=edit', '_blank');
}}
>
MyUIbrix Editor
</NavItem>
<NavItem
icon={FaBars}
to="/admin/navigace"
onClick={onClose}
>
Navigace
</NavItem>
<NavItem
icon={FaAward}
to="/admin/aliasy-soutezi"
onClick={onClose}
>
Alias soutěží
</NavItem>
<NavItem
icon={FaSyncAlt}
to="/admin/prefetch"
onClick={onClose}
>
Prefetch & Cache
</NavItem>
<NavItem
icon={FaUsers}
to="/admin/uzivatele"
onClick={onClose}
>
Uživatelé
</NavItem>
<NavItem
icon={FaPalette}
to="/admin/nastaveni"
onClick={onClose}
>
Nastavení
</NavItem>
<NavItem
icon={FaFolder}
to="/admin/soubory"
onClick={onClose}
>
Soubory
</NavItem>
</>
)}
</>
)}
<Box mt="auto" mb={4} px={2}>
<ChakraLink
as="button"
display="flex"
alignItems="center"
w="100%"
px={4}
py={2}
borderRadius="md"
_hover={{
textDecoration: 'none',
bg: useColorModeValue('red.50', 'red.900'),
color: 'red.500',
}}
onClick={logout}
color={useColorModeValue('red.500', 'red.300')}
>
<Icon as={FaSignOutAlt} mr={3} />
<Text>Odhlásit se</Text>
</ChakraLink>
</Box>
</VStack>
</Box>
);
};
export default AdminSidebar;