Layout.tsx 41 KB

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