Layout.tsx 27 KB

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