Layout.tsx 53 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171
  1. import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
  2. import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom';
  3. import { Printer, Archive, ListOrdered, BarChart3, Cloud, Settings, Sun, Moon, Monitor, ChevronLeft, ChevronRight, Keyboard, Github, ArrowUpCircle, Wrench, FolderKanban, FolderOpen, X, Menu, Info, Plug, Bug, LogOut, Key, Loader2, Disc3, ShieldAlert, Globe, Bell, Receipt, type LucideIcon } from 'lucide-react';
  4. import { useTranslation } from 'react-i18next';
  5. import { useTheme } from '../contexts/ThemeContext';
  6. import { KeyboardShortcutsModal } from './KeyboardShortcutsModal';
  7. import { InstallAppButton } from './InstallAppButton';
  8. import { SwitchbarPopover } from './SwitchbarPopover';
  9. import { useQuery, useQueries } from '@tanstack/react-query';
  10. import { api, supportApi, pendingUploadsApi, type Permission } from '../api/client';
  11. import { getIconByName } from './IconPicker';
  12. import { useIsSidebarCompact } from '../hooks/useIsSidebarCompact';
  13. import { useColorCatalogVersion } from '../hooks/useColorCatalogVersion';
  14. import { useSponsorPrompt } from '../hooks/useSponsorPrompt';
  15. import { useUnknownTagPrompt } from '../hooks/useUnknownTagPrompt';
  16. import { UnknownSpoolModal } from './UnknownSpoolModal';
  17. import { useAuth } from '../contexts/AuthContext';
  18. import { useToast } from '../contexts/ToastContext';
  19. import { Card, CardHeader, CardContent } from './Card';
  20. import { parseUTCDate } from '../utils/date';
  21. import { Button } from './Button';
  22. import { BugReportBubble } from './BugReportBubble';
  23. import {
  24. getHiddenSidebarSystemItemIds,
  25. getSidebarOrder,
  26. isExternalSidebarItemId,
  27. saveHiddenSidebarSystemItemIds,
  28. saveSidebarOrder,
  29. SIDEBAR_LAYOUT_CHANGED_EVENT,
  30. } from '../utils/sidebarLayout';
  31. interface NavItem {
  32. id: string;
  33. to: string;
  34. icon: LucideIcon;
  35. labelKey: string; // Translation key
  36. }
  37. export const defaultNavItems: NavItem[] = [
  38. { id: 'printers', to: '/', icon: Printer, labelKey: 'nav.printers' },
  39. { id: 'inventory', to: '/inventory', icon: Disc3, labelKey: 'nav.inventory' },
  40. { id: 'archives', to: '/archives', icon: Archive, labelKey: 'nav.archives' },
  41. { id: 'queue', to: '/queue', icon: ListOrdered, labelKey: 'nav.queue' },
  42. { id: 'projects', to: '/projects', icon: FolderKanban, labelKey: 'nav.projects' },
  43. { id: 'files', to: '/files', icon: FolderOpen, labelKey: 'nav.files' },
  44. { id: 'makerworld', to: '/makerworld', icon: Globe, labelKey: 'nav.makerworld' },
  45. { id: 'profiles', to: '/profiles', icon: Cloud, labelKey: 'nav.profiles' },
  46. { id: 'maintenance', to: '/maintenance', icon: Wrench, labelKey: 'nav.maintenance' },
  47. { id: 'stats', to: '/stats', icon: BarChart3, labelKey: 'nav.stats' },
  48. // Opt-in feature: gated in isHidden() on the billing_enabled setting, so the
  49. // entry stays out of the sidebar entirely until an admin turns billing on.
  50. { id: 'finance', to: '/finance', icon: Receipt, labelKey: 'nav.finance' },
  51. // User-account feature: gated in isHidden() on advanced auth + user_notifications
  52. // + the notifications:user_email permission. Kept adjacent to Settings
  53. // intentionally. Do not drop this entry — without it the /notifications page
  54. // is orphaned (route + page still exist but no nav link) (#1901).
  55. { id: 'notifications', to: '/notifications', icon: Bell, labelKey: 'nav.notifications' },
  56. { id: 'settings', to: '/settings', icon: Settings, labelKey: 'nav.settings' },
  57. ];
  58. // Get default view from localStorage
  59. export function getDefaultView(): string {
  60. return localStorage.getItem('defaultView') || '/';
  61. }
  62. // Save default view to localStorage
  63. export function setDefaultView(path: string) {
  64. localStorage.setItem('defaultView', path);
  65. }
  66. export function Layout() {
  67. const navigate = useNavigate();
  68. const location = useLocation();
  69. const { mode, resolvedMode, toggleMode } = useTheme();
  70. const { t } = useTranslation();
  71. const isSidebarCompact = useIsSidebarCompact();
  72. // Bug-report panel state lives here because the trigger moves (#2750,
  73. // reporter @goodjaltman). Below the sidebar-compact breakpoint the floating
  74. // disc is replaced by a button in the compact header: the bottom-right corner
  75. // is the most contended region in the app — the Profiles scroll-to-top FAB,
  76. // the floating camera window and its resize handle, the Group Edit save bar,
  77. // the bulk-selection toolbars, and the File Manager / Archives per-card
  78. // action buttons all live there — and a fixed 48px disc sits on top of
  79. // whichever of them happens to be underneath. Moving out of the corner is
  80. // the only fix that covers in-flow content as well as fixed overlays.
  81. const [bugReportOpen, setBugReportOpen] = useState(false);
  82. // A bug-report logging run survives the panel being closed (#2847). The
  83. // floating disc shows that itself; the compact header's button and the
  84. // debug-logging banner need telling.
  85. const [bugReportLogging, setBugReportLogging] = useState(false);
  86. // Theme toggle: mode → icon and tooltip
  87. const ThemeIcon = { dark: Sun, light: Monitor, system: Moon }[mode];
  88. const themeSwitchTitle = t({ dark: 'nav.switchToLight', light: 'nav.switchToSystem', system: 'nav.switchToDark' }[mode]);
  89. // Re-render Layout (and the page rendered inside <Outlet />) whenever the
  90. // backend color catalog is (re)populated, so pages that mounted before the
  91. // catalog fetched — and cached HSL-fallback color names during their first
  92. // render — refresh with the real catalog names. See #857.
  93. useColorCatalogVersion();
  94. const { user, authEnabled, logout, hasPermission } = useAuth();
  95. const { showToast } = useToast();
  96. const [showChangePasswordModal, setShowChangePasswordModal] = useState(false);
  97. const [changePasswordData, setChangePasswordData] = useState({ currentPassword: '', newPassword: '', confirmPassword: '' });
  98. const [changePasswordLoading, setChangePasswordLoading] = useState(false);
  99. const [sidebarExpanded, setSidebarExpanded] = useState(() => {
  100. const stored = localStorage.getItem('sidebarExpanded');
  101. return stored !== 'false';
  102. });
  103. const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false);
  104. const [showShortcuts, setShowShortcuts] = useState(false);
  105. const [showSwitchbar, setShowSwitchbar] = useState(false);
  106. const defaultSidebarOrder = useMemo(() => defaultNavItems.map(i => i.id), []);
  107. const [sidebarOrder, setSidebarOrder] = useState<string[]>(() => getSidebarOrder(defaultNavItems.map(i => i.id)));
  108. const [hiddenSystemItemIds, setHiddenSystemItemIds] = useState<string[]>(getHiddenSidebarSystemItemIds);
  109. const hasRedirected = useRef(false);
  110. const [dismissedUpdateVersion, setDismissedUpdateVersion] = useState<string | null>(() =>
  111. sessionStorage.getItem('dismissedUpdateVersion')
  112. );
  113. const [plateDetectionAlert, setPlateDetectionAlert] = useState<{
  114. printer_id: number;
  115. printer_name: string;
  116. message: string;
  117. } | null>(null);
  118. // Check for updates
  119. const { data: versionInfo } = useQuery({
  120. queryKey: ['version'],
  121. queryFn: api.getVersion,
  122. staleTime: Infinity,
  123. });
  124. const { data: settings } = useQuery({
  125. queryKey: ['settings'],
  126. queryFn: api.getSettings,
  127. staleTime: 5 * 60 * 1000, // 5 minutes
  128. });
  129. // Sponsor-prompt toast — fires once per session post-auth if a milestone is eligible.
  130. useSponsorPrompt(settings?.currency ?? 'EUR');
  131. // Unknown-spool prompt — surfaces a confirmation modal when the AMS reports a
  132. // tag with no inventory match (only when `auto_add_unknown_rfid` is off).
  133. const unknownSpool = useUnknownTagPrompt();
  134. // Fetch default sidebar order via a public endpoint (no settings:read needed)
  135. const { data: defaultSidebarData } = useQuery({
  136. queryKey: ['default-sidebar-order'],
  137. queryFn: api.getDefaultSidebarOrder,
  138. staleTime: 5 * 60 * 1000, // 5 minutes
  139. });
  140. // Apply admin default sidebar order once per user (skipped if already applied).
  141. // Uses a per-user localStorage flag to prevent re-application.
  142. useEffect(() => {
  143. const defaultOrder = defaultSidebarData?.default_sidebar_order;
  144. if (!defaultOrder) return;
  145. // Wait for auth state to settle before applying to avoid double-execution
  146. if (authEnabled && !user) return;
  147. const appliedKey = user ? `sidebarDefaultApplied_${user.id}` : 'sidebarDefaultApplied';
  148. if (localStorage.getItem(appliedKey)) return;
  149. try {
  150. const parsed = JSON.parse(defaultOrder);
  151. const orderArr = Array.isArray(parsed) ? parsed : parsed.order;
  152. if (!Array.isArray(orderArr) || orderArr.length === 0) return;
  153. // Filter to valid sidebar item IDs only
  154. const validIds = new Set(defaultNavItems.map(i => i.id));
  155. const filtered = orderArr.filter((id: string) => typeof id === 'string' && (validIds.has(id) || isExternalSidebarItemId(id)));
  156. if (filtered.length > 0) {
  157. setSidebarOrder(filtered);
  158. saveSidebarOrder(filtered);
  159. const hiddenIds = Array.isArray(parsed) ? [] : parsed.hiddenSystemItemIds;
  160. if (Array.isArray(hiddenIds)) {
  161. const filteredHiddenIds = hiddenIds.filter((id: string) => typeof id === 'string' && validIds.has(id) && id !== 'settings');
  162. setHiddenSystemItemIds(filteredHiddenIds);
  163. saveHiddenSidebarSystemItemIds(filteredHiddenIds);
  164. }
  165. localStorage.setItem(appliedKey, '1');
  166. }
  167. } catch (e) {
  168. console.error('Failed to apply default sidebar order:', e);
  169. }
  170. }, [defaultSidebarData?.default_sidebar_order, setSidebarOrder, user, authEnabled]);
  171. // Check advanced auth status — the notifications nav item is gated on it
  172. // (rendered only when authEnabled && advanced_auth_enabled && user_notifications_enabled).
  173. const { data: advancedAuthStatus } = useQuery({
  174. queryKey: ['advancedAuthStatus'],
  175. queryFn: api.getAdvancedAuthStatus,
  176. staleTime: 5 * 60 * 1000, // 5 minutes
  177. enabled: authEnabled,
  178. });
  179. const { data: updateCheck } = useQuery({
  180. queryKey: ['updateCheck'],
  181. queryFn: api.checkForUpdates,
  182. enabled: settings?.check_updates !== false,
  183. staleTime: 60 * 60 * 1000, // 1 hour
  184. refetchInterval: 60 * 60 * 1000, // Check every hour
  185. });
  186. // Fetch external links for sidebar
  187. const { data: externalLinks } = useQuery({
  188. queryKey: ['external-links'],
  189. queryFn: api.getExternalLinks,
  190. });
  191. // Fetch smart plugs to check for switchbar items
  192. const { data: smartPlugs } = useQuery({
  193. queryKey: ['smart-plugs'],
  194. queryFn: api.getSmartPlugs,
  195. staleTime: 30 * 1000, // 30 seconds
  196. });
  197. const hasSwitchbarPlugs = smartPlugs?.some(p => p.show_in_switchbar) ?? false;
  198. // Check debug logging state
  199. const { data: debugLoggingState } = useQuery({
  200. queryKey: ['debugLogging'],
  201. queryFn: supportApi.getDebugLoggingState,
  202. staleTime: 60 * 1000, // 1 minute
  203. refetchInterval: 60 * 1000, // Refresh every minute
  204. });
  205. // Check developer LAN mode warnings
  206. const { data: devModeWarnings } = useQuery({
  207. queryKey: ['developer-mode-warnings'],
  208. queryFn: api.getDeveloperModeWarnings,
  209. staleTime: 10 * 1000,
  210. refetchInterval: 30 * 1000,
  211. refetchOnWindowFocus: true,
  212. });
  213. // Fetch pending queue items count for badge
  214. const { data: queueItems } = useQuery({
  215. queryKey: ['queue', 'pending'],
  216. queryFn: () => api.getQueue(undefined, 'pending'),
  217. staleTime: 5 * 1000, // 5 seconds
  218. refetchInterval: 5 * 1000, // Refresh every 5 seconds
  219. refetchOnWindowFocus: true,
  220. });
  221. const pendingQueueCount = queueItems?.length ?? 0;
  222. // Fetch pending uploads count for archive badge (virtual printer review items)
  223. const { data: pendingUploadsData } = useQuery({
  224. queryKey: ['pending-uploads', 'count'],
  225. queryFn: pendingUploadsApi.getCount,
  226. staleTime: 5 * 1000, // 5 seconds
  227. refetchInterval: 5 * 1000, // Refresh every 5 seconds
  228. refetchOnWindowFocus: true,
  229. });
  230. const pendingUploadsCount = pendingUploadsData?.count ?? 0;
  231. // Check if any printer with pending queue items needs plate clearing
  232. const queuePrinterIds = useMemo(() => {
  233. const ids = new Set<number>();
  234. queueItems?.forEach(item => {
  235. if (item.printer_id) ids.add(item.printer_id);
  236. });
  237. return Array.from(ids);
  238. }, [queueItems]);
  239. const printerStatusQueries = useQueries({
  240. queries: queuePrinterIds.map(id => ({
  241. queryKey: ['printerStatus', id],
  242. queryFn: () => api.getPrinterStatus(id),
  243. staleTime: 30 * 1000, // WebSocket keeps this warm
  244. })),
  245. });
  246. const needsClearPlate = printerStatusQueries.some(result => {
  247. const status = result.data;
  248. if (!status) return false;
  249. return !!status.awaiting_plate_clear;
  250. });
  251. // Calculate debug duration client-side for real-time updates
  252. const [debugDuration, setDebugDuration] = useState<number | null>(null);
  253. useEffect(() => {
  254. if (!debugLoggingState?.enabled || !debugLoggingState.enabled_at) {
  255. setDebugDuration(null);
  256. return;
  257. }
  258. const enabledAt = parseUTCDate(debugLoggingState.enabled_at)?.getTime() ?? Date.now();
  259. const updateDuration = () => {
  260. setDebugDuration(Math.floor((Date.now() - enabledAt) / 1000));
  261. };
  262. updateDuration();
  263. const interval = setInterval(updateDuration, 1000);
  264. return () => clearInterval(interval);
  265. }, [debugLoggingState?.enabled, debugLoggingState?.enabled_at]);
  266. // Build the unified sidebar items list - memoized to prevent re-renders
  267. const navItemsMap = useMemo(() => new Map(defaultNavItems.map(item => [item.id, item])), []);
  268. const extLinksMap = useMemo(() => new Map((externalLinks || []).map(link => [`ext-${link.id}`, link])), [externalLinks]);
  269. // Compute the ordered sidebar: include stored order + any new items
  270. // Hide nav items the user doesn't have read permission for
  271. const orderedSidebarIds = (() => {
  272. const result: string[] = [];
  273. const seen = new Set<string>();
  274. // Map nav item IDs to the permission(s) required to see them. Resources
  275. // that ship in three tiers (legacy `*:read` + granular `*:read_own` /
  276. // `*:read_all`) list all three: the default Operators group is seeded
  277. // with `_own` only, so gating on the legacy alone hides the entry from
  278. // every non-admin user even though the underlying API accepts their
  279. // request (#1755).
  280. const navPermissions: Record<string, Permission | Permission[]> = {
  281. archives: ['archives:read', 'archives:read_own', 'archives:read_all'],
  282. queue: ['queue:read', 'queue:read_own', 'queue:read_all'],
  283. stats: 'stats:read',
  284. profiles: 'kprofiles:read',
  285. maintenance: 'maintenance:read',
  286. projects: 'projects:read',
  287. inventory: 'inventory:read',
  288. finance: 'cost_centers:read_own',
  289. files: ['library:read', 'library:read_own', 'library:read_all'],
  290. makerworld: 'makerworld:view',
  291. settings: 'settings:read',
  292. // The user-email-preferences API requires notifications:user_email, so
  293. // gate the nav item on the same permission (both default groups —
  294. // Administrators and Operators — hold it). The advanced-auth /
  295. // user_notifications enablement gate is applied separately below.
  296. notifications: 'notifications:user_email',
  297. };
  298. const isHidden = (id: string) => {
  299. // User-toggled hide (#1673) wins first — cheapest check, explicit intent.
  300. if (hiddenSystemItemIds.includes(id)) return true;
  301. // Permission gate accepts Permission | Permission[] so resources with
  302. // granular `*:read_own` / `*:read_all` tiers (default Operators group)
  303. // don't get hidden from users who only hold the granular variant (#1755).
  304. if (authEnabled && id in navPermissions) {
  305. const required = navPermissions[id];
  306. const granted = Array.isArray(required)
  307. ? required.some((p) => hasPermission(p))
  308. : hasPermission(required);
  309. if (!granted) return true;
  310. }
  311. // notifications nav item also requires advanced auth to be enabled and user_notifications_enabled setting
  312. if (id === 'notifications' && (!authEnabled || !advancedAuthStatus?.advanced_auth_enabled || (settings?.user_notifications_enabled === false))) return true;
  313. // Finance is off by default and the page is meaningless without it, so it
  314. // stays hidden until billing is explicitly on. Tested for `true` rather
  315. // than `!== false` on purpose: settings are undefined on the first render,
  316. // and a nav entry that appears and then vanishes reads as a glitch.
  317. if (id === 'finance' && settings?.billing_enabled !== true) return true;
  318. return false;
  319. };
  320. // Add items in stored order
  321. for (const id of sidebarOrder) {
  322. if (isHidden(id)) continue;
  323. if (navItemsMap.has(id) || extLinksMap.has(id)) {
  324. result.push(id);
  325. seen.add(id);
  326. }
  327. }
  328. // Add any new internal nav items not in stored order
  329. for (const item of defaultNavItems) {
  330. if (isHidden(item.id)) continue;
  331. if (!seen.has(item.id)) {
  332. result.push(item.id);
  333. seen.add(item.id);
  334. }
  335. }
  336. // Add any new external links not in stored order
  337. for (const link of externalLinks || []) {
  338. const extId = `ext-${link.id}`;
  339. if (!seen.has(extId)) {
  340. result.push(extId);
  341. seen.add(extId);
  342. }
  343. }
  344. return result;
  345. })();
  346. // Show update banner if update available and not dismissed for this version.
  347. // Suppressed when running as a Home Assistant addon — HA Supervisor surfaces
  348. // its own update notification in the HA UI, so the in-app banner is duplicate
  349. // noise that links to a page that just says "update via HA."
  350. const showUpdateBanner = updateCheck?.update_available &&
  351. updateCheck.latest_version &&
  352. updateCheck.latest_version !== dismissedUpdateVersion &&
  353. !updateCheck.is_ha_addon;
  354. const dismissUpdateBanner = () => {
  355. if (updateCheck?.latest_version) {
  356. sessionStorage.setItem('dismissedUpdateVersion', updateCheck.latest_version);
  357. setDismissedUpdateVersion(updateCheck.latest_version);
  358. }
  359. };
  360. // Redirect to default view on initial load
  361. useEffect(() => {
  362. if (!hasRedirected.current && location.pathname === '/') {
  363. const defaultView = getDefaultView();
  364. if (defaultView !== '/') {
  365. hasRedirected.current = true;
  366. navigate(defaultView, { replace: true });
  367. }
  368. }
  369. }, [location.pathname, navigate]);
  370. useEffect(() => {
  371. localStorage.setItem('sidebarExpanded', String(sidebarExpanded));
  372. }, [sidebarExpanded]);
  373. useEffect(() => {
  374. const refreshSidebarLayout = () => {
  375. setSidebarOrder(getSidebarOrder(defaultSidebarOrder));
  376. setHiddenSystemItemIds(getHiddenSidebarSystemItemIds());
  377. };
  378. window.addEventListener(SIDEBAR_LAYOUT_CHANGED_EVENT, refreshSidebarLayout);
  379. window.addEventListener('storage', refreshSidebarLayout);
  380. return () => {
  381. window.removeEventListener(SIDEBAR_LAYOUT_CHANGED_EVENT, refreshSidebarLayout);
  382. window.removeEventListener('storage', refreshSidebarLayout);
  383. };
  384. }, [defaultSidebarOrder]);
  385. // Close compact drawer on navigation
  386. useEffect(() => {
  387. if (isSidebarCompact) {
  388. setMobileDrawerOpen(false);
  389. }
  390. }, [location.pathname, isSidebarCompact]);
  391. // Listen for plate detection warnings (objects on plate, print paused)
  392. // Only show to users with printers:control permission
  393. useEffect(() => {
  394. const handlePlateNotEmpty = (event: Event) => {
  395. // Only show alert to users who can control printers
  396. if (!hasPermission('printers:control')) {
  397. return;
  398. }
  399. const detail = (event as CustomEvent).detail;
  400. setPlateDetectionAlert({
  401. printer_id: detail.printer_id,
  402. printer_name: detail.printer_name,
  403. message: detail.message,
  404. });
  405. };
  406. window.addEventListener('plate-not-empty', handlePlateNotEmpty);
  407. return () => window.removeEventListener('plate-not-empty', handlePlateNotEmpty);
  408. }, [hasPermission]);
  409. // Global keyboard shortcuts for navigation
  410. const handleKeyDown = useCallback((e: KeyboardEvent) => {
  411. const target = e.target as HTMLElement;
  412. // Ignore if typing in an input/textarea
  413. if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
  414. return;
  415. }
  416. // Number keys for navigation (1-9) - follows sidebar order including external links
  417. if (!e.metaKey && !e.ctrlKey && !e.altKey) {
  418. const keyNum = parseInt(e.key);
  419. if (keyNum >= 1 && keyNum <= orderedSidebarIds.length && keyNum <= 9) {
  420. const id = orderedSidebarIds[keyNum - 1];
  421. e.preventDefault();
  422. if (isExternalSidebarItemId(id)) {
  423. // External link
  424. const extLink = extLinksMap.get(id);
  425. if (extLink?.open_in_new_tab) {
  426. window.open(extLink.url, '_blank', 'noopener,noreferrer');
  427. } else {
  428. const linkId = id.replace('ext-', '');
  429. navigate(`/external/${linkId}`);
  430. }
  431. } else {
  432. // Internal nav item
  433. const navItem = navItemsMap.get(id);
  434. if (navItem) {
  435. navigate(navItem.to);
  436. }
  437. }
  438. return;
  439. }
  440. switch (e.key) {
  441. case '?':
  442. e.preventDefault();
  443. setShowShortcuts(true);
  444. break;
  445. case 'Escape':
  446. setShowShortcuts(false);
  447. break;
  448. }
  449. }
  450. }, [navigate, orderedSidebarIds, navItemsMap, extLinksMap]);
  451. useEffect(() => {
  452. document.addEventListener('keydown', handleKeyDown);
  453. return () => document.removeEventListener('keydown', handleKeyDown);
  454. }, [handleKeyDown]);
  455. return (
  456. <div className="flex min-h-screen">
  457. {/* Compact Header */}
  458. {isSidebarCompact && (
  459. <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">
  460. <button
  461. onClick={() => setMobileDrawerOpen(true)}
  462. className="p-2 -ml-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors"
  463. aria-label="Open menu"
  464. >
  465. <Menu className="w-6 h-6 text-white" />
  466. </button>
  467. <img
  468. src={resolvedMode === 'dark' ? '/img/bambuddy_logo_dark_transparent.png' : '/img/bambuddy_logo_light.png'}
  469. alt="Bambuddy"
  470. className="h-8 ml-3"
  471. />
  472. {/* Bug report — the compact-layout home of the floating bubble. */}
  473. <button
  474. onClick={() => setBugReportOpen(true)}
  475. className={`ml-auto p-2 -mr-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors ${
  476. bugReportLogging ? 'text-amber-500' : 'text-red-500'
  477. }`}
  478. title={bugReportLogging ? t('bugReport.resumeReport') : t('bugReport.title')}
  479. aria-label={bugReportLogging ? t('bugReport.resumeReport') : t('bugReport.title')}
  480. >
  481. <Bug className={`w-5 h-5 ${bugReportLogging ? 'animate-pulse' : ''}`} />
  482. </button>
  483. </header>
  484. )}
  485. {/* Compact Drawer Backdrop */}
  486. {isSidebarCompact && mobileDrawerOpen && (
  487. <div
  488. className="fixed inset-0 bg-black/60 z-40 transition-opacity"
  489. onClick={() => setMobileDrawerOpen(false)}
  490. />
  491. )}
  492. {/* Sidebar / Mobile Drawer */}
  493. <aside
  494. className={`bg-bambu-dark-secondary border-r border-bambu-dark-tertiary flex flex-col transition-all duration-300 ${
  495. isSidebarCompact
  496. ? `fixed inset-y-0 left-0 z-50 w-72 transform ${mobileDrawerOpen ? 'translate-x-0' : '-translate-x-full'}`
  497. : `fixed inset-y-0 left-0 z-30 ${sidebarExpanded ? 'w-64' : 'w-16'}`
  498. }`}
  499. >
  500. {/* Logo */}
  501. <div className={`border-b border-bambu-dark-tertiary flex items-center justify-center ${isSidebarCompact || sidebarExpanded ? 'p-4' : 'p-2'}`}>
  502. <img
  503. src={resolvedMode === 'dark' ? '/img/bambuddy_logo_dark_transparent.png' : '/img/bambuddy_logo_light.png'}
  504. alt="Bambuddy"
  505. className={isSidebarCompact || sidebarExpanded ? 'h-16 w-auto' : 'h-8 w-8 object-cover object-left'}
  506. />
  507. </div>
  508. {/* Navigation */}
  509. <nav className="flex-1 p-2 overflow-y-auto">
  510. <ul className="space-y-2">
  511. {orderedSidebarIds.map((id) => {
  512. const isExternal = isExternalSidebarItemId(id);
  513. if (isExternal) {
  514. // Render external link
  515. const link = extLinksMap.get(id);
  516. if (!link) return null;
  517. const LinkIcon = link.custom_icon ? null : getIconByName(link.icon);
  518. return (
  519. <li key={id}>
  520. {link.open_in_new_tab ? (
  521. <a
  522. href={link.url}
  523. target="_blank"
  524. rel="noopener noreferrer"
  525. 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`}
  526. title={!isSidebarCompact && !sidebarExpanded ? link.name : undefined}
  527. >
  528. {link.custom_icon ? (
  529. <img
  530. src={api.getExternalLinkIconUrl(link.id)}
  531. alt=""
  532. className="w-5 h-5 flex-shrink-0"
  533. />
  534. ) : (
  535. LinkIcon && <LinkIcon className="w-5 h-5 flex-shrink-0" />
  536. )}
  537. {(isSidebarCompact || sidebarExpanded) && <span>{link.name}</span>}
  538. </a>
  539. ) : (
  540. <NavLink
  541. to={`/external/${link.id}`}
  542. className={({ isActive }) =>
  543. `flex items-center ${isSidebarCompact || sidebarExpanded ? 'gap-3 px-4' : 'justify-center px-2'} py-3 rounded-lg transition-colors group ${
  544. isActive
  545. ? 'bg-bambu-green text-white'
  546. : 'text-bambu-gray-light hover:bg-bambu-dark-tertiary hover:text-white'
  547. }`
  548. }
  549. title={!isSidebarCompact && !sidebarExpanded ? link.name : undefined}
  550. >
  551. {link.custom_icon ? (
  552. <img
  553. src={api.getExternalLinkIconUrl(link.id)}
  554. alt=""
  555. className="w-5 h-5 flex-shrink-0"
  556. />
  557. ) : (
  558. LinkIcon && <LinkIcon className="w-5 h-5 flex-shrink-0" />
  559. )}
  560. {(isSidebarCompact || sidebarExpanded) && <span>{link.name}</span>}
  561. </NavLink>
  562. )}
  563. </li>
  564. );
  565. } else {
  566. // Render internal nav item
  567. const navItem = navItemsMap.get(id);
  568. if (!navItem) return null;
  569. const { to, icon: Icon, labelKey } = navItem;
  570. const showQueueBadge = id === 'queue' && pendingQueueCount > 0;
  571. const showArchiveBadge = id === 'archives' && pendingUploadsCount > 0;
  572. const badgeCount = showQueueBadge ? pendingQueueCount : showArchiveBadge ? pendingUploadsCount : 0;
  573. const showBadge = showQueueBadge || showArchiveBadge;
  574. const showClearPlateDot = id === 'printers' && needsClearPlate;
  575. return (
  576. <li key={id}>
  577. <NavLink
  578. to={to}
  579. className={({ isActive }) =>
  580. `flex items-center ${isSidebarCompact || sidebarExpanded ? 'gap-3 px-4' : 'justify-center px-2'} py-3 rounded-lg transition-colors group ${
  581. isActive
  582. ? 'bg-bambu-green text-white'
  583. : 'text-bambu-gray-light hover:bg-bambu-dark-tertiary hover:text-white'
  584. }`
  585. }
  586. title={!isSidebarCompact && !sidebarExpanded ? t(labelKey) : undefined}
  587. >
  588. <div className="relative">
  589. <Icon className="w-5 h-5 flex-shrink-0" />
  590. {showClearPlateDot && (
  591. <span className="absolute -top-0.5 -right-0.5 w-2.5 h-2.5 bg-yellow-500 rounded-full border-2 border-bambu-dark-secondary" />
  592. )}
  593. {showBadge && (
  594. <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 ${
  595. showArchiveBadge ? 'bg-blue-500 text-white' : 'bg-yellow-500 text-black'
  596. }`}>
  597. {badgeCount > 99 ? '99+' : badgeCount}
  598. </span>
  599. )}
  600. </div>
  601. {(isSidebarCompact || sidebarExpanded) && <span>{t(labelKey)}</span>}
  602. </NavLink>
  603. </li>
  604. );
  605. }
  606. })}
  607. </ul>
  608. </nav>
  609. {/* Collapse toggle - hide on compact sidebar */}
  610. {!isSidebarCompact && (
  611. <button
  612. onClick={() => setSidebarExpanded(!sidebarExpanded)}
  613. 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"
  614. title={sidebarExpanded ? t('nav.collapseSidebar') : t('nav.expandSidebar')}
  615. >
  616. {sidebarExpanded ? (
  617. <ChevronLeft className="w-5 h-5" />
  618. ) : (
  619. <ChevronRight className="w-5 h-5" />
  620. )}
  621. </button>
  622. )}
  623. {/* Footer */}
  624. <div className="flex-shrink-0 p-2 border-t border-bambu-dark-tertiary">
  625. {isSidebarCompact || sidebarExpanded ? (
  626. <div className="flex flex-col gap-2 px-2">
  627. {/* Top row: icons */}
  628. <div className="flex items-center justify-center gap-1 flex-wrap">
  629. {hasSwitchbarPlugs && (
  630. <div className="relative">
  631. <button
  632. onMouseEnter={() => setShowSwitchbar(true)}
  633. className={`p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors ${
  634. showSwitchbar ? 'text-bambu-green' : 'text-bambu-gray-light hover:text-white'
  635. }`}
  636. title={t('nav.smartSwitches', { defaultValue: 'Smart Switches' })}
  637. >
  638. <Plug className="w-5 h-5" />
  639. </button>
  640. {showSwitchbar && (
  641. <SwitchbarPopover onClose={() => setShowSwitchbar(false)} />
  642. )}
  643. </div>
  644. )}
  645. {hasPermission('system:read') ? (
  646. <NavLink
  647. to="/system"
  648. className={({ isActive }) =>
  649. `p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors ${
  650. isActive ? 'text-bambu-green' : 'text-bambu-gray-light hover:text-white'
  651. }`
  652. }
  653. title={t('nav.system')}
  654. >
  655. <Info className="w-5 h-5" />
  656. </NavLink>
  657. ) : (
  658. <span
  659. className="p-2 rounded-lg text-bambu-gray/50 cursor-not-allowed"
  660. title="You do not have permission to view system information"
  661. >
  662. <Info className="w-5 h-5" />
  663. </span>
  664. )}
  665. <InstallAppButton />
  666. <a
  667. href="https://github.com/maziggy/bambuddy"
  668. target="_blank"
  669. rel="noopener noreferrer"
  670. className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
  671. title={t('nav.viewOnGithub')}
  672. >
  673. <Github className="w-5 h-5" />
  674. </a>
  675. <button
  676. onClick={() => setShowShortcuts(true)}
  677. className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
  678. title={t('nav.keyboardShortcuts')}
  679. >
  680. <Keyboard className="w-5 h-5" />
  681. </button>
  682. <button
  683. onClick={toggleMode}
  684. className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
  685. title={themeSwitchTitle}
  686. >
  687. <ThemeIcon className="w-5 h-5" />
  688. </button>
  689. {authEnabled && user && (
  690. <>
  691. <button
  692. onClick={() => setShowChangePasswordModal(true)}
  693. className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
  694. title={t('changePassword.title')}
  695. >
  696. <Key className="w-5 h-5" />
  697. </button>
  698. <button
  699. onClick={logout}
  700. className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
  701. title={t('nav.logout', { defaultValue: 'Logout' })}
  702. >
  703. <LogOut className="w-5 h-5" />
  704. </button>
  705. </>
  706. )}
  707. </div>
  708. {/* Bottom row: version */}
  709. <div className="flex items-center justify-center gap-2">
  710. <span className="text-sm text-bambu-gray">v{versionInfo?.version || '...'}</span>
  711. {updateCheck?.update_available && (
  712. <button
  713. onClick={() => navigate('/settings')}
  714. className="flex items-center gap-1 text-xs text-bambu-green hover:text-bambu-green/80 transition-colors"
  715. title={t('nav.updateAvailable', { version: updateCheck.latest_version })}
  716. >
  717. <ArrowUpCircle className="w-4 h-4" />
  718. <span>{t('nav.update')}</span>
  719. </button>
  720. )}
  721. </div>
  722. </div>
  723. ) : (
  724. <div className="flex flex-col items-center gap-1 overflow-y-auto max-h-[50vh]">
  725. {updateCheck?.update_available && (
  726. <button
  727. onClick={() => navigate('/settings')}
  728. className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-green hover:text-bambu-green/80"
  729. title={t('nav.updateAvailable', { version: updateCheck.latest_version })}
  730. >
  731. <ArrowUpCircle className="w-5 h-5" />
  732. </button>
  733. )}
  734. {hasSwitchbarPlugs && (
  735. <div className="relative">
  736. <button
  737. onMouseEnter={() => setShowSwitchbar(true)}
  738. className={`p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors ${
  739. showSwitchbar ? 'text-bambu-green' : 'text-bambu-gray-light hover:text-white'
  740. }`}
  741. title={t('nav.smartSwitches', { defaultValue: 'Smart Switches' })}
  742. >
  743. <Plug className="w-5 h-5" />
  744. </button>
  745. {showSwitchbar && (
  746. <SwitchbarPopover onClose={() => setShowSwitchbar(false)} />
  747. )}
  748. </div>
  749. )}
  750. {hasPermission('system:read') ? (
  751. <NavLink
  752. to="/system"
  753. className={({ isActive }) =>
  754. `p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors ${
  755. isActive ? 'text-bambu-green' : 'text-bambu-gray-light hover:text-white'
  756. }`
  757. }
  758. title={t('nav.system')}
  759. >
  760. <Info className="w-5 h-5" />
  761. </NavLink>
  762. ) : (
  763. <span
  764. className="p-2 rounded-lg text-bambu-gray/50 cursor-not-allowed"
  765. title="You do not have permission to view system information"
  766. >
  767. <Info className="w-5 h-5" />
  768. </span>
  769. )}
  770. <InstallAppButton />
  771. <a
  772. href="https://github.com/maziggy/bambuddy"
  773. target="_blank"
  774. rel="noopener noreferrer"
  775. className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
  776. title={t('nav.viewOnGithub')}
  777. >
  778. <Github className="w-5 h-5" />
  779. </a>
  780. <button
  781. onClick={() => setShowShortcuts(true)}
  782. className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
  783. title={t('nav.keyboardShortcuts')}
  784. >
  785. <Keyboard className="w-5 h-5" />
  786. </button>
  787. <button
  788. onClick={toggleMode}
  789. className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
  790. title={themeSwitchTitle}
  791. >
  792. <ThemeIcon className="w-5 h-5" />
  793. </button>
  794. {authEnabled && user && (
  795. <>
  796. <button
  797. onClick={() => setShowChangePasswordModal(true)}
  798. className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
  799. title={t('changePassword.title')}
  800. >
  801. <Key className="w-5 h-5" />
  802. </button>
  803. <button
  804. onClick={logout}
  805. className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
  806. title={t('nav.logout', { defaultValue: 'Logout' })}
  807. >
  808. <LogOut className="w-5 h-5" />
  809. </button>
  810. </>
  811. )}
  812. </div>
  813. )}
  814. </div>
  815. </aside>
  816. {/* Main content */}
  817. <main className={`flex-1 bg-bambu-dark overflow-auto transition-all duration-300 ${
  818. isSidebarCompact ? 'mt-14' : sidebarExpanded ? 'ml-64' : 'ml-16'
  819. }`}>
  820. {/* Debug logging indicator */}
  821. {debugLoggingState?.enabled && (
  822. <div className="bg-amber-100 dark:bg-amber-500/20 border-b border-amber-300 dark:border-amber-500/30 px-4 py-2 flex items-center justify-between">
  823. <div className="flex items-center gap-2 text-sm">
  824. <Bug className="w-4 h-4 text-amber-500 animate-pulse" />
  825. <span className="text-amber-800 dark:text-amber-200">
  826. {t('support.debugLoggingActive', { defaultValue: 'Debug logging is active' })}
  827. {debugDuration !== null && (
  828. <span className="text-amber-700/80 dark:text-amber-300/70 ml-2">
  829. ({Math.floor(debugDuration / 60)}m {debugDuration % 60}s)
  830. </span>
  831. )}
  832. </span>
  833. {/* A run started from the bug-report panel ends at that panel's
  834. Stop & Submit button, so send the user back there rather than
  835. to the System page's raw toggle, which would drop the logs
  836. and the description they already wrote (#2847). */}
  837. {bugReportLogging && (
  838. <button
  839. onClick={() => setBugReportOpen(true)}
  840. className="text-amber-700 dark:text-amber-400 hover:text-amber-900 dark:hover:text-amber-300 font-medium underline ml-2"
  841. >
  842. {t('bugReport.resumeReport')}
  843. </button>
  844. )}
  845. <button
  846. onClick={() => navigate('/system')}
  847. className="text-amber-700 dark:text-amber-400 hover:text-amber-900 dark:hover:text-amber-300 font-medium underline ml-2"
  848. >
  849. {t('support.manageLogs', { defaultValue: 'Manage' })}
  850. </button>
  851. </div>
  852. </div>
  853. )}
  854. {devModeWarnings && devModeWarnings.length > 0 && (
  855. <div className="bg-orange-100 dark:bg-orange-500/20 border-b border-orange-300 dark:border-orange-500/30 px-4 py-2 flex items-center justify-between">
  856. <div className="flex items-center gap-2 text-sm">
  857. <ShieldAlert className="w-4 h-4 text-orange-500" />
  858. <span className="text-orange-800 dark:text-orange-200">
  859. {t('printers.developerModeWarning', {
  860. names: devModeWarnings.map(w => w.name).join(', '),
  861. defaultValue: `Developer LAN mode is not enabled on: ${devModeWarnings.map(w => w.name).join(', ')}. Some features may not work.`
  862. })}
  863. </span>
  864. <a href="https://wiki.bambulab.com/en/knowledge-sharing/enable-developer-mode"
  865. target="_blank" rel="noopener noreferrer"
  866. className="text-orange-700 dark:text-orange-400 hover:text-orange-900 dark:hover:text-orange-300 font-medium underline ml-2">
  867. {t('printers.howToEnable', { defaultValue: 'How to enable' })}
  868. </a>
  869. </div>
  870. </div>
  871. )}
  872. {/* Persistent update banner */}
  873. {showUpdateBanner && (
  874. <div className="bg-bambu-green/20 border-b border-bambu-green/30 px-4 py-2 flex items-center justify-between">
  875. <div className="flex items-center gap-2 text-sm">
  876. <ArrowUpCircle className="w-4 h-4 text-bambu-green" />
  877. <span>
  878. {t('nav.updateAvailableBanner', {
  879. version: updateCheck?.latest_version,
  880. defaultValue: `Version ${updateCheck?.latest_version} is available!`
  881. })}
  882. </span>
  883. <button
  884. onClick={() => navigate('/settings')}
  885. className="text-bambu-green hover:text-bambu-green/80 font-medium underline"
  886. >
  887. {t('nav.viewUpdate', { defaultValue: 'View update' })}
  888. </button>
  889. </div>
  890. <button
  891. onClick={dismissUpdateBanner}
  892. className="p-1 hover:bg-bambu-dark-tertiary rounded transition-colors"
  893. title={t('common.dismiss', { defaultValue: 'Dismiss' })}
  894. >
  895. <X className="w-4 h-4" />
  896. </button>
  897. </div>
  898. )}
  899. <Outlet />
  900. </main>
  901. <UnknownSpoolModal
  902. prompt={unknownSpool.prompt}
  903. isPending={unknownSpool.isPending}
  904. onConfirm={unknownSpool.confirm}
  905. onCancel={unknownSpool.cancel}
  906. />
  907. {/* Keyboard Shortcuts Modal */}
  908. {showShortcuts && (
  909. <KeyboardShortcutsModal
  910. onClose={() => setShowShortcuts(false)}
  911. sidebarItems={orderedSidebarIds.map(id => {
  912. if (isExternalSidebarItemId(id)) {
  913. const extLink = extLinksMap.get(id);
  914. return extLink ? { type: 'external' as const, label: extLink.name } : null;
  915. } else {
  916. const navItem = navItemsMap.get(id);
  917. return navItem ? { type: 'nav' as const, label: navItem.labelKey, labelKey: navItem.labelKey } : null;
  918. }
  919. }).filter(Boolean) as { type: 'nav' | 'external'; label: string; labelKey?: string }[]}
  920. />
  921. )}
  922. {/* Plate Detection Alert Modal */}
  923. {plateDetectionAlert && (
  924. <div className="fixed inset-0 bg-black/70 flex items-center justify-center z-[100] p-4">
  925. <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">
  926. <div className="p-6 text-center">
  927. <div className="w-16 h-16 mx-auto mb-4 rounded-full bg-yellow-500/20 flex items-center justify-center">
  928. <svg className="w-10 h-10 text-yellow-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  929. <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" />
  930. </svg>
  931. </div>
  932. <h2 className="text-xl font-bold text-yellow-700 dark:text-yellow-400 mb-2">
  933. {t('plateAlert.title')}
  934. </h2>
  935. <p className="text-lg text-white mb-2">
  936. {plateDetectionAlert.printer_name}
  937. </p>
  938. <p className="text-bambu-gray mb-6">
  939. {t('plateAlert.message')}
  940. </p>
  941. <button
  942. onClick={() => setPlateDetectionAlert(null)}
  943. className="w-full py-3 px-6 bg-yellow-500 hover:bg-yellow-600 text-black font-semibold rounded-lg transition-colors"
  944. >
  945. {t('plateAlert.understand')}
  946. </button>
  947. </div>
  948. </div>
  949. </div>
  950. )}
  951. {/* Change Password Modal */}
  952. {showChangePasswordModal && (
  953. <div
  954. className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4"
  955. onClick={() => {
  956. setShowChangePasswordModal(false);
  957. setChangePasswordData({ currentPassword: '', newPassword: '', confirmPassword: '' });
  958. }}
  959. >
  960. <Card
  961. className="w-full max-w-md"
  962. onClick={(e: React.MouseEvent) => e.stopPropagation()}
  963. >
  964. <CardHeader>
  965. <div className="flex items-center justify-between">
  966. <div className="flex items-center gap-2">
  967. <Key className="w-5 h-5 text-bambu-green" />
  968. <h2 className="text-lg font-semibold text-white">{t('changePassword.title')}</h2>
  969. </div>
  970. <Button
  971. variant="ghost"
  972. size="sm"
  973. onClick={() => {
  974. setShowChangePasswordModal(false);
  975. setChangePasswordData({ currentPassword: '', newPassword: '', confirmPassword: '' });
  976. }}
  977. >
  978. <X className="w-5 h-5" />
  979. </Button>
  980. </div>
  981. </CardHeader>
  982. <CardContent>
  983. <div className="space-y-4">
  984. <input
  985. type="text"
  986. name="username"
  987. autoComplete="username"
  988. value={user?.username ?? ''}
  989. readOnly
  990. hidden
  991. aria-hidden="true"
  992. tabIndex={-1}
  993. />
  994. <div>
  995. <label className="block text-sm font-medium text-white mb-2">
  996. {t('changePassword.currentPassword')}
  997. </label>
  998. <input
  999. type="password"
  1000. value={changePasswordData.currentPassword}
  1001. onChange={(e) => setChangePasswordData({ ...changePasswordData, currentPassword: e.target.value })}
  1002. 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"
  1003. placeholder={t('changePassword.currentPasswordPlaceholder')}
  1004. autoComplete="current-password"
  1005. />
  1006. </div>
  1007. <div>
  1008. <label className="block text-sm font-medium text-white mb-2">
  1009. {t('changePassword.newPassword')}
  1010. </label>
  1011. <input
  1012. type="password"
  1013. value={changePasswordData.newPassword}
  1014. onChange={(e) => setChangePasswordData({ ...changePasswordData, newPassword: e.target.value })}
  1015. 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"
  1016. placeholder={t('changePassword.newPasswordPlaceholder')}
  1017. autoComplete="new-password"
  1018. minLength={6}
  1019. />
  1020. </div>
  1021. <div>
  1022. <label className="block text-sm font-medium text-white mb-2">
  1023. {t('changePassword.confirmPassword')}
  1024. </label>
  1025. <input
  1026. type="password"
  1027. value={changePasswordData.confirmPassword}
  1028. onChange={(e) => setChangePasswordData({ ...changePasswordData, confirmPassword: e.target.value })}
  1029. 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 ${
  1030. changePasswordData.confirmPassword && changePasswordData.newPassword !== changePasswordData.confirmPassword
  1031. ? 'border-red-500'
  1032. : 'border-bambu-dark-tertiary'
  1033. }`}
  1034. placeholder={t('changePassword.confirmPasswordPlaceholder')}
  1035. autoComplete="new-password"
  1036. minLength={6}
  1037. />
  1038. {changePasswordData.confirmPassword && changePasswordData.newPassword !== changePasswordData.confirmPassword && (
  1039. <p className="text-red-700 dark:text-red-400 text-xs mt-1">{t('changePassword.passwordsDoNotMatch')}</p>
  1040. )}
  1041. </div>
  1042. </div>
  1043. <div className="mt-6 flex justify-end gap-3">
  1044. <Button
  1045. variant="secondary"
  1046. onClick={() => {
  1047. setShowChangePasswordModal(false);
  1048. setChangePasswordData({ currentPassword: '', newPassword: '', confirmPassword: '' });
  1049. }}
  1050. >
  1051. {t('common.cancel')}
  1052. </Button>
  1053. <Button
  1054. onClick={async () => {
  1055. if (changePasswordData.newPassword !== changePasswordData.confirmPassword) {
  1056. showToast(t('changePassword.passwordsDoNotMatch'), 'error');
  1057. return;
  1058. }
  1059. if (changePasswordData.newPassword.length < 6) {
  1060. showToast(t('changePassword.passwordTooShort'), 'error');
  1061. return;
  1062. }
  1063. setChangePasswordLoading(true);
  1064. try {
  1065. await api.changePassword(changePasswordData.currentPassword, changePasswordData.newPassword);
  1066. showToast(t('changePassword.success'), 'success');
  1067. setShowChangePasswordModal(false);
  1068. setChangePasswordData({ currentPassword: '', newPassword: '', confirmPassword: '' });
  1069. } catch (error: unknown) {
  1070. const message = error instanceof Error ? error.message : t('changePassword.failed');
  1071. showToast(message, 'error');
  1072. } finally {
  1073. setChangePasswordLoading(false);
  1074. }
  1075. }}
  1076. disabled={changePasswordLoading || !changePasswordData.currentPassword || !changePasswordData.newPassword || changePasswordData.newPassword !== changePasswordData.confirmPassword || changePasswordData.newPassword.length < 6}
  1077. >
  1078. {changePasswordLoading ? (
  1079. <>
  1080. <Loader2 className="w-4 h-4 animate-spin" />
  1081. {t('changePassword.changing')}
  1082. </>
  1083. ) : (
  1084. <>
  1085. <Key className="w-4 h-4" />
  1086. {t('changePassword.title')}
  1087. </>
  1088. )}
  1089. </Button>
  1090. </div>
  1091. </CardContent>
  1092. </Card>
  1093. </div>
  1094. )}
  1095. {/* The panel always mounts here, at the Layout root. It must not move
  1096. into the header alongside its compact-layout trigger: the header is
  1097. `fixed z-40` and so its own stacking context, which would cap the
  1098. z-50 panel at the header's level and bury it under every ordinary
  1099. modal in the app. */}
  1100. <BugReportBubble
  1101. showTrigger={!isSidebarCompact}
  1102. open={bugReportOpen}
  1103. onOpenChange={setBugReportOpen}
  1104. onLoggingChange={setBugReportLogging}
  1105. />
  1106. </div>
  1107. );
  1108. }