Layout.tsx 63 KB

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