Layout.tsx 77 KB

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