Layout.tsx 43 KB

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