Layout.tsx 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675
  1. import { Outlet, Link, useLocation } from 'react-router-dom'
  2. import { useEffect, useState, useRef } from 'react'
  3. import { toast } from 'sonner'
  4. import { NowPlayingBar } from '@/components/NowPlayingBar'
  5. import { Button } from '@/components/ui/button'
  6. const navItems = [
  7. { path: '/', label: 'Browse', icon: 'grid_view', title: 'Browse Patterns' },
  8. { path: '/playlists', label: 'Playlists', icon: 'playlist_play', title: 'Playlists' },
  9. { path: '/table-control', label: 'Control', icon: 'tune', title: 'Table Control' },
  10. { path: '/led', label: 'LED', icon: 'lightbulb', title: 'LED Control' },
  11. { path: '/settings', label: 'Settings', icon: 'settings', title: 'Settings' },
  12. ]
  13. const DEFAULT_APP_NAME = 'Dune Weaver'
  14. export function Layout() {
  15. const location = useLocation()
  16. const [isDark, setIsDark] = useState(() => {
  17. if (typeof window !== 'undefined') {
  18. const saved = localStorage.getItem('theme')
  19. if (saved) return saved === 'dark'
  20. return window.matchMedia('(prefers-color-scheme: dark)').matches
  21. }
  22. return false
  23. })
  24. // App customization
  25. const [appName, setAppName] = useState(DEFAULT_APP_NAME)
  26. const [customLogo, setCustomLogo] = useState<string | null>(null)
  27. // Connection status
  28. const [isConnected, setIsConnected] = useState(false)
  29. const [isBackendConnected, setIsBackendConnected] = useState(false)
  30. const [connectionAttempts, setConnectionAttempts] = useState(0)
  31. const wsRef = useRef<WebSocket | null>(null)
  32. // Fetch app settings
  33. const fetchAppSettings = () => {
  34. fetch('/api/settings')
  35. .then((r) => r.json())
  36. .then((settings) => {
  37. if (settings.app?.name) {
  38. setAppName(settings.app.name)
  39. } else {
  40. setAppName(DEFAULT_APP_NAME)
  41. }
  42. setCustomLogo(settings.app?.custom_logo || null)
  43. })
  44. .catch(() => {})
  45. }
  46. useEffect(() => {
  47. fetchAppSettings()
  48. // Listen for branding updates from Settings page
  49. const handleBrandingUpdate = () => {
  50. fetchAppSettings()
  51. }
  52. window.addEventListener('branding-updated', handleBrandingUpdate)
  53. return () => {
  54. window.removeEventListener('branding-updated', handleBrandingUpdate)
  55. }
  56. }, [])
  57. // Logs drawer state
  58. const [isLogsOpen, setIsLogsOpen] = useState(false)
  59. // Now Playing bar state
  60. const [isNowPlayingOpen, setIsNowPlayingOpen] = useState(false)
  61. const [openNowPlayingExpanded, setOpenNowPlayingExpanded] = useState(false)
  62. const wasPlayingRef = useRef(false) // Track previous playing state to detect start
  63. const [logs, setLogs] = useState<Array<{ timestamp: string; level: string; logger: string; message: string }>>([])
  64. const [logLevelFilter, setLogLevelFilter] = useState<string>('ALL')
  65. const logsWsRef = useRef<WebSocket | null>(null)
  66. const logsContainerRef = useRef<HTMLDivElement>(null)
  67. // Check device connection status via WebSocket
  68. useEffect(() => {
  69. const connectWebSocket = () => {
  70. const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
  71. const ws = new WebSocket(`${protocol}//${window.location.host}/ws/status`)
  72. ws.onopen = () => {
  73. setIsBackendConnected(true)
  74. setConnectionAttempts(0)
  75. // Dispatch event so pages can refetch data
  76. window.dispatchEvent(new CustomEvent('backend-connected'))
  77. }
  78. ws.onmessage = (event) => {
  79. try {
  80. const data = JSON.parse(event.data)
  81. // Handle status updates
  82. if (data.type === 'status_update' && data.data) {
  83. // Update device connection status from the status message
  84. if (data.data.connection_status !== undefined) {
  85. setIsConnected(data.data.connection_status)
  86. }
  87. // Auto-open/close Now Playing bar based on playback state
  88. const isPlaying = data.data.is_running || data.data.is_paused
  89. if (isPlaying && !wasPlayingRef.current) {
  90. // Playback just started - open the Now Playing bar in expanded mode
  91. setIsNowPlayingOpen(true)
  92. setOpenNowPlayingExpanded(true)
  93. // Close the logs drawer if open
  94. setIsLogsOpen(false)
  95. // Reset the expanded flag after a short delay
  96. setTimeout(() => setOpenNowPlayingExpanded(false), 500)
  97. // Dispatch event so pages can close their sidebars/panels
  98. window.dispatchEvent(new CustomEvent('playback-started'))
  99. } else if (!isPlaying && wasPlayingRef.current) {
  100. // Playback just stopped - close the Now Playing bar
  101. setIsNowPlayingOpen(false)
  102. }
  103. wasPlayingRef.current = isPlaying
  104. }
  105. } catch {
  106. // Ignore parse errors
  107. }
  108. }
  109. ws.onclose = () => {
  110. setIsBackendConnected(false)
  111. setConnectionAttempts((prev) => prev + 1)
  112. // Reconnect after 3 seconds (don't change device status on WS disconnect)
  113. setTimeout(connectWebSocket, 3000)
  114. }
  115. ws.onerror = () => {
  116. setIsBackendConnected(false)
  117. }
  118. wsRef.current = ws
  119. }
  120. connectWebSocket()
  121. return () => {
  122. if (wsRef.current) {
  123. wsRef.current.close()
  124. }
  125. }
  126. }, [])
  127. // Connect to logs WebSocket when drawer opens
  128. useEffect(() => {
  129. if (!isLogsOpen) {
  130. // Close WebSocket when drawer closes
  131. if (logsWsRef.current) {
  132. logsWsRef.current.close()
  133. logsWsRef.current = null
  134. }
  135. return
  136. }
  137. // Fetch initial logs
  138. const fetchInitialLogs = async () => {
  139. try {
  140. const response = await fetch('/api/logs?limit=200')
  141. const data = await response.json()
  142. // Filter out empty/invalid log entries
  143. const validLogs = (data.logs || []).filter(
  144. (log: { message?: string }) => log && log.message && log.message.trim() !== ''
  145. )
  146. // API returns newest first, reverse to show oldest first (newest at bottom)
  147. setLogs(validLogs.reverse())
  148. // Scroll to bottom after initial load
  149. setTimeout(() => {
  150. if (logsContainerRef.current) {
  151. logsContainerRef.current.scrollTop = logsContainerRef.current.scrollHeight
  152. }
  153. }, 100)
  154. } catch {
  155. // Ignore errors
  156. }
  157. }
  158. fetchInitialLogs()
  159. // Connect to WebSocket for real-time updates
  160. let reconnectTimeout: ReturnType<typeof setTimeout> | null = null
  161. const connectLogsWebSocket = () => {
  162. const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
  163. const ws = new WebSocket(`${protocol}//${window.location.host}/ws/logs`)
  164. ws.onopen = () => {
  165. console.log('Logs WebSocket connected')
  166. }
  167. ws.onmessage = (event) => {
  168. try {
  169. const message = JSON.parse(event.data)
  170. // Skip heartbeat messages
  171. if (message.type === 'heartbeat') {
  172. return
  173. }
  174. // Extract log from wrapped structure
  175. const log = message.type === 'log_entry' ? message.data : message
  176. // Skip empty or invalid log entries
  177. if (!log || !log.message || log.message.trim() === '') {
  178. return
  179. }
  180. setLogs((prev) => {
  181. const newLogs = [...prev, log]
  182. // Keep only last 500 logs to prevent memory issues
  183. if (newLogs.length > 500) {
  184. return newLogs.slice(-500)
  185. }
  186. return newLogs
  187. })
  188. // Auto-scroll to bottom
  189. setTimeout(() => {
  190. if (logsContainerRef.current) {
  191. logsContainerRef.current.scrollTop = logsContainerRef.current.scrollHeight
  192. }
  193. }, 10)
  194. } catch {
  195. // Ignore parse errors
  196. }
  197. }
  198. ws.onclose = () => {
  199. console.log('Logs WebSocket closed, reconnecting...')
  200. // Reconnect after 3 seconds if drawer is still open
  201. reconnectTimeout = setTimeout(() => {
  202. if (logsWsRef.current === ws) {
  203. connectLogsWebSocket()
  204. }
  205. }, 3000)
  206. }
  207. ws.onerror = (error) => {
  208. console.error('Logs WebSocket error:', error)
  209. }
  210. logsWsRef.current = ws
  211. }
  212. connectLogsWebSocket()
  213. return () => {
  214. if (reconnectTimeout) {
  215. clearTimeout(reconnectTimeout)
  216. }
  217. if (logsWsRef.current) {
  218. logsWsRef.current.close()
  219. logsWsRef.current = null
  220. }
  221. }
  222. }, [isLogsOpen])
  223. const handleOpenLogs = () => {
  224. setIsLogsOpen(true)
  225. }
  226. // Filter logs by level
  227. const filteredLogs = logLevelFilter === 'ALL'
  228. ? logs
  229. : logs.filter((log) => log.level === logLevelFilter)
  230. // Format timestamp safely
  231. const formatTimestamp = (timestamp: string) => {
  232. if (!timestamp) return '--:--:--'
  233. try {
  234. const date = new Date(timestamp)
  235. if (isNaN(date.getTime())) return '--:--:--'
  236. return date.toLocaleTimeString()
  237. } catch {
  238. return '--:--:--'
  239. }
  240. }
  241. // Copy logs to clipboard
  242. const handleCopyLogs = () => {
  243. const text = filteredLogs
  244. .map((log) => `${formatTimestamp(log.timestamp)} [${log.level}] ${log.message}`)
  245. .join('\n')
  246. navigator.clipboard.writeText(text)
  247. toast.success('Logs copied to clipboard')
  248. }
  249. // Download logs as file
  250. const handleDownloadLogs = () => {
  251. const text = filteredLogs
  252. .map((log) => `${log.timestamp} [${log.level}] [${log.logger}] ${log.message}`)
  253. .join('\n')
  254. const blob = new Blob([text], { type: 'text/plain' })
  255. const url = URL.createObjectURL(blob)
  256. const a = document.createElement('a')
  257. a.href = url
  258. a.download = `dune-weaver-logs-${new Date().toISOString().split('T')[0]}.txt`
  259. a.click()
  260. URL.revokeObjectURL(url)
  261. }
  262. const handleRestart = async () => {
  263. if (!confirm('Are you sure you want to restart the system?')) return
  264. try {
  265. const response = await fetch('/restart', { method: 'POST' })
  266. if (response.ok) {
  267. toast.success('System is restarting...')
  268. } else {
  269. throw new Error('Restart failed')
  270. }
  271. } catch {
  272. toast.error('Failed to restart system')
  273. }
  274. }
  275. const handleShutdown = async () => {
  276. if (!confirm('Are you sure you want to shutdown the system?')) return
  277. try {
  278. const response = await fetch('/shutdown', { method: 'POST' })
  279. if (response.ok) {
  280. toast.success('System is shutting down...')
  281. } else {
  282. throw new Error('Shutdown failed')
  283. }
  284. } catch {
  285. toast.error('Failed to shutdown system')
  286. }
  287. }
  288. // Update document title based on current page
  289. useEffect(() => {
  290. const currentNav = navItems.find((item) => item.path === location.pathname)
  291. if (currentNav) {
  292. document.title = `${currentNav.title} | ${appName}`
  293. } else {
  294. document.title = appName
  295. }
  296. }, [location.pathname, appName])
  297. useEffect(() => {
  298. if (isDark) {
  299. document.documentElement.classList.add('dark')
  300. localStorage.setItem('theme', 'dark')
  301. } else {
  302. document.documentElement.classList.remove('dark')
  303. localStorage.setItem('theme', 'light')
  304. }
  305. }, [isDark])
  306. // Blocking overlay logs state - shows connection attempts
  307. const [connectionLogs, setConnectionLogs] = useState<Array<{ timestamp: string; level: string; message: string }>>([])
  308. const blockingLogsRef = useRef<HTMLDivElement>(null)
  309. // Add connection attempt logs when backend is disconnected
  310. useEffect(() => {
  311. if (isBackendConnected) {
  312. setConnectionLogs([])
  313. return
  314. }
  315. // Add initial log entry
  316. const addLog = (level: string, message: string) => {
  317. setConnectionLogs((prev) => {
  318. const newLog = {
  319. timestamp: new Date().toISOString(),
  320. level,
  321. message,
  322. }
  323. const newLogs = [...prev, newLog].slice(-50) // Keep last 50 entries
  324. return newLogs
  325. })
  326. // Auto-scroll to bottom
  327. setTimeout(() => {
  328. if (blockingLogsRef.current) {
  329. blockingLogsRef.current.scrollTop = blockingLogsRef.current.scrollHeight
  330. }
  331. }, 10)
  332. }
  333. addLog('INFO', `Attempting to connect to backend at ${window.location.host}...`)
  334. // Log connection attempts
  335. const interval = setInterval(() => {
  336. addLog('INFO', `Retrying connection to WebSocket /ws/status...`)
  337. // Also try HTTP to see if backend is partially up
  338. fetch('/api/settings', { method: 'GET' })
  339. .then(() => {
  340. addLog('INFO', 'HTTP endpoint responding, waiting for WebSocket...')
  341. })
  342. .catch(() => {
  343. // Still down
  344. })
  345. }, 3000)
  346. return () => clearInterval(interval)
  347. }, [isBackendConnected])
  348. return (
  349. <div className="min-h-screen bg-background">
  350. {/* Backend Connection Blocking Overlay */}
  351. {!isBackendConnected && (
  352. <div className="fixed inset-0 z-50 bg-background/95 backdrop-blur-sm flex flex-col items-center justify-center p-4">
  353. <div className="w-full max-w-2xl space-y-6">
  354. {/* Connection Status */}
  355. <div className="text-center space-y-4">
  356. <div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-amber-500/10 mb-2">
  357. <span className="material-icons-outlined text-4xl text-amber-500 animate-pulse">
  358. sync
  359. </span>
  360. </div>
  361. <h2 className="text-2xl font-bold">Connecting to Backend</h2>
  362. <p className="text-muted-foreground">
  363. {connectionAttempts === 0
  364. ? 'Establishing connection...'
  365. : `Reconnecting... (attempt ${connectionAttempts})`
  366. }
  367. </p>
  368. <div className="flex items-center justify-center gap-2 text-sm text-muted-foreground">
  369. <span className="w-2 h-2 rounded-full bg-amber-500 animate-pulse" />
  370. <span>Waiting for server at {window.location.host}</span>
  371. </div>
  372. </div>
  373. {/* Connection Logs Panel */}
  374. <div className="bg-muted/50 rounded-lg border overflow-hidden">
  375. <div className="flex items-center justify-between px-4 py-2 border-b bg-muted">
  376. <div className="flex items-center gap-2">
  377. <span className="material-icons-outlined text-base">terminal</span>
  378. <span className="text-sm font-medium">Connection Log</span>
  379. </div>
  380. <span className="text-xs text-muted-foreground">
  381. {connectionLogs.length} entries
  382. </span>
  383. </div>
  384. <div
  385. ref={blockingLogsRef}
  386. className="h-48 overflow-auto p-3 font-mono text-xs space-y-0.5"
  387. >
  388. {connectionLogs.map((log, i) => (
  389. <div key={i} className="py-0.5 flex gap-2">
  390. <span className="text-muted-foreground shrink-0">
  391. {formatTimestamp(log.timestamp)}
  392. </span>
  393. <span className={`shrink-0 font-semibold ${
  394. log.level === 'ERROR' ? 'text-red-500' :
  395. log.level === 'WARNING' ? 'text-amber-500' :
  396. log.level === 'DEBUG' ? 'text-muted-foreground' :
  397. 'text-foreground'
  398. }`}>
  399. [{log.level}]
  400. </span>
  401. <span className="break-all">{log.message}</span>
  402. </div>
  403. ))}
  404. </div>
  405. </div>
  406. {/* Hint */}
  407. <p className="text-center text-xs text-muted-foreground">
  408. Make sure the backend server is running on port 8080
  409. </p>
  410. </div>
  411. </div>
  412. )}
  413. {/* Header */}
  414. <header className="sticky top-0 z-40 w-full border-b border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
  415. <div className="flex h-14 items-center justify-between px-4">
  416. <Link to="/" className="flex items-center gap-2">
  417. <img
  418. src={customLogo ? `/static/custom/${customLogo}` : '/static/android-chrome-192x192.png'}
  419. alt={appName}
  420. className="w-8 h-8 rounded-full object-cover"
  421. />
  422. <span className="font-semibold text-lg">{appName}</span>
  423. <span
  424. className={`w-2 h-2 rounded-full ${isConnected ? 'bg-green-500 animate-pulse' : 'bg-red-500'}`}
  425. title={isConnected ? 'Connected to table' : 'Disconnected from table'}
  426. />
  427. </Link>
  428. <div className="flex items-center gap-1">
  429. <Button
  430. variant="ghost"
  431. size="icon"
  432. onClick={() => setIsDark(!isDark)}
  433. className="rounded-full"
  434. aria-label="Toggle dark mode"
  435. title="Toggle Theme"
  436. >
  437. <span className="material-icons-outlined">
  438. {isDark ? 'light_mode' : 'dark_mode'}
  439. </span>
  440. </Button>
  441. <Button
  442. variant="ghost"
  443. size="icon"
  444. onClick={handleOpenLogs}
  445. className="rounded-full"
  446. aria-label="View logs"
  447. title="View Application Logs"
  448. >
  449. <span className="material-icons-outlined">article</span>
  450. </Button>
  451. <Button
  452. variant="ghost"
  453. size="icon"
  454. onClick={handleRestart}
  455. className="rounded-full text-amber-500 hover:text-amber-600"
  456. aria-label="Restart system"
  457. title="Restart System"
  458. >
  459. <span className="material-icons-outlined">restart_alt</span>
  460. </Button>
  461. <Button
  462. variant="ghost"
  463. size="icon"
  464. onClick={handleShutdown}
  465. className="rounded-full text-red-500 hover:text-red-600"
  466. aria-label="Shutdown system"
  467. title="Shutdown System"
  468. >
  469. <span className="material-icons-outlined">power_settings_new</span>
  470. </Button>
  471. </div>
  472. </div>
  473. </header>
  474. {/* Main Content */}
  475. <main className={`container mx-auto px-4 transition-all duration-300 ${
  476. isLogsOpen && isNowPlayingOpen ? 'pb-[576px]' :
  477. isLogsOpen ? 'pb-80' :
  478. isNowPlayingOpen ? 'pb-80' :
  479. 'pb-20'
  480. }`}>
  481. <Outlet />
  482. </main>
  483. {/* Now Playing Bar */}
  484. <NowPlayingBar
  485. isLogsOpen={isLogsOpen}
  486. isVisible={isNowPlayingOpen}
  487. openExpanded={openNowPlayingExpanded}
  488. onClose={() => setIsNowPlayingOpen(false)}
  489. />
  490. {/* Floating Now Playing Button */}
  491. {!isNowPlayingOpen && (
  492. <button
  493. onClick={() => setIsNowPlayingOpen(true)}
  494. className="fixed right-4 bottom-20 z-30 w-12 h-12 rounded-full bg-primary text-primary-foreground shadow-lg flex items-center justify-center transition-all duration-200 hover:bg-primary/90 hover:shadow-xl hover:scale-110 active:scale-95"
  495. title="Now Playing"
  496. >
  497. <span className="material-icons">play_circle</span>
  498. </button>
  499. )}
  500. {/* Logs Drawer */}
  501. <div
  502. className={`fixed left-0 right-0 z-30 bg-background border-t border-border transition-all duration-300 ${
  503. isLogsOpen ? 'bottom-16 h-64' : 'bottom-16 h-0'
  504. }`}
  505. >
  506. {isLogsOpen && (
  507. <>
  508. <div className="flex items-center justify-between px-4 py-2 border-b bg-muted/50">
  509. <div className="flex items-center gap-3">
  510. <h2 className="text-sm font-semibold">Logs</h2>
  511. <select
  512. value={logLevelFilter}
  513. onChange={(e) => setLogLevelFilter(e.target.value)}
  514. className="text-xs bg-background border rounded px-2 py-1"
  515. >
  516. <option value="ALL">All Levels</option>
  517. <option value="DEBUG">Debug</option>
  518. <option value="INFO">Info</option>
  519. <option value="WARNING">Warning</option>
  520. <option value="ERROR">Error</option>
  521. </select>
  522. <span className="text-xs text-muted-foreground">
  523. {filteredLogs.length} entries
  524. </span>
  525. </div>
  526. <div className="flex items-center gap-1">
  527. <Button
  528. variant="ghost"
  529. size="icon-sm"
  530. onClick={handleCopyLogs}
  531. className="rounded-full"
  532. title="Copy logs"
  533. >
  534. <span className="material-icons-outlined text-base">content_copy</span>
  535. </Button>
  536. <Button
  537. variant="ghost"
  538. size="icon-sm"
  539. onClick={handleDownloadLogs}
  540. className="rounded-full"
  541. title="Download logs"
  542. >
  543. <span className="material-icons-outlined text-base">download</span>
  544. </Button>
  545. <Button
  546. variant="ghost"
  547. size="icon-sm"
  548. onClick={() => setIsLogsOpen(false)}
  549. className="rounded-full"
  550. title="Close logs"
  551. >
  552. <span className="material-icons-outlined text-base">close</span>
  553. </Button>
  554. </div>
  555. </div>
  556. <div
  557. ref={logsContainerRef}
  558. className="h-[calc(100%-40px)] overflow-auto overscroll-contain p-3 font-mono text-xs space-y-0.5"
  559. >
  560. {filteredLogs.length > 0 ? (
  561. filteredLogs.map((log, i) => (
  562. <div key={i} className="py-0.5 flex gap-2">
  563. <span className="text-muted-foreground shrink-0">
  564. {formatTimestamp(log.timestamp)}
  565. </span>
  566. <span className={`shrink-0 font-semibold ${
  567. log.level === 'ERROR' ? 'text-red-500' :
  568. log.level === 'WARNING' ? 'text-amber-500' :
  569. log.level === 'DEBUG' ? 'text-muted-foreground' :
  570. 'text-foreground'
  571. }`}>
  572. [{log.level || 'LOG'}]
  573. </span>
  574. <span className="break-all">{log.message || ''}</span>
  575. </div>
  576. ))
  577. ) : (
  578. <p className="text-muted-foreground text-center py-4">No logs available</p>
  579. )}
  580. </div>
  581. </>
  582. )}
  583. </div>
  584. {/* Bottom Navigation */}
  585. <nav className="fixed bottom-0 left-0 right-0 z-40 border-t border-border bg-background">
  586. <div className="max-w-5xl mx-auto grid grid-cols-5 h-16">
  587. {navItems.map((item) => {
  588. const isActive = location.pathname === item.path
  589. return (
  590. <Link
  591. key={item.path}
  592. to={item.path}
  593. className={`relative flex flex-col items-center justify-center gap-1 transition-all duration-200 ${
  594. isActive
  595. ? 'text-primary'
  596. : 'text-muted-foreground hover:text-foreground active:scale-95'
  597. }`}
  598. >
  599. {/* Active indicator pill */}
  600. {isActive && (
  601. <span className="absolute -top-0.5 w-8 h-1 rounded-full bg-primary" />
  602. )}
  603. <span className={`text-xl ${isActive ? 'material-icons' : 'material-icons-outlined'}`}>
  604. {item.icon}
  605. </span>
  606. <span className="text-xs font-medium">{item.label}</span>
  607. </Link>
  608. )
  609. })}
  610. </div>
  611. </nav>
  612. </div>
  613. )
  614. }