import { useState, useEffect, useCallback, useRef, useMemo } from 'react'; import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom'; import { Printer, Archive, ListOrdered, BarChart3, Cloud, Settings, Sun, Moon, Monitor, ChevronLeft, ChevronRight, Keyboard, Github, ArrowUpCircle, Wrench, FolderKanban, FolderOpen, X, Menu, Info, Plug, Bug, LogOut, Key, Loader2, Disc3, ShieldAlert, Globe, Bell, Receipt, type LucideIcon } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { useTheme } from '../contexts/ThemeContext'; import { KeyboardShortcutsModal } from './KeyboardShortcutsModal'; import { InstallAppButton } from './InstallAppButton'; import { SwitchbarPopover } from './SwitchbarPopover'; import { useQuery, useQueries } from '@tanstack/react-query'; import { api, supportApi, pendingUploadsApi, type Permission } from '../api/client'; import { getIconByName } from './IconPicker'; import { useIsSidebarCompact } from '../hooks/useIsSidebarCompact'; import { useColorCatalogVersion } from '../hooks/useColorCatalogVersion'; import { useSponsorPrompt } from '../hooks/useSponsorPrompt'; import { useUnknownTagPrompt } from '../hooks/useUnknownTagPrompt'; import { UnknownSpoolModal } from './UnknownSpoolModal'; import { useAuth } from '../contexts/AuthContext'; import { useToast } from '../contexts/ToastContext'; import { Card, CardHeader, CardContent } from './Card'; import { parseUTCDate } from '../utils/date'; import { Button } from './Button'; import { BugReportBubble } from './BugReportBubble'; import { getHiddenSidebarSystemItemIds, getSidebarOrder, isExternalSidebarItemId, saveHiddenSidebarSystemItemIds, saveSidebarOrder, SIDEBAR_LAYOUT_CHANGED_EVENT, } from '../utils/sidebarLayout'; 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: 'inventory', to: '/inventory', icon: Disc3, labelKey: 'nav.inventory' }, { id: 'archives', to: '/archives', icon: Archive, labelKey: 'nav.archives' }, { id: 'queue', to: '/queue', icon: ListOrdered, labelKey: 'nav.queue' }, { id: 'projects', to: '/projects', icon: FolderKanban, labelKey: 'nav.projects' }, { id: 'files', to: '/files', icon: FolderOpen, labelKey: 'nav.files' }, { id: 'makerworld', to: '/makerworld', icon: Globe, labelKey: 'nav.makerworld' }, { id: 'profiles', to: '/profiles', icon: Cloud, labelKey: 'nav.profiles' }, { id: 'maintenance', to: '/maintenance', icon: Wrench, labelKey: 'nav.maintenance' }, { id: 'stats', to: '/stats', icon: BarChart3, labelKey: 'nav.stats' }, // Opt-in feature: gated in isHidden() on the billing_enabled setting, so the // entry stays out of the sidebar entirely until an admin turns billing on. { id: 'finance', to: '/finance', icon: Receipt, labelKey: 'nav.finance' }, // User-account feature: gated in isHidden() on advanced auth + user_notifications // + the notifications:user_email permission. Kept adjacent to Settings // intentionally. Do not drop this entry — without it the /notifications page // is orphaned (route + page still exist but no nav link) (#1901). { id: 'notifications', to: '/notifications', icon: Bell, labelKey: 'nav.notifications' }, { id: 'settings', to: '/settings', icon: Settings, labelKey: 'nav.settings' }, ]; // 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, resolvedMode, toggleMode } = useTheme(); const { t } = useTranslation(); const isSidebarCompact = useIsSidebarCompact(); // Bug-report panel state lives here because the trigger moves (#2750, // reporter @goodjaltman). Below the sidebar-compact breakpoint the floating // disc is replaced by a button in the compact header: the bottom-right corner // is the most contended region in the app — the Profiles scroll-to-top FAB, // the floating camera window and its resize handle, the Group Edit save bar, // the bulk-selection toolbars, and the File Manager / Archives per-card // action buttons all live there — and a fixed 48px disc sits on top of // whichever of them happens to be underneath. Moving out of the corner is // the only fix that covers in-flow content as well as fixed overlays. const [bugReportOpen, setBugReportOpen] = useState(false); // Theme toggle: mode → icon and tooltip const ThemeIcon = { dark: Sun, light: Monitor, system: Moon }[mode]; const themeSwitchTitle = t({ dark: 'nav.switchToLight', light: 'nav.switchToSystem', system: 'nav.switchToDark' }[mode]); // Re-render Layout (and the page rendered inside ) whenever the // backend color catalog is (re)populated, so pages that mounted before the // catalog fetched — and cached HSL-fallback color names during their first // render — refresh with the real catalog names. See #857. useColorCatalogVersion(); 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 defaultSidebarOrder = useMemo(() => defaultNavItems.map(i => i.id), []); const [sidebarOrder, setSidebarOrder] = useState(() => getSidebarOrder(defaultNavItems.map(i => i.id))); const [hiddenSystemItemIds, setHiddenSystemItemIds] = useState(getHiddenSidebarSystemItemIds); 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 }); // Sponsor-prompt toast — fires once per session post-auth if a milestone is eligible. useSponsorPrompt(settings?.currency ?? 'EUR'); // Unknown-spool prompt — surfaces a confirmation modal when the AMS reports a // tag with no inventory match (only when `auto_add_unknown_rfid` is off). const unknownSpool = useUnknownTagPrompt(); // Fetch default sidebar order via a public endpoint (no settings:read needed) const { data: defaultSidebarData } = useQuery({ queryKey: ['default-sidebar-order'], queryFn: api.getDefaultSidebarOrder, staleTime: 5 * 60 * 1000, // 5 minutes }); // Apply admin default sidebar order once per user (skipped if already applied). // Uses a per-user localStorage flag to prevent re-application. useEffect(() => { const defaultOrder = defaultSidebarData?.default_sidebar_order; if (!defaultOrder) return; // Wait for auth state to settle before applying to avoid double-execution if (authEnabled && !user) return; const appliedKey = user ? `sidebarDefaultApplied_${user.id}` : 'sidebarDefaultApplied'; if (localStorage.getItem(appliedKey)) return; try { const parsed = JSON.parse(defaultOrder); const orderArr = Array.isArray(parsed) ? parsed : parsed.order; if (!Array.isArray(orderArr) || orderArr.length === 0) return; // Filter to valid sidebar item IDs only const validIds = new Set(defaultNavItems.map(i => i.id)); const filtered = orderArr.filter((id: string) => typeof id === 'string' && (validIds.has(id) || isExternalSidebarItemId(id))); if (filtered.length > 0) { setSidebarOrder(filtered); saveSidebarOrder(filtered); const hiddenIds = Array.isArray(parsed) ? [] : parsed.hiddenSystemItemIds; if (Array.isArray(hiddenIds)) { const filteredHiddenIds = hiddenIds.filter((id: string) => typeof id === 'string' && validIds.has(id) && id !== 'settings'); setHiddenSystemItemIds(filteredHiddenIds); saveHiddenSidebarSystemItemIds(filteredHiddenIds); } localStorage.setItem(appliedKey, '1'); } } catch (e) { console.error('Failed to apply default sidebar order:', e); } }, [defaultSidebarData?.default_sidebar_order, setSidebarOrder, user, authEnabled]); // Check advanced auth status — the notifications nav item is gated on it // (rendered only when authEnabled && advanced_auth_enabled && user_notifications_enabled). const { data: advancedAuthStatus } = useQuery({ queryKey: ['advancedAuthStatus'], queryFn: api.getAdvancedAuthStatus, staleTime: 5 * 60 * 1000, // 5 minutes enabled: authEnabled, }); 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 }); // Check developer LAN mode warnings const { data: devModeWarnings } = useQuery({ queryKey: ['developer-mode-warnings'], queryFn: api.getDeveloperModeWarnings, staleTime: 10 * 1000, refetchInterval: 30 * 1000, refetchOnWindowFocus: true, }); // 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; // Check if any printer with pending queue items needs plate clearing const queuePrinterIds = useMemo(() => { const ids = new Set(); queueItems?.forEach(item => { if (item.printer_id) ids.add(item.printer_id); }); return Array.from(ids); }, [queueItems]); const printerStatusQueries = useQueries({ queries: queuePrinterIds.map(id => ({ queryKey: ['printerStatus', id], queryFn: () => api.getPrinterStatus(id), staleTime: 30 * 1000, // WebSocket keeps this warm })), }); const needsClearPlate = printerStatusQueries.some(result => { const status = result.data; if (!status) return false; return !!status.awaiting_plate_clear; }); // 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 = parseUTCDate(debugLoggingState.enabled_at)?.getTime() ?? Date.now(); 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 // Hide nav items the user doesn't have read permission for const orderedSidebarIds = (() => { const result: string[] = []; const seen = new Set(); // Map nav item IDs to the permission(s) required to see them. Resources // that ship in three tiers (legacy `*:read` + granular `*:read_own` / // `*:read_all`) list all three: the default Operators group is seeded // with `_own` only, so gating on the legacy alone hides the entry from // every non-admin user even though the underlying API accepts their // request (#1755). const navPermissions: Record = { archives: ['archives:read', 'archives:read_own', 'archives:read_all'], queue: ['queue:read', 'queue:read_own', 'queue:read_all'], stats: 'stats:read', profiles: 'kprofiles:read', maintenance: 'maintenance:read', projects: 'projects:read', inventory: 'inventory:read', finance: 'cost_centers:read_own', files: ['library:read', 'library:read_own', 'library:read_all'], makerworld: 'makerworld:view', settings: 'settings:read', // The user-email-preferences API requires notifications:user_email, so // gate the nav item on the same permission (both default groups — // Administrators and Operators — hold it). The advanced-auth / // user_notifications enablement gate is applied separately below. notifications: 'notifications:user_email', }; const isHidden = (id: string) => { // User-toggled hide (#1673) wins first — cheapest check, explicit intent. if (hiddenSystemItemIds.includes(id)) return true; // Permission gate accepts Permission | Permission[] so resources with // granular `*:read_own` / `*:read_all` tiers (default Operators group) // don't get hidden from users who only hold the granular variant (#1755). if (authEnabled && id in navPermissions) { const required = navPermissions[id]; const granted = Array.isArray(required) ? required.some((p) => hasPermission(p)) : hasPermission(required); if (!granted) return true; } // notifications nav item also requires advanced auth to be enabled and user_notifications_enabled setting if (id === 'notifications' && (!authEnabled || !advancedAuthStatus?.advanced_auth_enabled || (settings?.user_notifications_enabled === false))) return true; // Finance is off by default and the page is meaningless without it, so it // stays hidden until billing is explicitly on. Tested for `true` rather // than `!== false` on purpose: settings are undefined on the first render, // and a nav entry that appears and then vanishes reads as a glitch. if (id === 'finance' && settings?.billing_enabled !== true) return true; return false; }; // Add items in stored order for (const id of sidebarOrder) { if (isHidden(id)) 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) { if (isHidden(item.id)) 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; })(); // Show update banner if update available and not dismissed for this version. // Suppressed when running as a Home Assistant addon — HA Supervisor surfaces // its own update notification in the HA UI, so the in-app banner is duplicate // noise that links to a page that just says "update via HA." const showUpdateBanner = updateCheck?.update_available && updateCheck.latest_version && updateCheck.latest_version !== dismissedUpdateVersion && !updateCheck.is_ha_addon; 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]); useEffect(() => { const refreshSidebarLayout = () => { setSidebarOrder(getSidebarOrder(defaultSidebarOrder)); setHiddenSystemItemIds(getHiddenSidebarSystemItemIds()); }; window.addEventListener(SIDEBAR_LAYOUT_CHANGED_EVENT, refreshSidebarLayout); window.addEventListener('storage', refreshSidebarLayout); return () => { window.removeEventListener(SIDEBAR_LAYOUT_CHANGED_EVENT, refreshSidebarLayout); window.removeEventListener('storage', refreshSidebarLayout); }; }, [defaultSidebarOrder]); // Close compact drawer on navigation useEffect(() => { if (isSidebarCompact) { setMobileDrawerOpen(false); } }, [location.pathname, isSidebarCompact]); // 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 (isExternalSidebarItemId(id)) { // External link const extLink = extLinksMap.get(id); if (extLink?.open_in_new_tab) { window.open(extLink.url, '_blank', 'noopener,noreferrer'); } else { 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, extLinksMap]); useEffect(() => { document.addEventListener('keydown', handleKeyDown); return () => document.removeEventListener('keydown', handleKeyDown); }, [handleKeyDown]); return (
{/* Compact Header */} {isSidebarCompact && (
Bambuddy {/* Bug report — the compact-layout home of the floating bubble. */}
)} {/* Compact Drawer Backdrop */} {isSidebarCompact && 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) )}
)} {devModeWarnings && devModeWarnings.length > 0 && (
{t('printers.developerModeWarning', { names: devModeWarnings.map(w => w.name).join(', '), defaultValue: `Developer LAN mode is not enabled on: ${devModeWarnings.map(w => w.name).join(', ')}. Some features may not work.` })} {t('printers.howToEnable', { defaultValue: 'How to enable' })}
)} {/* 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 (isExternalSidebarItemId(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')}

)}
)} {/* The panel always mounts here, at the Layout root. It must not move into the header alongside its compact-layout trigger: the header is `fixed z-40` and so its own stacking context, which would cap the z-50 panel at the header's level and bury it under every ordinary modal in the app. */}
); }