Layout.tsx 56 KB

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