Layout.tsx 37 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007
  1. import { Outlet, Link, useLocation } from 'react-router-dom'
  2. import { useEffect, useState, useRef } from 'react'
  3. import { toast } from 'sonner'
  4. import { NowPlayingBar } from '@/components/NowPlayingBar'
  5. import { Button } from '@/components/ui/button'
  6. import { initPreviewCacheDB, getPreviewsFromCache, savePreviewToCache } from '@/lib/previewCache'
  7. const navItems = [
  8. { path: '/', label: 'Browse', icon: 'grid_view', title: 'Browse Patterns' },
  9. { path: '/playlists', label: 'Playlists', icon: 'playlist_play', title: 'Playlists' },
  10. { path: '/table-control', label: 'Control', icon: 'tune', title: 'Table Control' },
  11. { path: '/led', label: 'LED', icon: 'lightbulb', title: 'LED Control' },
  12. { path: '/settings', label: 'Settings', icon: 'settings', title: 'Settings' },
  13. ]
  14. const DEFAULT_APP_NAME = 'Dune Weaver'
  15. export function Layout() {
  16. const location = useLocation()
  17. const [isDark, setIsDark] = useState(() => {
  18. if (typeof window !== 'undefined') {
  19. const saved = localStorage.getItem('theme')
  20. if (saved) return saved === 'dark'
  21. return window.matchMedia('(prefers-color-scheme: dark)').matches
  22. }
  23. return false
  24. })
  25. // App customization
  26. const [appName, setAppName] = useState(DEFAULT_APP_NAME)
  27. const [customLogo, setCustomLogo] = useState<string | null>(null)
  28. // Connection status
  29. const [isConnected, setIsConnected] = useState(false)
  30. const [isBackendConnected, setIsBackendConnected] = useState(false)
  31. const [connectionAttempts, setConnectionAttempts] = useState(0)
  32. const wsRef = useRef<WebSocket | null>(null)
  33. // Fetch app settings
  34. const fetchAppSettings = () => {
  35. fetch('/api/settings')
  36. .then((r) => r.json())
  37. .then((settings) => {
  38. if (settings.app?.name) {
  39. setAppName(settings.app.name)
  40. } else {
  41. setAppName(DEFAULT_APP_NAME)
  42. }
  43. setCustomLogo(settings.app?.custom_logo || null)
  44. })
  45. .catch(() => {})
  46. }
  47. useEffect(() => {
  48. fetchAppSettings()
  49. // Listen for branding updates from Settings page
  50. const handleBrandingUpdate = () => {
  51. fetchAppSettings()
  52. }
  53. window.addEventListener('branding-updated', handleBrandingUpdate)
  54. return () => {
  55. window.removeEventListener('branding-updated', handleBrandingUpdate)
  56. }
  57. }, [])
  58. // Logs drawer state
  59. const [isLogsOpen, setIsLogsOpen] = useState(false)
  60. // Now Playing bar state
  61. const [isNowPlayingOpen, setIsNowPlayingOpen] = useState(false)
  62. const [openNowPlayingExpanded, setOpenNowPlayingExpanded] = useState(false)
  63. const wasPlayingRef = useRef<boolean | null>(null) // Track previous playing state (null = first message)
  64. const [logs, setLogs] = useState<Array<{ timestamp: string; level: string; logger: string; message: string }>>([])
  65. const [logLevelFilter, setLogLevelFilter] = useState<string>('ALL')
  66. const logsWsRef = useRef<WebSocket | null>(null)
  67. const logsContainerRef = useRef<HTMLDivElement>(null)
  68. // Check device connection status via WebSocket
  69. useEffect(() => {
  70. const connectWebSocket = () => {
  71. const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
  72. const ws = new WebSocket(`${protocol}//${window.location.host}/ws/status`)
  73. ws.onopen = () => {
  74. setIsBackendConnected(true)
  75. setConnectionAttempts(0)
  76. // Dispatch event so pages can refetch data
  77. window.dispatchEvent(new CustomEvent('backend-connected'))
  78. }
  79. ws.onmessage = (event) => {
  80. try {
  81. const data = JSON.parse(event.data)
  82. // Handle status updates
  83. if (data.type === 'status_update' && data.data) {
  84. // Update device connection status from the status message
  85. if (data.data.connection_status !== undefined) {
  86. setIsConnected(data.data.connection_status)
  87. }
  88. // Auto-open/close Now Playing bar based on playback state
  89. const isPlaying = data.data.is_running || data.data.is_paused
  90. // Skip auto-open on first message (page refresh) - only react to state changes
  91. if (wasPlayingRef.current !== null) {
  92. if (isPlaying && !wasPlayingRef.current) {
  93. // Playback just started - open the Now Playing bar in expanded mode
  94. setIsNowPlayingOpen(true)
  95. setOpenNowPlayingExpanded(true)
  96. // Close the logs drawer if open
  97. setIsLogsOpen(false)
  98. // Reset the expanded flag after a short delay
  99. setTimeout(() => setOpenNowPlayingExpanded(false), 500)
  100. // Dispatch event so pages can close their sidebars/panels
  101. window.dispatchEvent(new CustomEvent('playback-started'))
  102. } else if (!isPlaying && wasPlayingRef.current) {
  103. // Playback just stopped - close the Now Playing bar
  104. setIsNowPlayingOpen(false)
  105. }
  106. }
  107. wasPlayingRef.current = isPlaying
  108. }
  109. } catch {
  110. // Ignore parse errors
  111. }
  112. }
  113. ws.onclose = () => {
  114. setIsBackendConnected(false)
  115. setConnectionAttempts((prev) => prev + 1)
  116. // Reconnect after 3 seconds (don't change device status on WS disconnect)
  117. setTimeout(connectWebSocket, 3000)
  118. }
  119. ws.onerror = () => {
  120. setIsBackendConnected(false)
  121. }
  122. wsRef.current = ws
  123. }
  124. connectWebSocket()
  125. return () => {
  126. if (wsRef.current) {
  127. wsRef.current.close()
  128. }
  129. }
  130. }, [])
  131. // Connect to logs WebSocket when drawer opens
  132. useEffect(() => {
  133. if (!isLogsOpen) {
  134. // Close WebSocket when drawer closes
  135. if (logsWsRef.current) {
  136. logsWsRef.current.close()
  137. logsWsRef.current = null
  138. }
  139. return
  140. }
  141. // Fetch initial logs
  142. const fetchInitialLogs = async () => {
  143. try {
  144. const response = await fetch('/api/logs?limit=200')
  145. const data = await response.json()
  146. // Filter out empty/invalid log entries
  147. const validLogs = (data.logs || []).filter(
  148. (log: { message?: string }) => log && log.message && log.message.trim() !== ''
  149. )
  150. // API returns newest first, reverse to show oldest first (newest at bottom)
  151. setLogs(validLogs.reverse())
  152. // Scroll to bottom after initial load
  153. setTimeout(() => {
  154. if (logsContainerRef.current) {
  155. logsContainerRef.current.scrollTop = logsContainerRef.current.scrollHeight
  156. }
  157. }, 100)
  158. } catch {
  159. // Ignore errors
  160. }
  161. }
  162. fetchInitialLogs()
  163. // Connect to WebSocket for real-time updates
  164. let reconnectTimeout: ReturnType<typeof setTimeout> | null = null
  165. const connectLogsWebSocket = () => {
  166. const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
  167. const ws = new WebSocket(`${protocol}//${window.location.host}/ws/logs`)
  168. ws.onopen = () => {
  169. console.log('Logs WebSocket connected')
  170. }
  171. ws.onmessage = (event) => {
  172. try {
  173. const message = JSON.parse(event.data)
  174. // Skip heartbeat messages
  175. if (message.type === 'heartbeat') {
  176. return
  177. }
  178. // Extract log from wrapped structure
  179. const log = message.type === 'log_entry' ? message.data : message
  180. // Skip empty or invalid log entries
  181. if (!log || !log.message || log.message.trim() === '') {
  182. return
  183. }
  184. setLogs((prev) => {
  185. const newLogs = [...prev, log]
  186. // Keep only last 500 logs to prevent memory issues
  187. if (newLogs.length > 500) {
  188. return newLogs.slice(-500)
  189. }
  190. return newLogs
  191. })
  192. // Auto-scroll to bottom
  193. setTimeout(() => {
  194. if (logsContainerRef.current) {
  195. logsContainerRef.current.scrollTop = logsContainerRef.current.scrollHeight
  196. }
  197. }, 10)
  198. } catch {
  199. // Ignore parse errors
  200. }
  201. }
  202. ws.onclose = () => {
  203. console.log('Logs WebSocket closed, reconnecting...')
  204. // Reconnect after 3 seconds if drawer is still open
  205. reconnectTimeout = setTimeout(() => {
  206. if (logsWsRef.current === ws) {
  207. connectLogsWebSocket()
  208. }
  209. }, 3000)
  210. }
  211. ws.onerror = (error) => {
  212. console.error('Logs WebSocket error:', error)
  213. }
  214. logsWsRef.current = ws
  215. }
  216. connectLogsWebSocket()
  217. return () => {
  218. if (reconnectTimeout) {
  219. clearTimeout(reconnectTimeout)
  220. }
  221. if (logsWsRef.current) {
  222. logsWsRef.current.close()
  223. logsWsRef.current = null
  224. }
  225. }
  226. }, [isLogsOpen])
  227. const handleOpenLogs = () => {
  228. setIsLogsOpen(true)
  229. }
  230. // Filter logs by level
  231. const filteredLogs = logLevelFilter === 'ALL'
  232. ? logs
  233. : logs.filter((log) => log.level === logLevelFilter)
  234. // Format timestamp safely
  235. const formatTimestamp = (timestamp: string) => {
  236. if (!timestamp) return '--:--:--'
  237. try {
  238. const date = new Date(timestamp)
  239. if (isNaN(date.getTime())) return '--:--:--'
  240. return date.toLocaleTimeString()
  241. } catch {
  242. return '--:--:--'
  243. }
  244. }
  245. // Copy logs to clipboard
  246. const handleCopyLogs = () => {
  247. const text = filteredLogs
  248. .map((log) => `${formatTimestamp(log.timestamp)} [${log.level}] ${log.message}`)
  249. .join('\n')
  250. navigator.clipboard.writeText(text)
  251. toast.success('Logs copied to clipboard')
  252. }
  253. // Download logs as file
  254. const handleDownloadLogs = () => {
  255. const text = filteredLogs
  256. .map((log) => `${log.timestamp} [${log.level}] [${log.logger}] ${log.message}`)
  257. .join('\n')
  258. const blob = new Blob([text], { type: 'text/plain' })
  259. const url = URL.createObjectURL(blob)
  260. const a = document.createElement('a')
  261. a.href = url
  262. a.download = `dune-weaver-logs-${new Date().toISOString().split('T')[0]}.txt`
  263. a.click()
  264. URL.revokeObjectURL(url)
  265. }
  266. const handleRestart = async () => {
  267. if (!confirm('Are you sure you want to restart the system?')) return
  268. try {
  269. const response = await fetch('/restart', { method: 'POST' })
  270. if (response.ok) {
  271. toast.success('System is restarting...')
  272. } else {
  273. throw new Error('Restart failed')
  274. }
  275. } catch {
  276. toast.error('Failed to restart system')
  277. }
  278. }
  279. const handleShutdown = async () => {
  280. if (!confirm('Are you sure you want to shutdown the system?')) return
  281. try {
  282. const response = await fetch('/shutdown', { method: 'POST' })
  283. if (response.ok) {
  284. toast.success('System is shutting down...')
  285. } else {
  286. throw new Error('Shutdown failed')
  287. }
  288. } catch {
  289. toast.error('Failed to shutdown system')
  290. }
  291. }
  292. // Update document title based on current page
  293. useEffect(() => {
  294. const currentNav = navItems.find((item) => item.path === location.pathname)
  295. if (currentNav) {
  296. document.title = `${currentNav.title} | ${appName}`
  297. } else {
  298. document.title = appName
  299. }
  300. }, [location.pathname, appName])
  301. useEffect(() => {
  302. if (isDark) {
  303. document.documentElement.classList.add('dark')
  304. localStorage.setItem('theme', 'dark')
  305. } else {
  306. document.documentElement.classList.remove('dark')
  307. localStorage.setItem('theme', 'light')
  308. }
  309. }, [isDark])
  310. // Blocking overlay logs state - shows connection attempts
  311. const [connectionLogs, setConnectionLogs] = useState<Array<{ timestamp: string; level: string; message: string }>>([])
  312. const blockingLogsRef = useRef<HTMLDivElement>(null)
  313. // Cache progress state
  314. const [cacheProgress, setCacheProgress] = useState<{
  315. is_running: boolean
  316. stage: string
  317. processed_files: number
  318. total_files: number
  319. current_file: string
  320. error?: string
  321. } | null>(null)
  322. const cacheWsRef = useRef<WebSocket | null>(null)
  323. // Cache All Previews prompt state
  324. const [showCacheAllPrompt, setShowCacheAllPrompt] = useState(false)
  325. const [cacheAllProgress, setCacheAllProgress] = useState<{
  326. inProgress: boolean
  327. completed: number
  328. total: number
  329. done: boolean
  330. } | null>(null)
  331. // Add connection attempt logs when backend is disconnected
  332. useEffect(() => {
  333. if (isBackendConnected) {
  334. setConnectionLogs([])
  335. return
  336. }
  337. // Add initial log entry
  338. const addLog = (level: string, message: string) => {
  339. setConnectionLogs((prev) => {
  340. const newLog = {
  341. timestamp: new Date().toISOString(),
  342. level,
  343. message,
  344. }
  345. const newLogs = [...prev, newLog].slice(-50) // Keep last 50 entries
  346. return newLogs
  347. })
  348. // Auto-scroll to bottom
  349. setTimeout(() => {
  350. if (blockingLogsRef.current) {
  351. blockingLogsRef.current.scrollTop = blockingLogsRef.current.scrollHeight
  352. }
  353. }, 10)
  354. }
  355. addLog('INFO', `Attempting to connect to backend at ${window.location.host}...`)
  356. // Log connection attempts
  357. const interval = setInterval(() => {
  358. addLog('INFO', `Retrying connection to WebSocket /ws/status...`)
  359. // Also try HTTP to see if backend is partially up
  360. fetch('/api/settings', { method: 'GET' })
  361. .then(() => {
  362. addLog('INFO', 'HTTP endpoint responding, waiting for WebSocket...')
  363. })
  364. .catch(() => {
  365. // Still down
  366. })
  367. }, 3000)
  368. return () => clearInterval(interval)
  369. }, [isBackendConnected])
  370. // Cache progress WebSocket connection - always connected to monitor cache generation
  371. useEffect(() => {
  372. if (!isBackendConnected) return
  373. let reconnectTimeout: ReturnType<typeof setTimeout> | null = null
  374. const connectCacheWebSocket = () => {
  375. if (cacheWsRef.current) return
  376. const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
  377. const ws = new WebSocket(`${protocol}//${window.location.host}/ws/cache-progress`)
  378. ws.onmessage = (event) => {
  379. try {
  380. const message = JSON.parse(event.data)
  381. if (message.type === 'cache_progress') {
  382. const data = message.data
  383. if (data.is_running) {
  384. // Cache generation is running - show splash screen
  385. setCacheProgress(data)
  386. } else if (data.stage === 'complete') {
  387. // Cache generation just completed
  388. if (cacheProgress?.is_running) {
  389. // Was running before, now complete - show cache all prompt
  390. const promptShown = localStorage.getItem('cacheAllPromptShown')
  391. if (!promptShown) {
  392. setTimeout(() => {
  393. setCacheAllProgress(null) // Reset to clean state
  394. setShowCacheAllPrompt(true)
  395. }, 500)
  396. }
  397. }
  398. setCacheProgress(null)
  399. } else {
  400. // Not running and not complete (idle state)
  401. setCacheProgress(null)
  402. }
  403. }
  404. } catch {
  405. // Ignore parse errors
  406. }
  407. }
  408. ws.onclose = () => {
  409. cacheWsRef.current = null
  410. // Reconnect after 3 seconds
  411. if (isBackendConnected) {
  412. reconnectTimeout = setTimeout(connectCacheWebSocket, 3000)
  413. }
  414. }
  415. ws.onerror = () => {
  416. // Will trigger onclose
  417. }
  418. cacheWsRef.current = ws
  419. }
  420. connectCacheWebSocket()
  421. return () => {
  422. if (reconnectTimeout) {
  423. clearTimeout(reconnectTimeout)
  424. }
  425. if (cacheWsRef.current) {
  426. cacheWsRef.current.close()
  427. cacheWsRef.current = null
  428. }
  429. }
  430. }, [isBackendConnected]) // Only reconnect based on backend connection, not cache state
  431. // Calculate cache progress percentage
  432. const cachePercentage = cacheProgress?.total_files
  433. ? Math.round((cacheProgress.processed_files / cacheProgress.total_files) * 100)
  434. : 0
  435. const getCacheStageText = () => {
  436. if (!cacheProgress) return ''
  437. switch (cacheProgress.stage) {
  438. case 'starting':
  439. return 'Initializing...'
  440. case 'metadata':
  441. return 'Processing pattern metadata'
  442. case 'images':
  443. return 'Generating pattern previews'
  444. default:
  445. return 'Processing...'
  446. }
  447. }
  448. // Cache all previews in browser using IndexedDB
  449. const handleCacheAllPreviews = async () => {
  450. setCacheAllProgress({ inProgress: true, completed: 0, total: 0, done: false })
  451. try {
  452. // Initialize IndexedDB
  453. await initPreviewCacheDB()
  454. // Fetch all patterns
  455. const response = await fetch('/api/patterns')
  456. const data = await response.json()
  457. const patterns: { file: string }[] = data.patterns || []
  458. const allPaths = patterns.map((p) => p.file)
  459. // Check which patterns are already cached
  460. const cachedPreviews = await getPreviewsFromCache(allPaths)
  461. const uncachedPatterns = allPaths.filter((path) => !cachedPreviews.has(path))
  462. if (uncachedPatterns.length === 0) {
  463. toast.success('All patterns are already cached!')
  464. setCacheAllProgress({ inProgress: false, completed: patterns.length, total: patterns.length, done: true })
  465. return
  466. }
  467. setCacheAllProgress({ inProgress: true, completed: 0, total: uncachedPatterns.length, done: false })
  468. // Process in batches of 5
  469. const batchSize = 5
  470. let completed = 0
  471. for (let i = 0; i < uncachedPatterns.length; i += batchSize) {
  472. const batch = uncachedPatterns.slice(i, i + batchSize)
  473. const batchPromises = batch.map(async (patternPath: string) => {
  474. try {
  475. // Fetch preview data
  476. const previewResponse = await fetch(
  477. `/api/pattern/${encodeURIComponent(patternPath)}/preview`
  478. )
  479. if (previewResponse.ok) {
  480. const previewData = await previewResponse.json()
  481. if (previewData.image_data) {
  482. // Save to IndexedDB cache
  483. await savePreviewToCache(patternPath, previewData)
  484. }
  485. }
  486. } catch {
  487. // Continue even if one fails
  488. }
  489. })
  490. await Promise.all(batchPromises)
  491. completed += batch.length
  492. setCacheAllProgress({ inProgress: true, completed, total: uncachedPatterns.length, done: false })
  493. // Small delay between batches
  494. if (i + batchSize < uncachedPatterns.length) {
  495. await new Promise((resolve) => setTimeout(resolve, 100))
  496. }
  497. }
  498. setCacheAllProgress({ inProgress: false, completed: uncachedPatterns.length, total: uncachedPatterns.length, done: true })
  499. toast.success(`Cached ${uncachedPatterns.length} pattern previews`)
  500. } catch (error) {
  501. console.error('Error caching previews:', error)
  502. setCacheAllProgress(null)
  503. toast.error('Failed to cache previews')
  504. }
  505. }
  506. const handleSkipCacheAll = () => {
  507. localStorage.setItem('cacheAllPromptShown', 'true')
  508. setShowCacheAllPrompt(false)
  509. setCacheAllProgress(null)
  510. }
  511. const handleCloseCacheAllDone = () => {
  512. localStorage.setItem('cacheAllPromptShown', 'true')
  513. setShowCacheAllPrompt(false)
  514. setCacheAllProgress(null)
  515. }
  516. const cacheAllPercentage = cacheAllProgress?.total
  517. ? Math.round((cacheAllProgress.completed / cacheAllProgress.total) * 100)
  518. : 0
  519. return (
  520. <div className="min-h-screen bg-background">
  521. {/* Cache Progress Blocking Overlay */}
  522. {cacheProgress?.is_running && (
  523. <div className="fixed inset-0 z-50 bg-background/95 backdrop-blur-sm flex flex-col items-center justify-center p-4">
  524. <div className="w-full max-w-md space-y-6">
  525. <div className="text-center space-y-4">
  526. <div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-primary/10 mb-2">
  527. <span className="material-icons-outlined text-4xl text-primary animate-pulse">
  528. cached
  529. </span>
  530. </div>
  531. <h2 className="text-2xl font-bold">Initializing Pattern Cache</h2>
  532. <p className="text-muted-foreground">
  533. Preparing your pattern previews...
  534. </p>
  535. </div>
  536. {/* Progress Bar */}
  537. <div className="space-y-2">
  538. <div className="w-full bg-muted rounded-full h-2 overflow-hidden">
  539. <div
  540. className="bg-primary h-2 rounded-full transition-all duration-300"
  541. style={{ width: `${cachePercentage}%` }}
  542. />
  543. </div>
  544. <div className="flex justify-between text-sm text-muted-foreground">
  545. <span>
  546. {cacheProgress.processed_files} of {cacheProgress.total_files} patterns
  547. </span>
  548. <span>{cachePercentage}%</span>
  549. </div>
  550. </div>
  551. {/* Stage Info */}
  552. <div className="text-center space-y-1">
  553. <p className="text-sm font-medium">{getCacheStageText()}</p>
  554. {cacheProgress.current_file && (
  555. <p className="text-xs text-muted-foreground truncate max-w-full">
  556. {cacheProgress.current_file}
  557. </p>
  558. )}
  559. </div>
  560. {/* Hint */}
  561. <p className="text-center text-xs text-muted-foreground">
  562. This only happens once after updates or when new patterns are added
  563. </p>
  564. </div>
  565. </div>
  566. )}
  567. {/* Cache All Previews Prompt Modal */}
  568. {showCacheAllPrompt && (
  569. <div className="fixed inset-0 z-50 bg-black/50 backdrop-blur-sm flex items-center justify-center p-4">
  570. <div className="bg-background rounded-lg shadow-xl w-full max-w-md">
  571. <div className="p-6">
  572. <div className="text-center space-y-4">
  573. <div className="inline-flex items-center justify-center w-12 h-12 rounded-full bg-primary/10 mb-2">
  574. <span className="material-icons-outlined text-2xl text-primary">
  575. download_for_offline
  576. </span>
  577. </div>
  578. <h2 className="text-xl font-semibold">Cache All Pattern Previews?</h2>
  579. <p className="text-muted-foreground text-sm">
  580. Would you like to cache all pattern previews for faster browsing? This will download and store preview images in your browser for instant loading.
  581. </p>
  582. <div className="bg-amber-500/10 border border-amber-500/20 p-3 rounded-lg text-sm">
  583. <p className="text-amber-600 dark:text-amber-400">
  584. <strong>Note:</strong> This cache is browser-specific. You'll need to repeat this for each browser you use.
  585. </p>
  586. </div>
  587. {/* Initial state - show buttons */}
  588. {!cacheAllProgress && (
  589. <div className="flex gap-3 justify-center">
  590. <Button variant="ghost" onClick={handleSkipCacheAll}>
  591. Skip for now
  592. </Button>
  593. <Button onClick={handleCacheAllPreviews}>
  594. Cache All Previews
  595. </Button>
  596. </div>
  597. )}
  598. {/* Progress section */}
  599. {cacheAllProgress && !cacheAllProgress.done && (
  600. <div className="space-y-2">
  601. <div className="w-full bg-muted rounded-full h-2 overflow-hidden">
  602. <div
  603. className="bg-primary h-2 rounded-full transition-all duration-300"
  604. style={{ width: `${cacheAllPercentage}%` }}
  605. />
  606. </div>
  607. <div className="flex justify-between text-sm text-muted-foreground">
  608. <span>
  609. {cacheAllProgress.completed} of {cacheAllProgress.total} previews
  610. </span>
  611. <span>{cacheAllPercentage}%</span>
  612. </div>
  613. </div>
  614. )}
  615. {/* Completion message */}
  616. {cacheAllProgress?.done && (
  617. <div className="space-y-4">
  618. <p className="text-green-600 dark:text-green-400 flex items-center justify-center gap-2">
  619. <span className="material-icons text-base">check_circle</span>
  620. All {cacheAllProgress.total} previews cached successfully!
  621. </p>
  622. <Button onClick={handleCloseCacheAllDone} className="w-full">
  623. Done
  624. </Button>
  625. </div>
  626. )}
  627. </div>
  628. </div>
  629. </div>
  630. </div>
  631. )}
  632. {/* Backend Connection Blocking Overlay */}
  633. {!isBackendConnected && (
  634. <div className="fixed inset-0 z-50 bg-background/95 backdrop-blur-sm flex flex-col items-center justify-center p-4">
  635. <div className="w-full max-w-2xl space-y-6">
  636. {/* Connection Status */}
  637. <div className="text-center space-y-4">
  638. <div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-amber-500/10 mb-2">
  639. <span className="material-icons-outlined text-4xl text-amber-500 animate-pulse">
  640. sync
  641. </span>
  642. </div>
  643. <h2 className="text-2xl font-bold">Connecting to Backend</h2>
  644. <p className="text-muted-foreground">
  645. {connectionAttempts === 0
  646. ? 'Establishing connection...'
  647. : `Reconnecting... (attempt ${connectionAttempts})`
  648. }
  649. </p>
  650. <div className="flex items-center justify-center gap-2 text-sm text-muted-foreground">
  651. <span className="w-2 h-2 rounded-full bg-amber-500 animate-pulse" />
  652. <span>Waiting for server at {window.location.host}</span>
  653. </div>
  654. </div>
  655. {/* Connection Logs Panel */}
  656. <div className="bg-muted/50 rounded-lg border overflow-hidden">
  657. <div className="flex items-center justify-between px-4 py-2 border-b bg-muted">
  658. <div className="flex items-center gap-2">
  659. <span className="material-icons-outlined text-base">terminal</span>
  660. <span className="text-sm font-medium">Connection Log</span>
  661. </div>
  662. <span className="text-xs text-muted-foreground">
  663. {connectionLogs.length} entries
  664. </span>
  665. </div>
  666. <div
  667. ref={blockingLogsRef}
  668. className="h-48 overflow-auto p-3 font-mono text-xs space-y-0.5"
  669. >
  670. {connectionLogs.map((log, i) => (
  671. <div key={i} className="py-0.5 flex gap-2">
  672. <span className="text-muted-foreground shrink-0">
  673. {formatTimestamp(log.timestamp)}
  674. </span>
  675. <span className={`shrink-0 font-semibold ${
  676. log.level === 'ERROR' ? 'text-red-500' :
  677. log.level === 'WARNING' ? 'text-amber-500' :
  678. log.level === 'DEBUG' ? 'text-muted-foreground' :
  679. 'text-foreground'
  680. }`}>
  681. [{log.level}]
  682. </span>
  683. <span className="break-all">{log.message}</span>
  684. </div>
  685. ))}
  686. </div>
  687. </div>
  688. {/* Hint */}
  689. <p className="text-center text-xs text-muted-foreground">
  690. Make sure the backend server is running on port 8080
  691. </p>
  692. </div>
  693. </div>
  694. )}
  695. {/* Header */}
  696. <header className="sticky top-0 z-40 w-full border-b border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
  697. <div className="flex h-14 items-center justify-between px-4">
  698. <Link to="/" className="flex items-center gap-2">
  699. <img
  700. src={customLogo ? `/static/custom/${customLogo}` : '/static/android-chrome-192x192.png'}
  701. alt={appName}
  702. className="w-8 h-8 rounded-full object-cover"
  703. />
  704. <span className="font-semibold text-lg">{appName}</span>
  705. <span
  706. className={`w-2 h-2 rounded-full ${
  707. !isBackendConnected
  708. ? 'bg-gray-400'
  709. : isConnected
  710. ? 'bg-green-500 animate-pulse'
  711. : 'bg-red-500'
  712. }`}
  713. title={
  714. !isBackendConnected
  715. ? 'Backend not connected'
  716. : isConnected
  717. ? 'Table connected'
  718. : 'Table disconnected'
  719. }
  720. />
  721. </Link>
  722. <div className="flex items-center gap-1">
  723. <Button
  724. variant="ghost"
  725. size="icon"
  726. onClick={() => setIsDark(!isDark)}
  727. className="rounded-full"
  728. aria-label="Toggle dark mode"
  729. title="Toggle Theme"
  730. >
  731. <span className="material-icons-outlined">
  732. {isDark ? 'light_mode' : 'dark_mode'}
  733. </span>
  734. </Button>
  735. <Button
  736. variant="ghost"
  737. size="icon"
  738. onClick={handleOpenLogs}
  739. className="rounded-full"
  740. aria-label="View logs"
  741. title="View Application Logs"
  742. >
  743. <span className="material-icons-outlined">article</span>
  744. </Button>
  745. <Button
  746. variant="ghost"
  747. size="icon"
  748. onClick={handleRestart}
  749. className="rounded-full text-amber-500 hover:text-amber-600"
  750. aria-label="Restart system"
  751. title="Restart System"
  752. >
  753. <span className="material-icons-outlined">restart_alt</span>
  754. </Button>
  755. <Button
  756. variant="ghost"
  757. size="icon"
  758. onClick={handleShutdown}
  759. className="rounded-full text-red-500 hover:text-red-600"
  760. aria-label="Shutdown system"
  761. title="Shutdown System"
  762. >
  763. <span className="material-icons-outlined">power_settings_new</span>
  764. </Button>
  765. </div>
  766. </div>
  767. </header>
  768. {/* Main Content */}
  769. <main className={`container mx-auto px-4 transition-all duration-300 ${
  770. isLogsOpen && isNowPlayingOpen ? 'pb-[576px]' :
  771. isLogsOpen ? 'pb-80' :
  772. isNowPlayingOpen ? 'pb-80' :
  773. 'pb-20'
  774. }`}>
  775. <Outlet />
  776. </main>
  777. {/* Now Playing Bar */}
  778. <NowPlayingBar
  779. isLogsOpen={isLogsOpen}
  780. isVisible={isNowPlayingOpen}
  781. openExpanded={openNowPlayingExpanded}
  782. onClose={() => setIsNowPlayingOpen(false)}
  783. />
  784. {/* Floating Now Playing Button */}
  785. {!isNowPlayingOpen && (
  786. <button
  787. onClick={() => setIsNowPlayingOpen(true)}
  788. className="fixed right-4 bottom-20 z-30 w-12 h-12 rounded-full bg-primary text-primary-foreground shadow-lg flex items-center justify-center transition-all duration-200 hover:bg-primary/90 hover:shadow-xl hover:scale-110 active:scale-95"
  789. title="Now Playing"
  790. >
  791. <span className="material-icons">play_circle</span>
  792. </button>
  793. )}
  794. {/* Logs Drawer */}
  795. <div
  796. className={`fixed left-0 right-0 z-30 bg-background border-t border-border transition-all duration-300 ${
  797. isLogsOpen ? 'bottom-16 h-64' : 'bottom-16 h-0'
  798. }`}
  799. >
  800. {isLogsOpen && (
  801. <>
  802. <div className="flex items-center justify-between px-4 py-2 border-b bg-muted/50">
  803. <div className="flex items-center gap-3">
  804. <h2 className="text-sm font-semibold">Logs</h2>
  805. <select
  806. value={logLevelFilter}
  807. onChange={(e) => setLogLevelFilter(e.target.value)}
  808. className="text-xs bg-background border rounded px-2 py-1"
  809. >
  810. <option value="ALL">All Levels</option>
  811. <option value="DEBUG">Debug</option>
  812. <option value="INFO">Info</option>
  813. <option value="WARNING">Warning</option>
  814. <option value="ERROR">Error</option>
  815. </select>
  816. <span className="text-xs text-muted-foreground">
  817. {filteredLogs.length} entries
  818. </span>
  819. </div>
  820. <div className="flex items-center gap-1">
  821. <Button
  822. variant="ghost"
  823. size="icon-sm"
  824. onClick={handleCopyLogs}
  825. className="rounded-full"
  826. title="Copy logs"
  827. >
  828. <span className="material-icons-outlined text-base">content_copy</span>
  829. </Button>
  830. <Button
  831. variant="ghost"
  832. size="icon-sm"
  833. onClick={handleDownloadLogs}
  834. className="rounded-full"
  835. title="Download logs"
  836. >
  837. <span className="material-icons-outlined text-base">download</span>
  838. </Button>
  839. <Button
  840. variant="ghost"
  841. size="icon-sm"
  842. onClick={() => setIsLogsOpen(false)}
  843. className="rounded-full"
  844. title="Close logs"
  845. >
  846. <span className="material-icons-outlined text-base">close</span>
  847. </Button>
  848. </div>
  849. </div>
  850. <div
  851. ref={logsContainerRef}
  852. className="h-[calc(100%-40px)] overflow-auto overscroll-contain p-3 font-mono text-xs space-y-0.5"
  853. >
  854. {filteredLogs.length > 0 ? (
  855. filteredLogs.map((log, i) => (
  856. <div key={i} className="py-0.5 flex gap-2">
  857. <span className="text-muted-foreground shrink-0">
  858. {formatTimestamp(log.timestamp)}
  859. </span>
  860. <span className={`shrink-0 font-semibold ${
  861. log.level === 'ERROR' ? 'text-red-500' :
  862. log.level === 'WARNING' ? 'text-amber-500' :
  863. log.level === 'DEBUG' ? 'text-muted-foreground' :
  864. 'text-foreground'
  865. }`}>
  866. [{log.level || 'LOG'}]
  867. </span>
  868. <span className="break-all">{log.message || ''}</span>
  869. </div>
  870. ))
  871. ) : (
  872. <p className="text-muted-foreground text-center py-4">No logs available</p>
  873. )}
  874. </div>
  875. </>
  876. )}
  877. </div>
  878. {/* Bottom Navigation */}
  879. <nav className="fixed bottom-0 left-0 right-0 z-40 border-t border-border bg-background">
  880. <div className="max-w-5xl mx-auto grid grid-cols-5 h-16">
  881. {navItems.map((item) => {
  882. const isActive = location.pathname === item.path
  883. return (
  884. <Link
  885. key={item.path}
  886. to={item.path}
  887. className={`relative flex flex-col items-center justify-center gap-1 transition-all duration-200 ${
  888. isActive
  889. ? 'text-primary'
  890. : 'text-muted-foreground hover:text-foreground active:scale-95'
  891. }`}
  892. >
  893. {/* Active indicator pill */}
  894. {isActive && (
  895. <span className="absolute -top-0.5 w-8 h-1 rounded-full bg-primary" />
  896. )}
  897. <span className={`text-xl ${isActive ? 'material-icons' : 'material-icons-outlined'}`}>
  898. {item.icon}
  899. </span>
  900. <span className="text-xs font-medium">{item.label}</span>
  901. </Link>
  902. )
  903. })}
  904. </div>
  905. </nav>
  906. </div>
  907. )
  908. }