Layout.tsx 58 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538
  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. import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
  7. import { Separator } from '@/components/ui/separator'
  8. import { cacheAllPreviews } from '@/lib/previewCache'
  9. import { TableSelector } from '@/components/TableSelector'
  10. import { useTable } from '@/contexts/TableContext'
  11. import { apiClient } from '@/lib/apiClient'
  12. import ShinyText from '@/components/ShinyText'
  13. const navItems = [
  14. { path: '/', label: 'Browse', icon: 'grid_view', title: 'Browse Patterns' },
  15. { path: '/playlists', label: 'Playlists', icon: 'playlist_play', title: 'Playlists' },
  16. { path: '/table-control', label: 'Control', icon: 'tune', title: 'Table Control' },
  17. { path: '/led', label: 'LED', icon: 'lightbulb', title: 'LED Control' },
  18. { path: '/settings', label: 'Settings', icon: 'settings', title: 'Settings' },
  19. ]
  20. const DEFAULT_APP_NAME = 'Dune Weaver'
  21. export function Layout() {
  22. const location = useLocation()
  23. // Scroll to top on route change
  24. useEffect(() => {
  25. window.scrollTo(0, 0)
  26. }, [location.pathname])
  27. // Multi-table context - must be called before any hooks that depend on activeTable
  28. const { activeTable, tables } = useTable()
  29. // Use table name as app name when multiple tables exist
  30. const hasMultipleTables = tables.length > 1
  31. const [isDark, setIsDark] = useState(() => {
  32. if (typeof window !== 'undefined') {
  33. const saved = localStorage.getItem('theme')
  34. if (saved) return saved === 'dark'
  35. return window.matchMedia('(prefers-color-scheme: dark)').matches
  36. }
  37. return false
  38. })
  39. // App customization
  40. const [appName, setAppName] = useState(DEFAULT_APP_NAME)
  41. const [customLogo, setCustomLogo] = useState<string | null>(null)
  42. // Display name: when multiple tables exist, use the active table's name; otherwise use app settings
  43. // Get the table from the tables array (most up-to-date source) to ensure we have current data
  44. const activeTableData = tables.find(t => t.id === activeTable?.id)
  45. const tableName = activeTableData?.name || activeTable?.name
  46. const displayName = hasMultipleTables && tableName ? tableName : appName
  47. // Connection status
  48. const [isConnected, setIsConnected] = useState(false)
  49. const [isBackendConnected, setIsBackendConnected] = useState(false)
  50. const [isHoming, setIsHoming] = useState(false)
  51. const [homingDismissed, setHomingDismissed] = useState(false)
  52. const [homingJustCompleted, setHomingJustCompleted] = useState(false)
  53. const [homingCountdown, setHomingCountdown] = useState(0)
  54. const [keepHomingLogsOpen, setKeepHomingLogsOpen] = useState(false)
  55. const wasHomingRef = useRef(false)
  56. const [connectionAttempts, setConnectionAttempts] = useState(0)
  57. const wsRef = useRef<WebSocket | null>(null)
  58. // Fetch app settings
  59. const fetchAppSettings = () => {
  60. apiClient.get<{ app?: { name?: string; custom_logo?: string } }>('/api/settings')
  61. .then((settings) => {
  62. if (settings.app?.name) {
  63. setAppName(settings.app.name)
  64. } else {
  65. setAppName(DEFAULT_APP_NAME)
  66. }
  67. setCustomLogo(settings.app?.custom_logo || null)
  68. })
  69. .catch(() => {})
  70. }
  71. useEffect(() => {
  72. fetchAppSettings()
  73. // Listen for branding updates from Settings page
  74. const handleBrandingUpdate = () => {
  75. fetchAppSettings()
  76. }
  77. window.addEventListener('branding-updated', handleBrandingUpdate)
  78. return () => {
  79. window.removeEventListener('branding-updated', handleBrandingUpdate)
  80. }
  81. // Refetch when active table changes
  82. }, [activeTable?.id])
  83. // Homing completion countdown timer
  84. useEffect(() => {
  85. if (!homingJustCompleted || keepHomingLogsOpen) return
  86. if (homingCountdown <= 0) {
  87. // Countdown finished, dismiss the overlay
  88. setHomingJustCompleted(false)
  89. setKeepHomingLogsOpen(false)
  90. return
  91. }
  92. const timer = setTimeout(() => {
  93. setHomingCountdown((prev) => prev - 1)
  94. }, 1000)
  95. return () => clearTimeout(timer)
  96. }, [homingJustCompleted, homingCountdown, keepHomingLogsOpen])
  97. // Mobile menu state
  98. const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false)
  99. // Logs drawer state
  100. const [isLogsOpen, setIsLogsOpen] = useState(false)
  101. const [logsDrawerHeight, setLogsDrawerHeight] = useState(256) // Default 256px (h-64)
  102. const [isResizing, setIsResizing] = useState(false)
  103. const isResizingRef = useRef(false)
  104. const startYRef = useRef(0)
  105. const startHeightRef = useRef(0)
  106. // Handle drawer resize
  107. const handleResizeStart = (e: React.MouseEvent | React.TouchEvent) => {
  108. e.preventDefault()
  109. isResizingRef.current = true
  110. setIsResizing(true)
  111. startYRef.current = 'touches' in e ? e.touches[0].clientY : e.clientY
  112. startHeightRef.current = logsDrawerHeight
  113. document.body.style.cursor = 'ns-resize'
  114. document.body.style.userSelect = 'none'
  115. }
  116. useEffect(() => {
  117. const handleResizeMove = (e: MouseEvent | TouchEvent) => {
  118. if (!isResizingRef.current) return
  119. const clientY = 'touches' in e ? e.touches[0].clientY : e.clientY
  120. const delta = startYRef.current - clientY
  121. const newHeight = Math.min(Math.max(startHeightRef.current + delta, 150), window.innerHeight - 150)
  122. setLogsDrawerHeight(newHeight)
  123. }
  124. const handleResizeEnd = () => {
  125. if (isResizingRef.current) {
  126. isResizingRef.current = false
  127. setIsResizing(false)
  128. document.body.style.cursor = ''
  129. document.body.style.userSelect = ''
  130. }
  131. }
  132. window.addEventListener('mousemove', handleResizeMove)
  133. window.addEventListener('mouseup', handleResizeEnd)
  134. window.addEventListener('touchmove', handleResizeMove)
  135. window.addEventListener('touchend', handleResizeEnd)
  136. return () => {
  137. window.removeEventListener('mousemove', handleResizeMove)
  138. window.removeEventListener('mouseup', handleResizeEnd)
  139. window.removeEventListener('touchmove', handleResizeMove)
  140. window.removeEventListener('touchend', handleResizeEnd)
  141. }
  142. }, [])
  143. // Now Playing bar state
  144. const [isNowPlayingOpen, setIsNowPlayingOpen] = useState(false)
  145. const [openNowPlayingExpanded, setOpenNowPlayingExpanded] = useState(false)
  146. const [currentPlayingFile, setCurrentPlayingFile] = useState<string | null>(null) // Track current file for header button
  147. const wasPlayingRef = useRef<boolean | null>(null) // Track previous playing state (null = first message)
  148. // Derive isCurrentlyPlaying from currentPlayingFile
  149. const isCurrentlyPlaying = Boolean(currentPlayingFile)
  150. // Listen for playback-started event (dispatched when user starts a pattern)
  151. useEffect(() => {
  152. const handlePlaybackStarted = () => {
  153. setIsNowPlayingOpen(true)
  154. setOpenNowPlayingExpanded(true)
  155. setIsLogsOpen(false)
  156. // Reset expanded flag after animation
  157. setTimeout(() => setOpenNowPlayingExpanded(false), 500)
  158. }
  159. window.addEventListener('playback-started', handlePlaybackStarted)
  160. return () => window.removeEventListener('playback-started', handlePlaybackStarted)
  161. }, [])
  162. const [logs, setLogs] = useState<Array<{ timestamp: string; level: string; logger: string; message: string }>>([])
  163. const [logLevelFilter, setLogLevelFilter] = useState<string>('ALL')
  164. const logsWsRef = useRef<WebSocket | null>(null)
  165. const logsContainerRef = useRef<HTMLDivElement>(null)
  166. // Check device connection status via WebSocket
  167. // This effect runs once on mount and manages its own reconnection logic
  168. useEffect(() => {
  169. let reconnectTimeout: ReturnType<typeof setTimeout> | null = null
  170. let isMounted = true
  171. const connectWebSocket = () => {
  172. if (!isMounted) return
  173. // Only close existing connection if it's open (not still connecting)
  174. // This prevents "WebSocket closed before connection established" errors
  175. if (wsRef.current) {
  176. if (wsRef.current.readyState === WebSocket.OPEN) {
  177. wsRef.current.close()
  178. wsRef.current = null
  179. } else if (wsRef.current.readyState === WebSocket.CONNECTING) {
  180. // Already connecting, don't interrupt
  181. return
  182. }
  183. }
  184. const ws = new WebSocket(apiClient.getWebSocketUrl('/ws/status'))
  185. // Assign to ref IMMEDIATELY so concurrent calls see it's connecting
  186. wsRef.current = ws
  187. ws.onopen = () => {
  188. if (!isMounted) {
  189. // Component unmounted while connecting - close the WebSocket now
  190. ws.close()
  191. return
  192. }
  193. setIsBackendConnected(true)
  194. setConnectionAttempts(0)
  195. // Dispatch event so pages can refetch data
  196. window.dispatchEvent(new CustomEvent('backend-connected'))
  197. }
  198. ws.onmessage = (event) => {
  199. if (!isMounted) return
  200. try {
  201. const data = JSON.parse(event.data)
  202. // Handle status updates
  203. if (data.type === 'status_update' && data.data) {
  204. // Update device connection status from the status message
  205. if (data.data.connection_status !== undefined) {
  206. setIsConnected(data.data.connection_status)
  207. }
  208. // Update homing status and detect completion
  209. if (data.data.is_homing !== undefined) {
  210. const newIsHoming = data.data.is_homing
  211. // Detect transition from not homing to homing - reset dismissal
  212. if (!wasHomingRef.current && newIsHoming) {
  213. setHomingDismissed(false)
  214. }
  215. // Detect transition from homing to not homing
  216. if (wasHomingRef.current && !newIsHoming) {
  217. // Homing just completed - show completion state with countdown
  218. setHomingJustCompleted(true)
  219. setHomingCountdown(5)
  220. setHomingDismissed(false)
  221. }
  222. wasHomingRef.current = newIsHoming
  223. setIsHoming(newIsHoming)
  224. }
  225. // Auto-open/close Now Playing bar based on playback state
  226. // Track current file - this is the most reliable indicator of playback
  227. const currentFile = data.data.current_file || null
  228. setCurrentPlayingFile(currentFile)
  229. const isPlaying = Boolean(currentFile) || Boolean(data.data.is_running) || Boolean(data.data.is_paused)
  230. // Skip auto-open on first message (page refresh) - only react to state changes
  231. if (wasPlayingRef.current !== null) {
  232. if (isPlaying && !wasPlayingRef.current) {
  233. // Playback just started - open the Now Playing bar in expanded mode
  234. setIsNowPlayingOpen(true)
  235. setOpenNowPlayingExpanded(true)
  236. // Close the logs drawer if open
  237. setIsLogsOpen(false)
  238. // Reset the expanded flag after a short delay
  239. setTimeout(() => setOpenNowPlayingExpanded(false), 500)
  240. // Dispatch event so pages can close their sidebars/panels
  241. window.dispatchEvent(new CustomEvent('playback-started'))
  242. } else if (!isPlaying && wasPlayingRef.current) {
  243. // Playback just stopped - close the Now Playing bar
  244. setIsNowPlayingOpen(false)
  245. }
  246. }
  247. wasPlayingRef.current = isPlaying
  248. }
  249. } catch {
  250. // Ignore parse errors
  251. }
  252. }
  253. ws.onclose = () => {
  254. if (!isMounted) return
  255. wsRef.current = null
  256. setIsBackendConnected(false)
  257. setConnectionAttempts((prev) => prev + 1)
  258. // Reconnect after 3 seconds (don't change device status on WS disconnect)
  259. reconnectTimeout = setTimeout(connectWebSocket, 3000)
  260. }
  261. ws.onerror = () => {
  262. if (!isMounted) return
  263. setIsBackendConnected(false)
  264. }
  265. }
  266. // Reset playing state on mount
  267. wasPlayingRef.current = null
  268. // Connect on mount
  269. connectWebSocket()
  270. // Subscribe to base URL changes (when user switches tables)
  271. // This triggers reconnection to the new backend
  272. const unsubscribe = apiClient.onBaseUrlChange(() => {
  273. if (isMounted) {
  274. wasPlayingRef.current = null // Reset playing state for new table
  275. setCurrentPlayingFile(null) // Reset playback state for new table
  276. setIsConnected(false) // Reset connection status until new table reports
  277. setIsBackendConnected(false) // Show connecting state
  278. connectWebSocket()
  279. }
  280. })
  281. return () => {
  282. isMounted = false
  283. unsubscribe()
  284. if (reconnectTimeout) {
  285. clearTimeout(reconnectTimeout)
  286. }
  287. if (wsRef.current) {
  288. // Only close if already OPEN - CONNECTING WebSockets will close in onopen
  289. if (wsRef.current.readyState === WebSocket.OPEN) {
  290. wsRef.current.close()
  291. }
  292. wsRef.current = null
  293. }
  294. }
  295. }, []) // Empty deps - runs once on mount, reconnects via apiClient listener
  296. // Connect to logs WebSocket when drawer opens
  297. useEffect(() => {
  298. if (!isLogsOpen) {
  299. // Close WebSocket when drawer closes - only if OPEN (CONNECTING will close in onopen)
  300. if (logsWsRef.current && logsWsRef.current.readyState === WebSocket.OPEN) {
  301. logsWsRef.current.close()
  302. }
  303. logsWsRef.current = null
  304. return
  305. }
  306. let shouldConnect = true
  307. // Fetch initial logs
  308. const fetchInitialLogs = async () => {
  309. try {
  310. type LogEntry = { timestamp: string; level: string; logger: string; message: string }
  311. const data = await apiClient.get<{ logs: LogEntry[] }>('/api/logs?limit=200')
  312. // Filter out empty/invalid log entries
  313. const validLogs = (data.logs || []).filter(
  314. (log) => log && log.message && log.message.trim() !== ''
  315. )
  316. // API returns newest first, reverse to show oldest first (newest at bottom)
  317. setLogs(validLogs.reverse())
  318. // Scroll to bottom after initial load
  319. setTimeout(() => {
  320. if (logsContainerRef.current) {
  321. logsContainerRef.current.scrollTop = logsContainerRef.current.scrollHeight
  322. }
  323. }, 100)
  324. } catch {
  325. // Ignore errors
  326. }
  327. }
  328. fetchInitialLogs()
  329. // Connect to WebSocket for real-time updates
  330. let reconnectTimeout: ReturnType<typeof setTimeout> | null = null
  331. const connectLogsWebSocket = () => {
  332. // Don't interrupt an existing connection that's still connecting
  333. if (logsWsRef.current) {
  334. if (logsWsRef.current.readyState === WebSocket.CONNECTING) {
  335. return // Already connecting, wait for it
  336. }
  337. if (logsWsRef.current.readyState === WebSocket.OPEN) {
  338. logsWsRef.current.close()
  339. }
  340. logsWsRef.current = null
  341. }
  342. const ws = new WebSocket(apiClient.getWebSocketUrl('/ws/logs'))
  343. // Assign to ref IMMEDIATELY so concurrent calls see it's connecting
  344. logsWsRef.current = ws
  345. ws.onopen = () => {
  346. if (!shouldConnect) {
  347. // Effect cleanup ran while connecting - close now
  348. ws.close()
  349. return
  350. }
  351. console.log('Logs WebSocket connected')
  352. }
  353. ws.onmessage = (event) => {
  354. try {
  355. const message = JSON.parse(event.data)
  356. // Skip heartbeat messages
  357. if (message.type === 'heartbeat') {
  358. return
  359. }
  360. // Extract log from wrapped structure
  361. const log = message.type === 'log_entry' ? message.data : message
  362. // Skip empty or invalid log entries
  363. if (!log || !log.message || log.message.trim() === '') {
  364. return
  365. }
  366. setLogs((prev) => {
  367. const newLogs = [...prev, log]
  368. // Keep only last 500 logs to prevent memory issues
  369. if (newLogs.length > 500) {
  370. return newLogs.slice(-500)
  371. }
  372. return newLogs
  373. })
  374. // Auto-scroll to bottom
  375. setTimeout(() => {
  376. if (logsContainerRef.current) {
  377. logsContainerRef.current.scrollTop = logsContainerRef.current.scrollHeight
  378. }
  379. }, 10)
  380. } catch {
  381. // Ignore parse errors
  382. }
  383. }
  384. ws.onclose = () => {
  385. if (!shouldConnect) return
  386. console.log('Logs WebSocket closed, reconnecting...')
  387. // Reconnect after 3 seconds if drawer is still open
  388. reconnectTimeout = setTimeout(() => {
  389. if (shouldConnect && logsWsRef.current === ws) {
  390. connectLogsWebSocket()
  391. }
  392. }, 3000)
  393. }
  394. ws.onerror = (error) => {
  395. console.error('Logs WebSocket error:', error)
  396. }
  397. }
  398. connectLogsWebSocket()
  399. return () => {
  400. shouldConnect = false
  401. if (reconnectTimeout) {
  402. clearTimeout(reconnectTimeout)
  403. }
  404. if (logsWsRef.current) {
  405. // Only close if already OPEN - CONNECTING WebSockets will close in onopen
  406. if (logsWsRef.current.readyState === WebSocket.OPEN) {
  407. logsWsRef.current.close()
  408. }
  409. logsWsRef.current = null
  410. }
  411. }
  412. // Also reconnect when active table changes
  413. }, [isLogsOpen, activeTable?.id])
  414. const handleToggleLogs = () => {
  415. setIsLogsOpen((prev) => !prev)
  416. }
  417. // Filter logs by level
  418. const filteredLogs = logLevelFilter === 'ALL'
  419. ? logs
  420. : logs.filter((log) => log.level === logLevelFilter)
  421. // Format timestamp safely
  422. const formatTimestamp = (timestamp: string) => {
  423. if (!timestamp) return '--:--:--'
  424. try {
  425. const date = new Date(timestamp)
  426. if (isNaN(date.getTime())) return '--:--:--'
  427. return date.toLocaleTimeString()
  428. } catch {
  429. return '--:--:--'
  430. }
  431. }
  432. // Copy logs to clipboard (with fallback for non-HTTPS)
  433. const handleCopyLogs = () => {
  434. const text = filteredLogs
  435. .map((log) => `${formatTimestamp(log.timestamp)} [${log.level}] ${log.message}`)
  436. .join('\n')
  437. copyToClipboard(text)
  438. }
  439. // Helper to copy text with fallback for non-secure contexts
  440. const copyToClipboard = (text: string) => {
  441. if (navigator.clipboard && window.isSecureContext) {
  442. navigator.clipboard.writeText(text).then(() => {
  443. toast.success('Logs copied to clipboard')
  444. }).catch(() => {
  445. toast.error('Failed to copy logs')
  446. })
  447. } else {
  448. // Fallback for non-secure contexts (http://)
  449. const textArea = document.createElement('textarea')
  450. textArea.value = text
  451. textArea.style.position = 'fixed'
  452. textArea.style.left = '-9999px'
  453. document.body.appendChild(textArea)
  454. textArea.select()
  455. try {
  456. document.execCommand('copy')
  457. toast.success('Logs copied to clipboard')
  458. } catch {
  459. toast.error('Failed to copy logs')
  460. }
  461. document.body.removeChild(textArea)
  462. }
  463. }
  464. // Download logs as file
  465. const handleDownloadLogs = () => {
  466. const text = filteredLogs
  467. .map((log) => `${log.timestamp} [${log.level}] [${log.logger}] ${log.message}`)
  468. .join('\n')
  469. const blob = new Blob([text], { type: 'text/plain' })
  470. const url = URL.createObjectURL(blob)
  471. const a = document.createElement('a')
  472. a.href = url
  473. a.download = `dune-weaver-logs-${new Date().toISOString().split('T')[0]}.txt`
  474. a.click()
  475. URL.revokeObjectURL(url)
  476. }
  477. const handleRestart = async () => {
  478. if (!confirm('Are you sure you want to restart Docker containers?')) return
  479. try {
  480. await apiClient.post('/api/system/restart')
  481. toast.success('Docker containers are restarting...')
  482. } catch {
  483. toast.error('Failed to restart Docker containers')
  484. }
  485. }
  486. const handleShutdown = async () => {
  487. if (!confirm('Are you sure you want to shutdown the system?')) return
  488. try {
  489. await apiClient.post('/api/system/shutdown')
  490. toast.success('System is shutting down...')
  491. } catch {
  492. toast.error('Failed to shutdown system')
  493. }
  494. }
  495. // Update document title based on current page
  496. useEffect(() => {
  497. const currentNav = navItems.find((item) => item.path === location.pathname)
  498. if (currentNav) {
  499. document.title = `${currentNav.title} | ${displayName}`
  500. } else {
  501. document.title = displayName
  502. }
  503. }, [location.pathname, displayName])
  504. useEffect(() => {
  505. if (isDark) {
  506. document.documentElement.classList.add('dark')
  507. localStorage.setItem('theme', 'dark')
  508. } else {
  509. document.documentElement.classList.remove('dark')
  510. localStorage.setItem('theme', 'light')
  511. }
  512. }, [isDark])
  513. // Blocking overlay logs state - shows connection attempts
  514. const [connectionLogs, setConnectionLogs] = useState<Array<{ timestamp: string; level: string; message: string }>>([])
  515. const blockingLogsRef = useRef<HTMLDivElement>(null)
  516. // Cache progress state
  517. const [cacheProgress, setCacheProgress] = useState<{
  518. is_running: boolean
  519. stage: string
  520. processed_files: number
  521. total_files: number
  522. current_file: string
  523. error?: string
  524. } | null>(null)
  525. const cacheWsRef = useRef<WebSocket | null>(null)
  526. // Cache All Previews prompt state
  527. const [showCacheAllPrompt, setShowCacheAllPrompt] = useState(false)
  528. const [cacheAllProgress, setCacheAllProgress] = useState<{
  529. inProgress: boolean
  530. completed: number
  531. total: number
  532. done: boolean
  533. } | null>(null)
  534. // Blocking overlay logs WebSocket ref
  535. const blockingLogsWsRef = useRef<WebSocket | null>(null)
  536. // Add connection/homing logs when overlay is shown
  537. useEffect(() => {
  538. const showOverlay = !isBackendConnected || isHoming || homingJustCompleted
  539. if (!showOverlay) {
  540. setConnectionLogs([])
  541. // Close WebSocket if open - only if OPEN (CONNECTING will close in onopen)
  542. if (blockingLogsWsRef.current && blockingLogsWsRef.current.readyState === WebSocket.OPEN) {
  543. blockingLogsWsRef.current.close()
  544. }
  545. blockingLogsWsRef.current = null
  546. return
  547. }
  548. // Don't clear logs or reconnect WebSocket during completion state
  549. if (homingJustCompleted && !isHoming) {
  550. return
  551. }
  552. // Add log entry helper
  553. const addLog = (level: string, message: string, timestamp?: string) => {
  554. setConnectionLogs((prev) => {
  555. const newLog = {
  556. timestamp: timestamp || new Date().toISOString(),
  557. level,
  558. message,
  559. }
  560. const newLogs = [...prev, newLog].slice(-100) // Keep last 100 entries
  561. return newLogs
  562. })
  563. // Auto-scroll to bottom
  564. setTimeout(() => {
  565. if (blockingLogsRef.current) {
  566. blockingLogsRef.current.scrollTop = blockingLogsRef.current.scrollHeight
  567. }
  568. }, 10)
  569. }
  570. // If homing, connect to logs WebSocket to stream real logs
  571. if (isHoming && isBackendConnected) {
  572. addLog('INFO', 'Homing started...')
  573. let shouldConnect = true
  574. // Don't interrupt an existing connection that's still connecting
  575. if (blockingLogsWsRef.current) {
  576. if (blockingLogsWsRef.current.readyState === WebSocket.CONNECTING) {
  577. return // Already connecting, wait for it
  578. }
  579. if (blockingLogsWsRef.current.readyState === WebSocket.OPEN) {
  580. blockingLogsWsRef.current.close()
  581. }
  582. blockingLogsWsRef.current = null
  583. }
  584. const ws = new WebSocket(apiClient.getWebSocketUrl('/ws/logs'))
  585. // Assign to ref IMMEDIATELY so concurrent calls see it's connecting
  586. blockingLogsWsRef.current = ws
  587. ws.onopen = () => {
  588. if (!shouldConnect) {
  589. // Effect cleanup ran while connecting - close now
  590. ws.close()
  591. }
  592. }
  593. ws.onmessage = (event) => {
  594. try {
  595. const message = JSON.parse(event.data)
  596. if (message.type === 'heartbeat') return
  597. const log = message.type === 'log_entry' ? message.data : message
  598. if (!log || !log.message || log.message.trim() === '') return
  599. // Filter for homing-related logs
  600. const msg = log.message.toLowerCase()
  601. const isHomingLog =
  602. msg.includes('homing') ||
  603. msg.includes('home') ||
  604. msg.includes('$h') ||
  605. msg.includes('idle') ||
  606. msg.includes('unlock') ||
  607. msg.includes('alarm') ||
  608. msg.includes('grbl') ||
  609. msg.includes('connect') ||
  610. msg.includes('serial') ||
  611. msg.includes('device') ||
  612. msg.includes('position') ||
  613. msg.includes('zeroing') ||
  614. msg.includes('movement') ||
  615. log.logger?.includes('connection')
  616. if (isHomingLog) {
  617. addLog(log.level, log.message, log.timestamp)
  618. }
  619. } catch {
  620. // Ignore parse errors
  621. }
  622. }
  623. return () => {
  624. shouldConnect = false
  625. // Only close if already OPEN - CONNECTING WebSockets will close in onopen
  626. if (ws.readyState === WebSocket.OPEN) {
  627. ws.close()
  628. }
  629. blockingLogsWsRef.current = null
  630. }
  631. }
  632. // If backend disconnected, show connection retry logs
  633. if (!isBackendConnected) {
  634. addLog('INFO', `Attempting to connect to backend at ${window.location.host}...`)
  635. const interval = setInterval(() => {
  636. addLog('INFO', `Retrying connection to WebSocket /ws/status...`)
  637. apiClient.get('/api/settings')
  638. .then(() => {
  639. addLog('INFO', 'HTTP endpoint responding, waiting for WebSocket...')
  640. })
  641. .catch(() => {
  642. // Still down
  643. })
  644. }, 3000)
  645. return () => clearInterval(interval)
  646. }
  647. }, [isBackendConnected, isHoming, homingJustCompleted])
  648. // Cache progress WebSocket connection - always connected to monitor cache generation
  649. useEffect(() => {
  650. if (!isBackendConnected) return
  651. let reconnectTimeout: ReturnType<typeof setTimeout> | null = null
  652. let shouldConnect = true
  653. const connectCacheWebSocket = () => {
  654. if (!shouldConnect) return
  655. // Don't interrupt an existing connection that's still connecting
  656. if (cacheWsRef.current) {
  657. if (cacheWsRef.current.readyState === WebSocket.CONNECTING) {
  658. return // Already connecting, wait for it
  659. }
  660. if (cacheWsRef.current.readyState === WebSocket.OPEN) {
  661. return // Already connected
  662. }
  663. // CLOSING or CLOSED state - clear the ref
  664. cacheWsRef.current = null
  665. }
  666. const ws = new WebSocket(apiClient.getWebSocketUrl('/ws/cache-progress'))
  667. // Assign to ref IMMEDIATELY so concurrent calls see it's connecting
  668. cacheWsRef.current = ws
  669. ws.onopen = () => {
  670. if (!shouldConnect) {
  671. // Effect cleanup ran while connecting - close now
  672. ws.close()
  673. }
  674. }
  675. ws.onmessage = (event) => {
  676. try {
  677. const message = JSON.parse(event.data)
  678. if (message.type === 'cache_progress') {
  679. const data = message.data
  680. if (data.is_running) {
  681. // Cache generation is running - show splash screen
  682. setCacheProgress(data)
  683. } else if (data.stage === 'complete') {
  684. // Cache generation just completed
  685. if (cacheProgress?.is_running) {
  686. // Was running before, now complete - show cache all prompt
  687. const promptShown = localStorage.getItem('cacheAllPromptShown')
  688. if (!promptShown) {
  689. setTimeout(() => {
  690. setCacheAllProgress(null) // Reset to clean state
  691. setShowCacheAllPrompt(true)
  692. }, 500)
  693. }
  694. }
  695. setCacheProgress(null)
  696. } else {
  697. // Not running and not complete (idle state)
  698. setCacheProgress(null)
  699. }
  700. }
  701. } catch {
  702. // Ignore parse errors
  703. }
  704. }
  705. ws.onclose = () => {
  706. if (!shouldConnect) return
  707. cacheWsRef.current = null
  708. // Reconnect after 3 seconds
  709. if (shouldConnect && isBackendConnected) {
  710. reconnectTimeout = setTimeout(connectCacheWebSocket, 3000)
  711. }
  712. }
  713. ws.onerror = () => {
  714. // Will trigger onclose
  715. }
  716. }
  717. connectCacheWebSocket()
  718. return () => {
  719. shouldConnect = false
  720. if (reconnectTimeout) {
  721. clearTimeout(reconnectTimeout)
  722. }
  723. if (cacheWsRef.current) {
  724. // Only close if already OPEN - CONNECTING WebSockets will close in onopen
  725. if (cacheWsRef.current.readyState === WebSocket.OPEN) {
  726. cacheWsRef.current.close()
  727. }
  728. cacheWsRef.current = null
  729. }
  730. }
  731. }, [isBackendConnected]) // Only reconnect based on backend connection, not cache state
  732. // Calculate cache progress percentage
  733. const cachePercentage = cacheProgress?.total_files
  734. ? Math.round((cacheProgress.processed_files / cacheProgress.total_files) * 100)
  735. : 0
  736. const getCacheStageText = () => {
  737. if (!cacheProgress) return ''
  738. switch (cacheProgress.stage) {
  739. case 'starting':
  740. return 'Initializing...'
  741. case 'metadata':
  742. return 'Processing pattern metadata'
  743. case 'images':
  744. return 'Generating pattern previews'
  745. default:
  746. return 'Processing...'
  747. }
  748. }
  749. // Cache all previews in browser using IndexedDB
  750. const handleCacheAllPreviews = async () => {
  751. setCacheAllProgress({ inProgress: true, completed: 0, total: 0, done: false })
  752. const result = await cacheAllPreviews((progress) => {
  753. setCacheAllProgress({ inProgress: !progress.done, ...progress })
  754. })
  755. if (result.success) {
  756. if (result.cached === 0) {
  757. toast.success('All patterns are already cached!')
  758. } else {
  759. toast.success(`Cached ${result.cached} pattern previews`)
  760. }
  761. } else {
  762. setCacheAllProgress(null)
  763. toast.error('Failed to cache previews')
  764. }
  765. }
  766. const handleSkipCacheAll = () => {
  767. localStorage.setItem('cacheAllPromptShown', 'true')
  768. setShowCacheAllPrompt(false)
  769. setCacheAllProgress(null)
  770. }
  771. const handleCloseCacheAllDone = () => {
  772. localStorage.setItem('cacheAllPromptShown', 'true')
  773. setShowCacheAllPrompt(false)
  774. setCacheAllProgress(null)
  775. }
  776. const cacheAllPercentage = cacheAllProgress?.total
  777. ? Math.round((cacheAllProgress.completed / cacheAllProgress.total) * 100)
  778. : 0
  779. return (
  780. <div className="min-h-dvh bg-background flex flex-col">
  781. {/* Cache Progress Blocking Overlay */}
  782. {cacheProgress?.is_running && (
  783. <div className="fixed inset-0 z-50 bg-background/95 backdrop-blur-sm flex flex-col items-center justify-center p-4">
  784. <div className="w-full max-w-md space-y-6">
  785. <div className="text-center space-y-4">
  786. <div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-primary/10 mb-2">
  787. <span className="material-icons-outlined text-4xl text-primary animate-pulse">
  788. cached
  789. </span>
  790. </div>
  791. <h2 className="text-2xl font-bold">Initializing Pattern Cache</h2>
  792. <p className="text-muted-foreground">
  793. Preparing your pattern previews...
  794. </p>
  795. </div>
  796. {/* Progress Bar */}
  797. <div className="space-y-2">
  798. <div className="w-full bg-muted rounded-full h-2 overflow-hidden">
  799. <div
  800. className="bg-primary h-2 rounded-full transition-all duration-300"
  801. style={{ width: `${cachePercentage}%` }}
  802. />
  803. </div>
  804. <div className="flex justify-between text-sm text-muted-foreground">
  805. <span>
  806. {cacheProgress.processed_files} of {cacheProgress.total_files} patterns
  807. </span>
  808. <span>{cachePercentage}%</span>
  809. </div>
  810. </div>
  811. {/* Stage Info */}
  812. <div className="text-center space-y-1">
  813. <p className="text-sm font-medium">{getCacheStageText()}</p>
  814. {cacheProgress.current_file && (
  815. <p className="text-xs text-muted-foreground truncate max-w-full">
  816. {cacheProgress.current_file}
  817. </p>
  818. )}
  819. </div>
  820. {/* Hint */}
  821. <p className="text-center text-xs text-muted-foreground">
  822. This only happens once after updates or when new patterns are added
  823. </p>
  824. </div>
  825. </div>
  826. )}
  827. {/* Cache All Previews Prompt Modal */}
  828. {showCacheAllPrompt && (
  829. <div className="fixed inset-0 z-50 bg-black/50 backdrop-blur-sm flex items-center justify-center p-4">
  830. <div className="bg-background rounded-lg shadow-xl w-full max-w-md">
  831. <div className="p-6">
  832. <div className="text-center space-y-4">
  833. <div className="inline-flex items-center justify-center w-12 h-12 rounded-full bg-primary/10 mb-2">
  834. <span className="material-icons-outlined text-2xl text-primary">
  835. download_for_offline
  836. </span>
  837. </div>
  838. <h2 className="text-xl font-semibold">Cache All Pattern Previews?</h2>
  839. <p className="text-muted-foreground text-sm">
  840. Would you like to cache all pattern previews for faster browsing? This will download and store preview images in your browser for instant loading.
  841. </p>
  842. <div className="bg-amber-500/10 border border-amber-500/20 p-3 rounded-lg text-sm">
  843. <p className="text-amber-600 dark:text-amber-400">
  844. <strong>Note:</strong> This cache is browser-specific. You'll need to repeat this for each browser you use.
  845. </p>
  846. </div>
  847. {/* Initial state - show buttons */}
  848. {!cacheAllProgress && (
  849. <div className="flex gap-3 justify-center">
  850. <Button variant="ghost" onClick={handleSkipCacheAll}>
  851. Skip for now
  852. </Button>
  853. <Button variant="secondary" onClick={handleCacheAllPreviews} className="gap-2">
  854. <span className="material-icons-outlined text-lg">cached</span>
  855. Cache All
  856. </Button>
  857. </div>
  858. )}
  859. {/* Progress section */}
  860. {cacheAllProgress && !cacheAllProgress.done && (
  861. <div className="space-y-2">
  862. <div className="w-full bg-muted rounded-full h-2 overflow-hidden">
  863. <div
  864. className="bg-primary h-2 rounded-full transition-all duration-300"
  865. style={{ width: `${cacheAllPercentage}%` }}
  866. />
  867. </div>
  868. <div className="flex justify-between text-sm text-muted-foreground">
  869. <span>
  870. {cacheAllProgress.completed} of {cacheAllProgress.total} previews
  871. </span>
  872. <span>{cacheAllPercentage}%</span>
  873. </div>
  874. </div>
  875. )}
  876. {/* Completion message */}
  877. {cacheAllProgress?.done && (
  878. <div className="space-y-4">
  879. <p className="text-green-600 dark:text-green-400 flex items-center justify-center gap-2">
  880. <span className="material-icons text-base">check_circle</span>
  881. All {cacheAllProgress.total} previews cached successfully!
  882. </p>
  883. <Button onClick={handleCloseCacheAllDone} className="w-full">
  884. Done
  885. </Button>
  886. </div>
  887. )}
  888. </div>
  889. </div>
  890. </div>
  891. </div>
  892. )}
  893. {/* Backend Connection / Homing Blocking Overlay */}
  894. {(!isBackendConnected || (isHoming && !homingDismissed) || homingJustCompleted) && (
  895. <div className="fixed inset-0 z-50 bg-background/95 backdrop-blur-sm flex flex-col items-center justify-center p-4">
  896. <div className="w-full max-w-2xl space-y-6">
  897. {/* Status Header */}
  898. <div className="text-center space-y-4">
  899. <div className={`inline-flex items-center justify-center w-16 h-16 rounded-full mb-2 ${
  900. homingJustCompleted
  901. ? 'bg-green-500/10'
  902. : isHoming
  903. ? 'bg-primary/10'
  904. : 'bg-amber-500/10'
  905. }`}>
  906. <span className={`material-icons-outlined text-4xl ${
  907. homingJustCompleted
  908. ? 'text-green-500'
  909. : isHoming
  910. ? 'text-primary animate-spin'
  911. : 'text-amber-500 animate-pulse'
  912. }`}>
  913. {homingJustCompleted ? 'check_circle' : 'sync'}
  914. </span>
  915. </div>
  916. <h2 className="text-2xl font-bold">
  917. {homingJustCompleted
  918. ? 'Homing Complete'
  919. : isHoming
  920. ? 'Homing in Progress'
  921. : 'Connecting to Backend'
  922. }
  923. </h2>
  924. <p className="text-muted-foreground">
  925. {homingJustCompleted
  926. ? 'Table is ready to use'
  927. : isHoming
  928. ? 'Moving to home position... This may take up to 90 seconds.'
  929. : connectionAttempts === 0
  930. ? 'Establishing connection...'
  931. : `Reconnecting... (attempt ${connectionAttempts})`
  932. }
  933. </p>
  934. <div className="flex items-center justify-center gap-2 text-sm text-muted-foreground">
  935. <span className={`w-2 h-2 rounded-full ${
  936. homingJustCompleted
  937. ? 'bg-green-500'
  938. : isHoming
  939. ? 'bg-primary animate-pulse'
  940. : 'bg-amber-500 animate-pulse'
  941. }`} />
  942. <span>
  943. {homingJustCompleted
  944. ? keepHomingLogsOpen
  945. ? 'Viewing logs'
  946. : `Closing in ${homingCountdown}s...`
  947. : isHoming
  948. ? 'Do not interrupt the homing process'
  949. : `Waiting for server at ${window.location.host}`
  950. }
  951. </span>
  952. </div>
  953. </div>
  954. {/* Logs Panel */}
  955. <div className="bg-muted/50 rounded-lg border overflow-hidden">
  956. <div className="flex items-center justify-between px-4 py-2 border-b bg-muted">
  957. <div className="flex items-center gap-2">
  958. <span className="material-icons-outlined text-base">terminal</span>
  959. <span className="text-sm font-medium">
  960. {isHoming || homingJustCompleted ? 'Homing Log' : 'Connection Log'}
  961. </span>
  962. </div>
  963. <div className="flex items-center gap-2">
  964. <button
  965. onClick={() => {
  966. const logText = connectionLogs
  967. .map((log) => `[${new Date(log.timestamp).toLocaleTimeString()}] [${log.level}] ${log.message}`)
  968. .join('\n')
  969. copyToClipboard(logText)
  970. }}
  971. className="text-xs text-muted-foreground hover:text-foreground flex items-center gap-1 transition-colors"
  972. title="Copy logs to clipboard"
  973. >
  974. <span className="material-icons text-sm">content_copy</span>
  975. Copy
  976. </button>
  977. <span className="text-xs text-muted-foreground">
  978. {connectionLogs.length} entries
  979. </span>
  980. </div>
  981. </div>
  982. <div
  983. ref={blockingLogsRef}
  984. className="h-48 overflow-auto p-3 font-mono text-xs space-y-0.5"
  985. >
  986. {connectionLogs.map((log, i) => (
  987. <div key={i} className="py-0.5 flex gap-2">
  988. <span className="text-muted-foreground shrink-0">
  989. {formatTimestamp(log.timestamp)}
  990. </span>
  991. <span className={`shrink-0 font-semibold ${
  992. log.level === 'ERROR' ? 'text-red-500' :
  993. log.level === 'WARNING' ? 'text-amber-500' :
  994. log.level === 'DEBUG' ? 'text-muted-foreground' :
  995. 'text-foreground'
  996. }`}>
  997. [{log.level}]
  998. </span>
  999. <span className="break-all">{log.message}</span>
  1000. </div>
  1001. ))}
  1002. </div>
  1003. </div>
  1004. {/* Action buttons for homing completion */}
  1005. {homingJustCompleted && (
  1006. <div className="flex justify-center gap-3">
  1007. {!keepHomingLogsOpen ? (
  1008. <>
  1009. <Button
  1010. variant="secondary"
  1011. onClick={() => setKeepHomingLogsOpen(true)}
  1012. className="gap-2"
  1013. >
  1014. <span className="material-icons text-base">visibility</span>
  1015. Keep Open
  1016. </Button>
  1017. <Button
  1018. onClick={() => {
  1019. setHomingJustCompleted(false)
  1020. setKeepHomingLogsOpen(false)
  1021. }}
  1022. className="gap-2"
  1023. >
  1024. <span className="material-icons text-base">close</span>
  1025. Dismiss
  1026. </Button>
  1027. </>
  1028. ) : (
  1029. <Button
  1030. onClick={() => {
  1031. setHomingJustCompleted(false)
  1032. setKeepHomingLogsOpen(false)
  1033. }}
  1034. className="gap-2"
  1035. >
  1036. <span className="material-icons text-base">close</span>
  1037. Close Logs
  1038. </Button>
  1039. )}
  1040. </div>
  1041. )}
  1042. {/* Dismiss button during homing */}
  1043. {isHoming && !homingJustCompleted && (
  1044. <div className="flex justify-center">
  1045. <Button
  1046. variant="ghost"
  1047. onClick={() => setHomingDismissed(true)}
  1048. className="gap-2 text-muted-foreground"
  1049. >
  1050. <span className="material-icons text-base">visibility_off</span>
  1051. Dismiss
  1052. </Button>
  1053. </div>
  1054. )}
  1055. {/* Hint */}
  1056. {!homingJustCompleted && (
  1057. <p className="text-center text-xs text-muted-foreground">
  1058. {isHoming
  1059. ? 'Homing will continue in the background'
  1060. : 'Make sure the backend server is running on port 8080'
  1061. }
  1062. </p>
  1063. )}
  1064. </div>
  1065. </div>
  1066. )}
  1067. {/* Header - Floating Pill */}
  1068. <header className="fixed top-0 left-0 right-0 z-40 pt-safe">
  1069. {/* Blurry backdrop behind header - only on Browse page where content scrolls under */}
  1070. {location.pathname === '/' && (
  1071. <div className="absolute inset-0 bg-background/80 backdrop-blur-md supports-[backdrop-filter]:bg-background/50" style={{ height: 'calc(5rem + env(safe-area-inset-top, 0px))' }} />
  1072. )}
  1073. <div className="relative w-full max-w-5xl mx-auto px-3 sm:px-4 pt-3 pointer-events-none">
  1074. <div className="flex h-12 items-center justify-between px-4 rounded-full bg-card shadow-lg border border-border pointer-events-auto">
  1075. <Link to="/" className="flex items-center gap-2">
  1076. <img
  1077. src={customLogo ? apiClient.getAssetUrl(`/static/custom/${customLogo}`) : apiClient.getAssetUrl('/static/android-chrome-192x192.png')}
  1078. alt={displayName}
  1079. className="w-8 h-8 rounded-full object-cover"
  1080. />
  1081. <ShinyText
  1082. text={displayName}
  1083. className="font-semibold text-lg"
  1084. speed={4}
  1085. color={isDark ? '#a8a8a8' : '#555555'}
  1086. shineColor={isDark ? '#ffffff' : '#999999'}
  1087. spread={75}
  1088. />
  1089. <span
  1090. className={`w-2 h-2 rounded-full ${
  1091. !isBackendConnected
  1092. ? 'bg-gray-400'
  1093. : isConnected
  1094. ? 'bg-green-500 animate-pulse'
  1095. : 'bg-red-500'
  1096. }`}
  1097. title={
  1098. !isBackendConnected
  1099. ? 'Backend not connected'
  1100. : isConnected
  1101. ? 'Table connected'
  1102. : 'Table disconnected'
  1103. }
  1104. />
  1105. </Link>
  1106. {/* Desktop actions */}
  1107. <div className="hidden md:flex items-center gap-0 ml-2">
  1108. {/* Now Playing button */}
  1109. <Button
  1110. variant="ghost"
  1111. size="icon"
  1112. onClick={() => setIsNowPlayingOpen(!isNowPlayingOpen)}
  1113. className="rounded-full"
  1114. aria-label={isCurrentlyPlaying ? 'Now Playing' : 'Not Playing'}
  1115. title={currentPlayingFile ? `Playing: ${currentPlayingFile}` : 'Not Playing'}
  1116. >
  1117. <span className="material-icons-outlined">
  1118. {isCurrentlyPlaying ? 'play_circle' : 'stop_circle'}
  1119. </span>
  1120. </Button>
  1121. <TableSelector />
  1122. <Popover>
  1123. <PopoverTrigger asChild>
  1124. <Button
  1125. variant="ghost"
  1126. size="icon"
  1127. className="rounded-full"
  1128. aria-label="Open menu"
  1129. >
  1130. <span className="material-icons-outlined">menu</span>
  1131. </Button>
  1132. </PopoverTrigger>
  1133. <PopoverContent align="end" className="w-56 p-2">
  1134. <div className="flex flex-col gap-1">
  1135. <button
  1136. onClick={() => setIsDark(!isDark)}
  1137. className="flex items-center gap-3 w-full px-3 py-2 text-sm rounded-md hover:bg-accent transition-colors"
  1138. >
  1139. <span className="material-icons-outlined text-xl">
  1140. {isDark ? 'light_mode' : 'dark_mode'}
  1141. </span>
  1142. {isDark ? 'Light Mode' : 'Dark Mode'}
  1143. </button>
  1144. <button
  1145. onClick={handleToggleLogs}
  1146. className="flex items-center gap-3 w-full px-3 py-2 text-sm rounded-md hover:bg-accent transition-colors"
  1147. >
  1148. <span className="material-icons-outlined text-xl">article</span>
  1149. View Logs
  1150. </button>
  1151. <Separator className="my-1" />
  1152. <button
  1153. onClick={handleRestart}
  1154. className="flex items-center gap-3 w-full px-3 py-2 text-sm rounded-md hover:bg-accent transition-colors text-amber-500"
  1155. >
  1156. <span className="material-icons-outlined text-xl">restart_alt</span>
  1157. Restart Docker
  1158. </button>
  1159. <button
  1160. onClick={handleShutdown}
  1161. className="flex items-center gap-3 w-full px-3 py-2 text-sm rounded-md hover:bg-accent transition-colors text-red-500"
  1162. >
  1163. <span className="material-icons-outlined text-xl">power_settings_new</span>
  1164. Shutdown
  1165. </button>
  1166. </div>
  1167. </PopoverContent>
  1168. </Popover>
  1169. </div>
  1170. {/* Mobile actions */}
  1171. <div className="flex md:hidden items-center gap-0 ml-2">
  1172. {/* Now Playing button */}
  1173. <Button
  1174. variant="ghost"
  1175. size="icon"
  1176. onClick={() => setIsNowPlayingOpen(!isNowPlayingOpen)}
  1177. className="rounded-full"
  1178. aria-label={isCurrentlyPlaying ? 'Now Playing' : 'Not Playing'}
  1179. title={currentPlayingFile ? `Playing: ${currentPlayingFile}` : 'Not Playing'}
  1180. >
  1181. <span className="material-icons-outlined">
  1182. {isCurrentlyPlaying ? 'play_circle' : 'stop_circle'}
  1183. </span>
  1184. </Button>
  1185. <TableSelector />
  1186. <Popover open={isMobileMenuOpen} onOpenChange={setIsMobileMenuOpen}>
  1187. <PopoverTrigger asChild>
  1188. <Button
  1189. variant="ghost"
  1190. size="icon"
  1191. className="rounded-full"
  1192. aria-label="Open menu"
  1193. >
  1194. <span className="material-icons-outlined">
  1195. {isMobileMenuOpen ? 'close' : 'menu'}
  1196. </span>
  1197. </Button>
  1198. </PopoverTrigger>
  1199. <PopoverContent align="end" className="w-56 p-2">
  1200. <div className="flex flex-col gap-1">
  1201. <button
  1202. onClick={() => {
  1203. setIsDark(!isDark)
  1204. setIsMobileMenuOpen(false)
  1205. }}
  1206. className="flex items-center gap-3 w-full px-3 py-2 text-sm rounded-md hover:bg-accent transition-colors"
  1207. >
  1208. <span className="material-icons-outlined text-xl">
  1209. {isDark ? 'light_mode' : 'dark_mode'}
  1210. </span>
  1211. {isDark ? 'Light Mode' : 'Dark Mode'}
  1212. </button>
  1213. <button
  1214. onClick={() => {
  1215. handleToggleLogs()
  1216. setIsMobileMenuOpen(false)
  1217. }}
  1218. className="flex items-center gap-3 w-full px-3 py-2 text-sm rounded-md hover:bg-accent transition-colors"
  1219. >
  1220. <span className="material-icons-outlined text-xl">article</span>
  1221. View Logs
  1222. </button>
  1223. <Separator className="my-1" />
  1224. <button
  1225. onClick={() => {
  1226. handleRestart()
  1227. setIsMobileMenuOpen(false)
  1228. }}
  1229. className="flex items-center gap-3 w-full px-3 py-2 text-sm rounded-md hover:bg-accent transition-colors text-amber-500"
  1230. >
  1231. <span className="material-icons-outlined text-xl">restart_alt</span>
  1232. Restart Docker
  1233. </button>
  1234. <button
  1235. onClick={() => {
  1236. handleShutdown()
  1237. setIsMobileMenuOpen(false)
  1238. }}
  1239. className="flex items-center gap-3 w-full px-3 py-2 text-sm rounded-md hover:bg-accent transition-colors text-red-500"
  1240. >
  1241. <span className="material-icons-outlined text-xl">power_settings_new</span>
  1242. Shutdown
  1243. </button>
  1244. </div>
  1245. </PopoverContent>
  1246. </Popover>
  1247. </div>
  1248. </div>
  1249. </div>
  1250. </header>
  1251. {/* Main Content */}
  1252. <main
  1253. className={`container mx-auto px-4 transition-all duration-300 ${
  1254. !isLogsOpen && !isNowPlayingOpen ? 'pb-20' :
  1255. !isLogsOpen && isNowPlayingOpen ? 'pb-80' : ''
  1256. }`}
  1257. style={{
  1258. paddingTop: 'calc(4.5rem + env(safe-area-inset-top, 0px))',
  1259. paddingBottom: isLogsOpen
  1260. ? isNowPlayingOpen
  1261. ? logsDrawerHeight + 256 + 64 // drawer + now playing + nav
  1262. : logsDrawerHeight + 64 // drawer + nav
  1263. : undefined
  1264. }}
  1265. >
  1266. <Outlet />
  1267. </main>
  1268. {/* Now Playing Bar */}
  1269. <NowPlayingBar
  1270. isLogsOpen={isLogsOpen}
  1271. logsDrawerHeight={logsDrawerHeight}
  1272. isVisible={isNowPlayingOpen}
  1273. openExpanded={openNowPlayingExpanded}
  1274. onClose={() => setIsNowPlayingOpen(false)}
  1275. />
  1276. {/* Logs Drawer */}
  1277. <div
  1278. className={`fixed left-0 right-0 z-30 bg-background border-t border-border ${
  1279. isResizing ? '' : 'transition-[height] duration-300'
  1280. }`}
  1281. style={{
  1282. height: isLogsOpen ? logsDrawerHeight : 0,
  1283. bottom: 'calc(4rem + env(safe-area-inset-bottom, 0px))'
  1284. }}
  1285. >
  1286. {isLogsOpen && (
  1287. <>
  1288. {/* Resize Handle */}
  1289. <div
  1290. className="absolute top-0 left-0 right-0 h-2 cursor-ns-resize flex items-center justify-center group hover:bg-primary/10 -translate-y-1/2 z-10"
  1291. onMouseDown={handleResizeStart}
  1292. onTouchStart={handleResizeStart}
  1293. >
  1294. <div className="w-12 h-1 rounded-full bg-border group-hover:bg-primary transition-colors" />
  1295. </div>
  1296. {/* Logs Header */}
  1297. <div className="flex items-center justify-between px-4 py-2 border-b bg-muted/50">
  1298. <div className="flex items-center gap-3">
  1299. <span className="text-sm font-medium">Application Logs</span>
  1300. <select
  1301. value={logLevelFilter}
  1302. onChange={(e) => setLogLevelFilter(e.target.value)}
  1303. className="text-xs bg-background border rounded px-2 py-1"
  1304. >
  1305. <option value="ALL">All Levels</option>
  1306. <option value="DEBUG">Debug</option>
  1307. <option value="INFO">Info</option>
  1308. <option value="WARNING">Warning</option>
  1309. <option value="ERROR">Error</option>
  1310. </select>
  1311. <span className="text-xs text-muted-foreground">
  1312. {filteredLogs.length} entries
  1313. </span>
  1314. </div>
  1315. <div className="flex items-center gap-1">
  1316. <Button
  1317. variant="ghost"
  1318. size="icon-sm"
  1319. onClick={handleCopyLogs}
  1320. className="rounded-full"
  1321. title="Copy logs"
  1322. >
  1323. <span className="material-icons-outlined text-base">content_copy</span>
  1324. </Button>
  1325. <Button
  1326. variant="ghost"
  1327. size="icon-sm"
  1328. onClick={handleDownloadLogs}
  1329. className="rounded-full"
  1330. title="Download logs"
  1331. >
  1332. <span className="material-icons-outlined text-base">download</span>
  1333. </Button>
  1334. <Button
  1335. variant="ghost"
  1336. size="icon-sm"
  1337. onClick={() => setIsLogsOpen(false)}
  1338. className="rounded-full"
  1339. title="Close"
  1340. >
  1341. <span className="material-icons-outlined text-base">close</span>
  1342. </Button>
  1343. </div>
  1344. </div>
  1345. {/* Logs Content */}
  1346. <div
  1347. ref={logsContainerRef}
  1348. className="h-[calc(100%-40px)] overflow-auto overscroll-contain p-3 font-mono text-xs space-y-0.5"
  1349. >
  1350. {filteredLogs.length > 0 ? (
  1351. filteredLogs.map((log, i) => (
  1352. <div key={i} className="py-0.5 flex gap-2">
  1353. <span className="text-muted-foreground shrink-0">
  1354. {formatTimestamp(log.timestamp)}
  1355. </span>
  1356. <span className={`shrink-0 font-semibold ${
  1357. log.level === 'ERROR' ? 'text-red-500' :
  1358. log.level === 'WARNING' ? 'text-amber-500' :
  1359. log.level === 'DEBUG' ? 'text-muted-foreground' :
  1360. 'text-foreground'
  1361. }`}>
  1362. [{log.level || 'LOG'}]
  1363. </span>
  1364. <span className="break-all">{log.message || ''}</span>
  1365. </div>
  1366. ))
  1367. ) : (
  1368. <p className="text-muted-foreground text-center py-4">No logs available</p>
  1369. )}
  1370. </div>
  1371. </>
  1372. )}
  1373. </div>
  1374. {/* Bottom Navigation */}
  1375. <nav className="fixed bottom-0 left-0 right-0 z-40 border-t border-border bg-card pb-safe">
  1376. <div className="max-w-5xl mx-auto grid grid-cols-5 h-16">
  1377. {navItems.map((item) => {
  1378. const isActive = location.pathname === item.path
  1379. return (
  1380. <Link
  1381. key={item.path}
  1382. to={item.path}
  1383. className={`relative flex flex-col items-center justify-center gap-1 transition-all duration-200 ${
  1384. isActive
  1385. ? 'text-primary'
  1386. : 'text-muted-foreground hover:text-foreground active:scale-95'
  1387. }`}
  1388. >
  1389. {/* Active indicator pill */}
  1390. {isActive && (
  1391. <span className="absolute -top-0.5 w-8 h-1 rounded-full bg-primary" />
  1392. )}
  1393. <span className={`text-xl ${isActive ? 'material-icons' : 'material-icons-outlined'}`}>
  1394. {item.icon}
  1395. </span>
  1396. <span className="text-xs font-medium">{item.label}</span>
  1397. </Link>
  1398. )
  1399. })}
  1400. </div>
  1401. </nav>
  1402. </div>
  1403. )
  1404. }