Layout.tsx 74 KB

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