Files
bambuddy/frontend/src/components/Layout.tsx
T
Ed 14a6d294f4 Merge pull request #1272 from EdwardChamberlain/feat-page-icons
[Feature] Update page icons for consistent UI style
2026-05-12 14:01:37 +02:00

1153 lines
49 KiB
TypeScript

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, ChevronLeft, ChevronRight, Keyboard, Github, GripVertical, ArrowUpCircle, Wrench, FolderKanban, FolderOpen, X, Menu, Info, Plug, Bug, LogOut, Key, Loader2, Disc3, ShieldAlert, Bell, Globe, 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, 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 { 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';
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' },
// User-account features: kept adjacent to Settings intentionally
{ id: 'notifications', to: '/notifications', icon: Bell, labelKey: 'nav.notifications' },
{ 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 isSidebarCompact = useIsSidebarCompact();
// Re-render Layout (and the page rendered inside <Outlet />) 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 [sidebarOrder, setSidebarOrder] = useState<string[]>(getSidebarOrder);
const [draggedId, setDraggedId] = useState<string | null>(null);
const [dragOverId, setDragOverId] = useState<string | null>(null);
const hasRedirected = useRef(false);
const [dismissedUpdateVersion, setDismissedUpdateVersion] = useState<string | null>(() =>
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
});
// 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) || isExternalLinkId(id)));
if (filtered.length > 0) {
setSidebarOrder(filtered);
saveSidebarOrder(filtered);
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 for conditional nav items
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<number>();
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<number | null>(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<string>();
// Map nav item IDs to the permission required to see them
const navPermissions: Record<string, Permission> = {
archives: 'archives:read',
queue: 'queue:read',
stats: 'stats:read',
profiles: 'kprofiles:read',
maintenance: 'maintenance:read',
projects: 'projects:read',
inventory: 'inventory:read',
files: 'library:read',
makerworld: 'makerworld:view',
settings: 'settings:read',
notifications: 'notifications:user_email',
};
const isHidden = (id: string) => {
if (authEnabled && id in navPermissions && !hasPermission(navPermissions[id])) 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;
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;
})();
// 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.
// 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]);
// 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 (isExternalLinkId(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 (
<div className="flex min-h-screen">
{/* Compact Header */}
{isSidebarCompact && (
<header className="fixed top-0 left-0 right-0 z-40 h-14 bg-bambu-dark-secondary border-b border-bambu-dark-tertiary flex items-center px-4">
<button
onClick={() => setMobileDrawerOpen(true)}
className="p-2 -ml-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors"
aria-label="Open menu"
>
<Menu className="w-6 h-6 text-white" />
</button>
<img
src={mode === 'dark' ? '/img/bambuddy_logo_dark_transparent.png' : '/img/bambuddy_logo_light.png'}
alt="Bambuddy"
className="h-8 ml-3"
/>
</header>
)}
{/* Compact Drawer Backdrop */}
{isSidebarCompact && mobileDrawerOpen && (
<div
className="fixed inset-0 bg-black/60 z-40 transition-opacity"
onClick={() => setMobileDrawerOpen(false)}
/>
)}
{/* Sidebar / Mobile Drawer */}
<aside
className={`bg-bambu-dark-secondary border-r border-bambu-dark-tertiary flex flex-col transition-all duration-300 ${
isSidebarCompact
? `fixed inset-y-0 left-0 z-50 w-72 transform ${mobileDrawerOpen ? 'translate-x-0' : '-translate-x-full'}`
: `fixed inset-y-0 left-0 z-30 ${sidebarExpanded ? 'w-64' : 'w-16'}`
}`}
>
{/* Logo */}
<div className={`border-b border-bambu-dark-tertiary flex items-center justify-center ${isSidebarCompact || sidebarExpanded ? 'p-4' : 'p-2'}`}>
<img
src={mode === 'dark' ? '/img/bambuddy_logo_dark_transparent.png' : '/img/bambuddy_logo_light.png'}
alt="Bambuddy"
className={isSidebarCompact || sidebarExpanded ? 'h-16 w-auto' : 'h-8 w-8 object-cover object-left'}
/>
</div>
{/* Navigation */}
<nav className="flex-1 p-2 overflow-y-auto">
<ul className="space-y-2">
{orderedSidebarIds.map((id) => {
const isExternal = isExternalLinkId(id);
if (isExternal) {
// Render external link
const link = extLinksMap.get(id);
if (!link) return null;
const LinkIcon = link.custom_icon ? null : getIconByName(link.icon);
return (
<li
key={id}
draggable
onDragStart={(e) => handleDragStart(e, id)}
onDragOver={(e) => handleDragOver(e, id)}
onDragLeave={handleDragLeave}
onDrop={(e) => handleDrop(e, id)}
onDragEnd={handleDragEnd}
className={`relative ${
draggedId === id ? 'opacity-50' : ''
} ${
dragOverId === id && draggedId !== id
? 'before:absolute before:left-0 before:right-0 before:top-0 before:h-0.5 before:bg-bambu-green'
: ''
}`}
>
{link.open_in_new_tab ? (
<a
href={link.url}
target="_blank"
rel="noopener noreferrer"
className={`flex items-center ${isSidebarCompact || sidebarExpanded ? 'gap-3 px-4' : 'justify-center px-2'} py-3 rounded-lg transition-colors group text-bambu-gray-light hover:bg-bambu-dark-tertiary hover:text-white`}
title={!isSidebarCompact && !sidebarExpanded ? link.name : undefined}
>
{sidebarExpanded && !isSidebarCompact && (
<GripVertical className="w-4 h-4 flex-shrink-0 opacity-0 group-hover:opacity-50 cursor-grab active:cursor-grabbing -ml-1" />
)}
{link.custom_icon ? (
<img
src={api.getExternalLinkIconUrl(link.id)}
alt=""
className="w-5 h-5 flex-shrink-0"
/>
) : (
LinkIcon && <LinkIcon className="w-5 h-5 flex-shrink-0" />
)}
{(isSidebarCompact || sidebarExpanded) && <span>{link.name}</span>}
</a>
) : (
<NavLink
to={`/external/${link.id}`}
className={({ isActive }) =>
`flex items-center ${isSidebarCompact || sidebarExpanded ? 'gap-3 px-4' : 'justify-center px-2'} py-3 rounded-lg transition-colors group ${
isActive
? 'bg-bambu-green text-white'
: 'text-bambu-gray-light hover:bg-bambu-dark-tertiary hover:text-white'
}`
}
title={!isSidebarCompact && !sidebarExpanded ? link.name : undefined}
>
{sidebarExpanded && !isSidebarCompact && (
<GripVertical className="w-4 h-4 flex-shrink-0 opacity-0 group-hover:opacity-50 cursor-grab active:cursor-grabbing -ml-1" />
)}
{link.custom_icon ? (
<img
src={api.getExternalLinkIconUrl(link.id)}
alt=""
className="w-5 h-5 flex-shrink-0"
/>
) : (
LinkIcon && <LinkIcon className="w-5 h-5 flex-shrink-0" />
)}
{(isSidebarCompact || sidebarExpanded) && <span>{link.name}</span>}
</NavLink>
)}
</li>
);
} else {
// Render internal nav item
const navItem = navItemsMap.get(id);
if (!navItem) return null;
const { to, icon: Icon, labelKey } = navItem;
const showQueueBadge = id === 'queue' && pendingQueueCount > 0;
const showArchiveBadge = id === 'archives' && pendingUploadsCount > 0;
const badgeCount = showQueueBadge ? pendingQueueCount : showArchiveBadge ? pendingUploadsCount : 0;
const showBadge = showQueueBadge || showArchiveBadge;
const showClearPlateDot = id === 'printers' && needsClearPlate;
return (
<li
key={id}
draggable
onDragStart={(e) => handleDragStart(e, id)}
onDragOver={(e) => handleDragOver(e, id)}
onDragLeave={handleDragLeave}
onDrop={(e) => handleDrop(e, id)}
onDragEnd={handleDragEnd}
className={`relative ${
draggedId === id ? 'opacity-50' : ''
} ${
dragOverId === id && draggedId !== id
? 'before:absolute before:left-0 before:right-0 before:top-0 before:h-0.5 before:bg-bambu-green'
: ''
}`}
>
<NavLink
to={to}
className={({ isActive }) =>
`flex items-center ${isSidebarCompact || sidebarExpanded ? 'gap-3 px-4' : 'justify-center px-2'} py-3 rounded-lg transition-colors group ${
isActive
? 'bg-bambu-green text-white'
: 'text-bambu-gray-light hover:bg-bambu-dark-tertiary hover:text-white'
}`
}
title={!isSidebarCompact && !sidebarExpanded ? t(labelKey) : undefined}
>
{sidebarExpanded && !isSidebarCompact && (
<GripVertical className="w-4 h-4 flex-shrink-0 opacity-0 group-hover:opacity-50 cursor-grab active:cursor-grabbing -ml-1" />
)}
<div className="relative">
<Icon className="w-5 h-5 flex-shrink-0" />
{showClearPlateDot && (
<span className="absolute -top-0.5 -right-0.5 w-2.5 h-2.5 bg-yellow-500 rounded-full border-2 border-bambu-dark-secondary" />
)}
{showBadge && (
<span className={`absolute -top-1.5 -right-1.5 min-w-[18px] h-[18px] px-1 flex items-center justify-center text-[10px] font-bold rounded-full ${
showArchiveBadge ? 'bg-blue-500 text-white' : 'bg-yellow-500 text-black'
}`}>
{badgeCount > 99 ? '99+' : badgeCount}
</span>
)}
</div>
{(isSidebarCompact || sidebarExpanded) && <span>{t(labelKey)}</span>}
</NavLink>
</li>
);
}
})}
</ul>
</nav>
{/* Collapse toggle - hide on compact sidebar */}
{!isSidebarCompact && (
<button
onClick={() => setSidebarExpanded(!sidebarExpanded)}
className="p-2 mx-2 mb-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white flex items-center justify-center"
title={sidebarExpanded ? t('nav.collapseSidebar') : t('nav.expandSidebar')}
>
{sidebarExpanded ? (
<ChevronLeft className="w-5 h-5" />
) : (
<ChevronRight className="w-5 h-5" />
)}
</button>
)}
{/* Footer */}
<div className="flex-shrink-0 p-2 border-t border-bambu-dark-tertiary">
{isSidebarCompact || sidebarExpanded ? (
<div className="flex flex-col gap-2 px-2">
{/* Top row: icons */}
<div className="flex items-center justify-center gap-1 flex-wrap">
{hasSwitchbarPlugs && (
<div className="relative">
<button
onMouseEnter={() => setShowSwitchbar(true)}
className={`p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors ${
showSwitchbar ? 'text-bambu-green' : 'text-bambu-gray-light hover:text-white'
}`}
title={t('nav.smartSwitches', { defaultValue: 'Smart Switches' })}
>
<Plug className="w-5 h-5" />
</button>
{showSwitchbar && (
<SwitchbarPopover onClose={() => setShowSwitchbar(false)} />
)}
</div>
)}
{hasPermission('system:read') ? (
<NavLink
to="/system"
className={({ isActive }) =>
`p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors ${
isActive ? 'text-bambu-green' : 'text-bambu-gray-light hover:text-white'
}`
}
title={t('nav.system')}
>
<Info className="w-5 h-5" />
</NavLink>
) : (
<span
className="p-2 rounded-lg text-bambu-gray/50 cursor-not-allowed"
title="You do not have permission to view system information"
>
<Info className="w-5 h-5" />
</span>
)}
<a
href="https://github.com/maziggy/bambuddy"
target="_blank"
rel="noopener noreferrer"
className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
title={t('nav.viewOnGithub')}
>
<Github className="w-5 h-5" />
</a>
<button
onClick={() => setShowShortcuts(true)}
className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
title={t('nav.keyboardShortcuts')}
>
<Keyboard className="w-5 h-5" />
</button>
<button
onClick={toggleMode}
className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
title={mode === 'dark' ? t('nav.switchToLight') : t('nav.switchToDark')}
>
{mode === 'dark' ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />}
</button>
{authEnabled && user && (
<>
<button
onClick={() => setShowChangePasswordModal(true)}
className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
title={t('changePassword.title')}
>
<Key className="w-5 h-5" />
</button>
<button
onClick={logout}
className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
title={t('nav.logout', { defaultValue: 'Logout' })}
>
<LogOut className="w-5 h-5" />
</button>
</>
)}
</div>
{/* Bottom row: version */}
<div className="flex items-center justify-center gap-2">
<span className="text-sm text-bambu-gray">v{versionInfo?.version || '...'}</span>
{updateCheck?.update_available && (
<button
onClick={() => navigate('/settings')}
className="flex items-center gap-1 text-xs text-bambu-green hover:text-bambu-green/80 transition-colors"
title={t('nav.updateAvailable', { version: updateCheck.latest_version })}
>
<ArrowUpCircle className="w-4 h-4" />
<span>{t('nav.update')}</span>
</button>
)}
</div>
</div>
) : (
<div className="flex flex-col items-center gap-1 overflow-y-auto max-h-[50vh]">
{updateCheck?.update_available && (
<button
onClick={() => navigate('/settings')}
className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-green hover:text-bambu-green/80"
title={t('nav.updateAvailable', { version: updateCheck.latest_version })}
>
<ArrowUpCircle className="w-5 h-5" />
</button>
)}
{hasSwitchbarPlugs && (
<div className="relative">
<button
onMouseEnter={() => setShowSwitchbar(true)}
className={`p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors ${
showSwitchbar ? 'text-bambu-green' : 'text-bambu-gray-light hover:text-white'
}`}
title={t('nav.smartSwitches', { defaultValue: 'Smart Switches' })}
>
<Plug className="w-5 h-5" />
</button>
{showSwitchbar && (
<SwitchbarPopover onClose={() => setShowSwitchbar(false)} />
)}
</div>
)}
{hasPermission('system:read') ? (
<NavLink
to="/system"
className={({ isActive }) =>
`p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors ${
isActive ? 'text-bambu-green' : 'text-bambu-gray-light hover:text-white'
}`
}
title={t('nav.system')}
>
<Info className="w-5 h-5" />
</NavLink>
) : (
<span
className="p-2 rounded-lg text-bambu-gray/50 cursor-not-allowed"
title="You do not have permission to view system information"
>
<Info className="w-5 h-5" />
</span>
)}
<a
href="https://github.com/maziggy/bambuddy"
target="_blank"
rel="noopener noreferrer"
className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
title={t('nav.viewOnGithub')}
>
<Github className="w-5 h-5" />
</a>
<button
onClick={() => setShowShortcuts(true)}
className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
title={t('nav.keyboardShortcuts')}
>
<Keyboard className="w-5 h-5" />
</button>
<button
onClick={toggleMode}
className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
title={mode === 'dark' ? t('nav.switchToLight') : t('nav.switchToDark')}
>
{mode === 'dark' ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />}
</button>
{authEnabled && user && (
<>
<button
onClick={() => setShowChangePasswordModal(true)}
className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
title={t('changePassword.title')}
>
<Key className="w-5 h-5" />
</button>
<button
onClick={logout}
className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
title={t('nav.logout', { defaultValue: 'Logout' })}
>
<LogOut className="w-5 h-5" />
</button>
</>
)}
</div>
)}
</div>
</aside>
{/* Main content */}
<main className={`flex-1 bg-bambu-dark overflow-auto transition-all duration-300 ${
isSidebarCompact ? 'mt-14' : sidebarExpanded ? 'ml-64' : 'ml-16'
}`}>
{/* Debug logging indicator */}
{debugLoggingState?.enabled && (
<div className="bg-amber-500/20 border-b border-amber-500/30 px-4 py-2 flex items-center justify-between">
<div className="flex items-center gap-2 text-sm">
<Bug className="w-4 h-4 text-amber-500 animate-pulse" />
<span className="text-amber-200">
{t('support.debugLoggingActive', { defaultValue: 'Debug logging is active' })}
{debugDuration !== null && (
<span className="text-amber-300/70 ml-2">
({Math.floor(debugDuration / 60)}m {debugDuration % 60}s)
</span>
)}
</span>
<button
onClick={() => navigate('/system')}
className="text-amber-400 hover:text-amber-300 font-medium underline ml-2"
>
{t('support.manageLogs', { defaultValue: 'Manage' })}
</button>
</div>
</div>
)}
{devModeWarnings && devModeWarnings.length > 0 && (
<div className="bg-orange-500/20 border-b border-orange-500/30 px-4 py-2 flex items-center justify-between">
<div className="flex items-center gap-2 text-sm">
<ShieldAlert className="w-4 h-4 text-orange-500" />
<span className="text-orange-200">
{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.`
})}
</span>
<a href="https://wiki.bambulab.com/en/knowledge-sharing/enable-developer-mode"
target="_blank" rel="noopener noreferrer"
className="text-orange-400 hover:text-orange-300 font-medium underline ml-2">
{t('printers.howToEnable', { defaultValue: 'How to enable' })}
</a>
</div>
</div>
)}
{/* Persistent update banner */}
{showUpdateBanner && (
<div className="bg-bambu-green/20 border-b border-bambu-green/30 px-4 py-2 flex items-center justify-between">
<div className="flex items-center gap-2 text-sm">
<ArrowUpCircle className="w-4 h-4 text-bambu-green" />
<span>
{t('nav.updateAvailableBanner', {
version: updateCheck?.latest_version,
defaultValue: `Version ${updateCheck?.latest_version} is available!`
})}
</span>
<button
onClick={() => navigate('/settings')}
className="text-bambu-green hover:text-bambu-green/80 font-medium underline"
>
{t('nav.viewUpdate', { defaultValue: 'View update' })}
</button>
</div>
<button
onClick={dismissUpdateBanner}
className="p-1 hover:bg-bambu-dark-tertiary rounded transition-colors"
title={t('common.dismiss', { defaultValue: 'Dismiss' })}
>
<X className="w-4 h-4" />
</button>
</div>
)}
<Outlet />
</main>
{/* Keyboard Shortcuts Modal */}
{showShortcuts && (
<KeyboardShortcutsModal
onClose={() => 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 && (
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-[100] p-4">
<div className="bg-bambu-dark-secondary border-2 border-yellow-500 rounded-xl shadow-2xl max-w-md w-full animate-in fade-in zoom-in duration-200">
<div className="p-6 text-center">
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-yellow-500/20 flex items-center justify-center">
<svg className="w-10 h-10 text-yellow-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
<h2 className="text-xl font-bold text-yellow-400 mb-2">
{t('plateAlert.title')}
</h2>
<p className="text-lg text-white mb-2">
{plateDetectionAlert.printer_name}
</p>
<p className="text-bambu-gray mb-6">
{t('plateAlert.message')}
</p>
<button
onClick={() => setPlateDetectionAlert(null)}
className="w-full py-3 px-6 bg-yellow-500 hover:bg-yellow-600 text-black font-semibold rounded-lg transition-colors"
>
{t('plateAlert.understand')}
</button>
</div>
</div>
</div>
)}
{/* Change Password Modal */}
{showChangePasswordModal && (
<div
className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4"
onClick={() => {
setShowChangePasswordModal(false);
setChangePasswordData({ currentPassword: '', newPassword: '', confirmPassword: '' });
}}
>
<Card
className="w-full max-w-md"
onClick={(e: React.MouseEvent) => e.stopPropagation()}
>
<CardHeader>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Key className="w-5 h-5 text-bambu-green" />
<h2 className="text-lg font-semibold text-white">{t('changePassword.title')}</h2>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => {
setShowChangePasswordModal(false);
setChangePasswordData({ currentPassword: '', newPassword: '', confirmPassword: '' });
}}
>
<X className="w-5 h-5" />
</Button>
</div>
</CardHeader>
<CardContent>
<div className="space-y-4">
<input
type="text"
name="username"
autoComplete="username"
value={user?.username ?? ''}
readOnly
hidden
aria-hidden="true"
tabIndex={-1}
/>
<div>
<label className="block text-sm font-medium text-white mb-2">
{t('changePassword.currentPassword')}
</label>
<input
type="password"
value={changePasswordData.currentPassword}
onChange={(e) => 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"
/>
</div>
<div>
<label className="block text-sm font-medium text-white mb-2">
{t('changePassword.newPassword')}
</label>
<input
type="password"
value={changePasswordData.newPassword}
onChange={(e) => 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}
/>
</div>
<div>
<label className="block text-sm font-medium text-white mb-2">
{t('changePassword.confirmPassword')}
</label>
<input
type="password"
value={changePasswordData.confirmPassword}
onChange={(e) => 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 && (
<p className="text-red-400 text-xs mt-1">{t('changePassword.passwordsDoNotMatch')}</p>
)}
</div>
</div>
<div className="mt-6 flex justify-end gap-3">
<Button
variant="secondary"
onClick={() => {
setShowChangePasswordModal(false);
setChangePasswordData({ currentPassword: '', newPassword: '', confirmPassword: '' });
}}
>
{t('common.cancel')}
</Button>
<Button
onClick={async () => {
if (changePasswordData.newPassword !== changePasswordData.confirmPassword) {
showToast(t('changePassword.passwordsDoNotMatch'), 'error');
return;
}
if (changePasswordData.newPassword.length < 6) {
showToast(t('changePassword.passwordTooShort'), 'error');
return;
}
setChangePasswordLoading(true);
try {
await api.changePassword(changePasswordData.currentPassword, changePasswordData.newPassword);
showToast(t('changePassword.success'), 'success');
setShowChangePasswordModal(false);
setChangePasswordData({ currentPassword: '', newPassword: '', confirmPassword: '' });
} catch (error: unknown) {
const message = error instanceof Error ? error.message : t('changePassword.failed');
showToast(message, 'error');
} finally {
setChangePasswordLoading(false);
}
}}
disabled={changePasswordLoading || !changePasswordData.currentPassword || !changePasswordData.newPassword || changePasswordData.newPassword !== changePasswordData.confirmPassword || changePasswordData.newPassword.length < 6}
>
{changePasswordLoading ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
{t('changePassword.changing')}
</>
) : (
<>
<Key className="w-4 h-4" />
{t('changePassword.title')}
</>
)}
</Button>
</div>
</CardContent>
</Card>
</div>
)}
<BugReportBubble />
</div>
);
}