Layout.tsx 60 KB

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