import { Suspense, useState, useEffect, useCallback, useRef, useMemo } from 'react'; import { PageLoading } from './PageLoading'; 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, Megaphone, 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 { CameraLauncher } from './CameraLauncher'; 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 { ConfirmOutcomeDialog } from './ConfirmOutcomeDialog'; 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 { AnnouncementsPanel } from './AnnouncementsPanel'; import { AnnouncementBanner } from './AnnouncementBanner'; import { useAnnouncements } from '../hooks/useAnnouncements'; import { plateCheckFailureText } from '../utils/plateCheck'; 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 kept from when the page was MakerWorld-only, so saved sidebar orders and hidden items still apply. { id: 'makerworld', to: '/model-sources', icon: Globe, labelKey: 'nav.modelSources' }, { 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); // A bug-report logging run survives the panel being closed (#2847). The // floating disc shows that itself; the compact header's button and the // debug-logging banner need telling. const [bugReportLogging, setBugReportLogging] = useState(false); // The camera list (#2737), also opened with the C key: from the Printers row // of the sidebar, or from the compact header when the sidebar is a drawer. const [cameraListOpen, setCameraListOpen] = 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, hasAnyPermission, loading: authLoading } = 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); // Post-print outcome confirmation (#1898): archive waiting for a verdict, // set by the print_confirm_request WebSocket event. const [confirmOutcomeArchiveId, setConfirmOutcomeArchiveId] = useState(null); // Check for updates const { data: versionInfo } = useQuery({ queryKey: ['version'], queryFn: api.getVersion, staleTime: Infinity, }); // GET /settings requires settings:read, so for every non-admin this query // 403'd and each of the four gates below silently took its fallback: Finance // vanished from the sidebar for the users cost_centers:read_own exists for, // a disabled user_notifications setting stopped applying to them, the sponsor // prompt showed EUR whatever the install uses, and the update check ran where // it had been switched off. Two of those were invisible to an administrator // testing it, because an administrator can read /settings (#3023). const { data: uiFlags } = useQuery({ queryKey: ['ui-flags'], queryFn: api.getUiFlags, staleTime: 5 * 60 * 1000, // 5 minutes }); // Announcements from the Bambuddy maintainers: the sidebar entry above System, // the slide-over list, and the banner for unread important/critical ones. The // entry is there for whoever may see announcements, also with none published. const { visible: announcementsVisible, announcements, unread: unreadAnnouncements, bannerItems, markRead: markAnnouncementRead, } = useAnnouncements(); const [announcementsOpen, setAnnouncementsOpen] = useState(false); const [announcementFocus, setAnnouncementFocus] = useState(null); const openAnnouncements = useCallback((focusId?: string) => { setMobileDrawerOpen(false); setAnnouncementFocus(focusId ?? null); setAnnouncementsOpen(true); }, []); const closeAnnouncements = useCallback(() => setAnnouncementsOpen(false), []); // Sponsor-prompt toast — fires once per session post-auth if a milestone is eligible. useSponsorPrompt(uiFlags?.currency ?? 'USD'); // 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: uiFlags?.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, // Like the queue badge: apps behind the links report counts. refetchInterval: 30_000, }); // 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', 'manyfold: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 || (uiFlags?.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: the flags are undefined on the first // render, and a nav entry that appears and then vanishes reads as a // glitch. That polarity is also why reading this from /settings hid the // entry outright for anyone without settings:read, rather than failing // open the way the notifications gate two lines up did (#3023). if (id === 'finance' && uiFlags?.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]); // A plate check that could not run (no camera image, ...) does not hold the // print, but it is not an empty plate either: say so to those who can act. useEffect(() => { const handlePlateCheckFailed = (event: Event) => { if (!hasPermission('printers:control')) { return; } const detail = (event as CustomEvent).detail ?? {}; showToast( t('printers.plateDetection.couldNotRun', { printer: detail.printer_name ?? '', reason: plateCheckFailureText(t, detail.reason), }), 'warning', ); }; window.addEventListener('plate-check-failed', handlePlateCheckFailed); return () => window.removeEventListener('plate-check-failed', handlePlateCheckFailed); }, [hasPermission, showToast, t]); // A completed print asked for its outcome verdict (#1898). Same CustomEvent // relay as plate-not-empty, and gated the same way: the PATCH route decides // who may record a verdict, so a user who cannot should not be handed a // dialog whose only outcome is a 403. // Held until the auth state has landed: while it is loading, `authEnabled` // is still false and every permission check answers yes, which would open // the dialog for exactly the user this gate exists to spare. const canConfirmOutcome = !authLoading && hasAnyPermission('archives:update_all', 'archives:update_own'); useEffect(() => { const handleConfirmRequest = (event: Event) => { const detail = (event as CustomEvent).detail; if (typeof detail?.archive_id === 'number') { setConfirmOutcomeArchiveId(detail.archive_id); } }; window.addEventListener('print-confirm-request', handleConfirmRequest); return () => window.removeEventListener('print-confirm-request', handleConfirmRequest); }, []); // The ?confirm= deep link a push notification carries (#1898). It is read // here and not on ArchivesPage because that page renders inside this Layout's // : React flushes a child's effects before its parent's, so a page // dispatching `print-confirm-request` on mount fired before the listener above // existed — which is exactly the cold load a notification tap produces. The // parameter is stripped afterwards so a reload does not re-open the dialog. useEffect(() => { const params = new URLSearchParams(location.search); const confirmId = params.get('confirm'); if (!confirmId || !/^\d+$/.test(confirmId)) { return; } setConfirmOutcomeArchiveId(Number(confirmId)); params.delete('confirm'); const rest = params.toString(); navigate({ pathname: location.pathname, search: rest ? `?${rest}` : '' }, { replace: true }); }, [location.pathname, location.search, navigate]); // 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 'c': case 'C': e.preventDefault(); setCameraListOpen(open => !open); 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 {/* A camera from any page (#2737), beside the bug report. */}
{/* 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) )} {/* A run started from the bug-report panel ends at that panel's Stop Logging button, so send the user back there rather than to the System page's raw toggle, which would drop the logs and the description they already wrote (#2847). */} {bugReportLogging && ( )}
)} {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!` })}
)} {/* Pages load on first open (#3175); the sidebar stays meanwhile. */} }>
{/* 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')}

)} {/* Post-print outcome confirmation (#1898) */} {/* The dialog's only outcome for a user who may not record a verdict is a 403 from the PATCH, so it is gated on the permissions that route enforces — the names the June migration left in the default groups. Gated on the render rather than on the two ways a request arrives: one of them can land before /auth/me has answered, and a check made then would drop it for everybody. */} {canConfirmOutcome && confirmOutcomeArchiveId !== null && ( setConfirmOutcomeArchiveId(null)} /> )} {/* 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. */}
); }