|
@@ -1,5 +1,5 @@
|
|
|
import { Outlet, Link, useLocation } from 'react-router-dom'
|
|
import { Outlet, Link, useLocation } from 'react-router-dom'
|
|
|
-import { useEffect, useState, useRef } from 'react'
|
|
|
|
|
|
|
+import { useEffect, useState, useRef, useCallback, useMemo } from 'react'
|
|
|
import { toast } from 'sonner'
|
|
import { toast } from 'sonner'
|
|
|
import { NowPlayingBar } from '@/components/NowPlayingBar'
|
|
import { NowPlayingBar } from '@/components/NowPlayingBar'
|
|
|
import { Button } from '@/components/ui/button'
|
|
import { Button } from '@/components/ui/button'
|
|
@@ -66,6 +66,13 @@ export function Layout() {
|
|
|
const [connectionAttempts, setConnectionAttempts] = useState(0)
|
|
const [connectionAttempts, setConnectionAttempts] = useState(0)
|
|
|
const wsRef = useRef<WebSocket | null>(null)
|
|
const wsRef = useRef<WebSocket | null>(null)
|
|
|
|
|
|
|
|
|
|
+ // Sensor homing failure state
|
|
|
|
|
+ const [sensorHomingFailed, setSensorHomingFailed] = useState(false)
|
|
|
|
|
+ const [isRecoveringHoming, setIsRecoveringHoming] = useState(false)
|
|
|
|
|
+
|
|
|
|
|
+ // Update availability
|
|
|
|
|
+ const [updateAvailable, setUpdateAvailable] = useState(false)
|
|
|
|
|
+
|
|
|
// Fetch app settings
|
|
// Fetch app settings
|
|
|
const fetchAppSettings = () => {
|
|
const fetchAppSettings = () => {
|
|
|
apiClient.get<{ app?: { name?: string; custom_logo?: string } }>('/api/settings')
|
|
apiClient.get<{ app?: { name?: string; custom_logo?: string } }>('/api/settings')
|
|
@@ -95,6 +102,17 @@ export function Layout() {
|
|
|
// Refetch when active table changes
|
|
// Refetch when active table changes
|
|
|
}, [activeTable?.id])
|
|
}, [activeTable?.id])
|
|
|
|
|
|
|
|
|
|
+ // Check for software updates on mount
|
|
|
|
|
+ useEffect(() => {
|
|
|
|
|
+ apiClient.get<{ update_available?: boolean }>('/api/version')
|
|
|
|
|
+ .then((data) => {
|
|
|
|
|
+ if (data.update_available) {
|
|
|
|
|
+ setUpdateAvailable(true)
|
|
|
|
|
+ }
|
|
|
|
|
+ })
|
|
|
|
|
+ .catch(() => {})
|
|
|
|
|
+ }, [activeTable?.id])
|
|
|
|
|
+
|
|
|
// Homing completion countdown timer
|
|
// Homing completion countdown timer
|
|
|
useEffect(() => {
|
|
useEffect(() => {
|
|
|
if (!homingJustCompleted || keepHomingLogsOpen) return
|
|
if (!homingJustCompleted || keepHomingLogsOpen) return
|
|
@@ -124,6 +142,8 @@ export function Layout() {
|
|
|
const startYRef = useRef(0)
|
|
const startYRef = useRef(0)
|
|
|
const startHeightRef = useRef(0)
|
|
const startHeightRef = useRef(0)
|
|
|
|
|
|
|
|
|
|
+ const [logSearchQuery, setLogSearchQuery] = useState('')
|
|
|
|
|
+
|
|
|
// Handle drawer resize
|
|
// Handle drawer resize
|
|
|
const handleResizeStart = (e: React.MouseEvent | React.TouchEvent) => {
|
|
const handleResizeStart = (e: React.MouseEvent | React.TouchEvent) => {
|
|
|
e.preventDefault()
|
|
e.preventDefault()
|
|
@@ -172,6 +192,21 @@ export function Layout() {
|
|
|
const [currentPlayingFile, setCurrentPlayingFile] = useState<string | null>(null) // Track current file for header button
|
|
const [currentPlayingFile, setCurrentPlayingFile] = useState<string | null>(null) // Track current file for header button
|
|
|
const wasPlayingRef = useRef<boolean | null>(null) // Track previous playing state (null = first message)
|
|
const wasPlayingRef = useRef<boolean | null>(null) // Track previous playing state (null = first message)
|
|
|
|
|
|
|
|
|
|
+ // Draggable Now Playing button state
|
|
|
|
|
+ type SnapPosition = 'left' | 'center' | 'right'
|
|
|
|
|
+ const [nowPlayingButtonPos, setNowPlayingButtonPos] = useState<SnapPosition>(() => {
|
|
|
|
|
+ if (typeof window !== 'undefined') {
|
|
|
|
|
+ const saved = localStorage.getItem('nowPlayingButtonPos')
|
|
|
|
|
+ if (saved === 'left' || saved === 'center' || saved === 'right') return saved
|
|
|
|
|
+ }
|
|
|
|
|
+ return 'center'
|
|
|
|
|
+ })
|
|
|
|
|
+ const [isDraggingButton, setIsDraggingButton] = useState(false)
|
|
|
|
|
+ const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 })
|
|
|
|
|
+ const buttonRef = useRef<HTMLButtonElement>(null)
|
|
|
|
|
+ const dragStartRef = useRef<{ x: number; y: number; buttonX: number } | null>(null)
|
|
|
|
|
+ const wasDraggingRef = useRef(false) // Track if a meaningful drag occurred
|
|
|
|
|
+
|
|
|
// Derive isCurrentlyPlaying from currentPlayingFile
|
|
// Derive isCurrentlyPlaying from currentPlayingFile
|
|
|
const isCurrentlyPlaying = Boolean(currentPlayingFile)
|
|
const isCurrentlyPlaying = Boolean(currentPlayingFile)
|
|
|
|
|
|
|
@@ -189,8 +224,12 @@ export function Layout() {
|
|
|
}, [])
|
|
}, [])
|
|
|
const [logs, setLogs] = useState<Array<{ timestamp: string; level: string; logger: string; message: string }>>([])
|
|
const [logs, setLogs] = useState<Array<{ timestamp: string; level: string; logger: string; message: string }>>([])
|
|
|
const [logLevelFilter, setLogLevelFilter] = useState<string>('ALL')
|
|
const [logLevelFilter, setLogLevelFilter] = useState<string>('ALL')
|
|
|
|
|
+ const [logsTotal, setLogsTotal] = useState(0)
|
|
|
|
|
+ const [logsHasMore, setLogsHasMore] = useState(false)
|
|
|
|
|
+ const [isLoadingMoreLogs, setIsLoadingMoreLogs] = useState(false)
|
|
|
const logsWsRef = useRef<WebSocket | null>(null)
|
|
const logsWsRef = useRef<WebSocket | null>(null)
|
|
|
const logsContainerRef = useRef<HTMLDivElement>(null)
|
|
const logsContainerRef = useRef<HTMLDivElement>(null)
|
|
|
|
|
+ const logsLoadedCountRef = useRef(0) // Track how many logs we've loaded (for offset)
|
|
|
|
|
|
|
|
// Check device connection status via WebSocket
|
|
// Check device connection status via WebSocket
|
|
|
// This effect runs once on mount and manages its own reconnection logic
|
|
// This effect runs once on mount and manages its own reconnection logic
|
|
@@ -249,13 +288,20 @@ export function Layout() {
|
|
|
// Detect transition from homing to not homing
|
|
// Detect transition from homing to not homing
|
|
|
if (wasHomingRef.current && !newIsHoming) {
|
|
if (wasHomingRef.current && !newIsHoming) {
|
|
|
// Homing just completed - show completion state with countdown
|
|
// Homing just completed - show completion state with countdown
|
|
|
- setHomingJustCompleted(true)
|
|
|
|
|
- setHomingCountdown(5)
|
|
|
|
|
- setHomingDismissed(false)
|
|
|
|
|
|
|
+ // 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
|
|
wasHomingRef.current = newIsHoming
|
|
|
setIsHoming(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
|
|
// Auto-open/close Now Playing bar based on playback state
|
|
|
// Track current file - this is the most reliable indicator of playback
|
|
// Track current file - this is the most reliable indicator of playback
|
|
|
const currentFile = data.data.current_file || null
|
|
const currentFile = data.data.current_file || null
|
|
@@ -315,6 +361,7 @@ export function Layout() {
|
|
|
setCurrentPlayingFile(null) // Reset playback state for new table
|
|
setCurrentPlayingFile(null) // Reset playback state for new table
|
|
|
setIsConnected(false) // Reset connection status until new table reports
|
|
setIsConnected(false) // Reset connection status until new table reports
|
|
|
setIsBackendConnected(false) // Show connecting state
|
|
setIsBackendConnected(false) // Show connecting state
|
|
|
|
|
+ setSensorHomingFailed(false) // Reset sensor homing failure state for new table
|
|
|
connectWebSocket()
|
|
connectWebSocket()
|
|
|
}
|
|
}
|
|
|
})
|
|
})
|
|
@@ -348,17 +395,21 @@ export function Layout() {
|
|
|
|
|
|
|
|
let shouldConnect = true
|
|
let shouldConnect = true
|
|
|
|
|
|
|
|
- // Fetch initial logs
|
|
|
|
|
|
|
+ // Fetch initial logs (most recent)
|
|
|
const fetchInitialLogs = async () => {
|
|
const fetchInitialLogs = async () => {
|
|
|
try {
|
|
try {
|
|
|
type LogEntry = { timestamp: string; level: string; logger: string; message: string }
|
|
type LogEntry = { timestamp: string; level: string; logger: string; message: string }
|
|
|
- const data = await apiClient.get<{ logs: LogEntry[] }>('/api/logs?limit=200')
|
|
|
|
|
|
|
+ type LogsResponse = { logs: LogEntry[]; total: number; has_more: boolean }
|
|
|
|
|
+ const data = await apiClient.get<LogsResponse>('/api/logs?limit=200')
|
|
|
// Filter out empty/invalid log entries
|
|
// Filter out empty/invalid log entries
|
|
|
const validLogs = (data.logs || []).filter(
|
|
const validLogs = (data.logs || []).filter(
|
|
|
(log) => log && log.message && log.message.trim() !== ''
|
|
(log) => log && log.message && log.message.trim() !== ''
|
|
|
)
|
|
)
|
|
|
// API returns newest first, reverse to show oldest first (newest at bottom)
|
|
// API returns newest first, reverse to show oldest first (newest at bottom)
|
|
|
setLogs(validLogs.reverse())
|
|
setLogs(validLogs.reverse())
|
|
|
|
|
+ setLogsTotal(data.total || 0)
|
|
|
|
|
+ setLogsHasMore(data.has_more || false)
|
|
|
|
|
+ logsLoadedCountRef.current = validLogs.length
|
|
|
// Scroll to bottom after initial load
|
|
// Scroll to bottom after initial load
|
|
|
setTimeout(() => {
|
|
setTimeout(() => {
|
|
|
if (logsContainerRef.current) {
|
|
if (logsContainerRef.current) {
|
|
@@ -416,18 +467,16 @@ export function Layout() {
|
|
|
if (!log || !log.message || log.message.trim() === '') {
|
|
if (!log || !log.message || log.message.trim() === '') {
|
|
|
return
|
|
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
|
|
|
|
|
|
|
+ // 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(() => {
|
|
setTimeout(() => {
|
|
|
if (logsContainerRef.current) {
|
|
if (logsContainerRef.current) {
|
|
|
- logsContainerRef.current.scrollTop = logsContainerRef.current.scrollHeight
|
|
|
|
|
|
|
+ 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)
|
|
}, 10)
|
|
|
} catch {
|
|
} catch {
|
|
@@ -469,14 +518,80 @@ export function Layout() {
|
|
|
// Also reconnect when active table changes
|
|
// Also reconnect when active table changes
|
|
|
}, [isLogsOpen, activeTable?.id])
|
|
}, [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<LogsResponse>(`/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 = () => {
|
|
const handleToggleLogs = () => {
|
|
|
setIsLogsOpen((prev) => !prev)
|
|
setIsLogsOpen((prev) => !prev)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // Filter logs by level
|
|
|
|
|
- const filteredLogs = logLevelFilter === 'ALL'
|
|
|
|
|
- ? logs
|
|
|
|
|
- : logs.filter((log) => log.level === logLevelFilter)
|
|
|
|
|
|
|
+ // Filter logs by level and search query
|
|
|
|
|
+ const filteredLogs = useMemo(() => {
|
|
|
|
|
+ let result = logLevelFilter === 'ALL'
|
|
|
|
|
+ ? logs
|
|
|
|
|
+ : logs.filter((log) => log.level === logLevelFilter)
|
|
|
|
|
+ if (logSearchQuery) {
|
|
|
|
|
+ const q = logSearchQuery.toLowerCase()
|
|
|
|
|
+ result = result.filter((log) =>
|
|
|
|
|
+ log.message?.toLowerCase().includes(q) ||
|
|
|
|
|
+ log.logger?.toLowerCase().includes(q)
|
|
|
|
|
+ )
|
|
|
|
|
+ }
|
|
|
|
|
+ return result
|
|
|
|
|
+ }, [logs, logLevelFilter, logSearchQuery])
|
|
|
|
|
|
|
|
// Format timestamp safely
|
|
// Format timestamp safely
|
|
|
const formatTimestamp = (timestamp: string) => {
|
|
const formatTimestamp = (timestamp: string) => {
|
|
@@ -560,6 +675,34 @@ export function Layout() {
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ // 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
|
|
// Update document title based on current page
|
|
|
useEffect(() => {
|
|
useEffect(() => {
|
|
|
const currentNav = navItems.find((item) => item.path === location.pathname)
|
|
const currentNav = navItems.find((item) => item.path === location.pathname)
|
|
@@ -882,12 +1025,200 @@ export function Layout() {
|
|
|
setCacheAllProgress(null)
|
|
setCacheAllProgress(null)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ // Now Playing button drag handlers
|
|
|
|
|
+ const getSnapPositions = useCallback(() => {
|
|
|
|
|
+ const padding = 16
|
|
|
|
|
+ const buttonWidth = buttonRef.current?.offsetWidth || 140
|
|
|
|
|
+ return {
|
|
|
|
|
+ left: padding + buttonWidth / 2,
|
|
|
|
|
+ center: window.innerWidth / 2,
|
|
|
|
|
+ right: window.innerWidth - padding - buttonWidth / 2,
|
|
|
|
|
+ }
|
|
|
|
|
+ }, [])
|
|
|
|
|
+
|
|
|
|
|
+ const handleButtonDragStart = useCallback((clientX: number, clientY: number) => {
|
|
|
|
|
+ if (!buttonRef.current) return
|
|
|
|
|
+ const rect = buttonRef.current.getBoundingClientRect()
|
|
|
|
|
+ const buttonCenterX = rect.left + rect.width / 2
|
|
|
|
|
+ dragStartRef.current = { x: clientX, y: clientY, buttonX: buttonCenterX }
|
|
|
|
|
+ wasDraggingRef.current = false // Reset drag flag
|
|
|
|
|
+ setIsDraggingButton(true)
|
|
|
|
|
+ setDragOffset({ x: 0, y: 0 })
|
|
|
|
|
+ }, [])
|
|
|
|
|
+
|
|
|
|
|
+ const handleButtonDragMove = useCallback((clientX: number) => {
|
|
|
|
|
+ if (!dragStartRef.current || !isDraggingButton) return
|
|
|
|
|
+ const deltaX = clientX - dragStartRef.current.x
|
|
|
|
|
+ // Mark as dragging if moved more than 8px (to distinguish from clicks)
|
|
|
|
|
+ if (Math.abs(deltaX) > 8) {
|
|
|
|
|
+ wasDraggingRef.current = true
|
|
|
|
|
+ }
|
|
|
|
|
+ setDragOffset({ x: deltaX, y: 0 })
|
|
|
|
|
+ }, [isDraggingButton])
|
|
|
|
|
+
|
|
|
|
|
+ const handleButtonDragEnd = useCallback(() => {
|
|
|
|
|
+ if (!dragStartRef.current || !buttonRef.current) {
|
|
|
|
|
+ setIsDraggingButton(false)
|
|
|
|
|
+ setDragOffset({ x: 0, y: 0 })
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Calculate current position
|
|
|
|
|
+ const currentX = dragStartRef.current.buttonX + dragOffset.x
|
|
|
|
|
+ const snapPositions = getSnapPositions()
|
|
|
|
|
+
|
|
|
|
|
+ // Find nearest snap position
|
|
|
|
|
+ const distances = {
|
|
|
|
|
+ left: Math.abs(currentX - snapPositions.left),
|
|
|
|
|
+ center: Math.abs(currentX - snapPositions.center),
|
|
|
|
|
+ right: Math.abs(currentX - snapPositions.right),
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ let nearest: SnapPosition = 'center'
|
|
|
|
|
+ let minDistance = distances.center
|
|
|
|
|
+ if (distances.left < minDistance) {
|
|
|
|
|
+ nearest = 'left'
|
|
|
|
|
+ minDistance = distances.left
|
|
|
|
|
+ }
|
|
|
|
|
+ if (distances.right < minDistance) {
|
|
|
|
|
+ nearest = 'right'
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Update position and persist
|
|
|
|
|
+ setNowPlayingButtonPos(nearest)
|
|
|
|
|
+ localStorage.setItem('nowPlayingButtonPos', nearest)
|
|
|
|
|
+
|
|
|
|
|
+ // Reset drag state
|
|
|
|
|
+ setIsDraggingButton(false)
|
|
|
|
|
+ setDragOffset({ x: 0, y: 0 })
|
|
|
|
|
+ dragStartRef.current = null
|
|
|
|
|
+ }, [dragOffset.x, getSnapPositions])
|
|
|
|
|
+
|
|
|
|
|
+ // Mouse drag handlers
|
|
|
|
|
+ useEffect(() => {
|
|
|
|
|
+ if (!isDraggingButton) return
|
|
|
|
|
+
|
|
|
|
|
+ const handleMouseMove = (e: MouseEvent) => {
|
|
|
|
|
+ e.preventDefault()
|
|
|
|
|
+ handleButtonDragMove(e.clientX)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const handleMouseUp = () => {
|
|
|
|
|
+ handleButtonDragEnd()
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ window.addEventListener('mousemove', handleMouseMove)
|
|
|
|
|
+ window.addEventListener('mouseup', handleMouseUp)
|
|
|
|
|
+
|
|
|
|
|
+ return () => {
|
|
|
|
|
+ window.removeEventListener('mousemove', handleMouseMove)
|
|
|
|
|
+ window.removeEventListener('mouseup', handleMouseUp)
|
|
|
|
|
+ }
|
|
|
|
|
+ }, [isDraggingButton, handleButtonDragMove, handleButtonDragEnd])
|
|
|
|
|
+
|
|
|
|
|
+ // Get button position style
|
|
|
|
|
+ const getButtonPositionStyle = useCallback((): React.CSSProperties => {
|
|
|
|
|
+ const baseStyle: React.CSSProperties = {
|
|
|
|
|
+ bottom: 'calc(4.5rem + env(safe-area-inset-bottom, 0px))',
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (isDraggingButton && dragStartRef.current) {
|
|
|
|
|
+ // During drag, use transform for smooth movement
|
|
|
|
|
+ const snapPositions = getSnapPositions()
|
|
|
|
|
+ const startX = snapPositions[nowPlayingButtonPos]
|
|
|
|
|
+ return {
|
|
|
|
|
+ ...baseStyle,
|
|
|
|
|
+ left: startX,
|
|
|
|
|
+ transform: `translateX(calc(-50% + ${dragOffset.x}px))`,
|
|
|
|
|
+ transition: 'none',
|
|
|
|
|
+ cursor: 'grabbing',
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Snapped positions
|
|
|
|
|
+ switch (nowPlayingButtonPos) {
|
|
|
|
|
+ case 'left':
|
|
|
|
|
+ return { ...baseStyle, left: '1rem', transform: 'translateX(0)' }
|
|
|
|
|
+ case 'right':
|
|
|
|
|
+ return { ...baseStyle, right: '1rem', left: 'auto', transform: 'translateX(0)' }
|
|
|
|
|
+ case 'center':
|
|
|
|
|
+ default:
|
|
|
|
|
+ return { ...baseStyle, left: '50%', transform: 'translateX(-50%)' }
|
|
|
|
|
+ }
|
|
|
|
|
+ }, [isDraggingButton, dragOffset.x, nowPlayingButtonPos, getSnapPositions])
|
|
|
|
|
+
|
|
|
const cacheAllPercentage = cacheAllProgress?.total
|
|
const cacheAllPercentage = cacheAllProgress?.total
|
|
|
? Math.round((cacheAllProgress.completed / cacheAllProgress.total) * 100)
|
|
? Math.round((cacheAllProgress.completed / cacheAllProgress.total) * 100)
|
|
|
: 0
|
|
: 0
|
|
|
|
|
|
|
|
return (
|
|
return (
|
|
|
<div className="min-h-dvh bg-background flex flex-col">
|
|
<div className="min-h-dvh bg-background flex flex-col">
|
|
|
|
|
+ {/* Sensor Homing Failure Popup */}
|
|
|
|
|
+ {sensorHomingFailed && (
|
|
|
|
|
+ <div className="fixed inset-0 z-50 bg-black/50 backdrop-blur-sm flex items-center justify-center p-4">
|
|
|
|
|
+ <div className="bg-background rounded-lg shadow-xl w-full max-w-md border border-destructive/30">
|
|
|
|
|
+ <div className="p-6">
|
|
|
|
|
+ <div className="text-center space-y-4">
|
|
|
|
|
+ <div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-destructive/10 mb-2">
|
|
|
|
|
+ <span className="material-icons-outlined text-4xl text-destructive">
|
|
|
|
|
+ error_outline
|
|
|
|
|
+ </span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <h2 className="text-xl font-semibold">Sensor Homing Failed</h2>
|
|
|
|
|
+ <p className="text-muted-foreground text-sm">
|
|
|
|
|
+ The sensor homing process could not complete. The limit sensors may not be positioned correctly or may be malfunctioning.
|
|
|
|
|
+ </p>
|
|
|
|
|
+
|
|
|
|
|
+ <div className="bg-amber-500/10 border border-amber-500/20 p-3 rounded-lg text-sm text-left">
|
|
|
|
|
+ <p className="text-amber-600 dark:text-amber-400 font-medium mb-2">
|
|
|
|
|
+ Troubleshooting steps:
|
|
|
|
|
+ </p>
|
|
|
|
|
+ <ul className="text-amber-600 dark:text-amber-400 space-y-1 list-disc list-inside">
|
|
|
|
|
+ <li>Check that the limit sensors are properly connected</li>
|
|
|
|
|
+ <li>Verify the sensor positions are correct</li>
|
|
|
|
|
+ <li>Ensure nothing is blocking the sensor path</li>
|
|
|
|
|
+ <li>Check for loose wiring connections</li>
|
|
|
|
|
+ </ul>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <p className="text-muted-foreground text-sm">
|
|
|
|
|
+ Connection will not be established until this is resolved.
|
|
|
|
|
+ </p>
|
|
|
|
|
+
|
|
|
|
|
+ {/* Action Buttons */}
|
|
|
|
|
+ {!isRecoveringHoming ? (
|
|
|
|
|
+ <div className="flex flex-col gap-2 pt-2">
|
|
|
|
|
+ <Button
|
|
|
|
|
+ onClick={() => handleSensorHomingRecovery(false)}
|
|
|
|
|
+ className="w-full gap-2"
|
|
|
|
|
+ >
|
|
|
|
|
+ <span className="material-icons text-base">refresh</span>
|
|
|
|
|
+ Retry Sensor Homing
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ <Button
|
|
|
|
|
+ variant="secondary"
|
|
|
|
|
+ onClick={() => handleSensorHomingRecovery(true)}
|
|
|
|
|
+ className="w-full gap-2"
|
|
|
|
|
+ >
|
|
|
|
|
+ <span className="material-icons text-base">sync_alt</span>
|
|
|
|
|
+ Switch to Crash Homing
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ <p className="text-xs text-muted-foreground">
|
|
|
|
|
+ Crash homing moves the arm to a physical stop without using sensors.
|
|
|
|
|
+ </p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ ) : (
|
|
|
|
|
+ <div className="flex items-center justify-center gap-2 py-4">
|
|
|
|
|
+ <span className="material-icons-outlined text-primary animate-spin">sync</span>
|
|
|
|
|
+ <span className="text-muted-foreground">Attempting recovery...</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+
|
|
|
{/* Cache Progress Blocking Overlay */}
|
|
{/* Cache Progress Blocking Overlay */}
|
|
|
{cacheProgress?.is_running && (
|
|
{cacheProgress?.is_running && (
|
|
|
<div className="fixed inset-0 z-50 bg-background/95 backdrop-blur-sm flex flex-col items-center justify-center p-4">
|
|
<div className="fixed inset-0 z-50 bg-background/95 backdrop-blur-sm flex flex-col items-center justify-center p-4">
|
|
@@ -1010,7 +1341,8 @@ export function Layout() {
|
|
|
)}
|
|
)}
|
|
|
|
|
|
|
|
{/* Backend Connection / Homing Blocking Overlay */}
|
|
{/* Backend Connection / Homing Blocking Overlay */}
|
|
|
- {(!isBackendConnected || (isHoming && !homingDismissed) || homingJustCompleted) && (
|
|
|
|
|
|
|
+ {/* Don't show this overlay when sensor homing failed - that has its own dialog */}
|
|
|
|
|
+ {!sensorHomingFailed && (!isBackendConnected || (isHoming && !homingDismissed) || homingJustCompleted) && (
|
|
|
<div className="fixed inset-0 z-50 bg-background/95 backdrop-blur-sm flex flex-col items-center justify-center p-4">
|
|
<div className="fixed inset-0 z-50 bg-background/95 backdrop-blur-sm flex flex-col items-center justify-center p-4">
|
|
|
<div className="w-full max-w-2xl space-y-6">
|
|
<div className="w-full max-w-2xl space-y-6">
|
|
|
{/* Status Header */}
|
|
{/* Status Header */}
|
|
@@ -1239,6 +1571,14 @@ export function Layout() {
|
|
|
|
|
|
|
|
{/* Desktop actions */}
|
|
{/* Desktop actions */}
|
|
|
<div className="hidden md:flex items-center gap-0 ml-2">
|
|
<div className="hidden md:flex items-center gap-0 ml-2">
|
|
|
|
|
+ {updateAvailable && (
|
|
|
|
|
+ <Link to="/settings?section=version" title="Software update available">
|
|
|
|
|
+ <span className="relative flex items-center justify-center w-8 h-8 rounded-full hover:bg-accent transition-colors">
|
|
|
|
|
+ <span className="material-icons-outlined text-xl">download</span>
|
|
|
|
|
+ <span className="absolute top-1 right-1 w-2 h-2 rounded-full bg-blue-500 animate-pulse" />
|
|
|
|
|
+ </span>
|
|
|
|
|
+ </Link>
|
|
|
|
|
+ )}
|
|
|
<Popover>
|
|
<Popover>
|
|
|
<PopoverTrigger asChild>
|
|
<PopoverTrigger asChild>
|
|
|
<Button
|
|
<Button
|
|
@@ -1290,6 +1630,14 @@ export function Layout() {
|
|
|
|
|
|
|
|
{/* Mobile actions */}
|
|
{/* Mobile actions */}
|
|
|
<div className="flex md:hidden items-center gap-0 ml-2">
|
|
<div className="flex md:hidden items-center gap-0 ml-2">
|
|
|
|
|
+ {updateAvailable && (
|
|
|
|
|
+ <Link to="/settings?section=version" title="Software update available">
|
|
|
|
|
+ <span className="relative flex items-center justify-center w-8 h-8 rounded-full hover:bg-accent transition-colors">
|
|
|
|
|
+ <span className="material-icons-outlined text-xl">download</span>
|
|
|
|
|
+ <span className="absolute top-1 right-1 w-2 h-2 rounded-full bg-blue-500 animate-pulse" />
|
|
|
|
|
+ </span>
|
|
|
|
|
+ </Link>
|
|
|
|
|
+ )}
|
|
|
<Popover open={isMobileMenuOpen} onOpenChange={setIsMobileMenuOpen}>
|
|
<Popover open={isMobileMenuOpen} onOpenChange={setIsMobileMenuOpen}>
|
|
|
<PopoverTrigger asChild>
|
|
<PopoverTrigger asChild>
|
|
|
<Button
|
|
<Button
|
|
@@ -1358,17 +1706,16 @@ export function Layout() {
|
|
|
|
|
|
|
|
{/* Main Content */}
|
|
{/* Main Content */}
|
|
|
<main
|
|
<main
|
|
|
- className={`container mx-auto px-4 transition-all duration-300 ${
|
|
|
|
|
- !isLogsOpen && !isNowPlayingOpen ? 'pb-20' :
|
|
|
|
|
- !isLogsOpen && isNowPlayingOpen ? 'pb-80' : ''
|
|
|
|
|
- }`}
|
|
|
|
|
|
|
+ className="container mx-auto px-4 transition-all duration-300"
|
|
|
style={{
|
|
style={{
|
|
|
paddingTop: 'calc(4.5rem + env(safe-area-inset-top, 0px))',
|
|
paddingTop: 'calc(4.5rem + env(safe-area-inset-top, 0px))',
|
|
|
paddingBottom: isLogsOpen
|
|
paddingBottom: isLogsOpen
|
|
|
? isNowPlayingOpen
|
|
? isNowPlayingOpen
|
|
|
- ? logsDrawerHeight + 256 + 64 // drawer + now playing + nav
|
|
|
|
|
- : logsDrawerHeight + 64 // drawer + nav
|
|
|
|
|
- : undefined
|
|
|
|
|
|
|
+ ? `calc(${logsDrawerHeight + 256 + 64}px + env(safe-area-inset-bottom, 0px))` // drawer + now playing + nav + safe area
|
|
|
|
|
+ : `calc(${logsDrawerHeight + 64}px + env(safe-area-inset-bottom, 0px))` // drawer + nav + safe area
|
|
|
|
|
+ : isNowPlayingOpen
|
|
|
|
|
+ ? 'calc(20rem + env(safe-area-inset-bottom, 0px))' // now playing bar + nav + safe area
|
|
|
|
|
+ : 'calc(8rem + env(safe-area-inset-bottom, 0px))' // floating pill + nav + safe area
|
|
|
}}
|
|
}}
|
|
|
>
|
|
>
|
|
|
<Outlet />
|
|
<Outlet />
|
|
@@ -1406,9 +1753,9 @@ export function Layout() {
|
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
{/* Logs Header */}
|
|
{/* Logs Header */}
|
|
|
- <div className="flex items-center justify-between px-4 py-2 border-b bg-muted/50">
|
|
|
|
|
- <div className="flex items-center gap-3">
|
|
|
|
|
- <span className="text-sm font-medium">Application Logs</span>
|
|
|
|
|
|
|
+ <div className="flex items-center justify-between px-4 py-2 border-b bg-muted/50 gap-2">
|
|
|
|
|
+ <div className="flex items-center gap-2 sm:gap-3 flex-wrap min-w-0">
|
|
|
|
|
+ <span className="text-sm font-medium whitespace-nowrap">Application Logs</span>
|
|
|
<select
|
|
<select
|
|
|
value={logLevelFilter}
|
|
value={logLevelFilter}
|
|
|
onChange={(e) => setLogLevelFilter(e.target.value)}
|
|
onChange={(e) => setLogLevelFilter(e.target.value)}
|
|
@@ -1420,12 +1767,25 @@ export function Layout() {
|
|
|
<option value="WARNING">Warning</option>
|
|
<option value="WARNING">Warning</option>
|
|
|
<option value="ERROR">Error</option>
|
|
<option value="ERROR">Error</option>
|
|
|
</select>
|
|
</select>
|
|
|
|
|
+ <input
|
|
|
|
|
+ type="text"
|
|
|
|
|
+ value={logSearchQuery}
|
|
|
|
|
+ onChange={(e) => setLogSearchQuery(e.target.value)}
|
|
|
|
|
+ placeholder="Search logs..."
|
|
|
|
|
+ className="text-xs bg-background border rounded px-2 py-1 w-28 sm:w-40"
|
|
|
|
|
+ />
|
|
|
|
|
+ {logSearchQuery && (
|
|
|
|
|
+ <Button variant="ghost" size="icon-sm" onClick={() => setLogSearchQuery('')} className="rounded-full" title="Clear search">
|
|
|
|
|
+ <span className="material-icons-outlined text-sm">close</span>
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ )}
|
|
|
<span className="text-xs text-muted-foreground">
|
|
<span className="text-xs text-muted-foreground">
|
|
|
- {filteredLogs.length} entries
|
|
|
|
|
|
|
+ {filteredLogs.length}{logsTotal > 0 ? ` of ${logsTotal}` : ''} entries
|
|
|
|
|
+ {logsHasMore && <span className="text-primary ml-1">↑ scroll for more</span>}
|
|
|
</span>
|
|
</span>
|
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
- <div className="flex items-center gap-1">
|
|
|
|
|
|
|
+ <div className="flex items-center gap-1 shrink-0">
|
|
|
<Button
|
|
<Button
|
|
|
variant="ghost"
|
|
variant="ghost"
|
|
|
size="icon-sm"
|
|
size="icon-sm"
|
|
@@ -1461,6 +1821,19 @@ export function Layout() {
|
|
|
ref={logsContainerRef}
|
|
ref={logsContainerRef}
|
|
|
className="h-[calc(100%-40px)] overflow-auto overscroll-contain p-3 font-mono text-xs space-y-0.5"
|
|
className="h-[calc(100%-40px)] overflow-auto overscroll-contain p-3 font-mono text-xs space-y-0.5"
|
|
|
>
|
|
>
|
|
|
|
|
+ {/* Loading indicator for older logs */}
|
|
|
|
|
+ {isLoadingMoreLogs && (
|
|
|
|
|
+ <div className="flex items-center justify-center gap-2 py-2 text-muted-foreground">
|
|
|
|
|
+ <span className="material-icons-outlined text-sm animate-spin">sync</span>
|
|
|
|
|
+ <span>Loading older logs...</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ {/* Load more hint */}
|
|
|
|
|
+ {logsHasMore && !isLoadingMoreLogs && (
|
|
|
|
|
+ <div className="text-center py-2 text-muted-foreground text-xs">
|
|
|
|
|
+ ↑ Scroll up to load older logs
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
{filteredLogs.length > 0 ? (
|
|
{filteredLogs.length > 0 ? (
|
|
|
filteredLogs.map((log, i) => (
|
|
filteredLogs.map((log, i) => (
|
|
|
<div key={i} className="py-0.5 flex gap-2">
|
|
<div key={i} className="py-0.5 flex gap-2">
|
|
@@ -1486,12 +1859,38 @@ export function Layout() {
|
|
|
)}
|
|
)}
|
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
- {/* Floating Now Playing Button - hidden when Now Playing bar is open */}
|
|
|
|
|
|
|
+ {/* Floating Now Playing Button - draggable, snaps to left/center/right */}
|
|
|
{!isNowPlayingOpen && (
|
|
{!isNowPlayingOpen && (
|
|
|
<button
|
|
<button
|
|
|
- onClick={() => setIsNowPlayingOpen(true)}
|
|
|
|
|
- className="fixed z-40 left-1/2 -translate-x-1/2 flex items-center gap-2 px-4 py-2 rounded-full bg-card border border-border shadow-lg transition-all hover:shadow-xl hover:scale-105 active:scale-95"
|
|
|
|
|
- style={{ bottom: 'calc(4.5rem + env(safe-area-inset-bottom, 0px))' }}
|
|
|
|
|
|
|
+ ref={buttonRef}
|
|
|
|
|
+ onClick={() => {
|
|
|
|
|
+ // Only open if it wasn't a drag (to distinguish click from drag)
|
|
|
|
|
+ if (!wasDraggingRef.current) {
|
|
|
|
|
+ setIsNowPlayingOpen(true)
|
|
|
|
|
+ }
|
|
|
|
|
+ wasDraggingRef.current = false
|
|
|
|
|
+ }}
|
|
|
|
|
+ onMouseDown={(e) => {
|
|
|
|
|
+ e.preventDefault()
|
|
|
|
|
+ handleButtonDragStart(e.clientX, e.clientY)
|
|
|
|
|
+ }}
|
|
|
|
|
+ onTouchStart={(e) => {
|
|
|
|
|
+ const touch = e.touches[0]
|
|
|
|
|
+ handleButtonDragStart(touch.clientX, touch.clientY)
|
|
|
|
|
+ }}
|
|
|
|
|
+ onTouchMove={(e) => {
|
|
|
|
|
+ const touch = e.touches[0]
|
|
|
|
|
+ handleButtonDragMove(touch.clientX)
|
|
|
|
|
+ }}
|
|
|
|
|
+ onTouchEnd={() => {
|
|
|
|
|
+ handleButtonDragEnd()
|
|
|
|
|
+ }}
|
|
|
|
|
+ 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 ${
|
|
|
|
|
+ isDraggingButton
|
|
|
|
|
+ ? 'cursor-grabbing scale-105 shadow-xl'
|
|
|
|
|
+ : 'cursor-grab transition-all duration-300 hover:shadow-xl hover:scale-105 active:scale-95'
|
|
|
|
|
+ }`}
|
|
|
|
|
+ style={getButtonPositionStyle()}
|
|
|
aria-label={isCurrentlyPlaying ? 'Now Playing' : 'Not Playing'}
|
|
aria-label={isCurrentlyPlaying ? 'Now Playing' : 'Not Playing'}
|
|
|
>
|
|
>
|
|
|
<span className={`material-icons-outlined text-xl ${isCurrentlyPlaying ? 'text-primary' : 'text-muted-foreground'}`}>
|
|
<span className={`material-icons-outlined text-xl ${isCurrentlyPlaying ? 'text-primary' : 'text-muted-foreground'}`}>
|