import { Outlet, Link, useLocation } from 'react-router-dom' import { useEffect, useState, useRef } from 'react' import { toast } from 'sonner' import { NowPlayingBar } from '@/components/NowPlayingBar' import { Button } from '@/components/ui/button' 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() 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) // Connection status const [isConnected, setIsConnected] = useState(false) const [isBackendConnected, setIsBackendConnected] = useState(false) const [connectionAttempts, setConnectionAttempts] = useState(0) const wsRef = useRef(null) // Fetch app settings const fetchAppSettings = () => { fetch('/api/settings') .then((r) => r.json()) .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) } }, []) // Logs drawer state const [isLogsOpen, setIsLogsOpen] = useState(false) // Now Playing bar state const [isNowPlayingOpen, setIsNowPlayingOpen] = useState(false) const [openNowPlayingExpanded, setOpenNowPlayingExpanded] = useState(false) const wasPlayingRef = useRef(false) // Track previous playing state to detect start const [logs, setLogs] = useState>([]) const [logLevelFilter, setLogLevelFilter] = useState('ALL') const logsWsRef = useRef(null) const logsContainerRef = useRef(null) // Check device connection status via WebSocket useEffect(() => { const connectWebSocket = () => { const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:' const ws = new WebSocket(`${protocol}//${window.location.host}/ws/status`) ws.onopen = () => { setIsBackendConnected(true) setConnectionAttempts(0) // Dispatch event so pages can refetch data window.dispatchEvent(new CustomEvent('backend-connected')) } ws.onmessage = (event) => { 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) } // Auto-open/close Now Playing bar based on playback state const isPlaying = data.data.is_running || data.data.is_paused 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 = () => { setIsBackendConnected(false) setConnectionAttempts((prev) => prev + 1) // Reconnect after 3 seconds (don't change device status on WS disconnect) setTimeout(connectWebSocket, 3000) } ws.onerror = () => { setIsBackendConnected(false) } wsRef.current = ws } connectWebSocket() return () => { if (wsRef.current) { wsRef.current.close() } } }, []) // Connect to logs WebSocket when drawer opens useEffect(() => { if (!isLogsOpen) { // Close WebSocket when drawer closes if (logsWsRef.current) { logsWsRef.current.close() logsWsRef.current = null } return } // Fetch initial logs const fetchInitialLogs = async () => { try { const response = await fetch('/api/logs?limit=200') const data = await response.json() // Filter out empty/invalid log entries const validLogs = (data.logs || []).filter( (log: { message?: string }) => log && log.message && log.message.trim() !== '' ) // API returns newest first, reverse to show oldest first (newest at bottom) setLogs(validLogs.reverse()) // 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 = () => { const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:' const ws = new WebSocket(`${protocol}//${window.location.host}/ws/logs`) ws.onopen = () => { 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 } setLogs((prev) => { const newLogs = [...prev, log] // Keep only last 500 logs to prevent memory issues if (newLogs.length > 500) { return newLogs.slice(-500) } return newLogs }) // Auto-scroll to bottom setTimeout(() => { if (logsContainerRef.current) { logsContainerRef.current.scrollTop = logsContainerRef.current.scrollHeight } }, 10) } catch { // Ignore parse errors } } ws.onclose = () => { console.log('Logs WebSocket closed, reconnecting...') // Reconnect after 3 seconds if drawer is still open reconnectTimeout = setTimeout(() => { if (logsWsRef.current === ws) { connectLogsWebSocket() } }, 3000) } ws.onerror = (error) => { console.error('Logs WebSocket error:', error) } logsWsRef.current = ws } connectLogsWebSocket() return () => { if (reconnectTimeout) { clearTimeout(reconnectTimeout) } if (logsWsRef.current) { logsWsRef.current.close() logsWsRef.current = null } } }, [isLogsOpen]) const handleOpenLogs = () => { setIsLogsOpen(true) } // 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 const handleCopyLogs = () => { const text = filteredLogs .map((log) => `${formatTimestamp(log.timestamp)} [${log.level}] ${log.message}`) .join('\n') navigator.clipboard.writeText(text) toast.success('Logs copied to clipboard') } // 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 the system?')) return try { const response = await fetch('/restart', { method: 'POST' }) if (response.ok) { toast.success('System is restarting...') } else { throw new Error('Restart failed') } } catch { toast.error('Failed to restart system') } } const handleShutdown = async () => { if (!confirm('Are you sure you want to shutdown the system?')) return try { const response = await fetch('/shutdown', { method: 'POST' }) if (response.ok) { toast.success('System is shutting down...') } else { throw new Error('Shutdown failed') } } catch { toast.error('Failed to shutdown system') } } // Update document title based on current page useEffect(() => { const currentNav = navItems.find((item) => item.path === location.pathname) if (currentNav) { document.title = `${currentNav.title} | ${appName}` } else { document.title = appName } }, [location.pathname, appName]) 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) // Add connection attempt logs when backend is disconnected useEffect(() => { if (isBackendConnected) { setConnectionLogs([]) return } // Add initial log entry const addLog = (level: string, message: string) => { setConnectionLogs((prev) => { const newLog = { timestamp: new Date().toISOString(), level, message, } const newLogs = [...prev, newLog].slice(-50) // Keep last 50 entries return newLogs }) // Auto-scroll to bottom setTimeout(() => { if (blockingLogsRef.current) { blockingLogsRef.current.scrollTop = blockingLogsRef.current.scrollHeight } }, 10) } addLog('INFO', `Attempting to connect to backend at ${window.location.host}...`) // Log connection attempts const interval = setInterval(() => { addLog('INFO', `Retrying connection to WebSocket /ws/status...`) // Also try HTTP to see if backend is partially up fetch('/api/settings', { method: 'GET' }) .then(() => { addLog('INFO', 'HTTP endpoint responding, waiting for WebSocket...') }) .catch(() => { // Still down }) }, 3000) return () => clearInterval(interval) }, [isBackendConnected]) return (
{/* Backend Connection Blocking Overlay */} {!isBackendConnected && (
{/* Connection Status */}
sync

Connecting to Backend

{connectionAttempts === 0 ? 'Establishing connection...' : `Reconnecting... (attempt ${connectionAttempts})` }

Waiting for server at {window.location.host}
{/* Connection Logs Panel */}
terminal Connection Log
{connectionLogs.length} entries
{connectionLogs.map((log, i) => (
{formatTimestamp(log.timestamp)} [{log.level}] {log.message}
))}
{/* Hint */}

Make sure the backend server is running on port 8080

)} {/* Header */}
{appName} {appName}
{/* Main Content */}
{/* Now Playing Bar */} setIsNowPlayingOpen(false)} /> {/* Floating Now Playing Button */} {!isNowPlayingOpen && ( )} {/* Logs Drawer */}
{isLogsOpen && ( <>

Logs

{filteredLogs.length} entries
{filteredLogs.length > 0 ? ( filteredLogs.map((log, i) => (
{formatTimestamp(log.timestamp)} [{log.level || 'LOG'}] {log.message || ''}
)) ) : (

No logs available

)}
)}
{/* Bottom Navigation */}
) }