Layout.tsx 48 KB

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