Layout.tsx 49 KB

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