Layout.tsx 64 KB

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