import { Outlet, Link, useLocation } from 'react-router-dom' import { useEffect, useState, useRef, useCallback } from 'react' import { toast } from 'sonner' import { NowPlayingBar } from '@/components/NowPlayingBar' import { Button } from '@/components/ui/button' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { Separator } from '@/components/ui/separator' import { cacheAllPreviews } from '@/lib/previewCache' import { TableSelector } from '@/components/TableSelector' import { useTable } from '@/contexts/TableContext' import { apiClient } from '@/lib/apiClient' import ShinyText from '@/components/ShinyText' const navItems = [ { path: '/', label: 'Browse', icon: 'grid_view', title: 'Browse Patterns' }, { path: '/playlists', label: 'Playlists', icon: 'playlist_play', title: 'Playlists' }, { path: '/table-control', label: 'Control', icon: 'tune', title: 'Table Control' }, { path: '/led', label: 'LED', icon: 'lightbulb', title: 'LED Control' }, { path: '/settings', label: 'Settings', icon: 'settings', title: 'Settings' }, ] const DEFAULT_APP_NAME = 'Dune Weaver' export function Layout() { const location = useLocation() // Scroll to top on route change useEffect(() => { window.scrollTo(0, 0) }, [location.pathname]) // Multi-table context - must be called before any hooks that depend on activeTable const { activeTable, tables } = useTable() // Use table name as app name when multiple tables exist const hasMultipleTables = tables.length > 1 const [isDark, setIsDark] = useState(() => { if (typeof window !== 'undefined') { const saved = localStorage.getItem('theme') if (saved) return saved === 'dark' return window.matchMedia('(prefers-color-scheme: dark)').matches } return false }) // App customization const [appName, setAppName] = useState(DEFAULT_APP_NAME) const [customLogo, setCustomLogo] = useState(null) // Display name: when multiple tables exist, use the active table's name; otherwise use app settings // Get the table from the tables array (most up-to-date source) to ensure we have current data const activeTableData = tables.find(t => t.id === activeTable?.id) const tableName = activeTableData?.name || activeTable?.name const displayName = hasMultipleTables && tableName ? tableName : appName // Connection status const [isConnected, setIsConnected] = useState(false) const [isBackendConnected, setIsBackendConnected] = useState(false) const [isHoming, setIsHoming] = useState(false) const [homingDismissed, setHomingDismissed] = useState(false) const [homingJustCompleted, setHomingJustCompleted] = useState(false) const [homingCountdown, setHomingCountdown] = useState(0) const [keepHomingLogsOpen, setKeepHomingLogsOpen] = useState(false) const wasHomingRef = useRef(false) const [connectionAttempts, setConnectionAttempts] = useState(0) const wsRef = useRef(null) // Sensor homing failure state const [sensorHomingFailed, setSensorHomingFailed] = useState(false) const [isRecoveringHoming, setIsRecoveringHoming] = useState(false) // Fetch app settings const fetchAppSettings = () => { apiClient.get<{ app?: { name?: string; custom_logo?: string } }>('/api/settings') .then((settings) => { if (settings.app?.name) { setAppName(settings.app.name) } else { setAppName(DEFAULT_APP_NAME) } setCustomLogo(settings.app?.custom_logo || null) }) .catch(() => {}) } useEffect(() => { fetchAppSettings() // Listen for branding updates from Settings page const handleBrandingUpdate = () => { fetchAppSettings() } window.addEventListener('branding-updated', handleBrandingUpdate) return () => { window.removeEventListener('branding-updated', handleBrandingUpdate) } // Refetch when active table changes }, [activeTable?.id]) // Homing completion countdown timer useEffect(() => { if (!homingJustCompleted || keepHomingLogsOpen) return if (homingCountdown <= 0) { // Countdown finished, dismiss the overlay setHomingJustCompleted(false) setKeepHomingLogsOpen(false) return } const timer = setTimeout(() => { setHomingCountdown((prev) => prev - 1) }, 1000) return () => clearTimeout(timer) }, [homingJustCompleted, homingCountdown, keepHomingLogsOpen]) // Mobile menu state const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false) // Logs drawer state const [isLogsOpen, setIsLogsOpen] = useState(false) const [logsDrawerHeight, setLogsDrawerHeight] = useState(256) // Default 256px (h-64) const [isResizing, setIsResizing] = useState(false) const isResizingRef = useRef(false) const startYRef = useRef(0) const startHeightRef = useRef(0) // Handle drawer resize const handleResizeStart = (e: React.MouseEvent | React.TouchEvent) => { e.preventDefault() isResizingRef.current = true setIsResizing(true) startYRef.current = 'touches' in e ? e.touches[0].clientY : e.clientY startHeightRef.current = logsDrawerHeight document.body.style.cursor = 'ns-resize' document.body.style.userSelect = 'none' } useEffect(() => { const handleResizeMove = (e: MouseEvent | TouchEvent) => { if (!isResizingRef.current) return const clientY = 'touches' in e ? e.touches[0].clientY : e.clientY const delta = startYRef.current - clientY const newHeight = Math.min(Math.max(startHeightRef.current + delta, 150), window.innerHeight - 150) setLogsDrawerHeight(newHeight) } const handleResizeEnd = () => { if (isResizingRef.current) { isResizingRef.current = false setIsResizing(false) document.body.style.cursor = '' document.body.style.userSelect = '' } } window.addEventListener('mousemove', handleResizeMove) window.addEventListener('mouseup', handleResizeEnd) window.addEventListener('touchmove', handleResizeMove) window.addEventListener('touchend', handleResizeEnd) return () => { window.removeEventListener('mousemove', handleResizeMove) window.removeEventListener('mouseup', handleResizeEnd) window.removeEventListener('touchmove', handleResizeMove) window.removeEventListener('touchend', handleResizeEnd) } }, []) // Now Playing bar state const [isNowPlayingOpen, setIsNowPlayingOpen] = useState(false) const [openNowPlayingExpanded, setOpenNowPlayingExpanded] = useState(false) const [currentPlayingFile, setCurrentPlayingFile] = useState(null) // Track current file for header button const wasPlayingRef = useRef(null) // Track previous playing state (null = first message) // Derive isCurrentlyPlaying from currentPlayingFile const isCurrentlyPlaying = Boolean(currentPlayingFile) // Listen for playback-started event (dispatched when user starts a pattern) useEffect(() => { const handlePlaybackStarted = () => { setIsNowPlayingOpen(true) setOpenNowPlayingExpanded(true) setIsLogsOpen(false) // Reset expanded flag after animation setTimeout(() => setOpenNowPlayingExpanded(false), 500) } window.addEventListener('playback-started', handlePlaybackStarted) return () => window.removeEventListener('playback-started', handlePlaybackStarted) }, []) const [logs, setLogs] = useState>([]) const [logLevelFilter, setLogLevelFilter] = useState('ALL') const [logsTotal, setLogsTotal] = useState(0) const [logsHasMore, setLogsHasMore] = useState(false) const [isLoadingMoreLogs, setIsLoadingMoreLogs] = useState(false) const logsWsRef = useRef(null) const logsContainerRef = useRef(null) const logsLoadedCountRef = useRef(0) // Track how many logs we've loaded (for offset) // Check device connection status via WebSocket // This effect runs once on mount and manages its own reconnection logic useEffect(() => { let reconnectTimeout: ReturnType | null = null let isMounted = true const connectWebSocket = () => { if (!isMounted) return // Only close existing connection if it's open (not still connecting) // This prevents "WebSocket closed before connection established" errors if (wsRef.current) { if (wsRef.current.readyState === WebSocket.OPEN) { wsRef.current.close() wsRef.current = null } else if (wsRef.current.readyState === WebSocket.CONNECTING) { // Already connecting, don't interrupt return } } const ws = new WebSocket(apiClient.getWebSocketUrl('/ws/status')) // Assign to ref IMMEDIATELY so concurrent calls see it's connecting wsRef.current = ws ws.onopen = () => { if (!isMounted) { // Component unmounted while connecting - close the WebSocket now ws.close() return } setIsBackendConnected(true) setConnectionAttempts(0) // Dispatch event so pages can refetch data window.dispatchEvent(new CustomEvent('backend-connected')) } ws.onmessage = (event) => { if (!isMounted) return try { const data = JSON.parse(event.data) // Handle status updates if (data.type === 'status_update' && data.data) { // Update device connection status from the status message if (data.data.connection_status !== undefined) { setIsConnected(data.data.connection_status) } // Update homing status and detect completion if (data.data.is_homing !== undefined) { const newIsHoming = data.data.is_homing // Detect transition from not homing to homing - reset dismissal if (!wasHomingRef.current && newIsHoming) { setHomingDismissed(false) } // Detect transition from homing to not homing if (wasHomingRef.current && !newIsHoming) { // Homing just completed - show completion state with countdown // But not if sensor homing failed (that shows a different dialog) if (!data.data.sensor_homing_failed) { setHomingJustCompleted(true) setHomingCountdown(5) setHomingDismissed(false) } } wasHomingRef.current = newIsHoming setIsHoming(newIsHoming) } // Update sensor homing failure status if (data.data.sensor_homing_failed !== undefined) { setSensorHomingFailed(data.data.sensor_homing_failed) } // Auto-open/close Now Playing bar based on playback state // Track current file - this is the most reliable indicator of playback const currentFile = data.data.current_file || null setCurrentPlayingFile(currentFile) const isPlaying = Boolean(currentFile) || Boolean(data.data.is_running) || Boolean(data.data.is_paused) // Skip auto-open on first message (page refresh) - only react to state changes if (wasPlayingRef.current !== null) { if (isPlaying && !wasPlayingRef.current) { // Playback just started - open the Now Playing bar in expanded mode setIsNowPlayingOpen(true) setOpenNowPlayingExpanded(true) // Close the logs drawer if open setIsLogsOpen(false) // Reset the expanded flag after a short delay setTimeout(() => setOpenNowPlayingExpanded(false), 500) // Dispatch event so pages can close their sidebars/panels window.dispatchEvent(new CustomEvent('playback-started')) } else if (!isPlaying && wasPlayingRef.current) { // Playback just stopped - close the Now Playing bar setIsNowPlayingOpen(false) } } wasPlayingRef.current = isPlaying } } catch { // Ignore parse errors } } ws.onclose = () => { if (!isMounted) return wsRef.current = null setIsBackendConnected(false) setConnectionAttempts((prev) => prev + 1) // Reconnect after 3 seconds (don't change device status on WS disconnect) reconnectTimeout = setTimeout(connectWebSocket, 3000) } ws.onerror = () => { if (!isMounted) return setIsBackendConnected(false) } } // Reset playing state on mount wasPlayingRef.current = null // Connect on mount connectWebSocket() // Subscribe to base URL changes (when user switches tables) // This triggers reconnection to the new backend const unsubscribe = apiClient.onBaseUrlChange(() => { if (isMounted) { wasPlayingRef.current = null // Reset playing state for new table setCurrentPlayingFile(null) // Reset playback state for new table setIsConnected(false) // Reset connection status until new table reports setIsBackendConnected(false) // Show connecting state setSensorHomingFailed(false) // Reset sensor homing failure state for new table connectWebSocket() } }) return () => { isMounted = false unsubscribe() if (reconnectTimeout) { clearTimeout(reconnectTimeout) } if (wsRef.current) { // Only close if already OPEN - CONNECTING WebSockets will close in onopen if (wsRef.current.readyState === WebSocket.OPEN) { wsRef.current.close() } wsRef.current = null } } }, []) // Empty deps - runs once on mount, reconnects via apiClient listener // Connect to logs WebSocket when drawer opens useEffect(() => { if (!isLogsOpen) { // Close WebSocket when drawer closes - only if OPEN (CONNECTING will close in onopen) if (logsWsRef.current && logsWsRef.current.readyState === WebSocket.OPEN) { logsWsRef.current.close() } logsWsRef.current = null return } let shouldConnect = true // Fetch initial logs (most recent) const fetchInitialLogs = async () => { try { type LogEntry = { timestamp: string; level: string; logger: string; message: string } type LogsResponse = { logs: LogEntry[]; total: number; has_more: boolean } const data = await apiClient.get('/api/logs?limit=200') // Filter out empty/invalid log entries const validLogs = (data.logs || []).filter( (log) => log && log.message && log.message.trim() !== '' ) // API returns newest first, reverse to show oldest first (newest at bottom) setLogs(validLogs.reverse()) setLogsTotal(data.total || 0) setLogsHasMore(data.has_more || false) logsLoadedCountRef.current = validLogs.length // Scroll to bottom after initial load setTimeout(() => { if (logsContainerRef.current) { logsContainerRef.current.scrollTop = logsContainerRef.current.scrollHeight } }, 100) } catch { // Ignore errors } } fetchInitialLogs() // Connect to WebSocket for real-time updates let reconnectTimeout: ReturnType | null = null const connectLogsWebSocket = () => { // Don't interrupt an existing connection that's still connecting if (logsWsRef.current) { if (logsWsRef.current.readyState === WebSocket.CONNECTING) { return // Already connecting, wait for it } if (logsWsRef.current.readyState === WebSocket.OPEN) { logsWsRef.current.close() } logsWsRef.current = null } const ws = new WebSocket(apiClient.getWebSocketUrl('/ws/logs')) // Assign to ref IMMEDIATELY so concurrent calls see it's connecting logsWsRef.current = ws ws.onopen = () => { if (!shouldConnect) { // Effect cleanup ran while connecting - close now ws.close() return } console.log('Logs WebSocket connected') } ws.onmessage = (event) => { try { const message = JSON.parse(event.data) // Skip heartbeat messages if (message.type === 'heartbeat') { return } // Extract log from wrapped structure const log = message.type === 'log_entry' ? message.data : message // Skip empty or invalid log entries if (!log || !log.message || log.message.trim() === '') { return } // Append new log - no limit, lazy loading handles old logs setLogs((prev) => [...prev, log]) // Auto-scroll to bottom if user is near the bottom setTimeout(() => { if (logsContainerRef.current) { const { scrollTop, scrollHeight, clientHeight } = logsContainerRef.current // Only auto-scroll if user is within 100px of the bottom if (scrollHeight - scrollTop - clientHeight < 100) { logsContainerRef.current.scrollTop = scrollHeight } } }, 10) } catch { // Ignore parse errors } } ws.onclose = () => { if (!shouldConnect) return console.log('Logs WebSocket closed, reconnecting...') // Reconnect after 3 seconds if drawer is still open reconnectTimeout = setTimeout(() => { if (shouldConnect && logsWsRef.current === ws) { connectLogsWebSocket() } }, 3000) } ws.onerror = (error) => { console.error('Logs WebSocket error:', error) } } connectLogsWebSocket() return () => { shouldConnect = false if (reconnectTimeout) { clearTimeout(reconnectTimeout) } if (logsWsRef.current) { // Only close if already OPEN - CONNECTING WebSockets will close in onopen if (logsWsRef.current.readyState === WebSocket.OPEN) { logsWsRef.current.close() } logsWsRef.current = null } } // Also reconnect when active table changes }, [isLogsOpen, activeTable?.id]) // Load older logs when user scrolls to top (lazy loading) const loadOlderLogs = useCallback(async () => { if (isLoadingMoreLogs || !logsHasMore) return setIsLoadingMoreLogs(true) try { type LogEntry = { timestamp: string; level: string; logger: string; message: string } type LogsResponse = { logs: LogEntry[]; total: number; has_more: boolean } const offset = logsLoadedCountRef.current const data = await apiClient.get(`/api/logs?limit=100&offset=${offset}`) const validLogs = (data.logs || []).filter( (log) => log && log.message && log.message.trim() !== '' ) if (validLogs.length > 0) { // Prepend older logs (they come newest-first, so reverse them) setLogs((prev) => [...validLogs.reverse(), ...prev]) logsLoadedCountRef.current += validLogs.length setLogsHasMore(data.has_more || false) setLogsTotal(data.total || 0) // Maintain scroll position after prepending setTimeout(() => { if (logsContainerRef.current) { // Calculate approximate height of new content (rough estimate: 24px per log line) const newContentHeight = validLogs.length * 24 logsContainerRef.current.scrollTop = newContentHeight } }, 10) } else { setLogsHasMore(false) } } catch { // Ignore errors } finally { setIsLoadingMoreLogs(false) } }, [isLoadingMoreLogs, logsHasMore]) // Scroll event handler for lazy loading useEffect(() => { const container = logsContainerRef.current if (!container || !isLogsOpen) return const handleScroll = () => { // Load more when scrolled to top (within 50px) if (container.scrollTop < 50 && logsHasMore && !isLoadingMoreLogs) { loadOlderLogs() } } container.addEventListener('scroll', handleScroll) return () => container.removeEventListener('scroll', handleScroll) }, [isLogsOpen, logsHasMore, isLoadingMoreLogs, loadOlderLogs]) const handleToggleLogs = () => { setIsLogsOpen((prev) => !prev) } // Filter logs by level const filteredLogs = logLevelFilter === 'ALL' ? logs : logs.filter((log) => log.level === logLevelFilter) // Format timestamp safely const formatTimestamp = (timestamp: string) => { if (!timestamp) return '--:--:--' try { const date = new Date(timestamp) if (isNaN(date.getTime())) return '--:--:--' return date.toLocaleTimeString() } catch { return '--:--:--' } } // Copy logs to clipboard (with fallback for non-HTTPS) const handleCopyLogs = () => { const text = filteredLogs .map((log) => `${formatTimestamp(log.timestamp)} [${log.level}] ${log.message}`) .join('\n') copyToClipboard(text) } // Helper to copy text with fallback for non-secure contexts const copyToClipboard = (text: string) => { if (navigator.clipboard && window.isSecureContext) { navigator.clipboard.writeText(text).then(() => { toast.success('Logs copied to clipboard') }).catch(() => { toast.error('Failed to copy logs') }) } else { // Fallback for non-secure contexts (http://) const textArea = document.createElement('textarea') textArea.value = text textArea.style.position = 'fixed' textArea.style.left = '-9999px' document.body.appendChild(textArea) textArea.select() try { document.execCommand('copy') toast.success('Logs copied to clipboard') } catch { toast.error('Failed to copy logs') } document.body.removeChild(textArea) } } // Download logs as file const handleDownloadLogs = () => { const text = filteredLogs .map((log) => `${log.timestamp} [${log.level}] [${log.logger}] ${log.message}`) .join('\n') const blob = new Blob([text], { type: 'text/plain' }) const url = URL.createObjectURL(blob) const a = document.createElement('a') a.href = url a.download = `dune-weaver-logs-${new Date().toISOString().split('T')[0]}.txt` a.click() URL.revokeObjectURL(url) } const handleRestart = async () => { if (!confirm('Are you sure you want to restart Docker containers?')) return try { await apiClient.post('/api/system/restart') toast.success('Docker containers are restarting...') } catch { toast.error('Failed to restart Docker containers') } } const handleShutdown = async () => { if (!confirm('Are you sure you want to shutdown the system?')) return try { await apiClient.post('/api/system/shutdown') toast.success('System is shutting down...') } catch { toast.error('Failed to shutdown system') } } // Handle sensor homing recovery const handleSensorHomingRecovery = async (switchToCrashHoming: boolean) => { setIsRecoveringHoming(true) try { const response = await apiClient.post<{ success: boolean sensor_homing_failed?: boolean message?: string }>('/recover_sensor_homing', { switch_to_crash_homing: switchToCrashHoming }) if (response.success) { toast.success(response.message || 'Homing completed successfully') setSensorHomingFailed(false) } else if (response.sensor_homing_failed) { // Sensor homing failed again toast.error(response.message || 'Sensor homing failed again') } else { toast.error(response.message || 'Recovery failed') } } catch { toast.error('Failed to recover from sensor homing failure') } finally { setIsRecoveringHoming(false) } } // Update document title based on current page useEffect(() => { const currentNav = navItems.find((item) => item.path === location.pathname) if (currentNav) { document.title = `${currentNav.title} | ${displayName}` } else { document.title = displayName } }, [location.pathname, displayName]) useEffect(() => { if (isDark) { document.documentElement.classList.add('dark') localStorage.setItem('theme', 'dark') } else { document.documentElement.classList.remove('dark') localStorage.setItem('theme', 'light') } }, [isDark]) // Blocking overlay logs state - shows connection attempts const [connectionLogs, setConnectionLogs] = useState>([]) const blockingLogsRef = useRef(null) // Cache progress state const [cacheProgress, setCacheProgress] = useState<{ is_running: boolean stage: string processed_files: number total_files: number current_file: string error?: string } | null>(null) const cacheWsRef = useRef(null) // Cache All Previews prompt state const [showCacheAllPrompt, setShowCacheAllPrompt] = useState(false) const [cacheAllProgress, setCacheAllProgress] = useState<{ inProgress: boolean completed: number total: number done: boolean } | null>(null) // Blocking overlay logs WebSocket ref const blockingLogsWsRef = useRef(null) // Add connection/homing logs when overlay is shown useEffect(() => { const showOverlay = !isBackendConnected || isHoming || homingJustCompleted if (!showOverlay) { setConnectionLogs([]) // Close WebSocket if open - only if OPEN (CONNECTING will close in onopen) if (blockingLogsWsRef.current && blockingLogsWsRef.current.readyState === WebSocket.OPEN) { blockingLogsWsRef.current.close() } blockingLogsWsRef.current = null return } // Don't clear logs or reconnect WebSocket during completion state if (homingJustCompleted && !isHoming) { return } // Add log entry helper const addLog = (level: string, message: string, timestamp?: string) => { setConnectionLogs((prev) => { const newLog = { timestamp: timestamp || new Date().toISOString(), level, message, } const newLogs = [...prev, newLog].slice(-100) // Keep last 100 entries return newLogs }) // Auto-scroll to bottom setTimeout(() => { if (blockingLogsRef.current) { blockingLogsRef.current.scrollTop = blockingLogsRef.current.scrollHeight } }, 10) } // If homing, connect to logs WebSocket to stream real logs if (isHoming && isBackendConnected) { addLog('INFO', 'Homing started...') let shouldConnect = true // Don't interrupt an existing connection that's still connecting if (blockingLogsWsRef.current) { if (blockingLogsWsRef.current.readyState === WebSocket.CONNECTING) { return // Already connecting, wait for it } if (blockingLogsWsRef.current.readyState === WebSocket.OPEN) { blockingLogsWsRef.current.close() } blockingLogsWsRef.current = null } const ws = new WebSocket(apiClient.getWebSocketUrl('/ws/logs')) // Assign to ref IMMEDIATELY so concurrent calls see it's connecting blockingLogsWsRef.current = ws ws.onopen = () => { if (!shouldConnect) { // Effect cleanup ran while connecting - close now ws.close() } } ws.onmessage = (event) => { try { const message = JSON.parse(event.data) if (message.type === 'heartbeat') return const log = message.type === 'log_entry' ? message.data : message if (!log || !log.message || log.message.trim() === '') return // Filter for homing-related logs const msg = log.message.toLowerCase() const isHomingLog = msg.includes('homing') || msg.includes('home') || msg.includes('$h') || msg.includes('idle') || msg.includes('unlock') || msg.includes('alarm') || msg.includes('grbl') || msg.includes('connect') || msg.includes('serial') || msg.includes('device') || msg.includes('position') || msg.includes('zeroing') || msg.includes('movement') || log.logger?.includes('connection') if (isHomingLog) { addLog(log.level, log.message, log.timestamp) } } catch { // Ignore parse errors } } return () => { shouldConnect = false // Only close if already OPEN - CONNECTING WebSockets will close in onopen if (ws.readyState === WebSocket.OPEN) { ws.close() } blockingLogsWsRef.current = null } } // If backend disconnected, show connection retry logs if (!isBackendConnected) { addLog('INFO', `Attempting to connect to backend at ${window.location.host}...`) const interval = setInterval(() => { addLog('INFO', `Retrying connection to WebSocket /ws/status...`) apiClient.get('/api/settings') .then(() => { addLog('INFO', 'HTTP endpoint responding, waiting for WebSocket...') }) .catch(() => { // Still down }) }, 3000) return () => clearInterval(interval) } }, [isBackendConnected, isHoming, homingJustCompleted]) // Cache progress WebSocket connection - always connected to monitor cache generation useEffect(() => { if (!isBackendConnected) return let reconnectTimeout: ReturnType | null = null let shouldConnect = true const connectCacheWebSocket = () => { if (!shouldConnect) return // Don't interrupt an existing connection that's still connecting if (cacheWsRef.current) { if (cacheWsRef.current.readyState === WebSocket.CONNECTING) { return // Already connecting, wait for it } if (cacheWsRef.current.readyState === WebSocket.OPEN) { return // Already connected } // CLOSING or CLOSED state - clear the ref cacheWsRef.current = null } const ws = new WebSocket(apiClient.getWebSocketUrl('/ws/cache-progress')) // Assign to ref IMMEDIATELY so concurrent calls see it's connecting cacheWsRef.current = ws ws.onopen = () => { if (!shouldConnect) { // Effect cleanup ran while connecting - close now ws.close() } } ws.onmessage = (event) => { try { const message = JSON.parse(event.data) if (message.type === 'cache_progress') { const data = message.data if (data.is_running) { // Cache generation is running - show splash screen setCacheProgress(data) } else if (data.stage === 'complete') { // Cache generation just completed if (cacheProgress?.is_running) { // Was running before, now complete - show cache all prompt const promptShown = localStorage.getItem('cacheAllPromptShown') if (!promptShown) { setTimeout(() => { setCacheAllProgress(null) // Reset to clean state setShowCacheAllPrompt(true) }, 500) } } setCacheProgress(null) } else { // Not running and not complete (idle state) setCacheProgress(null) } } } catch { // Ignore parse errors } } ws.onclose = () => { if (!shouldConnect) return cacheWsRef.current = null // Reconnect after 3 seconds if (shouldConnect && isBackendConnected) { reconnectTimeout = setTimeout(connectCacheWebSocket, 3000) } } ws.onerror = () => { // Will trigger onclose } } connectCacheWebSocket() return () => { shouldConnect = false if (reconnectTimeout) { clearTimeout(reconnectTimeout) } if (cacheWsRef.current) { // Only close if already OPEN - CONNECTING WebSockets will close in onopen if (cacheWsRef.current.readyState === WebSocket.OPEN) { cacheWsRef.current.close() } cacheWsRef.current = null } } }, [isBackendConnected]) // Only reconnect based on backend connection, not cache state // Calculate cache progress percentage const cachePercentage = cacheProgress?.total_files ? Math.round((cacheProgress.processed_files / cacheProgress.total_files) * 100) : 0 const getCacheStageText = () => { if (!cacheProgress) return '' switch (cacheProgress.stage) { case 'starting': return 'Initializing...' case 'metadata': return 'Processing pattern metadata' case 'images': return 'Generating pattern previews' default: return 'Processing...' } } // Cache all previews in browser using IndexedDB const handleCacheAllPreviews = async () => { setCacheAllProgress({ inProgress: true, completed: 0, total: 0, done: false }) const result = await cacheAllPreviews((progress) => { setCacheAllProgress({ inProgress: !progress.done, ...progress }) }) if (result.success) { if (result.cached === 0) { toast.success('All patterns are already cached!') } else { toast.success(`Cached ${result.cached} pattern previews`) } } else { setCacheAllProgress(null) toast.error('Failed to cache previews') } } const handleSkipCacheAll = () => { localStorage.setItem('cacheAllPromptShown', 'true') setShowCacheAllPrompt(false) setCacheAllProgress(null) } const handleCloseCacheAllDone = () => { localStorage.setItem('cacheAllPromptShown', 'true') setShowCacheAllPrompt(false) setCacheAllProgress(null) } const cacheAllPercentage = cacheAllProgress?.total ? Math.round((cacheAllProgress.completed / cacheAllProgress.total) * 100) : 0 return (
{/* Sensor Homing Failure Popup */} {sensorHomingFailed && (
error_outline

Sensor Homing Failed

The sensor homing process could not complete. The limit sensors may not be positioned correctly or may be malfunctioning.

Troubleshooting steps:

  • Check that the limit sensors are properly connected
  • Verify the sensor positions are correct
  • Ensure nothing is blocking the sensor path
  • Check for loose wiring connections

Connection will not be established until this is resolved.

{/* Action Buttons */} {!isRecoveringHoming ? (

Crash homing moves the arm to a physical stop without using sensors.

) : (
sync Attempting recovery...
)}
)} {/* Cache Progress Blocking Overlay */} {cacheProgress?.is_running && (
cached

Initializing Pattern Cache

Preparing your pattern previews...

{/* Progress Bar */}
{cacheProgress.processed_files} of {cacheProgress.total_files} patterns {cachePercentage}%
{/* Stage Info */}

{getCacheStageText()}

{cacheProgress.current_file && (

{cacheProgress.current_file}

)}
{/* Hint */}

This only happens once after updates or when new patterns are added

)} {/* Cache All Previews Prompt Modal */} {showCacheAllPrompt && (
download_for_offline

Cache All Pattern Previews?

Would you like to cache all pattern previews for faster browsing? This will download and store preview images in your browser for instant loading.

Note: This cache is browser-specific. You'll need to repeat this for each browser you use.

{/* Initial state - show buttons */} {!cacheAllProgress && (
)} {/* Progress section */} {cacheAllProgress && !cacheAllProgress.done && (
{cacheAllProgress.completed} of {cacheAllProgress.total} previews {cacheAllPercentage}%
)} {/* Completion message */} {cacheAllProgress?.done && (

check_circle All {cacheAllProgress.total} previews cached successfully!

)}
)} {/* Backend Connection / Homing Blocking Overlay */} {/* Don't show this overlay when sensor homing failed - that has its own dialog */} {!sensorHomingFailed && (!isBackendConnected || (isHoming && !homingDismissed) || homingJustCompleted) && (
{/* Status Header */}
{homingJustCompleted ? 'check_circle' : 'sync'}

{homingJustCompleted ? 'Homing Complete' : isHoming ? 'Homing in Progress' : 'Connecting to Backend' }

{homingJustCompleted ? 'Table is ready to use' : isHoming ? 'Moving to home position... This may take up to 90 seconds.' : connectionAttempts === 0 ? 'Establishing connection...' : `Reconnecting... (attempt ${connectionAttempts})` }

{homingJustCompleted ? keepHomingLogsOpen ? 'Viewing logs' : `Closing in ${homingCountdown}s...` : isHoming ? 'Do not interrupt the homing process' : `Waiting for server at ${window.location.host}` }
{/* Logs Panel */}
terminal {isHoming || homingJustCompleted ? 'Homing Log' : 'Connection Log'}
{connectionLogs.length} entries
{connectionLogs.map((log, i) => (
{formatTimestamp(log.timestamp)} [{log.level}] {log.message}
))}
{/* Action buttons for homing completion */} {homingJustCompleted && (
{!keepHomingLogsOpen ? ( <> ) : ( )}
)} {/* Dismiss button during homing */} {isHoming && !homingJustCompleted && (
)} {/* Hint */} {!homingJustCompleted && (

{isHoming ? 'Homing will continue in the background' : 'Make sure the backend server is running on port 8080' }

)}
)} {/* Header - Floating Pill */}
{/* Blurry backdrop behind header - only on Browse page where content scrolls under */} {location.pathname === '/' && (
)}
{displayName}
{/* Desktop actions */}
{/* Mobile actions */}
{/* Main Content */}
{/* Now Playing Bar */} setIsNowPlayingOpen(false)} /> {/* Logs Drawer */}
{isLogsOpen && ( <> {/* Resize Handle */}
{/* Logs Header */}
Application Logs {filteredLogs.length}{logsTotal > 0 ? ` of ${logsTotal}` : ''} entries {logsHasMore && ↑ scroll for more}
{/* Logs Content */}
{/* Loading indicator for older logs */} {isLoadingMoreLogs && (
sync Loading older logs...
)} {/* Load more hint */} {logsHasMore && !isLoadingMoreLogs && (
↑ Scroll up to load older logs
)} {filteredLogs.length > 0 ? ( filteredLogs.map((log, i) => (
{formatTimestamp(log.timestamp)} [{log.level || 'LOG'}] {log.message || ''}
)) ) : (

No logs available

)}
)}
{/* Floating Now Playing Button - hidden when Now Playing bar is open */} {!isNowPlayingOpen && ( )} {/* Bottom Navigation */}
) }