Layout.tsx 62 KB

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