Layout.tsx 50 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171
  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(s) required to see them. Resources
  252. // that ship in three tiers (legacy `*:read` + granular `*:read_own` /
  253. // `*:read_all`) list all three: the default Operators group is seeded
  254. // with `_own` only, so gating on the legacy alone hides the entry from
  255. // every non-admin user even though the underlying API accepts their
  256. // request (#1755).
  257. const navPermissions: Record<string, Permission | Permission[]> = {
  258. archives: ['archives:read', 'archives:read_own', 'archives:read_all'],
  259. queue: ['queue:read', 'queue:read_own', 'queue:read_all'],
  260. stats: 'stats:read',
  261. profiles: 'kprofiles:read',
  262. maintenance: 'maintenance:read',
  263. projects: 'projects:read',
  264. inventory: 'inventory:read',
  265. files: ['library:read', 'library:read_own', 'library:read_all'],
  266. makerworld: 'makerworld:view',
  267. settings: 'settings:read',
  268. notifications: 'notifications:user_email',
  269. };
  270. const isHidden = (id: string) => {
  271. if (authEnabled && id in navPermissions) {
  272. const required = navPermissions[id];
  273. const granted = Array.isArray(required)
  274. ? required.some((p) => hasPermission(p))
  275. : hasPermission(required);
  276. if (!granted) return true;
  277. }
  278. // notifications nav item also requires advanced auth to be enabled and user_notifications_enabled setting
  279. if (id === 'notifications' && (!authEnabled || !advancedAuthStatus?.advanced_auth_enabled || (settings?.user_notifications_enabled === false))) return true;
  280. return false;
  281. };
  282. // Add items in stored order
  283. for (const id of sidebarOrder) {
  284. if (isHidden(id)) continue;
  285. if (navItemsMap.has(id) || extLinksMap.has(id)) {
  286. result.push(id);
  287. seen.add(id);
  288. }
  289. }
  290. // Add any new internal nav items not in stored order
  291. for (const item of defaultNavItems) {
  292. if (isHidden(item.id)) continue;
  293. if (!seen.has(item.id)) {
  294. result.push(item.id);
  295. seen.add(item.id);
  296. }
  297. }
  298. // Add any new external links not in stored order
  299. for (const link of externalLinks || []) {
  300. const extId = `ext-${link.id}`;
  301. if (!seen.has(extId)) {
  302. result.push(extId);
  303. seen.add(extId);
  304. }
  305. }
  306. return result;
  307. })();
  308. // Unified drag handlers
  309. const handleDragStart = (e: React.DragEvent, id: string) => {
  310. setDraggedId(id);
  311. e.dataTransfer.effectAllowed = 'move';
  312. e.dataTransfer.setData('text/plain', id);
  313. };
  314. const handleDragOver = (e: React.DragEvent, id: string) => {
  315. e.preventDefault();
  316. e.dataTransfer.dropEffect = 'move';
  317. setDragOverId(id);
  318. };
  319. const handleDragLeave = () => {
  320. setDragOverId(null);
  321. };
  322. const handleDrop = (e: React.DragEvent, targetId: string) => {
  323. e.preventDefault();
  324. if (draggedId === null || draggedId === targetId) {
  325. setDraggedId(null);
  326. setDragOverId(null);
  327. return;
  328. }
  329. const currentOrder = [...orderedSidebarIds];
  330. const draggedIndex = currentOrder.indexOf(draggedId);
  331. const targetIndex = currentOrder.indexOf(targetId);
  332. if (draggedIndex === -1 || targetIndex === -1) {
  333. setDraggedId(null);
  334. setDragOverId(null);
  335. return;
  336. }
  337. // Reorder
  338. currentOrder.splice(draggedIndex, 1);
  339. currentOrder.splice(targetIndex, 0, draggedId);
  340. // Save to localStorage and update state
  341. setSidebarOrder(currentOrder);
  342. saveSidebarOrder(currentOrder);
  343. setDraggedId(null);
  344. setDragOverId(null);
  345. };
  346. const handleDragEnd = () => {
  347. setDraggedId(null);
  348. setDragOverId(null);
  349. };
  350. // Show update banner if update available and not dismissed for this version.
  351. // Suppressed when running as a Home Assistant addon — HA Supervisor surfaces
  352. // its own update notification in the HA UI, so the in-app banner is duplicate
  353. // noise that links to a page that just says "update via HA."
  354. const showUpdateBanner = updateCheck?.update_available &&
  355. updateCheck.latest_version &&
  356. updateCheck.latest_version !== dismissedUpdateVersion &&
  357. !updateCheck.is_ha_addon;
  358. const dismissUpdateBanner = () => {
  359. if (updateCheck?.latest_version) {
  360. sessionStorage.setItem('dismissedUpdateVersion', updateCheck.latest_version);
  361. setDismissedUpdateVersion(updateCheck.latest_version);
  362. }
  363. };
  364. // Redirect to default view on initial load
  365. useEffect(() => {
  366. if (!hasRedirected.current && location.pathname === '/') {
  367. const defaultView = getDefaultView();
  368. if (defaultView !== '/') {
  369. hasRedirected.current = true;
  370. navigate(defaultView, { replace: true });
  371. }
  372. }
  373. }, [location.pathname, navigate]);
  374. useEffect(() => {
  375. localStorage.setItem('sidebarExpanded', String(sidebarExpanded));
  376. }, [sidebarExpanded]);
  377. // Close compact drawer on navigation
  378. useEffect(() => {
  379. if (isSidebarCompact) {
  380. setMobileDrawerOpen(false);
  381. }
  382. }, [location.pathname, isSidebarCompact]);
  383. // Listen for plate detection warnings (objects on plate, print paused)
  384. // Only show to users with printers:control permission
  385. useEffect(() => {
  386. const handlePlateNotEmpty = (event: Event) => {
  387. // Only show alert to users who can control printers
  388. if (!hasPermission('printers:control')) {
  389. return;
  390. }
  391. const detail = (event as CustomEvent).detail;
  392. setPlateDetectionAlert({
  393. printer_id: detail.printer_id,
  394. printer_name: detail.printer_name,
  395. message: detail.message,
  396. });
  397. };
  398. window.addEventListener('plate-not-empty', handlePlateNotEmpty);
  399. return () => window.removeEventListener('plate-not-empty', handlePlateNotEmpty);
  400. }, [hasPermission]);
  401. // Global keyboard shortcuts for navigation
  402. const handleKeyDown = useCallback((e: KeyboardEvent) => {
  403. const target = e.target as HTMLElement;
  404. // Ignore if typing in an input/textarea
  405. if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
  406. return;
  407. }
  408. // Number keys for navigation (1-9) - follows sidebar order including external links
  409. if (!e.metaKey && !e.ctrlKey && !e.altKey) {
  410. const keyNum = parseInt(e.key);
  411. if (keyNum >= 1 && keyNum <= orderedSidebarIds.length && keyNum <= 9) {
  412. const id = orderedSidebarIds[keyNum - 1];
  413. e.preventDefault();
  414. if (isExternalLinkId(id)) {
  415. // External link
  416. const extLink = extLinksMap.get(id);
  417. if (extLink?.open_in_new_tab) {
  418. window.open(extLink.url, '_blank', 'noopener,noreferrer');
  419. } else {
  420. const linkId = id.replace('ext-', '');
  421. navigate(`/external/${linkId}`);
  422. }
  423. } else {
  424. // Internal nav item
  425. const navItem = navItemsMap.get(id);
  426. if (navItem) {
  427. navigate(navItem.to);
  428. }
  429. }
  430. return;
  431. }
  432. switch (e.key) {
  433. case '?':
  434. e.preventDefault();
  435. setShowShortcuts(true);
  436. break;
  437. case 'Escape':
  438. setShowShortcuts(false);
  439. break;
  440. }
  441. }
  442. }, [navigate, orderedSidebarIds, navItemsMap, extLinksMap]);
  443. useEffect(() => {
  444. document.addEventListener('keydown', handleKeyDown);
  445. return () => document.removeEventListener('keydown', handleKeyDown);
  446. }, [handleKeyDown]);
  447. return (
  448. <div className="flex min-h-screen">
  449. {/* Compact Header */}
  450. {isSidebarCompact && (
  451. <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">
  452. <button
  453. onClick={() => setMobileDrawerOpen(true)}
  454. className="p-2 -ml-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors"
  455. aria-label="Open menu"
  456. >
  457. <Menu className="w-6 h-6 text-white" />
  458. </button>
  459. <img
  460. src={resolvedMode === 'dark' ? '/img/bambuddy_logo_dark_transparent.png' : '/img/bambuddy_logo_light.png'}
  461. alt="Bambuddy"
  462. className="h-8 ml-3"
  463. />
  464. </header>
  465. )}
  466. {/* Compact Drawer Backdrop */}
  467. {isSidebarCompact && mobileDrawerOpen && (
  468. <div
  469. className="fixed inset-0 bg-black/60 z-40 transition-opacity"
  470. onClick={() => setMobileDrawerOpen(false)}
  471. />
  472. )}
  473. {/* Sidebar / Mobile Drawer */}
  474. <aside
  475. className={`bg-bambu-dark-secondary border-r border-bambu-dark-tertiary flex flex-col transition-all duration-300 ${
  476. isSidebarCompact
  477. ? `fixed inset-y-0 left-0 z-50 w-72 transform ${mobileDrawerOpen ? 'translate-x-0' : '-translate-x-full'}`
  478. : `fixed inset-y-0 left-0 z-30 ${sidebarExpanded ? 'w-64' : 'w-16'}`
  479. }`}
  480. >
  481. {/* Logo */}
  482. <div className={`border-b border-bambu-dark-tertiary flex items-center justify-center ${isSidebarCompact || sidebarExpanded ? 'p-4' : 'p-2'}`}>
  483. <img
  484. src={resolvedMode === 'dark' ? '/img/bambuddy_logo_dark_transparent.png' : '/img/bambuddy_logo_light.png'}
  485. alt="Bambuddy"
  486. className={isSidebarCompact || sidebarExpanded ? 'h-16 w-auto' : 'h-8 w-8 object-cover object-left'}
  487. />
  488. </div>
  489. {/* Navigation */}
  490. <nav className="flex-1 p-2 overflow-y-auto">
  491. <ul className="space-y-2">
  492. {orderedSidebarIds.map((id) => {
  493. const isExternal = isExternalLinkId(id);
  494. if (isExternal) {
  495. // Render external link
  496. const link = extLinksMap.get(id);
  497. if (!link) return null;
  498. const LinkIcon = link.custom_icon ? null : getIconByName(link.icon);
  499. return (
  500. <li
  501. key={id}
  502. draggable
  503. onDragStart={(e) => handleDragStart(e, id)}
  504. onDragOver={(e) => handleDragOver(e, id)}
  505. onDragLeave={handleDragLeave}
  506. onDrop={(e) => handleDrop(e, id)}
  507. onDragEnd={handleDragEnd}
  508. className={`relative ${
  509. draggedId === id ? 'opacity-50' : ''
  510. } ${
  511. dragOverId === id && draggedId !== id
  512. ? 'before:absolute before:left-0 before:right-0 before:top-0 before:h-0.5 before:bg-bambu-green'
  513. : ''
  514. }`}
  515. >
  516. {link.open_in_new_tab ? (
  517. <a
  518. href={link.url}
  519. target="_blank"
  520. rel="noopener noreferrer"
  521. 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`}
  522. title={!isSidebarCompact && !sidebarExpanded ? link.name : undefined}
  523. >
  524. {sidebarExpanded && !isSidebarCompact && (
  525. <GripVertical className="w-4 h-4 flex-shrink-0 opacity-0 group-hover:opacity-50 cursor-grab active:cursor-grabbing -ml-1" />
  526. )}
  527. {link.custom_icon ? (
  528. <img
  529. src={api.getExternalLinkIconUrl(link.id)}
  530. alt=""
  531. className="w-5 h-5 flex-shrink-0"
  532. />
  533. ) : (
  534. LinkIcon && <LinkIcon className="w-5 h-5 flex-shrink-0" />
  535. )}
  536. {(isSidebarCompact || sidebarExpanded) && <span>{link.name}</span>}
  537. </a>
  538. ) : (
  539. <NavLink
  540. to={`/external/${link.id}`}
  541. className={({ isActive }) =>
  542. `flex items-center ${isSidebarCompact || sidebarExpanded ? 'gap-3 px-4' : 'justify-center px-2'} py-3 rounded-lg transition-colors group ${
  543. isActive
  544. ? 'bg-bambu-green text-white'
  545. : 'text-bambu-gray-light hover:bg-bambu-dark-tertiary hover:text-white'
  546. }`
  547. }
  548. title={!isSidebarCompact && !sidebarExpanded ? link.name : undefined}
  549. >
  550. {sidebarExpanded && !isSidebarCompact && (
  551. <GripVertical className="w-4 h-4 flex-shrink-0 opacity-0 group-hover:opacity-50 cursor-grab active:cursor-grabbing -ml-1" />
  552. )}
  553. {link.custom_icon ? (
  554. <img
  555. src={api.getExternalLinkIconUrl(link.id)}
  556. alt=""
  557. className="w-5 h-5 flex-shrink-0"
  558. />
  559. ) : (
  560. LinkIcon && <LinkIcon className="w-5 h-5 flex-shrink-0" />
  561. )}
  562. {(isSidebarCompact || sidebarExpanded) && <span>{link.name}</span>}
  563. </NavLink>
  564. )}
  565. </li>
  566. );
  567. } else {
  568. // Render internal nav item
  569. const navItem = navItemsMap.get(id);
  570. if (!navItem) return null;
  571. const { to, icon: Icon, labelKey } = navItem;
  572. const showQueueBadge = id === 'queue' && pendingQueueCount > 0;
  573. const showArchiveBadge = id === 'archives' && pendingUploadsCount > 0;
  574. const badgeCount = showQueueBadge ? pendingQueueCount : showArchiveBadge ? pendingUploadsCount : 0;
  575. const showBadge = showQueueBadge || showArchiveBadge;
  576. const showClearPlateDot = id === 'printers' && needsClearPlate;
  577. return (
  578. <li
  579. key={id}
  580. draggable
  581. onDragStart={(e) => handleDragStart(e, id)}
  582. onDragOver={(e) => handleDragOver(e, id)}
  583. onDragLeave={handleDragLeave}
  584. onDrop={(e) => handleDrop(e, id)}
  585. onDragEnd={handleDragEnd}
  586. className={`relative ${
  587. draggedId === id ? 'opacity-50' : ''
  588. } ${
  589. dragOverId === id && draggedId !== id
  590. ? 'before:absolute before:left-0 before:right-0 before:top-0 before:h-0.5 before:bg-bambu-green'
  591. : ''
  592. }`}
  593. >
  594. <NavLink
  595. to={to}
  596. className={({ isActive }) =>
  597. `flex items-center ${isSidebarCompact || sidebarExpanded ? 'gap-3 px-4' : 'justify-center px-2'} py-3 rounded-lg transition-colors group ${
  598. isActive
  599. ? 'bg-bambu-green text-white'
  600. : 'text-bambu-gray-light hover:bg-bambu-dark-tertiary hover:text-white'
  601. }`
  602. }
  603. title={!isSidebarCompact && !sidebarExpanded ? t(labelKey) : undefined}
  604. >
  605. {sidebarExpanded && !isSidebarCompact && (
  606. <GripVertical className="w-4 h-4 flex-shrink-0 opacity-0 group-hover:opacity-50 cursor-grab active:cursor-grabbing -ml-1" />
  607. )}
  608. <div className="relative">
  609. <Icon className="w-5 h-5 flex-shrink-0" />
  610. {showClearPlateDot && (
  611. <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" />
  612. )}
  613. {showBadge && (
  614. <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 ${
  615. showArchiveBadge ? 'bg-blue-500 text-white' : 'bg-yellow-500 text-black'
  616. }`}>
  617. {badgeCount > 99 ? '99+' : badgeCount}
  618. </span>
  619. )}
  620. </div>
  621. {(isSidebarCompact || sidebarExpanded) && <span>{t(labelKey)}</span>}
  622. </NavLink>
  623. </li>
  624. );
  625. }
  626. })}
  627. </ul>
  628. </nav>
  629. {/* Collapse toggle - hide on compact sidebar */}
  630. {!isSidebarCompact && (
  631. <button
  632. onClick={() => setSidebarExpanded(!sidebarExpanded)}
  633. 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"
  634. title={sidebarExpanded ? t('nav.collapseSidebar') : t('nav.expandSidebar')}
  635. >
  636. {sidebarExpanded ? (
  637. <ChevronLeft className="w-5 h-5" />
  638. ) : (
  639. <ChevronRight className="w-5 h-5" />
  640. )}
  641. </button>
  642. )}
  643. {/* Footer */}
  644. <div className="flex-shrink-0 p-2 border-t border-bambu-dark-tertiary">
  645. {isSidebarCompact || sidebarExpanded ? (
  646. <div className="flex flex-col gap-2 px-2">
  647. {/* Top row: icons */}
  648. <div className="flex items-center justify-center gap-1 flex-wrap">
  649. {hasSwitchbarPlugs && (
  650. <div className="relative">
  651. <button
  652. onMouseEnter={() => setShowSwitchbar(true)}
  653. className={`p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors ${
  654. showSwitchbar ? 'text-bambu-green' : 'text-bambu-gray-light hover:text-white'
  655. }`}
  656. title={t('nav.smartSwitches', { defaultValue: 'Smart Switches' })}
  657. >
  658. <Plug className="w-5 h-5" />
  659. </button>
  660. {showSwitchbar && (
  661. <SwitchbarPopover onClose={() => setShowSwitchbar(false)} />
  662. )}
  663. </div>
  664. )}
  665. {hasPermission('system:read') ? (
  666. <NavLink
  667. to="/system"
  668. className={({ isActive }) =>
  669. `p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors ${
  670. isActive ? 'text-bambu-green' : 'text-bambu-gray-light hover:text-white'
  671. }`
  672. }
  673. title={t('nav.system')}
  674. >
  675. <Info className="w-5 h-5" />
  676. </NavLink>
  677. ) : (
  678. <span
  679. className="p-2 rounded-lg text-bambu-gray/50 cursor-not-allowed"
  680. title="You do not have permission to view system information"
  681. >
  682. <Info className="w-5 h-5" />
  683. </span>
  684. )}
  685. <InstallAppButton />
  686. <a
  687. href="https://github.com/maziggy/bambuddy"
  688. target="_blank"
  689. rel="noopener noreferrer"
  690. className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
  691. title={t('nav.viewOnGithub')}
  692. >
  693. <Github className="w-5 h-5" />
  694. </a>
  695. <button
  696. onClick={() => setShowShortcuts(true)}
  697. className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
  698. title={t('nav.keyboardShortcuts')}
  699. >
  700. <Keyboard className="w-5 h-5" />
  701. </button>
  702. <button
  703. onClick={toggleMode}
  704. className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
  705. title={themeSwitchTitle}
  706. >
  707. <ThemeIcon className="w-5 h-5" />
  708. </button>
  709. {authEnabled && user && (
  710. <>
  711. <button
  712. onClick={() => setShowChangePasswordModal(true)}
  713. className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
  714. title={t('changePassword.title')}
  715. >
  716. <Key className="w-5 h-5" />
  717. </button>
  718. <button
  719. onClick={logout}
  720. className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
  721. title={t('nav.logout', { defaultValue: 'Logout' })}
  722. >
  723. <LogOut className="w-5 h-5" />
  724. </button>
  725. </>
  726. )}
  727. </div>
  728. {/* Bottom row: version */}
  729. <div className="flex items-center justify-center gap-2">
  730. <span className="text-sm text-bambu-gray">v{versionInfo?.version || '...'}</span>
  731. {updateCheck?.update_available && (
  732. <button
  733. onClick={() => navigate('/settings')}
  734. className="flex items-center gap-1 text-xs text-bambu-green hover:text-bambu-green/80 transition-colors"
  735. title={t('nav.updateAvailable', { version: updateCheck.latest_version })}
  736. >
  737. <ArrowUpCircle className="w-4 h-4" />
  738. <span>{t('nav.update')}</span>
  739. </button>
  740. )}
  741. </div>
  742. </div>
  743. ) : (
  744. <div className="flex flex-col items-center gap-1 overflow-y-auto max-h-[50vh]">
  745. {updateCheck?.update_available && (
  746. <button
  747. onClick={() => navigate('/settings')}
  748. className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-green hover:text-bambu-green/80"
  749. title={t('nav.updateAvailable', { version: updateCheck.latest_version })}
  750. >
  751. <ArrowUpCircle className="w-5 h-5" />
  752. </button>
  753. )}
  754. {hasSwitchbarPlugs && (
  755. <div className="relative">
  756. <button
  757. onMouseEnter={() => setShowSwitchbar(true)}
  758. className={`p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors ${
  759. showSwitchbar ? 'text-bambu-green' : 'text-bambu-gray-light hover:text-white'
  760. }`}
  761. title={t('nav.smartSwitches', { defaultValue: 'Smart Switches' })}
  762. >
  763. <Plug className="w-5 h-5" />
  764. </button>
  765. {showSwitchbar && (
  766. <SwitchbarPopover onClose={() => setShowSwitchbar(false)} />
  767. )}
  768. </div>
  769. )}
  770. {hasPermission('system:read') ? (
  771. <NavLink
  772. to="/system"
  773. className={({ isActive }) =>
  774. `p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors ${
  775. isActive ? 'text-bambu-green' : 'text-bambu-gray-light hover:text-white'
  776. }`
  777. }
  778. title={t('nav.system')}
  779. >
  780. <Info className="w-5 h-5" />
  781. </NavLink>
  782. ) : (
  783. <span
  784. className="p-2 rounded-lg text-bambu-gray/50 cursor-not-allowed"
  785. title="You do not have permission to view system information"
  786. >
  787. <Info className="w-5 h-5" />
  788. </span>
  789. )}
  790. <InstallAppButton />
  791. <a
  792. href="https://github.com/maziggy/bambuddy"
  793. target="_blank"
  794. rel="noopener noreferrer"
  795. className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
  796. title={t('nav.viewOnGithub')}
  797. >
  798. <Github className="w-5 h-5" />
  799. </a>
  800. <button
  801. onClick={() => setShowShortcuts(true)}
  802. className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
  803. title={t('nav.keyboardShortcuts')}
  804. >
  805. <Keyboard className="w-5 h-5" />
  806. </button>
  807. <button
  808. onClick={toggleMode}
  809. className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
  810. title={themeSwitchTitle}
  811. >
  812. <ThemeIcon className="w-5 h-5" />
  813. </button>
  814. {authEnabled && user && (
  815. <>
  816. <button
  817. onClick={() => setShowChangePasswordModal(true)}
  818. className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
  819. title={t('changePassword.title')}
  820. >
  821. <Key className="w-5 h-5" />
  822. </button>
  823. <button
  824. onClick={logout}
  825. className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
  826. title={t('nav.logout', { defaultValue: 'Logout' })}
  827. >
  828. <LogOut className="w-5 h-5" />
  829. </button>
  830. </>
  831. )}
  832. </div>
  833. )}
  834. </div>
  835. </aside>
  836. {/* Main content */}
  837. <main className={`flex-1 bg-bambu-dark overflow-auto transition-all duration-300 ${
  838. isSidebarCompact ? 'mt-14' : sidebarExpanded ? 'ml-64' : 'ml-16'
  839. }`}>
  840. {/* Debug logging indicator */}
  841. {debugLoggingState?.enabled && (
  842. <div className="bg-amber-500/20 border-b border-amber-500/30 px-4 py-2 flex items-center justify-between">
  843. <div className="flex items-center gap-2 text-sm">
  844. <Bug className="w-4 h-4 text-amber-500 animate-pulse" />
  845. <span className="text-amber-200">
  846. {t('support.debugLoggingActive', { defaultValue: 'Debug logging is active' })}
  847. {debugDuration !== null && (
  848. <span className="text-amber-300/70 ml-2">
  849. ({Math.floor(debugDuration / 60)}m {debugDuration % 60}s)
  850. </span>
  851. )}
  852. </span>
  853. <button
  854. onClick={() => navigate('/system')}
  855. className="text-amber-400 hover:text-amber-300 font-medium underline ml-2"
  856. >
  857. {t('support.manageLogs', { defaultValue: 'Manage' })}
  858. </button>
  859. </div>
  860. </div>
  861. )}
  862. {devModeWarnings && devModeWarnings.length > 0 && (
  863. <div className="bg-orange-500/20 border-b border-orange-500/30 px-4 py-2 flex items-center justify-between">
  864. <div className="flex items-center gap-2 text-sm">
  865. <ShieldAlert className="w-4 h-4 text-orange-500" />
  866. <span className="text-orange-200">
  867. {t('printers.developerModeWarning', {
  868. names: devModeWarnings.map(w => w.name).join(', '),
  869. defaultValue: `Developer LAN mode is not enabled on: ${devModeWarnings.map(w => w.name).join(', ')}. Some features may not work.`
  870. })}
  871. </span>
  872. <a href="https://wiki.bambulab.com/en/knowledge-sharing/enable-developer-mode"
  873. target="_blank" rel="noopener noreferrer"
  874. className="text-orange-400 hover:text-orange-300 font-medium underline ml-2">
  875. {t('printers.howToEnable', { defaultValue: 'How to enable' })}
  876. </a>
  877. </div>
  878. </div>
  879. )}
  880. {/* Persistent update banner */}
  881. {showUpdateBanner && (
  882. <div className="bg-bambu-green/20 border-b border-bambu-green/30 px-4 py-2 flex items-center justify-between">
  883. <div className="flex items-center gap-2 text-sm">
  884. <ArrowUpCircle className="w-4 h-4 text-bambu-green" />
  885. <span>
  886. {t('nav.updateAvailableBanner', {
  887. version: updateCheck?.latest_version,
  888. defaultValue: `Version ${updateCheck?.latest_version} is available!`
  889. })}
  890. </span>
  891. <button
  892. onClick={() => navigate('/settings')}
  893. className="text-bambu-green hover:text-bambu-green/80 font-medium underline"
  894. >
  895. {t('nav.viewUpdate', { defaultValue: 'View update' })}
  896. </button>
  897. </div>
  898. <button
  899. onClick={dismissUpdateBanner}
  900. className="p-1 hover:bg-bambu-dark-tertiary rounded transition-colors"
  901. title={t('common.dismiss', { defaultValue: 'Dismiss' })}
  902. >
  903. <X className="w-4 h-4" />
  904. </button>
  905. </div>
  906. )}
  907. <Outlet />
  908. </main>
  909. {/* Keyboard Shortcuts Modal */}
  910. {showShortcuts && (
  911. <KeyboardShortcutsModal
  912. onClose={() => setShowShortcuts(false)}
  913. sidebarItems={orderedSidebarIds.map(id => {
  914. if (isExternalLinkId(id)) {
  915. const extLink = extLinksMap.get(id);
  916. return extLink ? { type: 'external' as const, label: extLink.name } : null;
  917. } else {
  918. const navItem = navItemsMap.get(id);
  919. return navItem ? { type: 'nav' as const, label: navItem.labelKey, labelKey: navItem.labelKey } : null;
  920. }
  921. }).filter(Boolean) as { type: 'nav' | 'external'; label: string; labelKey?: string }[]}
  922. />
  923. )}
  924. {/* Plate Detection Alert Modal */}
  925. {plateDetectionAlert && (
  926. <div className="fixed inset-0 bg-black/70 flex items-center justify-center z-[100] p-4">
  927. <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">
  928. <div className="p-6 text-center">
  929. <div className="w-16 h-16 mx-auto mb-4 rounded-full bg-yellow-500/20 flex items-center justify-center">
  930. <svg className="w-10 h-10 text-yellow-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  931. <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" />
  932. </svg>
  933. </div>
  934. <h2 className="text-xl font-bold text-yellow-400 mb-2">
  935. {t('plateAlert.title')}
  936. </h2>
  937. <p className="text-lg text-white mb-2">
  938. {plateDetectionAlert.printer_name}
  939. </p>
  940. <p className="text-bambu-gray mb-6">
  941. {t('plateAlert.message')}
  942. </p>
  943. <button
  944. onClick={() => setPlateDetectionAlert(null)}
  945. className="w-full py-3 px-6 bg-yellow-500 hover:bg-yellow-600 text-black font-semibold rounded-lg transition-colors"
  946. >
  947. {t('plateAlert.understand')}
  948. </button>
  949. </div>
  950. </div>
  951. </div>
  952. )}
  953. {/* Change Password Modal */}
  954. {showChangePasswordModal && (
  955. <div
  956. className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4"
  957. onClick={() => {
  958. setShowChangePasswordModal(false);
  959. setChangePasswordData({ currentPassword: '', newPassword: '', confirmPassword: '' });
  960. }}
  961. >
  962. <Card
  963. className="w-full max-w-md"
  964. onClick={(e: React.MouseEvent) => e.stopPropagation()}
  965. >
  966. <CardHeader>
  967. <div className="flex items-center justify-between">
  968. <div className="flex items-center gap-2">
  969. <Key className="w-5 h-5 text-bambu-green" />
  970. <h2 className="text-lg font-semibold text-white">{t('changePassword.title')}</h2>
  971. </div>
  972. <Button
  973. variant="ghost"
  974. size="sm"
  975. onClick={() => {
  976. setShowChangePasswordModal(false);
  977. setChangePasswordData({ currentPassword: '', newPassword: '', confirmPassword: '' });
  978. }}
  979. >
  980. <X className="w-5 h-5" />
  981. </Button>
  982. </div>
  983. </CardHeader>
  984. <CardContent>
  985. <div className="space-y-4">
  986. <input
  987. type="text"
  988. name="username"
  989. autoComplete="username"
  990. value={user?.username ?? ''}
  991. readOnly
  992. hidden
  993. aria-hidden="true"
  994. tabIndex={-1}
  995. />
  996. <div>
  997. <label className="block text-sm font-medium text-white mb-2">
  998. {t('changePassword.currentPassword')}
  999. </label>
  1000. <input
  1001. type="password"
  1002. value={changePasswordData.currentPassword}
  1003. onChange={(e) => setChangePasswordData({ ...changePasswordData, currentPassword: e.target.value })}
  1004. 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"
  1005. placeholder={t('changePassword.currentPasswordPlaceholder')}
  1006. autoComplete="current-password"
  1007. />
  1008. </div>
  1009. <div>
  1010. <label className="block text-sm font-medium text-white mb-2">
  1011. {t('changePassword.newPassword')}
  1012. </label>
  1013. <input
  1014. type="password"
  1015. value={changePasswordData.newPassword}
  1016. onChange={(e) => setChangePasswordData({ ...changePasswordData, newPassword: e.target.value })}
  1017. 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"
  1018. placeholder={t('changePassword.newPasswordPlaceholder')}
  1019. autoComplete="new-password"
  1020. minLength={6}
  1021. />
  1022. </div>
  1023. <div>
  1024. <label className="block text-sm font-medium text-white mb-2">
  1025. {t('changePassword.confirmPassword')}
  1026. </label>
  1027. <input
  1028. type="password"
  1029. value={changePasswordData.confirmPassword}
  1030. onChange={(e) => setChangePasswordData({ ...changePasswordData, confirmPassword: e.target.value })}
  1031. 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 ${
  1032. changePasswordData.confirmPassword && changePasswordData.newPassword !== changePasswordData.confirmPassword
  1033. ? 'border-red-500'
  1034. : 'border-bambu-dark-tertiary'
  1035. }`}
  1036. placeholder={t('changePassword.confirmPasswordPlaceholder')}
  1037. autoComplete="new-password"
  1038. minLength={6}
  1039. />
  1040. {changePasswordData.confirmPassword && changePasswordData.newPassword !== changePasswordData.confirmPassword && (
  1041. <p className="text-red-400 text-xs mt-1">{t('changePassword.passwordsDoNotMatch')}</p>
  1042. )}
  1043. </div>
  1044. </div>
  1045. <div className="mt-6 flex justify-end gap-3">
  1046. <Button
  1047. variant="secondary"
  1048. onClick={() => {
  1049. setShowChangePasswordModal(false);
  1050. setChangePasswordData({ currentPassword: '', newPassword: '', confirmPassword: '' });
  1051. }}
  1052. >
  1053. {t('common.cancel')}
  1054. </Button>
  1055. <Button
  1056. onClick={async () => {
  1057. if (changePasswordData.newPassword !== changePasswordData.confirmPassword) {
  1058. showToast(t('changePassword.passwordsDoNotMatch'), 'error');
  1059. return;
  1060. }
  1061. if (changePasswordData.newPassword.length < 6) {
  1062. showToast(t('changePassword.passwordTooShort'), 'error');
  1063. return;
  1064. }
  1065. setChangePasswordLoading(true);
  1066. try {
  1067. await api.changePassword(changePasswordData.currentPassword, changePasswordData.newPassword);
  1068. showToast(t('changePassword.success'), 'success');
  1069. setShowChangePasswordModal(false);
  1070. setChangePasswordData({ currentPassword: '', newPassword: '', confirmPassword: '' });
  1071. } catch (error: unknown) {
  1072. const message = error instanceof Error ? error.message : t('changePassword.failed');
  1073. showToast(message, 'error');
  1074. } finally {
  1075. setChangePasswordLoading(false);
  1076. }
  1077. }}
  1078. disabled={changePasswordLoading || !changePasswordData.currentPassword || !changePasswordData.newPassword || changePasswordData.newPassword !== changePasswordData.confirmPassword || changePasswordData.newPassword.length < 6}
  1079. >
  1080. {changePasswordLoading ? (
  1081. <>
  1082. <Loader2 className="w-4 h-4 animate-spin" />
  1083. {t('changePassword.changing')}
  1084. </>
  1085. ) : (
  1086. <>
  1087. <Key className="w-4 h-4" />
  1088. {t('changePassword.title')}
  1089. </>
  1090. )}
  1091. </Button>
  1092. </div>
  1093. </CardContent>
  1094. </Card>
  1095. </div>
  1096. )}
  1097. <BugReportBubble />
  1098. </div>
  1099. );
  1100. }