import { useState, useEffect, useCallback, useRef, useMemo } from 'react'; import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom'; import { Printer, Archive, Calendar, BarChart3, Cloud, Settings, Sun, Moon, ChevronLeft, ChevronRight, Keyboard, Github, GripVertical, ArrowUpCircle, Wrench, FolderKanban, FolderOpen, X, Menu, Info, Plug, Bug, LogOut, Key, Loader2, type LucideIcon } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { useTheme } from '../contexts/ThemeContext'; import { KeyboardShortcutsModal } from './KeyboardShortcutsModal'; import { SwitchbarPopover } from './SwitchbarPopover'; import { useQuery } from '@tanstack/react-query'; import { api, supportApi, pendingUploadsApi } from '../api/client'; import { getIconByName } from './IconPicker'; import { useIsMobile } from '../hooks/useIsMobile'; import { useAuth } from '../contexts/AuthContext'; import { useToast } from '../contexts/ToastContext'; import { Card, CardHeader, CardContent } from './Card'; import { Button } from './Button'; interface NavItem { id: string; to: string; icon: LucideIcon; labelKey: string; // Translation key } export const defaultNavItems: NavItem[] = [ { id: 'printers', to: '/', icon: Printer, labelKey: 'nav.printers' }, { id: 'archives', to: '/archives', icon: Archive, labelKey: 'nav.archives' }, { id: 'queue', to: '/queue', icon: Calendar, labelKey: 'nav.queue' }, { id: 'stats', to: '/stats', icon: BarChart3, labelKey: 'nav.stats' }, { id: 'profiles', to: '/profiles', icon: Cloud, labelKey: 'nav.profiles' }, { id: 'maintenance', to: '/maintenance', icon: Wrench, labelKey: 'nav.maintenance' }, { id: 'projects', to: '/projects', icon: FolderKanban, labelKey: 'nav.projects' }, { id: 'files', to: '/files', icon: FolderOpen, labelKey: 'nav.files' }, { id: 'settings', to: '/settings', icon: Settings, labelKey: 'nav.settings' }, ]; // Get unified sidebar order from localStorage function getSidebarOrder(): string[] { const stored = localStorage.getItem('sidebarOrder'); if (stored) { try { return JSON.parse(stored); } catch { return defaultNavItems.map(i => i.id); } } return defaultNavItems.map(i => i.id); } // Save unified sidebar order to localStorage function saveSidebarOrder(order: string[]) { localStorage.setItem('sidebarOrder', JSON.stringify(order)); } // Check if an ID is an external link function isExternalLinkId(id: string): boolean { return id.startsWith('ext-'); } // Get default view from localStorage export function getDefaultView(): string { return localStorage.getItem('defaultView') || '/'; } // Save default view to localStorage export function setDefaultView(path: string) { localStorage.setItem('defaultView', path); } export function Layout() { const navigate = useNavigate(); const location = useLocation(); const { mode, toggleMode } = useTheme(); const { t } = useTranslation(); const isMobile = useIsMobile(); const { user, authEnabled, logout, hasPermission } = useAuth(); const { showToast } = useToast(); const [showChangePasswordModal, setShowChangePasswordModal] = useState(false); const [changePasswordData, setChangePasswordData] = useState({ currentPassword: '', newPassword: '', confirmPassword: '' }); const [changePasswordLoading, setChangePasswordLoading] = useState(false); const [sidebarExpanded, setSidebarExpanded] = useState(() => { const stored = localStorage.getItem('sidebarExpanded'); return stored !== 'false'; }); const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false); const [showShortcuts, setShowShortcuts] = useState(false); const [showSwitchbar, setShowSwitchbar] = useState(false); const [sidebarOrder, setSidebarOrder] = useState(getSidebarOrder); const [draggedId, setDraggedId] = useState(null); const [dragOverId, setDragOverId] = useState(null); const hasRedirected = useRef(false); const [dismissedUpdateVersion, setDismissedUpdateVersion] = useState(() => sessionStorage.getItem('dismissedUpdateVersion') ); const [plateDetectionAlert, setPlateDetectionAlert] = useState<{ printer_id: number; printer_name: string; message: string; } | null>(null); // Check for updates const { data: versionInfo } = useQuery({ queryKey: ['version'], queryFn: api.getVersion, staleTime: Infinity, }); const { data: settings } = useQuery({ queryKey: ['settings'], queryFn: api.getSettings, staleTime: 5 * 60 * 1000, // 5 minutes }); const { data: updateCheck } = useQuery({ queryKey: ['updateCheck'], queryFn: api.checkForUpdates, enabled: settings?.check_updates !== false, staleTime: 60 * 60 * 1000, // 1 hour refetchInterval: 60 * 60 * 1000, // Check every hour }); // Fetch external links for sidebar const { data: externalLinks } = useQuery({ queryKey: ['external-links'], queryFn: api.getExternalLinks, }); // Fetch smart plugs to check for switchbar items const { data: smartPlugs } = useQuery({ queryKey: ['smart-plugs'], queryFn: api.getSmartPlugs, staleTime: 30 * 1000, // 30 seconds }); const hasSwitchbarPlugs = smartPlugs?.some(p => p.show_in_switchbar) ?? false; // Check debug logging state const { data: debugLoggingState } = useQuery({ queryKey: ['debugLogging'], queryFn: supportApi.getDebugLoggingState, staleTime: 60 * 1000, // 1 minute refetchInterval: 60 * 1000, // Refresh every minute }); // Fetch pending queue items count for badge const { data: queueItems } = useQuery({ queryKey: ['queue', 'pending'], queryFn: () => api.getQueue(undefined, 'pending'), staleTime: 5 * 1000, // 5 seconds refetchInterval: 5 * 1000, // Refresh every 5 seconds refetchOnWindowFocus: true, }); const pendingQueueCount = queueItems?.length ?? 0; // Fetch pending uploads count for archive badge (virtual printer review items) const { data: pendingUploadsData } = useQuery({ queryKey: ['pending-uploads', 'count'], queryFn: pendingUploadsApi.getCount, staleTime: 5 * 1000, // 5 seconds refetchInterval: 5 * 1000, // Refresh every 5 seconds refetchOnWindowFocus: true, }); const pendingUploadsCount = pendingUploadsData?.count ?? 0; // Calculate debug duration client-side for real-time updates const [debugDuration, setDebugDuration] = useState(null); useEffect(() => { if (!debugLoggingState?.enabled || !debugLoggingState.enabled_at) { setDebugDuration(null); return; } const enabledAt = new Date(debugLoggingState.enabled_at).getTime(); const updateDuration = () => { setDebugDuration(Math.floor((Date.now() - enabledAt) / 1000)); }; updateDuration(); const interval = setInterval(updateDuration, 1000); return () => clearInterval(interval); }, [debugLoggingState?.enabled, debugLoggingState?.enabled_at]); // Build the unified sidebar items list - memoized to prevent re-renders const navItemsMap = useMemo(() => new Map(defaultNavItems.map(item => [item.id, item])), []); const extLinksMap = useMemo(() => new Map((externalLinks || []).map(link => [`ext-${link.id}`, link])), [externalLinks]); // Compute the ordered sidebar: include stored order + any new items // Filter out 'settings' for users with 'user' role const orderedSidebarIds = (() => { const result: string[] = []; const seen = new Set(); // Determine if settings should be hidden (user role and auth enabled) const hideSettings = authEnabled && user?.role === 'user'; // Add items in stored order for (const id of sidebarOrder) { // Skip settings if user is not admin if (hideSettings && id === 'settings') { continue; } if (navItemsMap.has(id) || extLinksMap.has(id)) { result.push(id); seen.add(id); } } // Add any new internal nav items not in stored order for (const item of defaultNavItems) { // Skip settings if user is not admin if (hideSettings && item.id === 'settings') { continue; } if (!seen.has(item.id)) { result.push(item.id); seen.add(item.id); } } // Add any new external links not in stored order for (const link of externalLinks || []) { const extId = `ext-${link.id}`; if (!seen.has(extId)) { result.push(extId); seen.add(extId); } } return result; })(); // Unified drag handlers const handleDragStart = (e: React.DragEvent, id: string) => { setDraggedId(id); e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', id); }; const handleDragOver = (e: React.DragEvent, id: string) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; setDragOverId(id); }; const handleDragLeave = () => { setDragOverId(null); }; const handleDrop = (e: React.DragEvent, targetId: string) => { e.preventDefault(); if (draggedId === null || draggedId === targetId) { setDraggedId(null); setDragOverId(null); return; } const currentOrder = [...orderedSidebarIds]; const draggedIndex = currentOrder.indexOf(draggedId); const targetIndex = currentOrder.indexOf(targetId); if (draggedIndex === -1 || targetIndex === -1) { setDraggedId(null); setDragOverId(null); return; } // Reorder currentOrder.splice(draggedIndex, 1); currentOrder.splice(targetIndex, 0, draggedId); // Save to localStorage and update state setSidebarOrder(currentOrder); saveSidebarOrder(currentOrder); setDraggedId(null); setDragOverId(null); }; const handleDragEnd = () => { setDraggedId(null); setDragOverId(null); }; // Show update banner if update available and not dismissed for this version const showUpdateBanner = updateCheck?.update_available && updateCheck.latest_version && updateCheck.latest_version !== dismissedUpdateVersion; const dismissUpdateBanner = () => { if (updateCheck?.latest_version) { sessionStorage.setItem('dismissedUpdateVersion', updateCheck.latest_version); setDismissedUpdateVersion(updateCheck.latest_version); } }; // Redirect to default view on initial load useEffect(() => { if (!hasRedirected.current && location.pathname === '/') { const defaultView = getDefaultView(); if (defaultView !== '/') { hasRedirected.current = true; navigate(defaultView, { replace: true }); } } }, [location.pathname, navigate]); useEffect(() => { localStorage.setItem('sidebarExpanded', String(sidebarExpanded)); }, [sidebarExpanded]); // Close mobile drawer on navigation useEffect(() => { if (isMobile) { setMobileDrawerOpen(false); } }, [location.pathname, isMobile]); // Listen for plate detection warnings (objects on plate, print paused) // Only show to users with printers:control permission useEffect(() => { const handlePlateNotEmpty = (event: Event) => { // Only show alert to users who can control printers if (!hasPermission('printers:control')) { return; } const detail = (event as CustomEvent).detail; setPlateDetectionAlert({ printer_id: detail.printer_id, printer_name: detail.printer_name, message: detail.message, }); }; window.addEventListener('plate-not-empty', handlePlateNotEmpty); return () => window.removeEventListener('plate-not-empty', handlePlateNotEmpty); }, [hasPermission]); // Global keyboard shortcuts for navigation const handleKeyDown = useCallback((e: KeyboardEvent) => { const target = e.target as HTMLElement; // Ignore if typing in an input/textarea if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) { return; } // Number keys for navigation (1-9) - follows sidebar order including external links if (!e.metaKey && !e.ctrlKey && !e.altKey) { const keyNum = parseInt(e.key); if (keyNum >= 1 && keyNum <= orderedSidebarIds.length && keyNum <= 9) { const id = orderedSidebarIds[keyNum - 1]; e.preventDefault(); if (isExternalLinkId(id)) { // External link - navigate to iframe page const linkId = id.replace('ext-', ''); navigate(`/external/${linkId}`); } else { // Internal nav item const navItem = navItemsMap.get(id); if (navItem) { navigate(navItem.to); } } return; } switch (e.key) { case '?': e.preventDefault(); setShowShortcuts(true); break; case 'Escape': setShowShortcuts(false); break; } } }, [navigate, orderedSidebarIds, navItemsMap]); useEffect(() => { document.addEventListener('keydown', handleKeyDown); return () => document.removeEventListener('keydown', handleKeyDown); }, [handleKeyDown]); return (
{/* Mobile Header */} {isMobile && (
Bambuddy
)} {/* Mobile Drawer Backdrop */} {isMobile && mobileDrawerOpen && (
setMobileDrawerOpen(false)} /> )} {/* Sidebar / Mobile Drawer */} {/* Main content */}
{/* Debug logging indicator */} {debugLoggingState?.enabled && (
{t('support.debugLoggingActive', { defaultValue: 'Debug logging is active' })} {debugDuration !== null && ( ({Math.floor(debugDuration / 60)}m {debugDuration % 60}s) )}
)} {/* Persistent update banner */} {showUpdateBanner && (
{t('nav.updateAvailableBanner', { version: updateCheck?.latest_version, defaultValue: `Version ${updateCheck?.latest_version} is available!` })}
)}
{/* Keyboard Shortcuts Modal */} {showShortcuts && ( setShowShortcuts(false)} sidebarItems={orderedSidebarIds.map(id => { if (isExternalLinkId(id)) { const extLink = extLinksMap.get(id); return extLink ? { type: 'external' as const, label: extLink.name } : null; } else { const navItem = navItemsMap.get(id); return navItem ? { type: 'nav' as const, label: navItem.labelKey, labelKey: navItem.labelKey } : null; } }).filter(Boolean) as { type: 'nav' | 'external'; label: string; labelKey?: string }[]} /> )} {/* Plate Detection Alert Modal */} {plateDetectionAlert && (

{t('plateAlert.title')}

{plateDetectionAlert.printer_name}

{t('plateAlert.message')}

)} {/* Change Password Modal */} {showChangePasswordModal && (
{ setShowChangePasswordModal(false); setChangePasswordData({ currentPassword: '', newPassword: '', confirmPassword: '' }); }} > e.stopPropagation()} >

{t('changePassword.title')}

setChangePasswordData({ ...changePasswordData, currentPassword: e.target.value })} className="w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors" placeholder={t('changePassword.currentPasswordPlaceholder')} autoComplete="current-password" />
setChangePasswordData({ ...changePasswordData, newPassword: e.target.value })} className="w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors" placeholder={t('changePassword.newPasswordPlaceholder')} autoComplete="new-password" minLength={6} />
setChangePasswordData({ ...changePasswordData, confirmPassword: e.target.value })} className={`w-full px-4 py-3 bg-bambu-dark-secondary border rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors ${ changePasswordData.confirmPassword && changePasswordData.newPassword !== changePasswordData.confirmPassword ? 'border-red-500' : 'border-bambu-dark-tertiary' }`} placeholder={t('changePassword.confirmPasswordPlaceholder')} autoComplete="new-password" minLength={6} /> {changePasswordData.confirmPassword && changePasswordData.newPassword !== changePasswordData.confirmPassword && (

{t('changePassword.passwordsDoNotMatch')}

)}
)}
); }