Layout.tsx 67 KB

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