Layout.tsx 48 KB

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