Layout.tsx 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723
  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, 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 } from '@tanstack/react-query';
  9. import { api, supportApi, pendingUploadsApi } from '../api/client';
  10. import { getIconByName } from './IconPicker';
  11. import { useIsMobile } from '../hooks/useIsMobile';
  12. interface NavItem {
  13. id: string;
  14. to: string;
  15. icon: LucideIcon;
  16. labelKey: string; // Translation key
  17. }
  18. export const defaultNavItems: NavItem[] = [
  19. { id: 'printers', to: '/', icon: Printer, labelKey: 'nav.printers' },
  20. { id: 'archives', to: '/archives', icon: Archive, labelKey: 'nav.archives' },
  21. { id: 'queue', to: '/queue', icon: Calendar, labelKey: 'nav.queue' },
  22. { id: 'stats', to: '/stats', icon: BarChart3, labelKey: 'nav.stats' },
  23. { id: 'profiles', to: '/profiles', icon: Cloud, labelKey: 'nav.profiles' },
  24. { id: 'maintenance', to: '/maintenance', icon: Wrench, labelKey: 'nav.maintenance' },
  25. { id: 'projects', to: '/projects', icon: FolderKanban, labelKey: 'nav.projects' },
  26. { id: 'files', to: '/files', icon: FolderOpen, labelKey: 'nav.files' },
  27. { id: 'settings', to: '/settings', icon: Settings, labelKey: 'nav.settings' },
  28. ];
  29. // Get unified sidebar order from localStorage
  30. function getSidebarOrder(): string[] {
  31. const stored = localStorage.getItem('sidebarOrder');
  32. if (stored) {
  33. try {
  34. return JSON.parse(stored);
  35. } catch {
  36. return defaultNavItems.map(i => i.id);
  37. }
  38. }
  39. return defaultNavItems.map(i => i.id);
  40. }
  41. // Save unified sidebar order to localStorage
  42. function saveSidebarOrder(order: string[]) {
  43. localStorage.setItem('sidebarOrder', JSON.stringify(order));
  44. }
  45. // Check if an ID is an external link
  46. function isExternalLinkId(id: string): boolean {
  47. return id.startsWith('ext-');
  48. }
  49. // Get default view from localStorage
  50. export function getDefaultView(): string {
  51. return localStorage.getItem('defaultView') || '/';
  52. }
  53. // Save default view to localStorage
  54. export function setDefaultView(path: string) {
  55. localStorage.setItem('defaultView', path);
  56. }
  57. export function Layout() {
  58. const navigate = useNavigate();
  59. const location = useLocation();
  60. const { mode, toggleMode } = useTheme();
  61. const { t } = useTranslation();
  62. const isMobile = useIsMobile();
  63. const [sidebarExpanded, setSidebarExpanded] = useState(() => {
  64. const stored = localStorage.getItem('sidebarExpanded');
  65. return stored !== 'false';
  66. });
  67. const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false);
  68. const [showShortcuts, setShowShortcuts] = useState(false);
  69. const [showSwitchbar, setShowSwitchbar] = useState(false);
  70. const [sidebarOrder, setSidebarOrder] = useState<string[]>(getSidebarOrder);
  71. const [draggedId, setDraggedId] = useState<string | null>(null);
  72. const [dragOverId, setDragOverId] = useState<string | null>(null);
  73. const hasRedirected = useRef(false);
  74. const [dismissedUpdateVersion, setDismissedUpdateVersion] = useState<string | null>(() =>
  75. sessionStorage.getItem('dismissedUpdateVersion')
  76. );
  77. // Check for updates
  78. const { data: versionInfo } = useQuery({
  79. queryKey: ['version'],
  80. queryFn: api.getVersion,
  81. staleTime: Infinity,
  82. });
  83. const { data: settings } = useQuery({
  84. queryKey: ['settings'],
  85. queryFn: api.getSettings,
  86. staleTime: 5 * 60 * 1000, // 5 minutes
  87. });
  88. const { data: updateCheck } = useQuery({
  89. queryKey: ['updateCheck'],
  90. queryFn: api.checkForUpdates,
  91. enabled: settings?.check_updates !== false,
  92. staleTime: 60 * 60 * 1000, // 1 hour
  93. refetchInterval: 60 * 60 * 1000, // Check every hour
  94. });
  95. // Fetch external links for sidebar
  96. const { data: externalLinks } = useQuery({
  97. queryKey: ['external-links'],
  98. queryFn: api.getExternalLinks,
  99. });
  100. // Fetch smart plugs to check for switchbar items
  101. const { data: smartPlugs } = useQuery({
  102. queryKey: ['smart-plugs'],
  103. queryFn: api.getSmartPlugs,
  104. staleTime: 30 * 1000, // 30 seconds
  105. });
  106. const hasSwitchbarPlugs = smartPlugs?.some(p => p.show_in_switchbar) ?? false;
  107. // Check debug logging state
  108. const { data: debugLoggingState } = useQuery({
  109. queryKey: ['debugLogging'],
  110. queryFn: supportApi.getDebugLoggingState,
  111. staleTime: 60 * 1000, // 1 minute
  112. refetchInterval: 60 * 1000, // Refresh every minute
  113. });
  114. // Fetch pending queue items count for badge
  115. const { data: queueItems } = useQuery({
  116. queryKey: ['queue', 'pending'],
  117. queryFn: () => api.getQueue(undefined, 'pending'),
  118. staleTime: 5 * 1000, // 5 seconds
  119. refetchInterval: 5 * 1000, // Refresh every 5 seconds
  120. refetchOnWindowFocus: true,
  121. });
  122. const pendingQueueCount = queueItems?.length ?? 0;
  123. // Fetch pending uploads count for archive badge (virtual printer review items)
  124. const { data: pendingUploadsData } = useQuery({
  125. queryKey: ['pending-uploads', 'count'],
  126. queryFn: pendingUploadsApi.getCount,
  127. staleTime: 5 * 1000, // 5 seconds
  128. refetchInterval: 5 * 1000, // Refresh every 5 seconds
  129. refetchOnWindowFocus: true,
  130. });
  131. const pendingUploadsCount = pendingUploadsData?.count ?? 0;
  132. // Calculate debug duration client-side for real-time updates
  133. const [debugDuration, setDebugDuration] = useState<number | null>(null);
  134. useEffect(() => {
  135. if (!debugLoggingState?.enabled || !debugLoggingState.enabled_at) {
  136. setDebugDuration(null);
  137. return;
  138. }
  139. const enabledAt = new Date(debugLoggingState.enabled_at).getTime();
  140. const updateDuration = () => {
  141. setDebugDuration(Math.floor((Date.now() - enabledAt) / 1000));
  142. };
  143. updateDuration();
  144. const interval = setInterval(updateDuration, 1000);
  145. return () => clearInterval(interval);
  146. }, [debugLoggingState?.enabled, debugLoggingState?.enabled_at]);
  147. // Build the unified sidebar items list - memoized to prevent re-renders
  148. const navItemsMap = useMemo(() => new Map(defaultNavItems.map(item => [item.id, item])), []);
  149. const extLinksMap = useMemo(() => new Map((externalLinks || []).map(link => [`ext-${link.id}`, link])), [externalLinks]);
  150. // Compute the ordered sidebar: include stored order + any new items
  151. const orderedSidebarIds = (() => {
  152. const result: string[] = [];
  153. const seen = new Set<string>();
  154. // Add items in stored order
  155. for (const id of sidebarOrder) {
  156. if (navItemsMap.has(id) || extLinksMap.has(id)) {
  157. result.push(id);
  158. seen.add(id);
  159. }
  160. }
  161. // Add any new internal nav items not in stored order
  162. for (const item of defaultNavItems) {
  163. if (!seen.has(item.id)) {
  164. result.push(item.id);
  165. seen.add(item.id);
  166. }
  167. }
  168. // Add any new external links not in stored order
  169. for (const link of externalLinks || []) {
  170. const extId = `ext-${link.id}`;
  171. if (!seen.has(extId)) {
  172. result.push(extId);
  173. seen.add(extId);
  174. }
  175. }
  176. return result;
  177. })();
  178. // Unified drag handlers
  179. const handleDragStart = (e: React.DragEvent, id: string) => {
  180. setDraggedId(id);
  181. e.dataTransfer.effectAllowed = 'move';
  182. e.dataTransfer.setData('text/plain', id);
  183. };
  184. const handleDragOver = (e: React.DragEvent, id: string) => {
  185. e.preventDefault();
  186. e.dataTransfer.dropEffect = 'move';
  187. setDragOverId(id);
  188. };
  189. const handleDragLeave = () => {
  190. setDragOverId(null);
  191. };
  192. const handleDrop = (e: React.DragEvent, targetId: string) => {
  193. e.preventDefault();
  194. if (draggedId === null || draggedId === targetId) {
  195. setDraggedId(null);
  196. setDragOverId(null);
  197. return;
  198. }
  199. const currentOrder = [...orderedSidebarIds];
  200. const draggedIndex = currentOrder.indexOf(draggedId);
  201. const targetIndex = currentOrder.indexOf(targetId);
  202. if (draggedIndex === -1 || targetIndex === -1) {
  203. setDraggedId(null);
  204. setDragOverId(null);
  205. return;
  206. }
  207. // Reorder
  208. currentOrder.splice(draggedIndex, 1);
  209. currentOrder.splice(targetIndex, 0, draggedId);
  210. // Save to localStorage and update state
  211. setSidebarOrder(currentOrder);
  212. saveSidebarOrder(currentOrder);
  213. setDraggedId(null);
  214. setDragOverId(null);
  215. };
  216. const handleDragEnd = () => {
  217. setDraggedId(null);
  218. setDragOverId(null);
  219. };
  220. // Show update banner if update available and not dismissed for this version
  221. const showUpdateBanner = updateCheck?.update_available &&
  222. updateCheck.latest_version &&
  223. updateCheck.latest_version !== dismissedUpdateVersion;
  224. const dismissUpdateBanner = () => {
  225. if (updateCheck?.latest_version) {
  226. sessionStorage.setItem('dismissedUpdateVersion', updateCheck.latest_version);
  227. setDismissedUpdateVersion(updateCheck.latest_version);
  228. }
  229. };
  230. // Redirect to default view on initial load
  231. useEffect(() => {
  232. if (!hasRedirected.current && location.pathname === '/') {
  233. const defaultView = getDefaultView();
  234. if (defaultView !== '/') {
  235. hasRedirected.current = true;
  236. navigate(defaultView, { replace: true });
  237. }
  238. }
  239. }, [location.pathname, navigate]);
  240. useEffect(() => {
  241. localStorage.setItem('sidebarExpanded', String(sidebarExpanded));
  242. }, [sidebarExpanded]);
  243. // Close mobile drawer on navigation
  244. useEffect(() => {
  245. if (isMobile) {
  246. setMobileDrawerOpen(false);
  247. }
  248. }, [location.pathname, isMobile]);
  249. // Global keyboard shortcuts for navigation
  250. const handleKeyDown = useCallback((e: KeyboardEvent) => {
  251. const target = e.target as HTMLElement;
  252. // Ignore if typing in an input/textarea
  253. if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
  254. return;
  255. }
  256. // Number keys for navigation (1-9) - follows sidebar order including external links
  257. if (!e.metaKey && !e.ctrlKey && !e.altKey) {
  258. const keyNum = parseInt(e.key);
  259. if (keyNum >= 1 && keyNum <= orderedSidebarIds.length && keyNum <= 9) {
  260. const id = orderedSidebarIds[keyNum - 1];
  261. e.preventDefault();
  262. if (isExternalLinkId(id)) {
  263. // External link - navigate to iframe page
  264. const linkId = id.replace('ext-', '');
  265. navigate(`/external/${linkId}`);
  266. } else {
  267. // Internal nav item
  268. const navItem = navItemsMap.get(id);
  269. if (navItem) {
  270. navigate(navItem.to);
  271. }
  272. }
  273. return;
  274. }
  275. switch (e.key) {
  276. case '?':
  277. e.preventDefault();
  278. setShowShortcuts(true);
  279. break;
  280. case 'Escape':
  281. setShowShortcuts(false);
  282. break;
  283. }
  284. }
  285. }, [navigate, orderedSidebarIds, navItemsMap]);
  286. useEffect(() => {
  287. document.addEventListener('keydown', handleKeyDown);
  288. return () => document.removeEventListener('keydown', handleKeyDown);
  289. }, [handleKeyDown]);
  290. return (
  291. <div className="flex min-h-screen">
  292. {/* Mobile Header */}
  293. {isMobile && (
  294. <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">
  295. <button
  296. onClick={() => setMobileDrawerOpen(true)}
  297. className="p-2 -ml-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors"
  298. aria-label="Open menu"
  299. >
  300. <Menu className="w-6 h-6 text-white" />
  301. </button>
  302. <img
  303. src={mode === 'dark' ? '/img/bambuddy_logo_dark_transparent.png' : '/img/bambuddy_logo_light.png'}
  304. alt="Bambuddy"
  305. className="h-8 ml-3"
  306. />
  307. </header>
  308. )}
  309. {/* Mobile Drawer Backdrop */}
  310. {isMobile && mobileDrawerOpen && (
  311. <div
  312. className="fixed inset-0 bg-black/60 z-40 transition-opacity"
  313. onClick={() => setMobileDrawerOpen(false)}
  314. />
  315. )}
  316. {/* Sidebar / Mobile Drawer */}
  317. <aside
  318. className={`bg-bambu-dark-secondary border-r border-bambu-dark-tertiary flex flex-col transition-all duration-300 ${
  319. isMobile
  320. ? `fixed inset-y-0 left-0 z-50 w-72 transform ${mobileDrawerOpen ? 'translate-x-0' : '-translate-x-full'}`
  321. : `fixed inset-y-0 left-0 z-30 ${sidebarExpanded ? 'w-64' : 'w-16'}`
  322. }`}
  323. >
  324. {/* Logo */}
  325. <div className={`border-b border-bambu-dark-tertiary flex items-center justify-center ${isMobile || sidebarExpanded ? 'p-4' : 'p-2'}`}>
  326. <img
  327. src={mode === 'dark' ? '/img/bambuddy_logo_dark_transparent.png' : '/img/bambuddy_logo_light.png'}
  328. alt="Bambuddy"
  329. className={isMobile || sidebarExpanded ? 'h-16 w-auto' : 'h-8 w-8 object-cover object-left'}
  330. />
  331. </div>
  332. {/* Navigation */}
  333. <nav className="flex-1 p-2">
  334. <ul className="space-y-2">
  335. {orderedSidebarIds.map((id) => {
  336. const isExternal = isExternalLinkId(id);
  337. if (isExternal) {
  338. // Render external link
  339. const link = extLinksMap.get(id);
  340. if (!link) return null;
  341. const LinkIcon = link.custom_icon ? null : getIconByName(link.icon);
  342. return (
  343. <li
  344. key={id}
  345. draggable
  346. onDragStart={(e) => handleDragStart(e, id)}
  347. onDragOver={(e) => handleDragOver(e, id)}
  348. onDragLeave={handleDragLeave}
  349. onDrop={(e) => handleDrop(e, id)}
  350. onDragEnd={handleDragEnd}
  351. className={`relative ${
  352. draggedId === id ? 'opacity-50' : ''
  353. } ${
  354. dragOverId === id && draggedId !== id
  355. ? 'before:absolute before:left-0 before:right-0 before:top-0 before:h-0.5 before:bg-bambu-green'
  356. : ''
  357. }`}
  358. >
  359. <NavLink
  360. to={`/external/${link.id}`}
  361. className={({ isActive }) =>
  362. `flex items-center ${isMobile || sidebarExpanded ? 'gap-3 px-4' : 'justify-center px-2'} py-3 rounded-lg transition-colors group ${
  363. isActive
  364. ? 'bg-bambu-green text-white'
  365. : 'text-bambu-gray-light hover:bg-bambu-dark-tertiary hover:text-white'
  366. }`
  367. }
  368. title={!isMobile && !sidebarExpanded ? link.name : undefined}
  369. >
  370. {sidebarExpanded && !isMobile && (
  371. <GripVertical className="w-4 h-4 flex-shrink-0 opacity-0 group-hover:opacity-50 cursor-grab active:cursor-grabbing -ml-1" />
  372. )}
  373. {link.custom_icon ? (
  374. <img
  375. src={`/api/v1/external-links/${link.id}/icon`}
  376. alt=""
  377. className={`w-5 h-5 flex-shrink-0 ${mode === 'dark' ? 'invert opacity-[0.65]' : 'opacity-60'}`}
  378. />
  379. ) : (
  380. LinkIcon && <LinkIcon className="w-5 h-5 flex-shrink-0" />
  381. )}
  382. {(isMobile || sidebarExpanded) && <span>{link.name}</span>}
  383. </NavLink>
  384. </li>
  385. );
  386. } else {
  387. // Render internal nav item
  388. const navItem = navItemsMap.get(id);
  389. if (!navItem) return null;
  390. const { to, icon: Icon, labelKey } = navItem;
  391. const showQueueBadge = id === 'queue' && pendingQueueCount > 0;
  392. const showArchiveBadge = id === 'archives' && pendingUploadsCount > 0;
  393. const badgeCount = showQueueBadge ? pendingQueueCount : showArchiveBadge ? pendingUploadsCount : 0;
  394. const showBadge = showQueueBadge || showArchiveBadge;
  395. return (
  396. <li
  397. key={id}
  398. draggable
  399. onDragStart={(e) => handleDragStart(e, id)}
  400. onDragOver={(e) => handleDragOver(e, id)}
  401. onDragLeave={handleDragLeave}
  402. onDrop={(e) => handleDrop(e, id)}
  403. onDragEnd={handleDragEnd}
  404. className={`relative ${
  405. draggedId === id ? 'opacity-50' : ''
  406. } ${
  407. dragOverId === id && draggedId !== id
  408. ? 'before:absolute before:left-0 before:right-0 before:top-0 before:h-0.5 before:bg-bambu-green'
  409. : ''
  410. }`}
  411. >
  412. <NavLink
  413. to={to}
  414. className={({ isActive }) =>
  415. `flex items-center ${isMobile || sidebarExpanded ? 'gap-3 px-4' : 'justify-center px-2'} py-3 rounded-lg transition-colors group ${
  416. isActive
  417. ? 'bg-bambu-green text-white'
  418. : 'text-bambu-gray-light hover:bg-bambu-dark-tertiary hover:text-white'
  419. }`
  420. }
  421. title={!isMobile && !sidebarExpanded ? t(labelKey) : undefined}
  422. >
  423. {sidebarExpanded && !isMobile && (
  424. <GripVertical className="w-4 h-4 flex-shrink-0 opacity-0 group-hover:opacity-50 cursor-grab active:cursor-grabbing -ml-1" />
  425. )}
  426. <div className="relative">
  427. <Icon className="w-5 h-5 flex-shrink-0" />
  428. {showBadge && (
  429. <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 ${
  430. showArchiveBadge ? 'bg-blue-500 text-white' : 'bg-yellow-500 text-black'
  431. }`}>
  432. {badgeCount > 99 ? '99+' : badgeCount}
  433. </span>
  434. )}
  435. </div>
  436. {(isMobile || sidebarExpanded) && <span>{t(labelKey)}</span>}
  437. </NavLink>
  438. </li>
  439. );
  440. }
  441. })}
  442. </ul>
  443. </nav>
  444. {/* Collapse toggle - hide on mobile */}
  445. {!isMobile && (
  446. <button
  447. onClick={() => setSidebarExpanded(!sidebarExpanded)}
  448. 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"
  449. title={sidebarExpanded ? t('nav.collapseSidebar') : t('nav.expandSidebar')}
  450. >
  451. {sidebarExpanded ? (
  452. <ChevronLeft className="w-5 h-5" />
  453. ) : (
  454. <ChevronRight className="w-5 h-5" />
  455. )}
  456. </button>
  457. )}
  458. {/* Footer */}
  459. <div className="p-2 border-t border-bambu-dark-tertiary">
  460. {isMobile || sidebarExpanded ? (
  461. <div className="flex flex-col gap-2 px-2">
  462. {/* Top row: icons */}
  463. <div className="flex items-center justify-center gap-1">
  464. {hasSwitchbarPlugs && (
  465. <div className="relative">
  466. <button
  467. onMouseEnter={() => setShowSwitchbar(true)}
  468. className={`p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors ${
  469. showSwitchbar ? 'text-bambu-green' : 'text-bambu-gray-light hover:text-white'
  470. }`}
  471. title={t('nav.smartSwitches', { defaultValue: 'Smart Switches' })}
  472. >
  473. <Plug className="w-5 h-5" />
  474. </button>
  475. {showSwitchbar && (
  476. <SwitchbarPopover onClose={() => setShowSwitchbar(false)} />
  477. )}
  478. </div>
  479. )}
  480. <NavLink
  481. to="/system"
  482. className={({ isActive }) =>
  483. `p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors ${
  484. isActive ? 'text-bambu-green' : 'text-bambu-gray-light hover:text-white'
  485. }`
  486. }
  487. title={t('nav.system')}
  488. >
  489. <Info className="w-5 h-5" />
  490. </NavLink>
  491. <a
  492. href="https://github.com/maziggy/bambuddy"
  493. target="_blank"
  494. rel="noopener noreferrer"
  495. className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
  496. title={t('nav.viewOnGithub')}
  497. >
  498. <Github className="w-5 h-5" />
  499. </a>
  500. <button
  501. onClick={() => setShowShortcuts(true)}
  502. className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
  503. title={t('nav.keyboardShortcuts')}
  504. >
  505. <Keyboard className="w-5 h-5" />
  506. </button>
  507. <button
  508. onClick={toggleMode}
  509. className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
  510. title={mode === 'dark' ? t('nav.switchToLight') : t('nav.switchToDark')}
  511. >
  512. {mode === 'dark' ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />}
  513. </button>
  514. </div>
  515. {/* Bottom row: version */}
  516. <div className="flex items-center justify-center gap-2">
  517. <span className="text-sm text-bambu-gray">v{versionInfo?.version || '...'}</span>
  518. {updateCheck?.update_available && (
  519. <button
  520. onClick={() => navigate('/settings')}
  521. className="flex items-center gap-1 text-xs text-bambu-green hover:text-bambu-green/80 transition-colors"
  522. title={t('nav.updateAvailable', { version: updateCheck.latest_version })}
  523. >
  524. <ArrowUpCircle className="w-4 h-4" />
  525. <span>{t('nav.update')}</span>
  526. </button>
  527. )}
  528. </div>
  529. </div>
  530. ) : (
  531. <div className="flex flex-col items-center gap-1">
  532. {updateCheck?.update_available && (
  533. <button
  534. onClick={() => navigate('/settings')}
  535. className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-green hover:text-bambu-green/80"
  536. title={t('nav.updateAvailable', { version: updateCheck.latest_version })}
  537. >
  538. <ArrowUpCircle className="w-5 h-5" />
  539. </button>
  540. )}
  541. {hasSwitchbarPlugs && (
  542. <div className="relative">
  543. <button
  544. onMouseEnter={() => setShowSwitchbar(true)}
  545. className={`p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors ${
  546. showSwitchbar ? 'text-bambu-green' : 'text-bambu-gray-light hover:text-white'
  547. }`}
  548. title={t('nav.smartSwitches', { defaultValue: 'Smart Switches' })}
  549. >
  550. <Plug className="w-5 h-5" />
  551. </button>
  552. {showSwitchbar && (
  553. <SwitchbarPopover onClose={() => setShowSwitchbar(false)} />
  554. )}
  555. </div>
  556. )}
  557. <NavLink
  558. to="/system"
  559. className={({ isActive }) =>
  560. `p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors ${
  561. isActive ? 'text-bambu-green' : 'text-bambu-gray-light hover:text-white'
  562. }`
  563. }
  564. title={t('nav.system')}
  565. >
  566. <Info className="w-5 h-5" />
  567. </NavLink>
  568. <a
  569. href="https://github.com/maziggy/bambuddy"
  570. target="_blank"
  571. rel="noopener noreferrer"
  572. className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
  573. title={t('nav.viewOnGithub')}
  574. >
  575. <Github className="w-5 h-5" />
  576. </a>
  577. <button
  578. onClick={() => setShowShortcuts(true)}
  579. className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
  580. title={t('nav.keyboardShortcuts')}
  581. >
  582. <Keyboard className="w-5 h-5" />
  583. </button>
  584. <button
  585. onClick={toggleMode}
  586. className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
  587. title={mode === 'dark' ? t('nav.switchToLight') : t('nav.switchToDark')}
  588. >
  589. {mode === 'dark' ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />}
  590. </button>
  591. </div>
  592. )}
  593. </div>
  594. </aside>
  595. {/* Main content */}
  596. <main className={`flex-1 bg-bambu-dark overflow-auto transition-all duration-300 ${
  597. isMobile ? 'mt-14' : sidebarExpanded ? 'ml-64' : 'ml-16'
  598. }`}>
  599. {/* Debug logging indicator */}
  600. {debugLoggingState?.enabled && (
  601. <div className="bg-amber-500/20 border-b border-amber-500/30 px-4 py-2 flex items-center justify-between">
  602. <div className="flex items-center gap-2 text-sm">
  603. <Bug className="w-4 h-4 text-amber-500 animate-pulse" />
  604. <span className="text-amber-200">
  605. {t('support.debugLoggingActive', { defaultValue: 'Debug logging is active' })}
  606. {debugDuration !== null && (
  607. <span className="text-amber-300/70 ml-2">
  608. ({Math.floor(debugDuration / 60)}m {debugDuration % 60}s)
  609. </span>
  610. )}
  611. </span>
  612. <button
  613. onClick={() => navigate('/system')}
  614. className="text-amber-400 hover:text-amber-300 font-medium underline ml-2"
  615. >
  616. {t('support.manageLogs', { defaultValue: 'Manage' })}
  617. </button>
  618. </div>
  619. </div>
  620. )}
  621. {/* Persistent update banner */}
  622. {showUpdateBanner && (
  623. <div className="bg-bambu-green/20 border-b border-bambu-green/30 px-4 py-2 flex items-center justify-between">
  624. <div className="flex items-center gap-2 text-sm">
  625. <ArrowUpCircle className="w-4 h-4 text-bambu-green" />
  626. <span>
  627. {t('nav.updateAvailableBanner', {
  628. version: updateCheck?.latest_version,
  629. defaultValue: `Version ${updateCheck?.latest_version} is available!`
  630. })}
  631. </span>
  632. <button
  633. onClick={() => navigate('/settings')}
  634. className="text-bambu-green hover:text-bambu-green/80 font-medium underline"
  635. >
  636. {t('nav.viewUpdate', { defaultValue: 'View update' })}
  637. </button>
  638. </div>
  639. <button
  640. onClick={dismissUpdateBanner}
  641. className="p-1 hover:bg-bambu-dark-tertiary rounded transition-colors"
  642. title={t('common.dismiss', { defaultValue: 'Dismiss' })}
  643. >
  644. <X className="w-4 h-4" />
  645. </button>
  646. </div>
  647. )}
  648. <Outlet />
  649. </main>
  650. {/* Keyboard Shortcuts Modal */}
  651. {showShortcuts && (
  652. <KeyboardShortcutsModal
  653. onClose={() => setShowShortcuts(false)}
  654. sidebarItems={orderedSidebarIds.map(id => {
  655. if (isExternalLinkId(id)) {
  656. const extLink = extLinksMap.get(id);
  657. return extLink ? { type: 'external' as const, label: extLink.name } : null;
  658. } else {
  659. const navItem = navItemsMap.get(id);
  660. return navItem ? { type: 'nav' as const, label: navItem.labelKey, labelKey: navItem.labelKey } : null;
  661. }
  662. }).filter(Boolean) as { type: 'nav' | 'external'; label: string; labelKey?: string }[]}
  663. />
  664. )}
  665. </div>
  666. );
  667. }