Layout.tsx 49 KB

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