Layout.tsx 47 KB

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