Layout.tsx 45 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065
  1. import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
  2. import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom';
  3. 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, Disc3, ShieldAlert, type LucideIcon } from 'lucide-react';
  4. import { useTranslation } from 'react-i18next';
  5. import { useTheme } from '../contexts/ThemeContext';
  6. import { KeyboardShortcutsModal } from './KeyboardShortcutsModal';
  7. import { SwitchbarPopover } from './SwitchbarPopover';
  8. import { useQuery, useQueries } from '@tanstack/react-query';
  9. import { api, supportApi, pendingUploadsApi } from '../api/client';
  10. import { getIconByName } from './IconPicker';
  11. import { useIsSidebarCompact } from '../hooks/useIsSidebarCompact';
  12. import { useAuth } from '../contexts/AuthContext';
  13. import { useToast } from '../contexts/ToastContext';
  14. import { Card, CardHeader, CardContent } from './Card';
  15. import { parseUTCDate } from '../utils/date';
  16. import { Button } from './Button';
  17. interface NavItem {
  18. id: string;
  19. to: string;
  20. icon: LucideIcon;
  21. labelKey: string; // Translation key
  22. }
  23. export const defaultNavItems: NavItem[] = [
  24. { id: 'printers', to: '/', icon: Printer, labelKey: 'nav.printers' },
  25. { id: 'archives', to: '/archives', icon: Archive, labelKey: 'nav.archives' },
  26. { id: 'queue', to: '/queue', icon: Calendar, labelKey: 'nav.queue' },
  27. { id: 'stats', to: '/stats', icon: BarChart3, labelKey: 'nav.stats' },
  28. { id: 'profiles', to: '/profiles', icon: Cloud, labelKey: 'nav.profiles' },
  29. { id: 'maintenance', to: '/maintenance', icon: Wrench, labelKey: 'nav.maintenance' },
  30. { id: 'projects', to: '/projects', icon: FolderKanban, labelKey: 'nav.projects' },
  31. { id: 'inventory', to: '/inventory', icon: Disc3, labelKey: 'nav.inventory' },
  32. { id: 'files', to: '/files', icon: FolderOpen, labelKey: 'nav.files' },
  33. { id: 'settings', to: '/settings', icon: Settings, labelKey: 'nav.settings' },
  34. ];
  35. // Get unified sidebar order from localStorage
  36. function getSidebarOrder(): string[] {
  37. const stored = localStorage.getItem('sidebarOrder');
  38. if (stored) {
  39. try {
  40. return JSON.parse(stored);
  41. } catch {
  42. return defaultNavItems.map(i => i.id);
  43. }
  44. }
  45. return defaultNavItems.map(i => i.id);
  46. }
  47. // Save unified sidebar order to localStorage
  48. function saveSidebarOrder(order: string[]) {
  49. localStorage.setItem('sidebarOrder', JSON.stringify(order));
  50. }
  51. // Check if an ID is an external link
  52. function isExternalLinkId(id: string): boolean {
  53. return id.startsWith('ext-');
  54. }
  55. // Get default view from localStorage
  56. export function getDefaultView(): string {
  57. return localStorage.getItem('defaultView') || '/';
  58. }
  59. // Save default view to localStorage
  60. export function setDefaultView(path: string) {
  61. localStorage.setItem('defaultView', path);
  62. }
  63. export function Layout() {
  64. const navigate = useNavigate();
  65. const location = useLocation();
  66. const { mode, toggleMode } = useTheme();
  67. const { t } = useTranslation();
  68. const isSidebarCompact = useIsSidebarCompact();
  69. const { user, authEnabled, logout, hasPermission } = useAuth();
  70. const { showToast } = useToast();
  71. const [showChangePasswordModal, setShowChangePasswordModal] = useState(false);
  72. const [changePasswordData, setChangePasswordData] = useState({ currentPassword: '', newPassword: '', confirmPassword: '' });
  73. const [changePasswordLoading, setChangePasswordLoading] = useState(false);
  74. const [sidebarExpanded, setSidebarExpanded] = useState(() => {
  75. const stored = localStorage.getItem('sidebarExpanded');
  76. return stored !== 'false';
  77. });
  78. const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false);
  79. const [showShortcuts, setShowShortcuts] = useState(false);
  80. const [showSwitchbar, setShowSwitchbar] = useState(false);
  81. const [sidebarOrder, setSidebarOrder] = useState<string[]>(getSidebarOrder);
  82. const [draggedId, setDraggedId] = useState<string | null>(null);
  83. const [dragOverId, setDragOverId] = useState<string | null>(null);
  84. const hasRedirected = useRef(false);
  85. const [dismissedUpdateVersion, setDismissedUpdateVersion] = useState<string | null>(() =>
  86. sessionStorage.getItem('dismissedUpdateVersion')
  87. );
  88. const [plateDetectionAlert, setPlateDetectionAlert] = useState<{
  89. printer_id: number;
  90. printer_name: string;
  91. message: string;
  92. } | null>(null);
  93. // Check for updates
  94. const { data: versionInfo } = useQuery({
  95. queryKey: ['version'],
  96. queryFn: api.getVersion,
  97. staleTime: Infinity,
  98. });
  99. const { data: settings } = useQuery({
  100. queryKey: ['settings'],
  101. queryFn: api.getSettings,
  102. staleTime: 5 * 60 * 1000, // 5 minutes
  103. });
  104. const { data: updateCheck } = useQuery({
  105. queryKey: ['updateCheck'],
  106. queryFn: api.checkForUpdates,
  107. enabled: settings?.check_updates !== false,
  108. staleTime: 60 * 60 * 1000, // 1 hour
  109. refetchInterval: 60 * 60 * 1000, // Check every hour
  110. });
  111. // Fetch external links for sidebar
  112. const { data: externalLinks } = useQuery({
  113. queryKey: ['external-links'],
  114. queryFn: api.getExternalLinks,
  115. });
  116. // Fetch smart plugs to check for switchbar items
  117. const { data: smartPlugs } = useQuery({
  118. queryKey: ['smart-plugs'],
  119. queryFn: api.getSmartPlugs,
  120. staleTime: 30 * 1000, // 30 seconds
  121. });
  122. const hasSwitchbarPlugs = smartPlugs?.some(p => p.show_in_switchbar) ?? false;
  123. // Check debug logging state
  124. const { data: debugLoggingState } = useQuery({
  125. queryKey: ['debugLogging'],
  126. queryFn: supportApi.getDebugLoggingState,
  127. staleTime: 60 * 1000, // 1 minute
  128. refetchInterval: 60 * 1000, // Refresh every minute
  129. });
  130. // Check developer LAN mode warnings
  131. const { data: devModeWarnings } = useQuery({
  132. queryKey: ['developer-mode-warnings'],
  133. queryFn: api.getDeveloperModeWarnings,
  134. staleTime: 10 * 1000,
  135. refetchInterval: 30 * 1000,
  136. refetchOnWindowFocus: true,
  137. });
  138. // Fetch pending queue items count for badge
  139. const { data: queueItems } = useQuery({
  140. queryKey: ['queue', 'pending'],
  141. queryFn: () => api.getQueue(undefined, 'pending'),
  142. staleTime: 5 * 1000, // 5 seconds
  143. refetchInterval: 5 * 1000, // Refresh every 5 seconds
  144. refetchOnWindowFocus: true,
  145. });
  146. const pendingQueueCount = queueItems?.length ?? 0;
  147. // Fetch pending uploads count for archive badge (virtual printer review items)
  148. const { data: pendingUploadsData } = useQuery({
  149. queryKey: ['pending-uploads', 'count'],
  150. queryFn: pendingUploadsApi.getCount,
  151. staleTime: 5 * 1000, // 5 seconds
  152. refetchInterval: 5 * 1000, // Refresh every 5 seconds
  153. refetchOnWindowFocus: true,
  154. });
  155. const pendingUploadsCount = pendingUploadsData?.count ?? 0;
  156. // Check if any printer with pending queue items needs plate clearing
  157. const queuePrinterIds = useMemo(() => {
  158. const ids = new Set<number>();
  159. queueItems?.forEach(item => {
  160. if (item.printer_id) ids.add(item.printer_id);
  161. });
  162. return Array.from(ids);
  163. }, [queueItems]);
  164. const printerStatusQueries = useQueries({
  165. queries: queuePrinterIds.map(id => ({
  166. queryKey: ['printerStatus', id],
  167. queryFn: () => api.getPrinterStatus(id),
  168. staleTime: 30 * 1000, // WebSocket keeps this warm
  169. })),
  170. });
  171. const needsClearPlate = printerStatusQueries.some(result => {
  172. const status = result.data;
  173. if (!status) return false;
  174. return (status.state === 'FINISH' || status.state === 'FAILED') && !status.plate_cleared;
  175. });
  176. // Calculate debug duration client-side for real-time updates
  177. const [debugDuration, setDebugDuration] = useState<number | null>(null);
  178. useEffect(() => {
  179. if (!debugLoggingState?.enabled || !debugLoggingState.enabled_at) {
  180. setDebugDuration(null);
  181. return;
  182. }
  183. const enabledAt = parseUTCDate(debugLoggingState.enabled_at)?.getTime() ?? Date.now();
  184. const updateDuration = () => {
  185. setDebugDuration(Math.floor((Date.now() - enabledAt) / 1000));
  186. };
  187. updateDuration();
  188. const interval = setInterval(updateDuration, 1000);
  189. return () => clearInterval(interval);
  190. }, [debugLoggingState?.enabled, debugLoggingState?.enabled_at]);
  191. // Build the unified sidebar items list - memoized to prevent re-renders
  192. const navItemsMap = useMemo(() => new Map(defaultNavItems.map(item => [item.id, item])), []);
  193. const extLinksMap = useMemo(() => new Map((externalLinks || []).map(link => [`ext-${link.id}`, link])), [externalLinks]);
  194. // Compute the ordered sidebar: include stored order + any new items
  195. // Filter out 'settings' for users with 'user' role
  196. const orderedSidebarIds = (() => {
  197. const result: string[] = [];
  198. const seen = new Set<string>();
  199. // Determine if settings should be hidden (no settings:read permission)
  200. const hideSettings = authEnabled && !hasPermission('settings:read');
  201. // Add items in stored order
  202. for (const id of sidebarOrder) {
  203. if (hideSettings && id === 'settings') continue;
  204. if (navItemsMap.has(id) || extLinksMap.has(id)) {
  205. result.push(id);
  206. seen.add(id);
  207. }
  208. }
  209. // Add any new internal nav items not in stored order
  210. for (const item of defaultNavItems) {
  211. if (hideSettings && item.id === 'settings') continue;
  212. if (!seen.has(item.id)) {
  213. result.push(item.id);
  214. seen.add(item.id);
  215. }
  216. }
  217. // Add any new external links not in stored order
  218. for (const link of externalLinks || []) {
  219. const extId = `ext-${link.id}`;
  220. if (!seen.has(extId)) {
  221. result.push(extId);
  222. seen.add(extId);
  223. }
  224. }
  225. return result;
  226. })();
  227. // Unified drag handlers
  228. const handleDragStart = (e: React.DragEvent, id: string) => {
  229. setDraggedId(id);
  230. e.dataTransfer.effectAllowed = 'move';
  231. e.dataTransfer.setData('text/plain', id);
  232. };
  233. const handleDragOver = (e: React.DragEvent, id: string) => {
  234. e.preventDefault();
  235. e.dataTransfer.dropEffect = 'move';
  236. setDragOverId(id);
  237. };
  238. const handleDragLeave = () => {
  239. setDragOverId(null);
  240. };
  241. const handleDrop = (e: React.DragEvent, targetId: string) => {
  242. e.preventDefault();
  243. if (draggedId === null || draggedId === targetId) {
  244. setDraggedId(null);
  245. setDragOverId(null);
  246. return;
  247. }
  248. const currentOrder = [...orderedSidebarIds];
  249. const draggedIndex = currentOrder.indexOf(draggedId);
  250. const targetIndex = currentOrder.indexOf(targetId);
  251. if (draggedIndex === -1 || targetIndex === -1) {
  252. setDraggedId(null);
  253. setDragOverId(null);
  254. return;
  255. }
  256. // Reorder
  257. currentOrder.splice(draggedIndex, 1);
  258. currentOrder.splice(targetIndex, 0, draggedId);
  259. // Save to localStorage and update state
  260. setSidebarOrder(currentOrder);
  261. saveSidebarOrder(currentOrder);
  262. setDraggedId(null);
  263. setDragOverId(null);
  264. };
  265. const handleDragEnd = () => {
  266. setDraggedId(null);
  267. setDragOverId(null);
  268. };
  269. // Show update banner if update available and not dismissed for this version
  270. const showUpdateBanner = updateCheck?.update_available &&
  271. updateCheck.latest_version &&
  272. updateCheck.latest_version !== dismissedUpdateVersion;
  273. const dismissUpdateBanner = () => {
  274. if (updateCheck?.latest_version) {
  275. sessionStorage.setItem('dismissedUpdateVersion', updateCheck.latest_version);
  276. setDismissedUpdateVersion(updateCheck.latest_version);
  277. }
  278. };
  279. // Redirect to default view on initial load
  280. useEffect(() => {
  281. if (!hasRedirected.current && location.pathname === '/') {
  282. const defaultView = getDefaultView();
  283. if (defaultView !== '/') {
  284. hasRedirected.current = true;
  285. navigate(defaultView, { replace: true });
  286. }
  287. }
  288. }, [location.pathname, navigate]);
  289. useEffect(() => {
  290. localStorage.setItem('sidebarExpanded', String(sidebarExpanded));
  291. }, [sidebarExpanded]);
  292. // Close compact drawer on navigation
  293. useEffect(() => {
  294. if (isSidebarCompact) {
  295. setMobileDrawerOpen(false);
  296. }
  297. }, [location.pathname, isSidebarCompact]);
  298. // Listen for plate detection warnings (objects on plate, print paused)
  299. // Only show to users with printers:control permission
  300. useEffect(() => {
  301. const handlePlateNotEmpty = (event: Event) => {
  302. // Only show alert to users who can control printers
  303. if (!hasPermission('printers:control')) {
  304. return;
  305. }
  306. const detail = (event as CustomEvent).detail;
  307. setPlateDetectionAlert({
  308. printer_id: detail.printer_id,
  309. printer_name: detail.printer_name,
  310. message: detail.message,
  311. });
  312. };
  313. window.addEventListener('plate-not-empty', handlePlateNotEmpty);
  314. return () => window.removeEventListener('plate-not-empty', handlePlateNotEmpty);
  315. }, [hasPermission]);
  316. // Global keyboard shortcuts for navigation
  317. const handleKeyDown = useCallback((e: KeyboardEvent) => {
  318. const target = e.target as HTMLElement;
  319. // Ignore if typing in an input/textarea
  320. if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
  321. return;
  322. }
  323. // Number keys for navigation (1-9) - follows sidebar order including external links
  324. if (!e.metaKey && !e.ctrlKey && !e.altKey) {
  325. const keyNum = parseInt(e.key);
  326. if (keyNum >= 1 && keyNum <= orderedSidebarIds.length && keyNum <= 9) {
  327. const id = orderedSidebarIds[keyNum - 1];
  328. e.preventDefault();
  329. if (isExternalLinkId(id)) {
  330. // External link
  331. const extLink = extLinksMap.get(id);
  332. if (extLink?.open_in_new_tab) {
  333. window.open(extLink.url, '_blank', 'noopener,noreferrer');
  334. } else {
  335. const linkId = id.replace('ext-', '');
  336. navigate(`/external/${linkId}`);
  337. }
  338. } else {
  339. // Internal nav item
  340. const navItem = navItemsMap.get(id);
  341. if (navItem) {
  342. navigate(navItem.to);
  343. }
  344. }
  345. return;
  346. }
  347. switch (e.key) {
  348. case '?':
  349. e.preventDefault();
  350. setShowShortcuts(true);
  351. break;
  352. case 'Escape':
  353. setShowShortcuts(false);
  354. break;
  355. }
  356. }
  357. }, [navigate, orderedSidebarIds, navItemsMap, extLinksMap]);
  358. useEffect(() => {
  359. document.addEventListener('keydown', handleKeyDown);
  360. return () => document.removeEventListener('keydown', handleKeyDown);
  361. }, [handleKeyDown]);
  362. return (
  363. <div className="flex min-h-screen">
  364. {/* Compact Header */}
  365. {isSidebarCompact && (
  366. <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">
  367. <button
  368. onClick={() => setMobileDrawerOpen(true)}
  369. className="p-2 -ml-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors"
  370. aria-label="Open menu"
  371. >
  372. <Menu className="w-6 h-6 text-white" />
  373. </button>
  374. <img
  375. src={mode === 'dark' ? '/img/bambuddy_logo_dark_transparent.png' : '/img/bambuddy_logo_light.png'}
  376. alt="Bambuddy"
  377. className="h-8 ml-3"
  378. />
  379. </header>
  380. )}
  381. {/* Compact Drawer Backdrop */}
  382. {isSidebarCompact && mobileDrawerOpen && (
  383. <div
  384. className="fixed inset-0 bg-black/60 z-40 transition-opacity"
  385. onClick={() => setMobileDrawerOpen(false)}
  386. />
  387. )}
  388. {/* Sidebar / Mobile Drawer */}
  389. <aside
  390. className={`bg-bambu-dark-secondary border-r border-bambu-dark-tertiary flex flex-col transition-all duration-300 ${
  391. isSidebarCompact
  392. ? `fixed inset-y-0 left-0 z-50 w-72 transform ${mobileDrawerOpen ? 'translate-x-0' : '-translate-x-full'}`
  393. : `fixed inset-y-0 left-0 z-30 ${sidebarExpanded ? 'w-64' : 'w-16'}`
  394. }`}
  395. >
  396. {/* Logo */}
  397. <div className={`border-b border-bambu-dark-tertiary flex items-center justify-center ${isSidebarCompact || sidebarExpanded ? 'p-4' : 'p-2'}`}>
  398. <img
  399. src={mode === 'dark' ? '/img/bambuddy_logo_dark_transparent.png' : '/img/bambuddy_logo_light.png'}
  400. alt="Bambuddy"
  401. className={isSidebarCompact || sidebarExpanded ? 'h-16 w-auto' : 'h-8 w-8 object-cover object-left'}
  402. />
  403. </div>
  404. {/* Navigation */}
  405. <nav className="flex-1 p-2">
  406. <ul className="space-y-2">
  407. {orderedSidebarIds.map((id) => {
  408. const isExternal = isExternalLinkId(id);
  409. if (isExternal) {
  410. // Render external link
  411. const link = extLinksMap.get(id);
  412. if (!link) return null;
  413. const LinkIcon = link.custom_icon ? null : getIconByName(link.icon);
  414. return (
  415. <li
  416. key={id}
  417. draggable
  418. onDragStart={(e) => handleDragStart(e, id)}
  419. onDragOver={(e) => handleDragOver(e, id)}
  420. onDragLeave={handleDragLeave}
  421. onDrop={(e) => handleDrop(e, id)}
  422. onDragEnd={handleDragEnd}
  423. className={`relative ${
  424. draggedId === id ? 'opacity-50' : ''
  425. } ${
  426. dragOverId === id && draggedId !== id
  427. ? 'before:absolute before:left-0 before:right-0 before:top-0 before:h-0.5 before:bg-bambu-green'
  428. : ''
  429. }`}
  430. >
  431. {link.open_in_new_tab ? (
  432. <a
  433. href={link.url}
  434. target="_blank"
  435. rel="noopener noreferrer"
  436. 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`}
  437. title={!isSidebarCompact && !sidebarExpanded ? link.name : undefined}
  438. >
  439. {sidebarExpanded && !isSidebarCompact && (
  440. <GripVertical className="w-4 h-4 flex-shrink-0 opacity-0 group-hover:opacity-50 cursor-grab active:cursor-grabbing -ml-1" />
  441. )}
  442. {link.custom_icon ? (
  443. <img
  444. src={`/api/v1/external-links/${link.id}/icon`}
  445. alt=""
  446. className="w-5 h-5 flex-shrink-0"
  447. />
  448. ) : (
  449. LinkIcon && <LinkIcon className="w-5 h-5 flex-shrink-0" />
  450. )}
  451. {(isSidebarCompact || sidebarExpanded) && <span>{link.name}</span>}
  452. </a>
  453. ) : (
  454. <NavLink
  455. to={`/external/${link.id}`}
  456. className={({ isActive }) =>
  457. `flex items-center ${isSidebarCompact || sidebarExpanded ? 'gap-3 px-4' : 'justify-center px-2'} py-3 rounded-lg transition-colors group ${
  458. isActive
  459. ? 'bg-bambu-green text-white'
  460. : 'text-bambu-gray-light hover:bg-bambu-dark-tertiary hover:text-white'
  461. }`
  462. }
  463. title={!isSidebarCompact && !sidebarExpanded ? link.name : undefined}
  464. >
  465. {sidebarExpanded && !isSidebarCompact && (
  466. <GripVertical className="w-4 h-4 flex-shrink-0 opacity-0 group-hover:opacity-50 cursor-grab active:cursor-grabbing -ml-1" />
  467. )}
  468. {link.custom_icon ? (
  469. <img
  470. src={`/api/v1/external-links/${link.id}/icon`}
  471. alt=""
  472. className="w-5 h-5 flex-shrink-0"
  473. />
  474. ) : (
  475. LinkIcon && <LinkIcon className="w-5 h-5 flex-shrink-0" />
  476. )}
  477. {(isSidebarCompact || sidebarExpanded) && <span>{link.name}</span>}
  478. </NavLink>
  479. )}
  480. </li>
  481. );
  482. } else {
  483. // Render internal nav item
  484. const navItem = navItemsMap.get(id);
  485. if (!navItem) return null;
  486. const { to, icon: Icon, labelKey } = navItem;
  487. const showQueueBadge = id === 'queue' && pendingQueueCount > 0;
  488. const showArchiveBadge = id === 'archives' && pendingUploadsCount > 0;
  489. const badgeCount = showQueueBadge ? pendingQueueCount : showArchiveBadge ? pendingUploadsCount : 0;
  490. const showBadge = showQueueBadge || showArchiveBadge;
  491. const showClearPlateDot = id === 'printers' && needsClearPlate;
  492. return (
  493. <li
  494. key={id}
  495. draggable
  496. onDragStart={(e) => handleDragStart(e, id)}
  497. onDragOver={(e) => handleDragOver(e, id)}
  498. onDragLeave={handleDragLeave}
  499. onDrop={(e) => handleDrop(e, id)}
  500. onDragEnd={handleDragEnd}
  501. className={`relative ${
  502. draggedId === id ? 'opacity-50' : ''
  503. } ${
  504. dragOverId === id && draggedId !== id
  505. ? 'before:absolute before:left-0 before:right-0 before:top-0 before:h-0.5 before:bg-bambu-green'
  506. : ''
  507. }`}
  508. >
  509. <NavLink
  510. to={to}
  511. className={({ isActive }) =>
  512. `flex items-center ${isSidebarCompact || sidebarExpanded ? 'gap-3 px-4' : 'justify-center px-2'} py-3 rounded-lg transition-colors group ${
  513. isActive
  514. ? 'bg-bambu-green text-white'
  515. : 'text-bambu-gray-light hover:bg-bambu-dark-tertiary hover:text-white'
  516. }`
  517. }
  518. title={!isSidebarCompact && !sidebarExpanded ? t(labelKey) : undefined}
  519. >
  520. {sidebarExpanded && !isSidebarCompact && (
  521. <GripVertical className="w-4 h-4 flex-shrink-0 opacity-0 group-hover:opacity-50 cursor-grab active:cursor-grabbing -ml-1" />
  522. )}
  523. <div className="relative">
  524. <Icon className="w-5 h-5 flex-shrink-0" />
  525. {showClearPlateDot && (
  526. <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" />
  527. )}
  528. {showBadge && (
  529. <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 ${
  530. showArchiveBadge ? 'bg-blue-500 text-white' : 'bg-yellow-500 text-black'
  531. }`}>
  532. {badgeCount > 99 ? '99+' : badgeCount}
  533. </span>
  534. )}
  535. </div>
  536. {(isSidebarCompact || sidebarExpanded) && <span>{t(labelKey)}</span>}
  537. </NavLink>
  538. </li>
  539. );
  540. }
  541. })}
  542. </ul>
  543. </nav>
  544. {/* Collapse toggle - hide on compact sidebar */}
  545. {!isSidebarCompact && (
  546. <button
  547. onClick={() => setSidebarExpanded(!sidebarExpanded)}
  548. 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"
  549. title={sidebarExpanded ? t('nav.collapseSidebar') : t('nav.expandSidebar')}
  550. >
  551. {sidebarExpanded ? (
  552. <ChevronLeft className="w-5 h-5" />
  553. ) : (
  554. <ChevronRight className="w-5 h-5" />
  555. )}
  556. </button>
  557. )}
  558. {/* Footer */}
  559. <div className="p-2 border-t border-bambu-dark-tertiary">
  560. {isSidebarCompact || sidebarExpanded ? (
  561. <div className="flex flex-col gap-2 px-2">
  562. {/* Top row: icons */}
  563. <div className="flex items-center justify-center gap-1">
  564. {hasSwitchbarPlugs && (
  565. <div className="relative">
  566. <button
  567. onMouseEnter={() => setShowSwitchbar(true)}
  568. className={`p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors ${
  569. showSwitchbar ? 'text-bambu-green' : 'text-bambu-gray-light hover:text-white'
  570. }`}
  571. title={t('nav.smartSwitches', { defaultValue: 'Smart Switches' })}
  572. >
  573. <Plug className="w-5 h-5" />
  574. </button>
  575. {showSwitchbar && (
  576. <SwitchbarPopover onClose={() => setShowSwitchbar(false)} />
  577. )}
  578. </div>
  579. )}
  580. {hasPermission('system:read') ? (
  581. <NavLink
  582. to="/system"
  583. className={({ isActive }) =>
  584. `p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors ${
  585. isActive ? 'text-bambu-green' : 'text-bambu-gray-light hover:text-white'
  586. }`
  587. }
  588. title={t('nav.system')}
  589. >
  590. <Info className="w-5 h-5" />
  591. </NavLink>
  592. ) : (
  593. <span
  594. className="p-2 rounded-lg text-bambu-gray/50 cursor-not-allowed"
  595. title="You do not have permission to view system information"
  596. >
  597. <Info className="w-5 h-5" />
  598. </span>
  599. )}
  600. <a
  601. href="https://github.com/maziggy/bambuddy"
  602. target="_blank"
  603. rel="noopener noreferrer"
  604. className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
  605. title={t('nav.viewOnGithub')}
  606. >
  607. <Github className="w-5 h-5" />
  608. </a>
  609. <button
  610. onClick={() => setShowShortcuts(true)}
  611. className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
  612. title={t('nav.keyboardShortcuts')}
  613. >
  614. <Keyboard className="w-5 h-5" />
  615. </button>
  616. <button
  617. onClick={toggleMode}
  618. className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
  619. title={mode === 'dark' ? t('nav.switchToLight') : t('nav.switchToDark')}
  620. >
  621. {mode === 'dark' ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />}
  622. </button>
  623. {authEnabled && user && (
  624. <>
  625. <button
  626. onClick={() => setShowChangePasswordModal(true)}
  627. className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
  628. title={t('changePassword.title')}
  629. >
  630. <Key className="w-5 h-5" />
  631. </button>
  632. <button
  633. onClick={logout}
  634. className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
  635. title={t('nav.logout', { defaultValue: 'Logout' })}
  636. >
  637. <LogOut className="w-5 h-5" />
  638. </button>
  639. </>
  640. )}
  641. </div>
  642. {/* Bottom row: version */}
  643. <div className="flex items-center justify-center gap-2">
  644. <span className="text-sm text-bambu-gray">v{versionInfo?.version || '...'}</span>
  645. {updateCheck?.update_available && (
  646. <button
  647. onClick={() => navigate('/settings')}
  648. className="flex items-center gap-1 text-xs text-bambu-green hover:text-bambu-green/80 transition-colors"
  649. title={t('nav.updateAvailable', { version: updateCheck.latest_version })}
  650. >
  651. <ArrowUpCircle className="w-4 h-4" />
  652. <span>{t('nav.update')}</span>
  653. </button>
  654. )}
  655. </div>
  656. </div>
  657. ) : (
  658. <div className="flex flex-col items-center gap-1">
  659. {updateCheck?.update_available && (
  660. <button
  661. onClick={() => navigate('/settings')}
  662. className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-green hover:text-bambu-green/80"
  663. title={t('nav.updateAvailable', { version: updateCheck.latest_version })}
  664. >
  665. <ArrowUpCircle className="w-5 h-5" />
  666. </button>
  667. )}
  668. {hasSwitchbarPlugs && (
  669. <div className="relative">
  670. <button
  671. onMouseEnter={() => setShowSwitchbar(true)}
  672. className={`p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors ${
  673. showSwitchbar ? 'text-bambu-green' : 'text-bambu-gray-light hover:text-white'
  674. }`}
  675. title={t('nav.smartSwitches', { defaultValue: 'Smart Switches' })}
  676. >
  677. <Plug className="w-5 h-5" />
  678. </button>
  679. {showSwitchbar && (
  680. <SwitchbarPopover onClose={() => setShowSwitchbar(false)} />
  681. )}
  682. </div>
  683. )}
  684. {hasPermission('system:read') ? (
  685. <NavLink
  686. to="/system"
  687. className={({ isActive }) =>
  688. `p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors ${
  689. isActive ? 'text-bambu-green' : 'text-bambu-gray-light hover:text-white'
  690. }`
  691. }
  692. title={t('nav.system')}
  693. >
  694. <Info className="w-5 h-5" />
  695. </NavLink>
  696. ) : (
  697. <span
  698. className="p-2 rounded-lg text-bambu-gray/50 cursor-not-allowed"
  699. title="You do not have permission to view system information"
  700. >
  701. <Info className="w-5 h-5" />
  702. </span>
  703. )}
  704. <a
  705. href="https://github.com/maziggy/bambuddy"
  706. target="_blank"
  707. rel="noopener noreferrer"
  708. className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
  709. title={t('nav.viewOnGithub')}
  710. >
  711. <Github className="w-5 h-5" />
  712. </a>
  713. <button
  714. onClick={() => setShowShortcuts(true)}
  715. className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
  716. title={t('nav.keyboardShortcuts')}
  717. >
  718. <Keyboard className="w-5 h-5" />
  719. </button>
  720. <button
  721. onClick={toggleMode}
  722. className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
  723. title={mode === 'dark' ? t('nav.switchToLight') : t('nav.switchToDark')}
  724. >
  725. {mode === 'dark' ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />}
  726. </button>
  727. {authEnabled && user && (
  728. <>
  729. <button
  730. onClick={() => setShowChangePasswordModal(true)}
  731. className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
  732. title={t('changePassword.title')}
  733. >
  734. <Key className="w-5 h-5" />
  735. </button>
  736. <button
  737. onClick={logout}
  738. className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
  739. title={t('nav.logout', { defaultValue: 'Logout' })}
  740. >
  741. <LogOut className="w-5 h-5" />
  742. </button>
  743. </>
  744. )}
  745. </div>
  746. )}
  747. </div>
  748. </aside>
  749. {/* Main content */}
  750. <main className={`flex-1 bg-bambu-dark overflow-auto transition-all duration-300 ${
  751. isSidebarCompact ? 'mt-14' : sidebarExpanded ? 'ml-64' : 'ml-16'
  752. }`}>
  753. {/* Debug logging indicator */}
  754. {debugLoggingState?.enabled && (
  755. <div className="bg-amber-500/20 border-b border-amber-500/30 px-4 py-2 flex items-center justify-between">
  756. <div className="flex items-center gap-2 text-sm">
  757. <Bug className="w-4 h-4 text-amber-500 animate-pulse" />
  758. <span className="text-amber-200">
  759. {t('support.debugLoggingActive', { defaultValue: 'Debug logging is active' })}
  760. {debugDuration !== null && (
  761. <span className="text-amber-300/70 ml-2">
  762. ({Math.floor(debugDuration / 60)}m {debugDuration % 60}s)
  763. </span>
  764. )}
  765. </span>
  766. <button
  767. onClick={() => navigate('/system')}
  768. className="text-amber-400 hover:text-amber-300 font-medium underline ml-2"
  769. >
  770. {t('support.manageLogs', { defaultValue: 'Manage' })}
  771. </button>
  772. </div>
  773. </div>
  774. )}
  775. {devModeWarnings && devModeWarnings.length > 0 && (
  776. <div className="bg-orange-500/20 border-b border-orange-500/30 px-4 py-2 flex items-center justify-between">
  777. <div className="flex items-center gap-2 text-sm">
  778. <ShieldAlert className="w-4 h-4 text-orange-500" />
  779. <span className="text-orange-200">
  780. {t('printers.developerModeWarning', {
  781. names: devModeWarnings.map(w => w.name).join(', '),
  782. defaultValue: `Developer LAN mode is not enabled on: ${devModeWarnings.map(w => w.name).join(', ')}. Some features may not work.`
  783. })}
  784. </span>
  785. <a href="https://wiki.bambulab.com/en/knowledge-sharing/enable-developer-mode"
  786. target="_blank" rel="noopener noreferrer"
  787. className="text-orange-400 hover:text-orange-300 font-medium underline ml-2">
  788. {t('printers.howToEnable', { defaultValue: 'How to enable' })}
  789. </a>
  790. </div>
  791. </div>
  792. )}
  793. {/* Persistent update banner */}
  794. {showUpdateBanner && (
  795. <div className="bg-bambu-green/20 border-b border-bambu-green/30 px-4 py-2 flex items-center justify-between">
  796. <div className="flex items-center gap-2 text-sm">
  797. <ArrowUpCircle className="w-4 h-4 text-bambu-green" />
  798. <span>
  799. {t('nav.updateAvailableBanner', {
  800. version: updateCheck?.latest_version,
  801. defaultValue: `Version ${updateCheck?.latest_version} is available!`
  802. })}
  803. </span>
  804. <button
  805. onClick={() => navigate('/settings')}
  806. className="text-bambu-green hover:text-bambu-green/80 font-medium underline"
  807. >
  808. {t('nav.viewUpdate', { defaultValue: 'View update' })}
  809. </button>
  810. </div>
  811. <button
  812. onClick={dismissUpdateBanner}
  813. className="p-1 hover:bg-bambu-dark-tertiary rounded transition-colors"
  814. title={t('common.dismiss', { defaultValue: 'Dismiss' })}
  815. >
  816. <X className="w-4 h-4" />
  817. </button>
  818. </div>
  819. )}
  820. <Outlet />
  821. </main>
  822. {/* Keyboard Shortcuts Modal */}
  823. {showShortcuts && (
  824. <KeyboardShortcutsModal
  825. onClose={() => setShowShortcuts(false)}
  826. sidebarItems={orderedSidebarIds.map(id => {
  827. if (isExternalLinkId(id)) {
  828. const extLink = extLinksMap.get(id);
  829. return extLink ? { type: 'external' as const, label: extLink.name } : null;
  830. } else {
  831. const navItem = navItemsMap.get(id);
  832. return navItem ? { type: 'nav' as const, label: navItem.labelKey, labelKey: navItem.labelKey } : null;
  833. }
  834. }).filter(Boolean) as { type: 'nav' | 'external'; label: string; labelKey?: string }[]}
  835. />
  836. )}
  837. {/* Plate Detection Alert Modal */}
  838. {plateDetectionAlert && (
  839. <div className="fixed inset-0 bg-black/70 flex items-center justify-center z-[100] p-4">
  840. <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">
  841. <div className="p-6 text-center">
  842. <div className="w-16 h-16 mx-auto mb-4 rounded-full bg-yellow-500/20 flex items-center justify-center">
  843. <svg className="w-10 h-10 text-yellow-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  844. <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" />
  845. </svg>
  846. </div>
  847. <h2 className="text-xl font-bold text-yellow-400 mb-2">
  848. {t('plateAlert.title')}
  849. </h2>
  850. <p className="text-lg text-white mb-2">
  851. {plateDetectionAlert.printer_name}
  852. </p>
  853. <p className="text-bambu-gray mb-6">
  854. {t('plateAlert.message')}
  855. </p>
  856. <button
  857. onClick={() => setPlateDetectionAlert(null)}
  858. className="w-full py-3 px-6 bg-yellow-500 hover:bg-yellow-600 text-black font-semibold rounded-lg transition-colors"
  859. >
  860. {t('plateAlert.understand')}
  861. </button>
  862. </div>
  863. </div>
  864. </div>
  865. )}
  866. {/* Change Password Modal */}
  867. {showChangePasswordModal && (
  868. <div
  869. className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4"
  870. onClick={() => {
  871. setShowChangePasswordModal(false);
  872. setChangePasswordData({ currentPassword: '', newPassword: '', confirmPassword: '' });
  873. }}
  874. >
  875. <Card
  876. className="w-full max-w-md"
  877. onClick={(e: React.MouseEvent) => e.stopPropagation()}
  878. >
  879. <CardHeader>
  880. <div className="flex items-center justify-between">
  881. <div className="flex items-center gap-2">
  882. <Key className="w-5 h-5 text-bambu-green" />
  883. <h2 className="text-lg font-semibold text-white">{t('changePassword.title')}</h2>
  884. </div>
  885. <Button
  886. variant="ghost"
  887. size="sm"
  888. onClick={() => {
  889. setShowChangePasswordModal(false);
  890. setChangePasswordData({ currentPassword: '', newPassword: '', confirmPassword: '' });
  891. }}
  892. >
  893. <X className="w-5 h-5" />
  894. </Button>
  895. </div>
  896. </CardHeader>
  897. <CardContent>
  898. <div className="space-y-4">
  899. <div>
  900. <label className="block text-sm font-medium text-white mb-2">
  901. {t('changePassword.currentPassword')}
  902. </label>
  903. <input
  904. type="password"
  905. value={changePasswordData.currentPassword}
  906. onChange={(e) => setChangePasswordData({ ...changePasswordData, currentPassword: e.target.value })}
  907. 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"
  908. placeholder={t('changePassword.currentPasswordPlaceholder')}
  909. autoComplete="current-password"
  910. />
  911. </div>
  912. <div>
  913. <label className="block text-sm font-medium text-white mb-2">
  914. {t('changePassword.newPassword')}
  915. </label>
  916. <input
  917. type="password"
  918. value={changePasswordData.newPassword}
  919. onChange={(e) => setChangePasswordData({ ...changePasswordData, newPassword: e.target.value })}
  920. 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"
  921. placeholder={t('changePassword.newPasswordPlaceholder')}
  922. autoComplete="new-password"
  923. minLength={6}
  924. />
  925. </div>
  926. <div>
  927. <label className="block text-sm font-medium text-white mb-2">
  928. {t('changePassword.confirmPassword')}
  929. </label>
  930. <input
  931. type="password"
  932. value={changePasswordData.confirmPassword}
  933. onChange={(e) => setChangePasswordData({ ...changePasswordData, confirmPassword: e.target.value })}
  934. 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 ${
  935. changePasswordData.confirmPassword && changePasswordData.newPassword !== changePasswordData.confirmPassword
  936. ? 'border-red-500'
  937. : 'border-bambu-dark-tertiary'
  938. }`}
  939. placeholder={t('changePassword.confirmPasswordPlaceholder')}
  940. autoComplete="new-password"
  941. minLength={6}
  942. />
  943. {changePasswordData.confirmPassword && changePasswordData.newPassword !== changePasswordData.confirmPassword && (
  944. <p className="text-red-400 text-xs mt-1">{t('changePassword.passwordsDoNotMatch')}</p>
  945. )}
  946. </div>
  947. </div>
  948. <div className="mt-6 flex justify-end gap-3">
  949. <Button
  950. variant="secondary"
  951. onClick={() => {
  952. setShowChangePasswordModal(false);
  953. setChangePasswordData({ currentPassword: '', newPassword: '', confirmPassword: '' });
  954. }}
  955. >
  956. {t('common.cancel')}
  957. </Button>
  958. <Button
  959. onClick={async () => {
  960. if (changePasswordData.newPassword !== changePasswordData.confirmPassword) {
  961. showToast(t('changePassword.passwordsDoNotMatch'), 'error');
  962. return;
  963. }
  964. if (changePasswordData.newPassword.length < 6) {
  965. showToast(t('changePassword.passwordTooShort'), 'error');
  966. return;
  967. }
  968. setChangePasswordLoading(true);
  969. try {
  970. await api.changePassword(changePasswordData.currentPassword, changePasswordData.newPassword);
  971. showToast(t('changePassword.success'), 'success');
  972. setShowChangePasswordModal(false);
  973. setChangePasswordData({ currentPassword: '', newPassword: '', confirmPassword: '' });
  974. } catch (error: unknown) {
  975. const message = error instanceof Error ? error.message : t('changePassword.failed');
  976. showToast(message, 'error');
  977. } finally {
  978. setChangePasswordLoading(false);
  979. }
  980. }}
  981. disabled={changePasswordLoading || !changePasswordData.currentPassword || !changePasswordData.newPassword || changePasswordData.newPassword !== changePasswordData.confirmPassword || changePasswordData.newPassword.length < 6}
  982. >
  983. {changePasswordLoading ? (
  984. <>
  985. <Loader2 className="w-4 h-4 animate-spin" />
  986. {t('changePassword.changing')}
  987. </>
  988. ) : (
  989. <>
  990. <Key className="w-4 h-4" />
  991. {t('changePassword.title')}
  992. </>
  993. )}
  994. </Button>
  995. </div>
  996. </CardContent>
  997. </Card>
  998. </div>
  999. )}
  1000. </div>
  1001. );
  1002. }