Layout.tsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356
  1. import { useState, useEffect, useCallback, useRef } from 'react';
  2. import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom';
  3. import { Printer, Archive, Calendar, BarChart3, Cloud, Settings, Sun, Moon, ChevronLeft, ChevronRight, Keyboard, Github, GripVertical, ArrowUpCircle, Wrench, Gamepad2, type LucideIcon } from 'lucide-react';
  4. import { useTranslation } from 'react-i18next';
  5. import { useTheme } from '../contexts/ThemeContext';
  6. import { KeyboardShortcutsModal } from './KeyboardShortcutsModal';
  7. import { useQuery } from '@tanstack/react-query';
  8. import { api } from '../api/client';
  9. interface NavItem {
  10. id: string;
  11. to: string;
  12. icon: LucideIcon;
  13. labelKey: string; // Translation key
  14. }
  15. export const defaultNavItems: NavItem[] = [
  16. { id: 'printers', to: '/', icon: Printer, labelKey: 'nav.printers' },
  17. { id: 'control', to: '/control', icon: Gamepad2, labelKey: 'nav.control' },
  18. { id: 'archives', to: '/archives', icon: Archive, labelKey: 'nav.archives' },
  19. { id: 'queue', to: '/queue', icon: Calendar, labelKey: 'nav.queue' },
  20. { id: 'stats', to: '/stats', icon: BarChart3, labelKey: 'nav.stats' },
  21. { id: 'profiles', to: '/profiles', icon: Cloud, labelKey: 'nav.profiles' },
  22. { id: 'maintenance', to: '/maintenance', icon: Wrench, labelKey: 'nav.maintenance' },
  23. { id: 'settings', to: '/settings', icon: Settings, labelKey: 'nav.settings' },
  24. ];
  25. // Get ordered nav items from localStorage
  26. function getOrderedNavItems(): NavItem[] {
  27. const stored = localStorage.getItem('sidebarOrder');
  28. if (stored) {
  29. try {
  30. const order: string[] = JSON.parse(stored);
  31. const itemMap = new Map(defaultNavItems.map(item => [item.id, item]));
  32. const ordered: NavItem[] = [];
  33. for (const id of order) {
  34. const item = itemMap.get(id);
  35. if (item) {
  36. ordered.push(item);
  37. itemMap.delete(id);
  38. }
  39. }
  40. // Add any new items that weren't in the stored order
  41. for (const item of itemMap.values()) {
  42. ordered.push(item);
  43. }
  44. return ordered;
  45. } catch {
  46. return defaultNavItems;
  47. }
  48. }
  49. return defaultNavItems;
  50. }
  51. // Save nav item order to localStorage
  52. function saveNavOrder(items: NavItem[]) {
  53. localStorage.setItem('sidebarOrder', JSON.stringify(items.map(i => i.id)));
  54. }
  55. // Get default view from localStorage
  56. export function getDefaultView(): string {
  57. return localStorage.getItem('defaultView') || '/';
  58. }
  59. // Save default view to localStorage
  60. export function setDefaultView(path: string) {
  61. localStorage.setItem('defaultView', path);
  62. }
  63. export function Layout() {
  64. const navigate = useNavigate();
  65. const location = useLocation();
  66. const { theme, toggleTheme } = useTheme();
  67. const { t } = useTranslation();
  68. const [sidebarExpanded, setSidebarExpanded] = useState(() => {
  69. const stored = localStorage.getItem('sidebarExpanded');
  70. return stored !== 'false';
  71. });
  72. const [showShortcuts, setShowShortcuts] = useState(false);
  73. const [navItems, setNavItems] = useState<NavItem[]>(getOrderedNavItems);
  74. const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
  75. const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
  76. const hasRedirected = useRef(false);
  77. // Check for updates
  78. const { data: versionInfo } = useQuery({
  79. queryKey: ['version'],
  80. queryFn: api.getVersion,
  81. staleTime: Infinity,
  82. });
  83. const { data: settings } = useQuery({
  84. queryKey: ['settings'],
  85. queryFn: api.getSettings,
  86. staleTime: 5 * 60 * 1000, // 5 minutes
  87. });
  88. const { data: updateCheck } = useQuery({
  89. queryKey: ['updateCheck'],
  90. queryFn: api.checkForUpdates,
  91. enabled: settings?.check_updates !== false,
  92. staleTime: 60 * 60 * 1000, // 1 hour
  93. refetchInterval: 60 * 60 * 1000, // Check every hour
  94. });
  95. // Redirect to default view on initial load
  96. useEffect(() => {
  97. if (!hasRedirected.current && location.pathname === '/') {
  98. const defaultView = getDefaultView();
  99. if (defaultView !== '/') {
  100. hasRedirected.current = true;
  101. navigate(defaultView, { replace: true });
  102. }
  103. }
  104. }, [location.pathname, navigate]);
  105. useEffect(() => {
  106. localStorage.setItem('sidebarExpanded', String(sidebarExpanded));
  107. }, [sidebarExpanded]);
  108. // Drag and drop handlers
  109. const handleDragStart = (e: React.DragEvent, index: number) => {
  110. setDraggedIndex(index);
  111. e.dataTransfer.effectAllowed = 'move';
  112. e.dataTransfer.setData('text/plain', String(index));
  113. };
  114. const handleDragOver = (e: React.DragEvent, index: number) => {
  115. e.preventDefault();
  116. e.dataTransfer.dropEffect = 'move';
  117. setDragOverIndex(index);
  118. };
  119. const handleDragLeave = () => {
  120. setDragOverIndex(null);
  121. };
  122. const handleDrop = (e: React.DragEvent, dropIndex: number) => {
  123. e.preventDefault();
  124. if (draggedIndex === null || draggedIndex === dropIndex) {
  125. setDraggedIndex(null);
  126. setDragOverIndex(null);
  127. return;
  128. }
  129. const newItems = [...navItems];
  130. const [draggedItem] = newItems.splice(draggedIndex, 1);
  131. newItems.splice(dropIndex, 0, draggedItem);
  132. setNavItems(newItems);
  133. saveNavOrder(newItems);
  134. setDraggedIndex(null);
  135. setDragOverIndex(null);
  136. };
  137. const handleDragEnd = () => {
  138. setDraggedIndex(null);
  139. setDragOverIndex(null);
  140. };
  141. // Global keyboard shortcuts for navigation
  142. const handleKeyDown = useCallback((e: KeyboardEvent) => {
  143. const target = e.target as HTMLElement;
  144. // Ignore if typing in an input/textarea
  145. if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
  146. return;
  147. }
  148. // Number keys for navigation (1-6) - follows sidebar order
  149. if (!e.metaKey && !e.ctrlKey && !e.altKey) {
  150. const keyNum = parseInt(e.key);
  151. if (keyNum >= 1 && keyNum <= navItems.length) {
  152. e.preventDefault();
  153. navigate(navItems[keyNum - 1].to);
  154. return;
  155. }
  156. switch (e.key) {
  157. case '?':
  158. e.preventDefault();
  159. setShowShortcuts(true);
  160. break;
  161. case 'Escape':
  162. setShowShortcuts(false);
  163. break;
  164. }
  165. }
  166. }, [navigate, navItems]);
  167. useEffect(() => {
  168. document.addEventListener('keydown', handleKeyDown);
  169. return () => document.removeEventListener('keydown', handleKeyDown);
  170. }, [handleKeyDown]);
  171. return (
  172. <div className="flex min-h-screen">
  173. {/* Sidebar */}
  174. <aside
  175. className={`${sidebarExpanded ? 'w-64' : 'w-16'} bg-bambu-dark-secondary border-r border-bambu-dark-tertiary flex flex-col fixed inset-y-0 left-0 z-30 transition-all duration-300`}
  176. >
  177. {/* Logo */}
  178. <div className={`border-b border-bambu-dark-tertiary flex items-center justify-center ${sidebarExpanded ? 'p-4' : 'p-2'}`}>
  179. <img
  180. src={theme === 'dark' ? '/img/bambusy_logo_dark.png' : '/img/bambusy_logo_light.png'}
  181. alt="Bambusy"
  182. className={sidebarExpanded ? 'h-16 w-auto' : 'h-8 w-8 object-cover object-left'}
  183. />
  184. </div>
  185. {/* Navigation */}
  186. <nav className="flex-1 p-2">
  187. <ul className="space-y-2">
  188. {navItems.map(({ id, to, icon: Icon, labelKey }, index) => (
  189. <li
  190. key={id}
  191. draggable
  192. onDragStart={(e) => handleDragStart(e, index)}
  193. onDragOver={(e) => handleDragOver(e, index)}
  194. onDragLeave={handleDragLeave}
  195. onDrop={(e) => handleDrop(e, index)}
  196. onDragEnd={handleDragEnd}
  197. className={`relative ${
  198. draggedIndex === index ? 'opacity-50' : ''
  199. } ${
  200. dragOverIndex === index && draggedIndex !== index
  201. ? 'before:absolute before:left-0 before:right-0 before:top-0 before:h-0.5 before:bg-bambu-green'
  202. : ''
  203. }`}
  204. >
  205. <NavLink
  206. to={to}
  207. className={({ isActive }) =>
  208. `flex items-center ${sidebarExpanded ? 'gap-3 px-4' : 'justify-center px-2'} py-3 rounded-lg transition-colors group ${
  209. isActive
  210. ? 'bg-bambu-green text-white'
  211. : 'text-bambu-gray-light hover:bg-bambu-dark-tertiary hover:text-white'
  212. }`
  213. }
  214. title={!sidebarExpanded ? t(labelKey) : undefined}
  215. >
  216. {sidebarExpanded && (
  217. <GripVertical className="w-4 h-4 flex-shrink-0 opacity-0 group-hover:opacity-50 cursor-grab active:cursor-grabbing -ml-1" />
  218. )}
  219. <Icon className="w-5 h-5 flex-shrink-0" />
  220. {sidebarExpanded && <span>{t(labelKey)}</span>}
  221. </NavLink>
  222. </li>
  223. ))}
  224. </ul>
  225. </nav>
  226. {/* Collapse toggle */}
  227. <button
  228. onClick={() => setSidebarExpanded(!sidebarExpanded)}
  229. 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"
  230. title={sidebarExpanded ? t('nav.collapseSidebar') : t('nav.expandSidebar')}
  231. >
  232. {sidebarExpanded ? (
  233. <ChevronLeft className="w-5 h-5" />
  234. ) : (
  235. <ChevronRight className="w-5 h-5" />
  236. )}
  237. </button>
  238. {/* Footer */}
  239. <div className="p-2 border-t border-bambu-dark-tertiary">
  240. {sidebarExpanded ? (
  241. <div className="flex items-center justify-between px-2">
  242. <div className="flex items-center gap-2">
  243. <span className="text-sm text-bambu-gray">v{versionInfo?.version || '...'}</span>
  244. {updateCheck?.update_available && (
  245. <button
  246. onClick={() => navigate('/settings')}
  247. className="flex items-center gap-1 text-xs text-bambu-green hover:text-bambu-green/80 transition-colors"
  248. title={t('nav.updateAvailable', { version: updateCheck.latest_version })}
  249. >
  250. <ArrowUpCircle className="w-4 h-4" />
  251. <span>{t('nav.update')}</span>
  252. </button>
  253. )}
  254. </div>
  255. <div className="flex items-center gap-1">
  256. <a
  257. href="https://github.com/maziggy/bambusy"
  258. target="_blank"
  259. rel="noopener noreferrer"
  260. className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
  261. title={t('nav.viewOnGithub')}
  262. >
  263. <Github className="w-5 h-5" />
  264. </a>
  265. <button
  266. onClick={() => setShowShortcuts(true)}
  267. className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
  268. title={t('nav.keyboardShortcuts')}
  269. >
  270. <Keyboard className="w-5 h-5" />
  271. </button>
  272. <button
  273. onClick={toggleTheme}
  274. className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
  275. title={theme === 'dark' ? t('nav.switchToLight') : t('nav.switchToDark')}
  276. >
  277. {theme === 'dark' ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />}
  278. </button>
  279. </div>
  280. </div>
  281. ) : (
  282. <div className="flex flex-col items-center gap-1">
  283. {updateCheck?.update_available && (
  284. <button
  285. onClick={() => navigate('/settings')}
  286. className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-green hover:text-bambu-green/80"
  287. title={t('nav.updateAvailable', { version: updateCheck.latest_version })}
  288. >
  289. <ArrowUpCircle className="w-5 h-5" />
  290. </button>
  291. )}
  292. <a
  293. href="https://github.com/maziggy/bambusy"
  294. target="_blank"
  295. rel="noopener noreferrer"
  296. className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
  297. title={t('nav.viewOnGithub')}
  298. >
  299. <Github className="w-5 h-5" />
  300. </a>
  301. <button
  302. onClick={() => setShowShortcuts(true)}
  303. className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
  304. title={t('nav.keyboardShortcuts')}
  305. >
  306. <Keyboard className="w-5 h-5" />
  307. </button>
  308. <button
  309. onClick={toggleTheme}
  310. className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
  311. title={theme === 'dark' ? t('nav.switchToLight') : t('nav.switchToDark')}
  312. >
  313. {theme === 'dark' ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />}
  314. </button>
  315. </div>
  316. )}
  317. </div>
  318. </aside>
  319. {/* Main content */}
  320. <main className={`flex-1 bg-bambu-dark overflow-auto ${sidebarExpanded ? 'ml-64' : 'ml-16'} transition-all duration-300`}>
  321. <Outlet />
  322. </main>
  323. {/* Keyboard Shortcuts Modal */}
  324. {showShortcuts && <KeyboardShortcutsModal onClose={() => setShowShortcuts(false)} navItems={navItems} />}
  325. </div>
  326. );
  327. }