Chore: rename api->camera-management-api and web->management-dashboard-web-app; update compose, ignore, README references
This commit is contained in:
33
management-dashboard-web-app/src/App.css
Normal file
33
management-dashboard-web-app/src/App.css
Normal file
@@ -0,0 +1,33 @@
|
||||
/* App-specific styles that don't conflict with Tailwind */
|
||||
/* Most styling is now handled by Tailwind CSS classes */
|
||||
|
||||
.logo {
|
||||
height: 6em;
|
||||
padding: 1.5em;
|
||||
will-change: filter;
|
||||
transition: filter 300ms;
|
||||
}
|
||||
|
||||
.logo:hover {
|
||||
filter: drop-shadow(0 0 2em #646cffaa);
|
||||
}
|
||||
|
||||
.logo.react:hover {
|
||||
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||
}
|
||||
|
||||
@keyframes logo-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
a:nth-of-type(2) .logo {
|
||||
animation: logo-spin infinite 20s linear;
|
||||
}
|
||||
}
|
||||
121
management-dashboard-web-app/src/App.tsx
Normal file
121
management-dashboard-web-app/src/App.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { supabase } from './lib/supabase'
|
||||
import { Login } from './components/Login'
|
||||
import { Dashboard } from './components/Dashboard'
|
||||
|
||||
function App() {
|
||||
const [isAuthenticated, setIsAuthenticated] = useState<boolean | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [currentRoute, setCurrentRoute] = useState(window.location.pathname)
|
||||
|
||||
useEffect(() => {
|
||||
// Check initial auth state
|
||||
checkAuthState()
|
||||
|
||||
// Listen for auth changes
|
||||
const { data: { subscription } } = supabase.auth.onAuthStateChange((event, session) => {
|
||||
console.log('Auth state changed:', event, !!session)
|
||||
setIsAuthenticated(!!session)
|
||||
setLoading(false)
|
||||
|
||||
// Handle signout route
|
||||
if (event === 'SIGNED_OUT') {
|
||||
setCurrentRoute('/')
|
||||
window.history.pushState({}, '', '/')
|
||||
}
|
||||
})
|
||||
|
||||
// Handle browser navigation
|
||||
const handlePopState = () => {
|
||||
setCurrentRoute(window.location.pathname)
|
||||
}
|
||||
|
||||
window.addEventListener('popstate', handlePopState)
|
||||
|
||||
return () => {
|
||||
subscription.unsubscribe()
|
||||
window.removeEventListener('popstate', handlePopState)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
// Handle signout route
|
||||
if (currentRoute === '/signout') {
|
||||
handleLogout()
|
||||
}
|
||||
}, [currentRoute])
|
||||
|
||||
const checkAuthState = async () => {
|
||||
try {
|
||||
const { data: { session } } = await supabase.auth.getSession()
|
||||
setIsAuthenticated(!!session)
|
||||
} catch (error) {
|
||||
console.error('Error checking auth state:', error)
|
||||
setIsAuthenticated(false)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleLoginSuccess = () => {
|
||||
setIsAuthenticated(true)
|
||||
setCurrentRoute('/')
|
||||
window.history.pushState({}, '', '/')
|
||||
}
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
// Clear Supabase session
|
||||
await supabase.auth.signOut()
|
||||
|
||||
// Clear any local storage items
|
||||
localStorage.removeItem('supabase.auth.token')
|
||||
|
||||
// Reset state
|
||||
setIsAuthenticated(false)
|
||||
setCurrentRoute('/')
|
||||
window.history.pushState({}, '', '/')
|
||||
} catch (error) {
|
||||
console.error('Logout error:', error)
|
||||
// Still reset state even if there's an error
|
||||
setIsAuthenticated(false)
|
||||
setCurrentRoute('/')
|
||||
window.history.pushState({}, '', '/')
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600 mx-auto"></div>
|
||||
<p className="mt-4 text-gray-600">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Handle signout route
|
||||
if (currentRoute === '/signout') {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600 mx-auto"></div>
|
||||
<p className="mt-4 text-gray-600">Signing out...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{isAuthenticated ? (
|
||||
<Dashboard onLogout={handleLogout} />
|
||||
) : (
|
||||
<Login onLoginSuccess={handleLoginSuccess} />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
1
management-dashboard-web-app/src/assets/react.svg
Normal file
1
management-dashboard-web-app/src/assets/react.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
@@ -0,0 +1,162 @@
|
||||
import { memo, useState, useEffect } from 'react'
|
||||
import { visionApi, type AutoRecordingStatusResponse } from '../lib/visionApi'
|
||||
import { useAuth } from '../hooks/useAuth'
|
||||
|
||||
const AutoRecordingStatus = memo(() => {
|
||||
const { isAdmin } = useAuth()
|
||||
const isAdminUser = isAdmin()
|
||||
const [status, setStatus] = useState<AutoRecordingStatusResponse | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Fetch auto-recording status
|
||||
const fetchStatus = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
const statusData = await visionApi.getAutoRecordingStatus()
|
||||
setStatus(statusData)
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to fetch auto-recording status'
|
||||
setError(errorMessage)
|
||||
console.error('Failed to fetch auto-recording status:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch status on mount and set up polling
|
||||
useEffect(() => {
|
||||
if (!isAdminUser) {
|
||||
return
|
||||
}
|
||||
|
||||
fetchStatus()
|
||||
const interval = setInterval(fetchStatus, 10000) // Poll every 10 seconds
|
||||
return () => clearInterval(interval)
|
||||
}, [isAdminUser])
|
||||
|
||||
// Only show to admins
|
||||
if (!isAdminUser) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white shadow rounded-lg">
|
||||
<div className="px-4 py-5 sm:px-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg leading-6 font-medium text-gray-900">Auto-Recording System</h3>
|
||||
<p className="mt-1 max-w-2xl text-sm text-gray-500">
|
||||
Server-side automatic recording based on machine state changes
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${status?.running ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'
|
||||
}`}>
|
||||
{status?.running ? 'Running' : 'Stopped'}
|
||||
</div>
|
||||
<button
|
||||
onClick={fetchStatus}
|
||||
disabled={loading}
|
||||
className="bg-indigo-600 text-white px-3 py-1 rounded-md text-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Refreshing...' : 'Refresh'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="px-4 py-3 border-t border-gray-200">
|
||||
<div className="bg-red-50 border border-red-200 rounded-md p-3">
|
||||
<p className="text-red-800 text-sm">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status && (
|
||||
<div className="border-t border-gray-200">
|
||||
<div className="px-4 py-5 sm:px-6">
|
||||
<h4 className="text-md font-medium text-gray-900 mb-3">System Status</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">System Running:</span>
|
||||
<span className={`font-medium ${status.running ? 'text-green-600' : 'text-red-600'
|
||||
}`}>
|
||||
{status.running ? 'YES' : 'NO'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Auto-Recording Enabled:</span>
|
||||
<span className={`font-medium ${status.auto_recording_enabled ? 'text-green-600' : 'text-gray-600'
|
||||
}`}>
|
||||
{status.auto_recording_enabled ? 'YES' : 'NO'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Enabled Cameras:</span>
|
||||
<span className="font-medium text-gray-900">
|
||||
{status.enabled_cameras.length}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Retry Queue:</span>
|
||||
<span className="font-medium text-gray-900">
|
||||
{Object.keys(status.retry_queue).length} items
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{status.enabled_cameras.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<h5 className="text-sm font-medium text-gray-900 mb-2">Enabled Cameras:</h5>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{status.enabled_cameras.map((camera) => (
|
||||
<span
|
||||
key={camera}
|
||||
className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800"
|
||||
>
|
||||
{camera}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{Object.keys(status.retry_queue).length > 0 && (
|
||||
<div className="mt-4">
|
||||
<h5 className="text-sm font-medium text-gray-900 mb-2">Retry Queue:</h5>
|
||||
<div className="space-y-1">
|
||||
{Object.entries(status.retry_queue).map(([camera, retryInfo]) => (
|
||||
<div key={camera} className="text-xs text-gray-600 bg-yellow-50 p-2 rounded">
|
||||
<strong>{camera}:</strong> {JSON.stringify(retryInfo)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!status && !loading && !error && (
|
||||
<div className="border-t border-gray-200 px-4 py-5 sm:px-6">
|
||||
<div className="text-center text-gray-500">
|
||||
<p>Auto-recording status not available</p>
|
||||
<p className="text-sm mt-1">Click "Refresh" to fetch the current status</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
AutoRecordingStatus.displayName = 'AutoRecordingStatus'
|
||||
|
||||
export { AutoRecordingStatus }
|
||||
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* Auto-Recording Test Component
|
||||
*
|
||||
* This component provides a testing interface for the auto-recording functionality.
|
||||
* It allows admins to simulate MQTT events and verify auto-recording behavior.
|
||||
*/
|
||||
|
||||
import { useState } from 'react'
|
||||
import { visionApi } from '../lib/visionApi'
|
||||
import { useAuth } from '../hooks/useAuth'
|
||||
|
||||
interface TestEvent {
|
||||
machine: string
|
||||
state: 'on' | 'off'
|
||||
timestamp: Date
|
||||
result?: string
|
||||
}
|
||||
|
||||
export function AutoRecordingTest() {
|
||||
const { isAdmin } = useAuth()
|
||||
const [testEvents, setTestEvents] = useState<TestEvent[]>([])
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
if (!isAdmin()) {
|
||||
return null
|
||||
}
|
||||
|
||||
const simulateEvent = async (machine: string, state: 'on' | 'off') => {
|
||||
setIsLoading(true)
|
||||
|
||||
const event: TestEvent = {
|
||||
machine,
|
||||
state,
|
||||
timestamp: new Date()
|
||||
}
|
||||
|
||||
try {
|
||||
// Map machines to their corresponding cameras
|
||||
const machineToCamera: Record<string, string> = {
|
||||
'blower_separator': 'camera1', // camera1 is for blower separator
|
||||
'vibratory_conveyor': 'camera2' // camera2 is for conveyor
|
||||
}
|
||||
|
||||
const cameraName = machineToCamera[machine]
|
||||
if (!cameraName) {
|
||||
event.result = `❌ Error: No camera mapped for machine ${machine}`
|
||||
setTestEvents(prev => [event, ...prev.slice(0, 9)])
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
if (state === 'on') {
|
||||
// Simulate starting recording on the correct camera
|
||||
const result = await visionApi.startRecording(cameraName, {
|
||||
filename: `test_auto_${machine}_${Date.now()}.mp4`
|
||||
})
|
||||
event.result = result.success ? `✅ Recording started on ${cameraName}: ${result.filename}` : `❌ Failed: ${result.message}`
|
||||
} else {
|
||||
// Simulate stopping recording on the correct camera
|
||||
const result = await visionApi.stopRecording(cameraName)
|
||||
event.result = result.success ? `⏹️ Recording stopped on ${cameraName} (${result.duration_seconds}s)` : `❌ Failed: ${result.message}`
|
||||
}
|
||||
} catch (error) {
|
||||
event.result = `❌ Error: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
}
|
||||
|
||||
setTestEvents(prev => [event, ...prev.slice(0, 9)]) // Keep last 10 events
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
const clearEvents = () => {
|
||||
setTestEvents([])
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white shadow rounded-lg">
|
||||
<div className="px-4 py-5 sm:px-6">
|
||||
<h3 className="text-lg leading-6 font-medium text-gray-900">Auto-Recording Test</h3>
|
||||
<p className="mt-1 max-w-2xl text-sm text-gray-500">
|
||||
Simulate machine state changes to test auto-recording functionality
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-200 px-4 py-5 sm:px-6">
|
||||
<div className="space-y-4">
|
||||
{/* Test Controls */}
|
||||
<div>
|
||||
<h4 className="text-md font-medium text-gray-900 mb-3">Simulate Machine Events</h4>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<button
|
||||
onClick={() => simulateEvent('vibratory_conveyor', 'on')}
|
||||
disabled={isLoading}
|
||||
className="bg-green-600 text-white px-3 py-2 rounded-md text-sm hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Conveyor ON
|
||||
</button>
|
||||
<button
|
||||
onClick={() => simulateEvent('vibratory_conveyor', 'off')}
|
||||
disabled={isLoading}
|
||||
className="bg-red-600 text-white px-3 py-2 rounded-md text-sm hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Conveyor OFF
|
||||
</button>
|
||||
<button
|
||||
onClick={() => simulateEvent('blower_separator', 'on')}
|
||||
disabled={isLoading}
|
||||
className="bg-green-600 text-white px-3 py-2 rounded-md text-sm hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Blower ON
|
||||
</button>
|
||||
<button
|
||||
onClick={() => simulateEvent('blower_separator', 'off')}
|
||||
disabled={isLoading}
|
||||
className="bg-red-600 text-white px-3 py-2 rounded-md text-sm hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Blower OFF
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Clear Button */}
|
||||
{testEvents.length > 0 && (
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={clearEvents}
|
||||
className="bg-gray-600 text-white px-3 py-2 rounded-md text-sm hover:bg-gray-700"
|
||||
>
|
||||
Clear Events
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Test Results */}
|
||||
{testEvents.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-md font-medium text-gray-900 mb-3">Test Results</h4>
|
||||
<div className="space-y-2">
|
||||
{testEvents.map((event, index) => (
|
||||
<div key={index} className="border border-gray-200 rounded-lg p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<span className="text-sm font-medium text-gray-900">
|
||||
{event.machine.replace(/_/g, ' ')}
|
||||
</span>
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${event.state === 'on'
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-red-100 text-red-800'
|
||||
}`}>
|
||||
{event.state.toUpperCase()}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{event.timestamp.toLocaleTimeString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{event.result && (
|
||||
<div className="mt-2 text-sm text-gray-700">
|
||||
{event.result}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Instructions */}
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-md p-4">
|
||||
<h4 className="text-sm font-medium text-blue-900 mb-2">Testing Instructions</h4>
|
||||
<ul className="text-sm text-blue-800 space-y-1">
|
||||
<li>1. Ensure auto-recording is enabled for cameras in their configuration</li>
|
||||
<li>2. Start the auto-recording manager in the Vision System page</li>
|
||||
<li>3. Click the buttons above to simulate machine state changes</li>
|
||||
<li>4. Verify that recordings start/stop automatically</li>
|
||||
<li>5. Check the storage section for auto-generated recording files</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Expected Behavior */}
|
||||
<div className="bg-gray-50 border border-gray-200 rounded-md p-4">
|
||||
<h4 className="text-sm font-medium text-gray-900 mb-2">Expected Behavior</h4>
|
||||
<div className="text-sm text-gray-700 space-y-1">
|
||||
<div><strong>Conveyor ON:</strong> Camera2 should start recording automatically</div>
|
||||
<div><strong>Conveyor OFF:</strong> Camera2 should stop recording automatically</div>
|
||||
<div><strong>Blower ON:</strong> Camera1 should start recording automatically</div>
|
||||
<div><strong>Blower OFF:</strong> Camera1 should stop recording automatically</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,754 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { visionApi, type CameraConfig, type CameraConfigUpdate } from '../lib/visionApi'
|
||||
|
||||
|
||||
interface CameraConfigModalProps {
|
||||
cameraName: string
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onSuccess?: (message: string) => void
|
||||
onError?: (error: string) => void
|
||||
}
|
||||
|
||||
export function CameraConfigModal({ cameraName, isOpen, onClose, onSuccess, onError }: CameraConfigModalProps) {
|
||||
const [config, setConfig] = useState<CameraConfig | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [hasChanges, setHasChanges] = useState(false)
|
||||
const [originalConfig, setOriginalConfig] = useState<CameraConfig | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && cameraName) {
|
||||
loadConfig()
|
||||
}
|
||||
}, [isOpen, cameraName])
|
||||
|
||||
const loadConfig = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
const configData = await visionApi.getCameraConfig(cameraName)
|
||||
|
||||
// The API should now include all fields including video format settings
|
||||
const configWithDefaults = configData
|
||||
|
||||
setConfig(configWithDefaults)
|
||||
setOriginalConfig(configWithDefaults)
|
||||
setHasChanges(false)
|
||||
} catch (err) {
|
||||
let errorMessage = 'Failed to load camera configuration'
|
||||
|
||||
if (err instanceof Error) {
|
||||
errorMessage = err.message
|
||||
|
||||
// Handle specific API validation errors for missing video format fields
|
||||
if (err.message.includes('video_format') || err.message.includes('video_codec') || err.message.includes('video_quality')) {
|
||||
errorMessage = 'Camera configuration is missing video format settings. This may indicate the backend needs to be updated to support MP4 format. Using default values.'
|
||||
|
||||
// Create a default configuration for display
|
||||
const defaultConfig = {
|
||||
name: cameraName,
|
||||
machine_topic: '',
|
||||
storage_path: '',
|
||||
enabled: true,
|
||||
auto_record_on_machine_start: false,
|
||||
auto_start_recording_enabled: false,
|
||||
auto_recording_max_retries: 3,
|
||||
auto_recording_retry_delay_seconds: 2,
|
||||
exposure_ms: 1.0,
|
||||
gain: 3.5,
|
||||
target_fps: 0,
|
||||
video_format: 'mp4',
|
||||
video_codec: 'mp4v',
|
||||
video_quality: 95,
|
||||
sharpness: 120,
|
||||
contrast: 110,
|
||||
saturation: 100,
|
||||
gamma: 100,
|
||||
noise_filter_enabled: true,
|
||||
denoise_3d_enabled: false,
|
||||
auto_white_balance: true,
|
||||
color_temperature_preset: 0,
|
||||
anti_flicker_enabled: true,
|
||||
light_frequency: 1,
|
||||
bit_depth: 8,
|
||||
hdr_enabled: false,
|
||||
hdr_gain_mode: 0,
|
||||
}
|
||||
|
||||
setConfig(defaultConfig)
|
||||
setOriginalConfig(defaultConfig)
|
||||
setHasChanges(false)
|
||||
setError(errorMessage)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
setError(errorMessage)
|
||||
onError?.(errorMessage)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const updateSetting = (key: keyof CameraConfigUpdate, value: number | boolean | string) => {
|
||||
if (!config) return
|
||||
|
||||
const newConfig = { ...config, [key]: value }
|
||||
setConfig(newConfig)
|
||||
|
||||
// Check if there are changes from original
|
||||
const hasChanges = originalConfig && Object.keys(newConfig).some(k => {
|
||||
const configKey = k as keyof CameraConfig
|
||||
return newConfig[configKey] !== originalConfig[configKey]
|
||||
})
|
||||
setHasChanges(!!hasChanges)
|
||||
|
||||
// Video format settings are read-only, no validation needed
|
||||
}
|
||||
|
||||
const saveConfig = async () => {
|
||||
if (!config || !originalConfig) return
|
||||
|
||||
try {
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
|
||||
// Build update object with only changed values
|
||||
const updates: CameraConfigUpdate = {}
|
||||
const configKeys: (keyof CameraConfigUpdate)[] = [
|
||||
'exposure_ms', 'gain', 'target_fps', 'sharpness', 'contrast', 'saturation',
|
||||
'gamma', 'noise_filter_enabled', 'denoise_3d_enabled', 'auto_white_balance',
|
||||
'color_temperature_preset', 'anti_flicker_enabled', 'light_frequency',
|
||||
'hdr_enabled', 'hdr_gain_mode', 'auto_record_on_machine_start',
|
||||
'auto_start_recording_enabled', 'auto_recording_max_retries', 'auto_recording_retry_delay_seconds'
|
||||
]
|
||||
|
||||
configKeys.forEach(key => {
|
||||
if (config[key] !== originalConfig[key]) {
|
||||
updates[key] = config[key] as any
|
||||
}
|
||||
})
|
||||
|
||||
if (Object.keys(updates).length === 0) {
|
||||
onSuccess?.('No changes to save')
|
||||
return
|
||||
}
|
||||
|
||||
const result = await visionApi.updateCameraConfig(cameraName, updates)
|
||||
|
||||
if (result.success) {
|
||||
setOriginalConfig(config)
|
||||
setHasChanges(false)
|
||||
onSuccess?.(`Configuration updated: ${result.updated_settings.join(', ')}`)
|
||||
} else {
|
||||
throw new Error(result.message)
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to save configuration'
|
||||
setError(errorMessage)
|
||||
onError?.(errorMessage)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
const resetChanges = () => {
|
||||
if (originalConfig) {
|
||||
setConfig(originalConfig)
|
||||
setHasChanges(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 flex items-center justify-center overflow-y-auto modal z-999999">
|
||||
<div
|
||||
className="fixed inset-0 h-full w-full bg-gray-400/50 backdrop-blur-[32px]"
|
||||
onClick={onClose}
|
||||
/>
|
||||
<div className="relative w-full rounded-2xl bg-white shadow-theme-xl dark:bg-gray-900 max-w-4xl mx-4 max-h-[90vh] overflow-hidden" onClick={(e) => e.stopPropagation()}>
|
||||
{/* Close Button */}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute right-3 top-3 z-999 flex h-9.5 w-9.5 items-center justify-center rounded-full bg-gray-100 text-gray-400 transition-colors hover:bg-gray-200 hover:text-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white sm:right-6 sm:top-6 sm:h-11 sm:w-11"
|
||||
>
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M6.04289 16.5413C5.65237 16.9318 5.65237 17.565 6.04289 17.9555C6.43342 18.346 7.06658 18.346 7.45711 17.9555L11.9987 13.4139L16.5408 17.956C16.9313 18.3466 17.5645 18.3466 17.955 17.956C18.3455 17.5655 18.3455 16.9323 17.955 16.5418L13.4129 11.9997L17.955 7.4576C18.3455 7.06707 18.3455 6.43391 17.955 6.04338C17.5645 5.65286 16.9313 5.65286 16.5408 6.04338L11.9987 10.5855L7.45711 6.0439C7.06658 5.65338 6.43342 5.65338 6.04289 6.0439C5.65237 6.43442 5.65237 7.06759 6.04289 7.45811L10.5845 11.9997L6.04289 16.5413Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Header */}
|
||||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-800">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white/90">
|
||||
Camera Configuration - {cameraName}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="px-6 py-4 overflow-y-auto max-h-[calc(90vh-140px)]">
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-indigo-600"></div>
|
||||
<span className="ml-2 text-gray-600">Loading configuration...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-4 bg-red-50 border border-red-200 rounded-md">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<svg className="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-red-800">Configuration Error</h3>
|
||||
<p className="mt-2 text-sm text-red-700">{error}</p>
|
||||
{error.includes('video_format') && (
|
||||
<p className="mt-2 text-sm text-red-600">
|
||||
<strong>Note:</strong> The video format settings are displayed with default values.
|
||||
You can still modify and save the configuration, but the backend may need to be updated
|
||||
to fully support MP4 format settings.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{config && !loading && (
|
||||
<div className="space-y-6">
|
||||
{/* System Information (Read-Only) */}
|
||||
<div>
|
||||
<h4 className="text-md font-medium text-gray-900 mb-4">System Information</h4>
|
||||
<div className="bg-gray-50 border border-gray-200 rounded-md p-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Camera Name</label>
|
||||
<div className="text-sm text-gray-900 font-medium">{config.name}</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Machine Topic</label>
|
||||
<div className="text-sm text-gray-900 font-medium">{config.machine_topic}</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Storage Path</label>
|
||||
<div className="text-sm text-gray-900 font-medium">{config.storage_path}</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Status</label>
|
||||
<div className="text-sm text-gray-900 font-medium">
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${config.enabled ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
|
||||
}`}>
|
||||
{config.enabled ? 'Enabled' : 'Disabled'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Auto-Recording Settings (Read-Only) */}
|
||||
<div>
|
||||
<h4 className="text-md font-medium text-gray-900 mb-4">Auto-Recording Settings</h4>
|
||||
<div className="bg-gray-50 border border-gray-200 rounded-md p-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Auto Recording</label>
|
||||
<div className="text-sm text-gray-900 font-medium">
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${config.auto_start_recording_enabled ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
|
||||
}`}>
|
||||
{config.auto_start_recording_enabled ? 'Enabled' : 'Disabled'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Max Retries</label>
|
||||
<div className="text-sm text-gray-900 font-medium">{config.auto_recording_max_retries}</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Retry Delay</label>
|
||||
<div className="text-sm text-gray-900 font-medium">{config.auto_recording_retry_delay_seconds}s</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-3">Auto-recording settings are configured in the system configuration file</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Basic Settings */}
|
||||
<div>
|
||||
<h4 className="text-md font-medium text-gray-900 mb-4">Basic Settings</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Exposure (ms): {config.exposure_ms}
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0.1"
|
||||
max="10"
|
||||
step="0.1"
|
||||
value={config.exposure_ms}
|
||||
onChange={(e) => updateSetting('exposure_ms', parseFloat(e.target.value))}
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-gray-500 mt-1">
|
||||
<span>0.1ms</span>
|
||||
<span>10ms</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Gain: {config.gain}
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="10"
|
||||
step="0.1"
|
||||
value={config.gain}
|
||||
onChange={(e) => updateSetting('gain', parseFloat(e.target.value))}
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-gray-500 mt-1">
|
||||
<span>0</span>
|
||||
<span>10</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Target FPS: {config.target_fps} {config.target_fps === 0 ? '(Maximum)' : ''}
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="30"
|
||||
step="1"
|
||||
value={config.target_fps}
|
||||
onChange={(e) => updateSetting('target_fps', parseInt(e.target.value))}
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-gray-500 mt-1">
|
||||
<span>0 (Max)</span>
|
||||
<span>30</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Image Quality Settings */}
|
||||
<div>
|
||||
<h4 className="text-md font-medium text-gray-900 mb-4">Image Quality</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Sharpness: {config.sharpness}
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="200"
|
||||
value={config.sharpness}
|
||||
onChange={(e) => updateSetting('sharpness', parseInt(e.target.value))}
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-gray-500 mt-1">
|
||||
<span>0</span>
|
||||
<span>200</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Contrast: {config.contrast}
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="200"
|
||||
value={config.contrast}
|
||||
onChange={(e) => updateSetting('contrast', parseInt(e.target.value))}
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-gray-500 mt-1">
|
||||
<span>0</span>
|
||||
<span>200</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Saturation: {config.saturation}
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="200"
|
||||
value={config.saturation}
|
||||
onChange={(e) => updateSetting('saturation', parseInt(e.target.value))}
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-gray-500 mt-1">
|
||||
<span>0</span>
|
||||
<span>200</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Gamma: {config.gamma}
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="300"
|
||||
value={config.gamma}
|
||||
onChange={(e) => updateSetting('gamma', parseInt(e.target.value))}
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-gray-500 mt-1">
|
||||
<span>0</span>
|
||||
<span>300</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Color Settings */}
|
||||
<div>
|
||||
<h4 className="text-md font-medium text-gray-900 mb-4">Color Settings</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.auto_white_balance}
|
||||
onChange={(e) => updateSetting('auto_white_balance', e.target.checked)}
|
||||
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
||||
/>
|
||||
<span className="text-sm font-medium text-gray-700">Auto White Balance</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Color Temperature Preset: {config.color_temperature_preset} {config.color_temperature_preset === 0 ? '(Auto)' : ''}
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="10"
|
||||
value={config.color_temperature_preset}
|
||||
onChange={(e) => updateSetting('color_temperature_preset', parseInt(e.target.value))}
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-gray-500 mt-1">
|
||||
<span>0 (Auto)</span>
|
||||
<span>10</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* White Balance RGB Gains */}
|
||||
<div>
|
||||
<h4 className="text-md font-medium text-gray-900 mb-4">White Balance RGB Gains</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Red Gain: {config.wb_red_gain?.toFixed(2) || '1.00'}
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="3.99"
|
||||
step="0.01"
|
||||
value={config.wb_red_gain || 1.0}
|
||||
onChange={(e) => updateSetting('wb_red_gain', parseFloat(e.target.value))}
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-gray-500 mt-1">
|
||||
<span>0.00</span>
|
||||
<span>3.99</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Green Gain: {config.wb_green_gain?.toFixed(2) || '1.00'}
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="3.99"
|
||||
step="0.01"
|
||||
value={config.wb_green_gain || 1.0}
|
||||
onChange={(e) => updateSetting('wb_green_gain', parseFloat(e.target.value))}
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-gray-500 mt-1">
|
||||
<span>0.00</span>
|
||||
<span>3.99</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Blue Gain: {config.wb_blue_gain?.toFixed(2) || '1.00'}
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="3.99"
|
||||
step="0.01"
|
||||
value={config.wb_blue_gain || 1.0}
|
||||
onChange={(e) => updateSetting('wb_blue_gain', parseFloat(e.target.value))}
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-gray-500 mt-1">
|
||||
<span>0.00</span>
|
||||
<span>3.99</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-2">Manual white balance gains (only effective when Auto White Balance is disabled)</p>
|
||||
</div>
|
||||
|
||||
{/* Advanced Settings */}
|
||||
<div>
|
||||
<h4 className="text-md font-medium text-gray-900 mb-4">Advanced Settings</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.anti_flicker_enabled}
|
||||
onChange={(e) => updateSetting('anti_flicker_enabled', e.target.checked)}
|
||||
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
||||
/>
|
||||
<span className="text-sm font-medium text-gray-700">Anti-flicker Enabled</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Light Frequency: {config.light_frequency === 0 ? '50Hz' : '60Hz'}
|
||||
</label>
|
||||
<select
|
||||
value={config.light_frequency}
|
||||
onChange={(e) => updateSetting('light_frequency', parseInt(e.target.value))}
|
||||
className="w-full border-gray-300 rounded-md focus:ring-indigo-500 focus:border-indigo-500"
|
||||
>
|
||||
<option value={0}>50Hz</option>
|
||||
<option value={1}>60Hz</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.noise_filter_enabled}
|
||||
onChange={(e) => updateSetting('noise_filter_enabled', e.target.checked)}
|
||||
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
||||
/>
|
||||
<span className="text-sm font-medium text-gray-700">Noise Filter Enabled</span>
|
||||
</label>
|
||||
<p className="text-xs text-gray-500 mt-1">Requires restart to apply</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.denoise_3d_enabled}
|
||||
onChange={(e) => updateSetting('denoise_3d_enabled', e.target.checked)}
|
||||
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
||||
/>
|
||||
<span className="text-sm font-medium text-gray-700">3D Denoise Enabled</span>
|
||||
</label>
|
||||
<p className="text-xs text-gray-500 mt-1">Requires restart to apply</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* HDR Settings */}
|
||||
<div>
|
||||
<h4 className="text-md font-medium text-gray-900 mb-4">HDR Settings</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.hdr_enabled}
|
||||
onChange={(e) => updateSetting('hdr_enabled', e.target.checked)}
|
||||
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
||||
/>
|
||||
<span className="text-sm font-medium text-gray-700">HDR Enabled</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
HDR Gain Mode: {config.hdr_gain_mode}
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="3"
|
||||
value={config.hdr_gain_mode}
|
||||
onChange={(e) => updateSetting('hdr_gain_mode', parseInt(e.target.value))}
|
||||
className="w-full"
|
||||
disabled={!config.hdr_enabled}
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-gray-500 mt-1">
|
||||
<span>0</span>
|
||||
<span>3</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Video Recording Settings (Read-Only) */}
|
||||
<div>
|
||||
<h4 className="text-md font-medium text-gray-900 mb-4">Video Recording Settings</h4>
|
||||
<div className="bg-gray-50 border border-gray-200 rounded-md p-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Video Format
|
||||
</label>
|
||||
<div className="text-sm text-gray-900 font-medium">
|
||||
{config.video_format?.toUpperCase() || 'MP4'}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">Current recording format</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Video Codec
|
||||
</label>
|
||||
<div className="text-sm text-gray-900 font-medium">
|
||||
{config.video_codec?.toUpperCase() || 'MP4V'}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">Compression codec</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Video Quality
|
||||
</label>
|
||||
<div className="text-sm text-gray-900 font-medium">
|
||||
{config.video_quality || 95}%
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">Recording quality</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 p-3 bg-blue-50 border border-blue-200 rounded-md">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<svg className="h-5 w-5 text-blue-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-blue-800">Video Format Information</h3>
|
||||
<div className="mt-2 text-sm text-blue-700">
|
||||
<p>Video recording settings are configured in the system configuration file and require a service restart to modify.</p>
|
||||
<p className="mt-1"><strong>Current benefits:</strong> MP4 format provides ~40% smaller file sizes and better web compatibility than AVI.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
{/* Information */}
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-md p-4">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<svg className="h-5 w-5 text-blue-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-blue-800">Configuration Notes</h3>
|
||||
<div className="mt-2 text-sm text-blue-700">
|
||||
<ul className="list-disc list-inside space-y-1">
|
||||
<li><strong>Real-time settings:</strong> Exposure, gain, image quality, white balance - apply immediately</li>
|
||||
<li><strong>System settings:</strong> Video format, noise reduction, auto-recording - configured in system files</li>
|
||||
<li><strong>Performance:</strong> HDR mode may impact frame rate when enabled</li>
|
||||
<li><strong>White balance:</strong> RGB gains only effective when auto white balance is disabled</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
{config && !loading && (
|
||||
<div className="px-6 py-4 border-t border-gray-200 bg-gray-50">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
{hasChanges && (
|
||||
<span className="text-sm text-orange-600 font-medium">
|
||||
You have unsaved changes
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center space-x-3">
|
||||
{hasChanges && (
|
||||
<button
|
||||
onClick={resetChanges}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={saveConfig}
|
||||
disabled={!hasChanges || saving}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-indigo-600 border border-transparent rounded-md hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{saving ? 'Saving...' : 'Save Changes'}
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { visionApi } from '../lib/visionApi'
|
||||
|
||||
interface CameraPreviewModalProps {
|
||||
cameraName: string
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onError?: (error: string) => void
|
||||
}
|
||||
|
||||
export function CameraPreviewModal({ cameraName, isOpen, onClose, onError }: CameraPreviewModalProps) {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [streaming, setStreaming] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const imgRef = useRef<HTMLImageElement>(null)
|
||||
const streamUrlRef = useRef<string | null>(null)
|
||||
|
||||
// Start streaming when modal opens
|
||||
useEffect(() => {
|
||||
if (isOpen && cameraName) {
|
||||
startStreaming()
|
||||
}
|
||||
}, [isOpen, cameraName])
|
||||
|
||||
// Stop streaming when modal closes
|
||||
useEffect(() => {
|
||||
if (!isOpen && streaming) {
|
||||
stopStreaming()
|
||||
}
|
||||
}, [isOpen, streaming])
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (streaming) {
|
||||
stopStreaming()
|
||||
}
|
||||
}
|
||||
}, [streaming])
|
||||
|
||||
const startStreaming = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
const result = await visionApi.startStream(cameraName)
|
||||
|
||||
if (result.success) {
|
||||
setStreaming(true)
|
||||
const streamUrl = visionApi.getStreamUrl(cameraName)
|
||||
streamUrlRef.current = streamUrl
|
||||
|
||||
// Add timestamp to prevent caching
|
||||
if (imgRef.current) {
|
||||
imgRef.current.src = `${streamUrl}?t=${Date.now()}`
|
||||
}
|
||||
} else {
|
||||
throw new Error(result.message)
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to start stream'
|
||||
setError(errorMessage)
|
||||
onError?.(errorMessage)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const stopStreaming = async () => {
|
||||
try {
|
||||
if (streaming) {
|
||||
await visionApi.stopStream(cameraName)
|
||||
setStreaming(false)
|
||||
streamUrlRef.current = null
|
||||
|
||||
// Clear the image source
|
||||
if (imgRef.current) {
|
||||
imgRef.current.src = ''
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error stopping stream:', err)
|
||||
// Don't show error to user for stop stream failures
|
||||
}
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
stopStreaming()
|
||||
onClose()
|
||||
}
|
||||
|
||||
const handleImageError = () => {
|
||||
setError('Failed to load camera stream')
|
||||
}
|
||||
|
||||
const handleImageLoad = () => {
|
||||
setError(null)
|
||||
}
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 flex items-center justify-center overflow-y-auto modal z-999999">
|
||||
<div
|
||||
className="fixed inset-0 h-full w-full bg-gray-400/50 backdrop-blur-[32px]"
|
||||
onClick={handleClose}
|
||||
/>
|
||||
<div className="relative w-11/12 max-w-4xl rounded-2xl bg-white shadow-theme-xl dark:bg-gray-900 p-5" onClick={(e) => e.stopPropagation()}>
|
||||
{/* Close Button */}
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="absolute right-3 top-3 z-999 flex h-9.5 w-9.5 items-center justify-center rounded-full bg-gray-100 text-gray-400 transition-colors hover:bg-gray-200 hover:text-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white sm:right-6 sm:top-6 sm:h-11 sm:w-11"
|
||||
>
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M6.04289 16.5413C5.65237 16.9318 5.65237 17.565 6.04289 17.9555C6.43342 18.346 7.06658 18.346 7.45711 17.9555L11.9987 13.4139L16.5408 17.956C16.9313 18.3466 17.5645 18.3466 17.955 17.956C18.3455 17.5655 18.3455 16.9323 17.955 16.5418L13.4129 11.9997L17.955 7.4576C18.3455 7.06707 18.3455 6.43391 17.955 6.04338C17.5645 5.65286 16.9313 5.65286 16.5408 6.04338L11.9987 10.5855L7.45711 6.0439C7.06658 5.65338 6.43342 5.65338 6.04289 6.0439C5.65237 6.43442 5.65237 7.06759 6.04289 7.45811L10.5845 11.9997L6.04289 16.5413Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div className="mt-3">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white/90">
|
||||
Camera Preview: {cameraName}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="mb-4">
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center h-64 bg-gray-100 rounded-lg">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600 mx-auto"></div>
|
||||
<p className="mt-4 text-gray-600">Starting camera stream...</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-md p-4">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<svg className="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-red-800">Stream Error</h3>
|
||||
<div className="mt-2 text-sm text-red-700">
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<button
|
||||
onClick={startStreaming}
|
||||
className="bg-red-600 text-white px-4 py-2 rounded-md hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{streaming && !loading && !error && (
|
||||
<div className="bg-black rounded-lg overflow-hidden">
|
||||
<img
|
||||
ref={imgRef}
|
||||
alt={`Live stream from ${cameraName}`}
|
||||
className="w-full h-auto max-h-96 object-contain"
|
||||
onError={handleImageError}
|
||||
onLoad={handleImageLoad}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
{streaming && (
|
||||
<div className="flex items-center text-green-600">
|
||||
<div className="w-2 h-2 bg-green-500 rounded-full mr-2 animate-pulse"></div>
|
||||
<span className="text-sm font-medium">Live Stream Active</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex space-x-3">
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 border border-gray-300 rounded-md hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
288
management-dashboard-web-app/src/components/CreateUserModal.tsx
Normal file
288
management-dashboard-web-app/src/components/CreateUserModal.tsx
Normal file
@@ -0,0 +1,288 @@
|
||||
import { useState } from 'react'
|
||||
import { userManagement, type User, type Role, type RoleName, type CreateUserRequest } from '../lib/supabase'
|
||||
|
||||
interface CreateUserModalProps {
|
||||
roles: Role[]
|
||||
onClose: () => void
|
||||
onUserCreated: (user: User) => void
|
||||
}
|
||||
|
||||
export function CreateUserModal({ roles, onClose, onUserCreated }: CreateUserModalProps) {
|
||||
const [formData, setFormData] = useState<CreateUserRequest>({
|
||||
email: '',
|
||||
roles: [],
|
||||
tempPassword: ''
|
||||
})
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [generatedPassword, setGeneratedPassword] = useState<string | null>(null)
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
|
||||
const handleRoleToggle = (roleName: RoleName) => {
|
||||
if (formData.roles.includes(roleName)) {
|
||||
setFormData({
|
||||
...formData,
|
||||
roles: formData.roles.filter(r => r !== roleName)
|
||||
})
|
||||
} else {
|
||||
setFormData({
|
||||
...formData,
|
||||
roles: [...formData.roles, roleName]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const generatePassword = () => {
|
||||
const chars = 'ABCDEFGHJKMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789'
|
||||
let result = ''
|
||||
for (let i = 0; i < 12; i++) {
|
||||
result += chars.charAt(Math.floor(Math.random() * chars.length))
|
||||
}
|
||||
setFormData({ ...formData, tempPassword: result })
|
||||
setGeneratedPassword(result)
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
|
||||
// Validation
|
||||
if (!formData.email) {
|
||||
setError('Email is required')
|
||||
return
|
||||
}
|
||||
|
||||
if (formData.roles.length === 0) {
|
||||
setError('At least one role must be selected')
|
||||
return
|
||||
}
|
||||
|
||||
if (!formData.tempPassword) {
|
||||
setError('Password is required')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true)
|
||||
|
||||
const response = await userManagement.createUser(formData)
|
||||
|
||||
// Create user object for the parent component
|
||||
const newUser: User = {
|
||||
id: response.user_id,
|
||||
email: response.email,
|
||||
roles: response.roles,
|
||||
status: response.status,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
}
|
||||
|
||||
onUserCreated(newUser)
|
||||
|
||||
// Show success message with password
|
||||
alert(`User created successfully!\n\nEmail: ${response.email}\nTemporary Password: ${response.temp_password}\n\nPlease save this password as it won't be shown again.`)
|
||||
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to create user')
|
||||
console.error('Create user error:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const getRoleBadgeColor = (role: string) => {
|
||||
switch (role) {
|
||||
case 'admin':
|
||||
return 'bg-red-100 text-red-800'
|
||||
case 'conductor':
|
||||
return 'bg-blue-100 text-blue-800'
|
||||
case 'analyst':
|
||||
return 'bg-green-100 text-green-800'
|
||||
case 'data recorder':
|
||||
return 'bg-purple-100 text-purple-800'
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800'
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 flex items-center justify-center overflow-y-auto modal z-999999">
|
||||
<div
|
||||
className="fixed inset-0 h-full w-full bg-gray-400/50 backdrop-blur-[32px]"
|
||||
onClick={onClose}
|
||||
/>
|
||||
<div className="relative w-full rounded-2xl bg-white shadow-theme-xl dark:bg-gray-900 max-w-md mx-auto p-4" onClick={(e) => e.stopPropagation()}>
|
||||
{/* Close Button */}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute right-3 top-3 z-999 flex h-9.5 w-9.5 items-center justify-center rounded-full bg-gray-100 text-gray-400 transition-colors hover:bg-gray-200 hover:text-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white sm:right-6 sm:top-6 sm:h-11 sm:w-11"
|
||||
>
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M6.04289 16.5413C5.65237 16.9318 5.65237 17.565 6.04289 17.9555C6.43342 18.346 7.06658 18.346 7.45711 17.9555L11.9987 13.4139L16.5408 17.956C16.9313 18.3466 17.5645 18.3466 17.955 17.956C18.3455 17.5655 18.3455 16.9323 17.955 16.5418L13.4129 11.9997L17.955 7.4576C18.3455 7.06707 18.3455 6.43391 17.955 6.04338C17.5645 5.65286 16.9313 5.65286 16.5408 6.04338L11.9987 10.5855L7.45711 6.0439C7.06658 5.65338 6.43342 5.65338 6.04289 6.0439C5.65237 6.43442 5.65237 7.06759 6.04289 7.45811L10.5845 11.9997L6.04289 16.5413Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-800">
|
||||
<h3 className="text-xl font-semibold text-gray-900 dark:text-white/90">Create New User</h3>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
|
||||
{/* Form */}
|
||||
<form id="create-user-form" onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Email */}
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Email Address
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
value={formData.email}
|
||||
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||
className="h-11 w-full rounded-lg border appearance-none px-4 py-2.5 text-sm shadow-theme-xs placeholder:text-gray-400 focus:outline-hidden focus:ring-3 dark:bg-gray-900 dark:text-white/90 dark:placeholder:text-white/30 bg-transparent text-gray-800 border-gray-300 focus:border-brand-300 focus:ring-brand-500/20 dark:border-gray-700 dark:focus:border-brand-800"
|
||||
placeholder="user@example.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Roles */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-3">
|
||||
Roles (select at least one)
|
||||
</label>
|
||||
<div className="space-y-3">
|
||||
{roles.map((role) => (
|
||||
<label key={role.id} className="flex items-start p-3 border border-gray-200 rounded-lg hover:bg-gray-50 cursor-pointer transition-colors">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.roles.includes(role.name)}
|
||||
onChange={() => handleRoleToggle(role.name)}
|
||||
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded mt-0.5"
|
||||
/>
|
||||
<div className="ml-3 flex-1">
|
||||
<span className="text-sm font-medium text-gray-900 capitalize">{role.name}</span>
|
||||
<p className="text-xs text-gray-500 mt-1">{role.description}</p>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Selected roles preview */}
|
||||
{formData.roles.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<div className="text-xs text-gray-500 mb-1">Selected roles:</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{formData.roles.map((role) => (
|
||||
<span
|
||||
key={role}
|
||||
className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${getRoleBadgeColor(role)}`}
|
||||
>
|
||||
{role.charAt(0).toUpperCase() + role.slice(1)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Password */}
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Temporary Password
|
||||
</label>
|
||||
<div className="flex rounded-lg border border-gray-300 overflow-hidden focus-within:ring-2 focus-within:ring-blue-500 focus-within:border-blue-500">
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
id="password"
|
||||
value={formData.tempPassword}
|
||||
onChange={(e) => setFormData({ ...formData, tempPassword: e.target.value })}
|
||||
className="flex-1 px-4 py-3 border-0 focus:ring-0 focus:outline-none text-sm placeholder-gray-400"
|
||||
placeholder="Enter password or generate one"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="px-3 py-3 bg-gray-50 hover:bg-gray-100 text-gray-600 border-l border-gray-300 transition-colors"
|
||||
title={showPassword ? 'Hide password' : 'Show password'}
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
{showPassword ? (
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.878 9.878L8.464 8.464M9.878 9.878l-1.414-1.414M14.12 14.12l1.414 1.414M14.12 14.12L15.536 15.536M14.12 14.12l1.414 1.414" />
|
||||
) : (
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
)}
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={generatePassword}
|
||||
className="px-4 py-3 bg-blue-50 hover:bg-blue-100 text-blue-600 border-l border-gray-300 text-sm font-medium transition-colors"
|
||||
>
|
||||
Generate
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-gray-500">
|
||||
User will need to change this password on first login
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="rounded-lg bg-red-50 border border-red-200 p-4">
|
||||
<div className="flex items-center">
|
||||
<svg className="w-5 h-5 text-red-400 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div className="text-sm text-red-700">{error}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end space-x-3 p-6 border-t border-gray-200 dark:border-gray-800 bg-gray-50 dark:bg-gray-800/50 rounded-b-2xl">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-6 py-2.5 border border-gray-300 dark:border-gray-700 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-3 focus:ring-brand-500/10 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
form="create-user-form"
|
||||
disabled={loading}
|
||||
className="px-6 py-2.5 border border-transparent rounded-lg shadow-sm text-sm font-medium text-white bg-brand-500 hover:bg-brand-600 focus:outline-none focus:ring-3 focus:ring-brand-500/10 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{loading ? (
|
||||
<div className="flex items-center">
|
||||
<svg className="animate-spin -ml-1 mr-2 h-4 w-4 text-white" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Creating...
|
||||
</div>
|
||||
) : (
|
||||
'Create User'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { DashboardLayout } from "./DashboardLayout"
|
||||
|
||||
interface DashboardProps {
|
||||
onLogout: () => void
|
||||
}
|
||||
|
||||
export function Dashboard({ onLogout }: DashboardProps) {
|
||||
return <DashboardLayout onLogout={onLogout} />
|
||||
}
|
||||
185
management-dashboard-web-app/src/components/DashboardHome.tsx
Normal file
185
management-dashboard-web-app/src/components/DashboardHome.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
import type { User } from '../lib/supabase'
|
||||
|
||||
interface DashboardHomeProps {
|
||||
user: User
|
||||
}
|
||||
|
||||
export function DashboardHome({ user }: DashboardHomeProps) {
|
||||
const getRoleBadgeColor = (role: string) => {
|
||||
switch (role) {
|
||||
case 'admin':
|
||||
return 'bg-error-50 text-error-600 dark:bg-error-500/15 dark:text-error-500'
|
||||
case 'conductor':
|
||||
return 'bg-brand-50 text-brand-500 dark:bg-brand-500/15 dark:text-brand-400'
|
||||
case 'analyst':
|
||||
return 'bg-success-50 text-success-600 dark:bg-success-500/15 dark:text-success-500'
|
||||
case 'data recorder':
|
||||
return 'bg-theme-purple-500/10 text-theme-purple-500'
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-700 dark:bg-white/5 dark:text-white/80'
|
||||
}
|
||||
}
|
||||
|
||||
const getPermissionsByRole = (role: string) => {
|
||||
switch (role) {
|
||||
case 'admin':
|
||||
return ['Full system access', 'User management', 'All modules', 'System configuration']
|
||||
case 'conductor':
|
||||
return ['Experiment management', 'Data collection', 'Analytics access', 'Data entry']
|
||||
case 'analyst':
|
||||
return ['Data analysis', 'Report generation', 'Read-only access', 'Analytics dashboard']
|
||||
case 'data recorder':
|
||||
return ['Data entry', 'Record management', 'Basic reporting', 'Data validation']
|
||||
default:
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-12 gap-4 md:gap-6">
|
||||
{/* Welcome Section */}
|
||||
<div className="col-span-12 mb-6">
|
||||
<h1 className="text-title-md font-bold text-gray-800 dark:text-white/90">Dashboard</h1>
|
||||
<p className="mt-2 text-gray-500 dark:text-gray-400">Welcome to the Pecan Experiments Dashboard</p>
|
||||
</div>
|
||||
|
||||
{/* User Information Card */}
|
||||
<div className="col-span-12 xl:col-span-8">
|
||||
<div className="rounded-2xl border border-gray-200 bg-white p-5 dark:border-gray-800 dark:bg-white/[0.03] md:p-6">
|
||||
<div className="flex items-center justify-center w-12 h-12 bg-gray-100 rounded-xl dark:bg-gray-800 mb-5">
|
||||
<svg className="text-gray-800 size-6 dark:text-white/90" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<h3 className="text-lg font-bold text-gray-800 dark:text-white/90 mb-2">
|
||||
User Information
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-6">
|
||||
Your account details and role permissions.
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between py-3 border-b border-gray-200 dark:border-gray-800">
|
||||
<span className="text-sm font-medium text-gray-500 dark:text-gray-400">Email</span>
|
||||
<span className="text-sm text-gray-800 dark:text-white/90">{user.email}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between py-3 border-b border-gray-200 dark:border-gray-800">
|
||||
<span className="text-sm font-medium text-gray-500 dark:text-gray-400">Roles</span>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{user.roles.map((role) => (
|
||||
<span
|
||||
key={role}
|
||||
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getRoleBadgeColor(role)}`}
|
||||
>
|
||||
{role.charAt(0).toUpperCase() + role.slice(1)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between py-3 border-b border-gray-200 dark:border-gray-800">
|
||||
<span className="text-sm font-medium text-gray-500 dark:text-gray-400">Status</span>
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${user.status === 'active'
|
||||
? 'bg-success-50 text-success-600 dark:bg-success-500/15 dark:text-success-500'
|
||||
: 'bg-error-50 text-error-600 dark:bg-error-500/15 dark:text-error-500'
|
||||
}`}>
|
||||
{user.status.charAt(0).toUpperCase() + user.status.slice(1)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between py-3 border-b border-gray-200 dark:border-gray-800">
|
||||
<span className="text-sm font-medium text-gray-500 dark:text-gray-400">User ID</span>
|
||||
<span className="text-sm text-gray-800 dark:text-white/90 font-mono">{user.id}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between py-3">
|
||||
<span className="text-sm font-medium text-gray-500 dark:text-gray-400">Member since</span>
|
||||
<span className="text-sm text-gray-800 dark:text-white/90">
|
||||
{new Date(user.created_at).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Role Permissions */}
|
||||
<div className="col-span-12 xl:col-span-4">
|
||||
<div className="rounded-2xl border border-gray-200 bg-white p-5 dark:border-gray-800 dark:bg-white/[0.03] md:p-6">
|
||||
<div className="flex items-center justify-center w-12 h-12 bg-gray-100 rounded-xl dark:bg-gray-800 mb-5">
|
||||
<svg className="text-gray-800 size-6 dark:text-white/90" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<h3 className="text-lg font-bold text-gray-800 dark:text-white/90 mb-2">
|
||||
Role Permissions
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-6">
|
||||
Your access levels and capabilities.
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
{user.roles.map((role) => (
|
||||
<div key={role} className="border border-gray-200 dark:border-gray-800 rounded-lg p-4">
|
||||
<div className="flex items-center mb-3">
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getRoleBadgeColor(role)}`}>
|
||||
{role.charAt(0).toUpperCase() + role.slice(1)}
|
||||
</span>
|
||||
</div>
|
||||
<ul className="space-y-2">
|
||||
{getPermissionsByRole(role).map((permission, index) => (
|
||||
<li key={index} className="flex items-center text-sm text-gray-600 dark:text-gray-400">
|
||||
<span className="text-success-500 mr-2">✓</span>
|
||||
{permission}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
{user.roles.includes('admin') && (
|
||||
<div className="col-span-12">
|
||||
<div className="rounded-2xl border border-gray-200 bg-white p-5 dark:border-gray-800 dark:bg-white/[0.03] md:p-6">
|
||||
<div className="flex items-center justify-center w-12 h-12 bg-gray-100 rounded-xl dark:bg-gray-800 mb-5">
|
||||
<svg className="text-gray-800 size-6 dark:text-white/90" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<h3 className="text-lg font-bold text-gray-800 dark:text-white/90 mb-2">
|
||||
Quick Actions
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-6">
|
||||
Administrative shortcuts and tools.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<button className="inline-flex items-center justify-center px-4 py-2.5 border border-transparent text-sm font-medium rounded-lg text-white bg-brand-500 hover:bg-brand-600 focus:outline-none focus:ring-3 focus:ring-brand-500/10 transition-colors">
|
||||
👥 Manage Users
|
||||
</button>
|
||||
<button className="inline-flex items-center justify-center px-4 py-2.5 border border-gray-200 text-sm font-medium rounded-lg text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-3 focus:ring-brand-500/10 dark:border-gray-800 dark:bg-white/[0.03] dark:text-gray-300 dark:hover:bg-white/5 transition-colors">
|
||||
🧪 View Experiments
|
||||
</button>
|
||||
<button className="inline-flex items-center justify-center px-4 py-2.5 border border-gray-200 text-sm font-medium rounded-lg text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-3 focus:ring-brand-500/10 dark:border-gray-800 dark:bg-white/[0.03] dark:text-gray-300 dark:hover:bg-white/5 transition-colors">
|
||||
📊 Analytics
|
||||
</button>
|
||||
<button className="inline-flex items-center justify-center px-4 py-2.5 border border-gray-200 text-sm font-medium rounded-lg text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-3 focus:ring-brand-500/10 dark:border-gray-800 dark:bg-white/[0.03] dark:text-gray-300 dark:hover:bg-white/5 transition-colors">
|
||||
⚙️ Settings
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
196
management-dashboard-web-app/src/components/DashboardLayout.tsx
Normal file
196
management-dashboard-web-app/src/components/DashboardLayout.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Sidebar } from './Sidebar'
|
||||
import { TopNavbar } from './TopNavbar'
|
||||
import { DashboardHome } from './DashboardHome'
|
||||
import { UserManagement } from './UserManagement'
|
||||
import { Experiments } from './Experiments'
|
||||
import { DataEntry } from './DataEntry'
|
||||
import { VisionSystem } from './VisionSystem'
|
||||
import { VideoStreamingPage } from '../features/video-streaming'
|
||||
import { userManagement, type User } from '../lib/supabase'
|
||||
|
||||
interface DashboardLayoutProps {
|
||||
onLogout: () => void
|
||||
}
|
||||
|
||||
export function DashboardLayout({ onLogout }: DashboardLayoutProps) {
|
||||
const [user, setUser] = useState<User | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [currentView, setCurrentView] = useState('dashboard')
|
||||
const [isExpanded, setIsExpanded] = useState(true)
|
||||
const [isMobileOpen, setIsMobileOpen] = useState(false)
|
||||
const [isHovered, setIsHovered] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
fetchUserProfile()
|
||||
}, [])
|
||||
|
||||
const fetchUserProfile = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
const currentUser = await userManagement.getCurrentUser()
|
||||
if (currentUser) {
|
||||
setUser(currentUser)
|
||||
} else {
|
||||
setError('No authenticated user found')
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Failed to fetch user profile')
|
||||
console.error('Profile fetch error:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleLogout = async () => {
|
||||
// Navigate to signout route which will handle the actual logout
|
||||
window.history.pushState({}, '', '/signout')
|
||||
window.dispatchEvent(new PopStateEvent('popstate'))
|
||||
}
|
||||
|
||||
const toggleSidebar = () => {
|
||||
setIsExpanded(!isExpanded)
|
||||
}
|
||||
|
||||
const toggleMobileSidebar = () => {
|
||||
setIsMobileOpen(!isMobileOpen)
|
||||
}
|
||||
|
||||
const handleToggleSidebar = () => {
|
||||
if (window.innerWidth >= 1024) {
|
||||
toggleSidebar()
|
||||
} else {
|
||||
toggleMobileSidebar()
|
||||
}
|
||||
}
|
||||
|
||||
const renderCurrentView = () => {
|
||||
if (!user) return null
|
||||
|
||||
switch (currentView) {
|
||||
case 'dashboard':
|
||||
return <DashboardHome user={user} />
|
||||
case 'user-management':
|
||||
if (user.roles.includes('admin')) {
|
||||
return <UserManagement />
|
||||
} else {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="bg-red-50 border border-red-200 rounded-md p-4">
|
||||
<div className="text-sm text-red-700">
|
||||
Access denied. You need admin privileges to access user management.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
case 'experiments':
|
||||
return <Experiments />
|
||||
case 'analytics':
|
||||
return (
|
||||
<div className="p-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-4">Analytics</h1>
|
||||
<div className="bg-green-50 border border-green-200 rounded-md p-4">
|
||||
<div className="text-sm text-green-700">
|
||||
Analytics module coming soon...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
case 'data-entry':
|
||||
return <DataEntry />
|
||||
case 'vision-system':
|
||||
return <VisionSystem />
|
||||
case 'video-library':
|
||||
return <VideoStreamingPage />
|
||||
default:
|
||||
return <DashboardHome user={user} />
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-brand-500 mx-auto"></div>
|
||||
<p className="mt-4 text-gray-600 dark:text-gray-400">Loading dashboard...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="max-w-md w-full">
|
||||
<div className="rounded-2xl bg-error-50 border border-error-200 p-4 dark:bg-error-500/15 dark:border-error-500/20">
|
||||
<div className="text-sm text-error-700 dark:text-error-500">{error}</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="mt-4 w-full flex justify-center py-2.5 px-4 border border-transparent text-sm font-medium rounded-lg text-white bg-gray-600 hover:bg-gray-700 dark:bg-gray-700 dark:hover:bg-gray-600 transition-colors"
|
||||
>
|
||||
Back to Login
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="text-center">
|
||||
<div className="text-gray-600 dark:text-gray-400">No user data available</div>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="mt-4 px-4 py-2.5 bg-gray-600 text-white rounded-lg hover:bg-gray-700 dark:bg-gray-700 dark:hover:bg-gray-600 transition-colors"
|
||||
>
|
||||
Back to Login
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen xl:flex">
|
||||
<div>
|
||||
<Sidebar
|
||||
user={user}
|
||||
currentView={currentView}
|
||||
onViewChange={setCurrentView}
|
||||
isExpanded={isExpanded}
|
||||
isMobileOpen={isMobileOpen}
|
||||
isHovered={isHovered}
|
||||
setIsHovered={setIsHovered}
|
||||
/>
|
||||
{/* Backdrop for mobile */}
|
||||
{isMobileOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-40 bg-gray-900/50 lg:hidden"
|
||||
onClick={() => setIsMobileOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={`flex-1 transition-all duration-300 ease-in-out ${isExpanded || isHovered ? "lg:ml-[290px]" : "lg:ml-[90px]"
|
||||
} ${isMobileOpen ? "ml-0" : ""}`}
|
||||
>
|
||||
<TopNavbar
|
||||
user={user}
|
||||
onLogout={handleLogout}
|
||||
currentView={currentView}
|
||||
onToggleSidebar={handleToggleSidebar}
|
||||
isSidebarOpen={isMobileOpen}
|
||||
/>
|
||||
<div className="p-4 mx-auto max-w-(--breakpoint-2xl) md:p-6">
|
||||
{renderCurrentView()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
327
management-dashboard-web-app/src/components/DataEntry.tsx
Normal file
327
management-dashboard-web-app/src/components/DataEntry.tsx
Normal file
@@ -0,0 +1,327 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { experimentManagement, repetitionManagement, userManagement, type Experiment, type ExperimentRepetition, type User } from '../lib/supabase'
|
||||
import { RepetitionDataEntryInterface } from './RepetitionDataEntryInterface'
|
||||
|
||||
export function DataEntry() {
|
||||
const [experiments, setExperiments] = useState<Experiment[]>([])
|
||||
const [experimentRepetitions, setExperimentRepetitions] = useState<Record<string, ExperimentRepetition[]>>({})
|
||||
const [selectedRepetition, setSelectedRepetition] = useState<{ experiment: Experiment; repetition: ExperimentRepetition } | null>(null)
|
||||
const [currentUser, setCurrentUser] = useState<User | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
}, [])
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
const [experimentsData, userData] = await Promise.all([
|
||||
experimentManagement.getAllExperiments(),
|
||||
userManagement.getCurrentUser()
|
||||
])
|
||||
|
||||
setExperiments(experimentsData)
|
||||
setCurrentUser(userData)
|
||||
|
||||
// Load repetitions for each experiment
|
||||
const repetitionsMap: Record<string, ExperimentRepetition[]> = {}
|
||||
for (const experiment of experimentsData) {
|
||||
try {
|
||||
const repetitions = await repetitionManagement.getExperimentRepetitions(experiment.id)
|
||||
repetitionsMap[experiment.id] = repetitions
|
||||
} catch (err) {
|
||||
console.error(`Failed to load repetitions for experiment ${experiment.id}:`, err)
|
||||
repetitionsMap[experiment.id] = []
|
||||
}
|
||||
}
|
||||
setExperimentRepetitions(repetitionsMap)
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to load data')
|
||||
console.error('Load data error:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRepetitionSelect = (experiment: Experiment, repetition: ExperimentRepetition) => {
|
||||
setSelectedRepetition({ experiment, repetition })
|
||||
}
|
||||
|
||||
const handleBackToList = () => {
|
||||
setSelectedRepetition(null)
|
||||
}
|
||||
|
||||
const getAllRepetitionsWithExperiments = () => {
|
||||
const allRepetitions: Array<{ experiment: Experiment; repetition: ExperimentRepetition }> = []
|
||||
|
||||
experiments.forEach(experiment => {
|
||||
const repetitions = experimentRepetitions[experiment.id] || []
|
||||
repetitions.forEach(repetition => {
|
||||
allRepetitions.push({ experiment, repetition })
|
||||
})
|
||||
})
|
||||
|
||||
return allRepetitions
|
||||
}
|
||||
|
||||
const categorizeRepetitions = () => {
|
||||
const allRepetitions = getAllRepetitionsWithExperiments()
|
||||
const now = new Date()
|
||||
|
||||
const past = allRepetitions.filter(({ repetition }) =>
|
||||
repetition.completion_status || (repetition.scheduled_date && new Date(repetition.scheduled_date) < now)
|
||||
)
|
||||
|
||||
const inProgress = allRepetitions.filter(({ repetition }) =>
|
||||
!repetition.completion_status &&
|
||||
repetition.scheduled_date &&
|
||||
new Date(repetition.scheduled_date) <= now &&
|
||||
new Date(repetition.scheduled_date) > new Date(now.getTime() - 24 * 60 * 60 * 1000)
|
||||
)
|
||||
|
||||
const upcoming = allRepetitions.filter(({ repetition }) =>
|
||||
!repetition.completion_status &&
|
||||
repetition.scheduled_date &&
|
||||
new Date(repetition.scheduled_date) > now
|
||||
)
|
||||
|
||||
return { past, inProgress, upcoming }
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
|
||||
<p className="mt-4 text-gray-600">Loading experiments...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="bg-red-50 border border-red-200 rounded-md p-4">
|
||||
<div className="text-sm text-red-700">{error}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (selectedRepetition) {
|
||||
return (
|
||||
<RepetitionDataEntryInterface
|
||||
experiment={selectedRepetition.experiment}
|
||||
repetition={selectedRepetition.repetition}
|
||||
currentUser={currentUser!}
|
||||
onBack={handleBackToList}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Data Entry</h1>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Select a repetition to enter measurement data
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Repetitions organized by status - flat list */}
|
||||
{(() => {
|
||||
const { past: pastRepetitions, inProgress: inProgressRepetitions, upcoming: upcomingRepetitions } = categorizeRepetitions()
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
{/* Past/Completed Repetitions */}
|
||||
<div className="bg-white shadow overflow-hidden sm:rounded-lg">
|
||||
<div className="px-4 py-5 sm:px-6 border-b border-gray-200">
|
||||
<h2 className="text-xl font-bold text-gray-900 flex items-center">
|
||||
<span className="w-4 h-4 bg-green-500 rounded-full mr-3"></span>
|
||||
Past/Completed ({pastRepetitions.length})
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Completed or past scheduled repetitions
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<div className="space-y-3 max-h-96 overflow-y-auto">
|
||||
{pastRepetitions.map(({ experiment, repetition }) => (
|
||||
<RepetitionCard
|
||||
key={repetition.id}
|
||||
experiment={experiment}
|
||||
repetition={repetition}
|
||||
onSelect={handleRepetitionSelect}
|
||||
status="past"
|
||||
/>
|
||||
))}
|
||||
{pastRepetitions.length === 0 && (
|
||||
<p className="text-sm text-gray-500 italic text-center py-8">
|
||||
No completed repetitions
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* In Progress Repetitions */}
|
||||
<div className="bg-white shadow overflow-hidden sm:rounded-lg">
|
||||
<div className="px-4 py-5 sm:px-6 border-b border-gray-200">
|
||||
<h2 className="text-xl font-bold text-gray-900 flex items-center">
|
||||
<span className="w-4 h-4 bg-blue-500 rounded-full mr-3"></span>
|
||||
In Progress ({inProgressRepetitions.length})
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Currently scheduled or active repetitions
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<div className="space-y-3 max-h-96 overflow-y-auto">
|
||||
{inProgressRepetitions.map(({ experiment, repetition }) => (
|
||||
<RepetitionCard
|
||||
key={repetition.id}
|
||||
experiment={experiment}
|
||||
repetition={repetition}
|
||||
onSelect={handleRepetitionSelect}
|
||||
status="in-progress"
|
||||
/>
|
||||
))}
|
||||
{inProgressRepetitions.length === 0 && (
|
||||
<p className="text-sm text-gray-500 italic text-center py-8">
|
||||
No repetitions in progress
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Upcoming Repetitions */}
|
||||
<div className="bg-white shadow overflow-hidden sm:rounded-lg">
|
||||
<div className="px-4 py-5 sm:px-6 border-b border-gray-200">
|
||||
<h2 className="text-xl font-bold text-gray-900 flex items-center">
|
||||
<span className="w-4 h-4 bg-yellow-500 rounded-full mr-3"></span>
|
||||
Upcoming ({upcomingRepetitions.length})
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Future scheduled repetitions
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<div className="space-y-3 max-h-96 overflow-y-auto">
|
||||
{upcomingRepetitions.map(({ experiment, repetition }) => (
|
||||
<RepetitionCard
|
||||
key={repetition.id}
|
||||
experiment={experiment}
|
||||
repetition={repetition}
|
||||
onSelect={handleRepetitionSelect}
|
||||
status="upcoming"
|
||||
/>
|
||||
))}
|
||||
{upcomingRepetitions.length === 0 && (
|
||||
<p className="text-sm text-gray-500 italic text-center py-8">
|
||||
No upcoming repetitions
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
|
||||
{experiments.length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<div className="text-gray-500">
|
||||
No experiments available for data entry
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// RepetitionCard component for displaying individual repetitions
|
||||
interface RepetitionCardProps {
|
||||
experiment: Experiment
|
||||
repetition: ExperimentRepetition
|
||||
onSelect: (experiment: Experiment, repetition: ExperimentRepetition) => void
|
||||
status: 'past' | 'in-progress' | 'upcoming'
|
||||
}
|
||||
|
||||
function RepetitionCard({ experiment, repetition, onSelect, status }: RepetitionCardProps) {
|
||||
const getStatusColor = () => {
|
||||
switch (status) {
|
||||
case 'past':
|
||||
return 'border-green-200 bg-green-50 hover:bg-green-100'
|
||||
case 'in-progress':
|
||||
return 'border-blue-200 bg-blue-50 hover:bg-blue-100'
|
||||
case 'upcoming':
|
||||
return 'border-yellow-200 bg-yellow-50 hover:bg-yellow-100'
|
||||
default:
|
||||
return 'border-gray-200 bg-gray-50 hover:bg-gray-100'
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusIcon = () => {
|
||||
switch (status) {
|
||||
case 'past':
|
||||
return '✓'
|
||||
case 'in-progress':
|
||||
return '▶'
|
||||
case 'upcoming':
|
||||
return '⏰'
|
||||
default:
|
||||
return '○'
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => onSelect(experiment, repetition)}
|
||||
className={`w-full text-left p-4 border-2 rounded-lg hover:shadow-lg transition-all duration-200 ${getStatusColor()}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center space-x-3">
|
||||
{/* Large, bold experiment number */}
|
||||
<span className="text-2xl font-bold text-gray-900">
|
||||
#{experiment.experiment_number}
|
||||
</span>
|
||||
{/* Smaller repetition number */}
|
||||
<span className="text-lg font-semibold text-gray-700">
|
||||
Rep #{repetition.repetition_number}
|
||||
</span>
|
||||
<span className="text-lg">{getStatusIcon()}</span>
|
||||
</div>
|
||||
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${repetition.schedule_status === 'scheduled'
|
||||
? 'bg-blue-100 text-blue-800'
|
||||
: 'bg-yellow-100 text-yellow-800'
|
||||
}`}>
|
||||
{repetition.schedule_status === 'pending schedule' ? 'Pending' : repetition.schedule_status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Experiment details */}
|
||||
<div className="text-sm text-gray-600 mb-2">
|
||||
{experiment.soaking_duration_hr}h soaking • {experiment.air_drying_time_min}min drying
|
||||
</div>
|
||||
|
||||
{repetition.scheduled_date && (
|
||||
<div className="text-sm text-gray-600 mb-2">
|
||||
<strong>Scheduled:</strong> {new Date(repetition.scheduled_date).toLocaleString()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="text-xs text-gray-500">
|
||||
Click to enter data for this repetition
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { type Experiment, type User, type ExperimentPhase } from '../lib/supabase'
|
||||
import { DraftManager } from './DraftManager'
|
||||
import { PhaseSelector } from './PhaseSelector'
|
||||
|
||||
// DEPRECATED: This component is deprecated in favor of RepetitionDataEntryInterface
|
||||
// which uses the new phase-specific draft system
|
||||
|
||||
interface DataEntryInterfaceProps {
|
||||
experiment: Experiment
|
||||
currentUser: User
|
||||
onBack: () => void
|
||||
}
|
||||
|
||||
// Temporary type for backward compatibility
|
||||
interface LegacyDataEntry {
|
||||
id: string
|
||||
experiment_id: string
|
||||
user_id: string
|
||||
status: 'draft' | 'submitted'
|
||||
entry_name?: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
submitted_at?: string | null
|
||||
}
|
||||
|
||||
export function DataEntryInterface({ experiment, currentUser, onBack }: DataEntryInterfaceProps) {
|
||||
const [userDataEntries, setUserDataEntries] = useState<LegacyDataEntry[]>([])
|
||||
const [selectedDataEntry, setSelectedDataEntry] = useState<LegacyDataEntry | null>(null)
|
||||
const [selectedPhase, setSelectedPhase] = useState<ExperimentPhase | null>(null)
|
||||
const [showDraftManager, setShowDraftManager] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
loadUserDataEntries()
|
||||
}, [experiment.id, currentUser.id])
|
||||
|
||||
const loadUserDataEntries = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
// DEPRECATED: Using empty array since this component is deprecated
|
||||
const entries: LegacyDataEntry[] = []
|
||||
setUserDataEntries(entries)
|
||||
|
||||
// Auto-select the most recent draft or create a new one
|
||||
const drafts = entries.filter(entry => entry.status === 'draft')
|
||||
if (drafts.length > 0) {
|
||||
setSelectedDataEntry(drafts[0])
|
||||
} else {
|
||||
// Create a new draft entry
|
||||
await handleCreateNewDraft()
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to load data entries')
|
||||
console.error('Load data entries error:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreateNewDraft = async () => {
|
||||
setError('This component is deprecated. Please use the new repetition-based data entry system.')
|
||||
}
|
||||
|
||||
const handleSelectDataEntry = (entry: LegacyDataEntry) => {
|
||||
setSelectedDataEntry(entry)
|
||||
setShowDraftManager(false)
|
||||
setSelectedPhase(null)
|
||||
}
|
||||
|
||||
const handleDeleteDraft = async (_entryId: string) => {
|
||||
setError('This component is deprecated. Please use the new repetition-based data entry system.')
|
||||
}
|
||||
|
||||
const handleSubmitEntry = async (_entryId: string) => {
|
||||
setError('This component is deprecated. Please use the new repetition-based data entry system.')
|
||||
}
|
||||
|
||||
const handlePhaseSelect = (phase: ExperimentPhase) => {
|
||||
setSelectedPhase(phase)
|
||||
}
|
||||
|
||||
const handleBackToPhases = () => {
|
||||
setSelectedPhase(null)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
|
||||
<p className="mt-4 text-gray-600">Loading data entries...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="bg-red-50 border border-red-200 rounded-md p-4 mb-4">
|
||||
<div className="text-sm text-red-700">{error}</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="px-4 py-2 bg-gray-600 text-white rounded-md hover:bg-gray-700"
|
||||
>
|
||||
Back to Experiments
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="flex items-center text-gray-600 hover:text-gray-900 mb-2"
|
||||
>
|
||||
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Back to Experiments
|
||||
</button>
|
||||
<h1 className="text-3xl font-bold text-gray-900">
|
||||
Experiment #{experiment.experiment_number}
|
||||
</h1>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<button
|
||||
onClick={() => setShowDraftManager(true)}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 mr-2"
|
||||
>
|
||||
Manage Drafts
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCreateNewDraft}
|
||||
className="px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700"
|
||||
>
|
||||
New Draft
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Experiment Details */}
|
||||
<div className="mt-4 bg-gray-50 rounded-lg p-4">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="font-medium text-gray-700">Repetitions:</span>
|
||||
<span className="ml-1 text-gray-900">{experiment.reps_required}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium text-gray-700">Soaking Duration:</span>
|
||||
<span className="ml-1 text-gray-900">{experiment.soaking_duration_hr}h</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium text-gray-700">Air Drying:</span>
|
||||
<span className="ml-1 text-gray-900">{experiment.air_drying_time_min}min</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium text-gray-700">Status:</span>
|
||||
<span className={`ml-1 ${experiment.completion_status ? 'text-green-600' : 'text-yellow-600'}`}>
|
||||
{experiment.completion_status ? 'Completed' : 'In Progress'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* Scheduled date removed - this is now handled at repetition level */}
|
||||
</div>
|
||||
|
||||
{/* Current Draft Info */}
|
||||
{selectedDataEntry && (
|
||||
<div className="mt-4 bg-blue-50 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<span className="font-medium text-blue-700">Current Draft:</span>
|
||||
<span className="ml-2 text-blue-900">{selectedDataEntry.entry_name}</span>
|
||||
<span className="ml-2 text-sm text-blue-600">
|
||||
Created: {new Date(selectedDataEntry.created_at).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleSubmitEntry(selectedDataEntry.id)}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 text-sm"
|
||||
>
|
||||
Submit Entry
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
{showDraftManager ? (
|
||||
<DraftManager
|
||||
userDataEntries={userDataEntries}
|
||||
selectedDataEntry={selectedDataEntry}
|
||||
onSelectEntry={handleSelectDataEntry}
|
||||
onDeleteDraft={handleDeleteDraft}
|
||||
onCreateNew={handleCreateNewDraft}
|
||||
onClose={() => setShowDraftManager(false)}
|
||||
/>
|
||||
) : selectedPhase ? (
|
||||
<div className="bg-red-50 border border-red-200 rounded-md p-4">
|
||||
<div className="text-sm text-red-700">
|
||||
This component is deprecated. Please use the new repetition-based data entry system.
|
||||
</div>
|
||||
</div>
|
||||
) : selectedDataEntry ? (
|
||||
<PhaseSelector
|
||||
dataEntry={selectedDataEntry}
|
||||
onPhaseSelect={handlePhaseSelect}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-center text-gray-500">
|
||||
No data entry selected. Please create a new draft or select an existing one.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
188
management-dashboard-web-app/src/components/DraftManager.tsx
Normal file
188
management-dashboard-web-app/src/components/DraftManager.tsx
Normal file
@@ -0,0 +1,188 @@
|
||||
// DEPRECATED: This component is deprecated in favor of PhaseDraftManager
|
||||
|
||||
// Temporary type for backward compatibility
|
||||
interface LegacyDataEntry {
|
||||
id: string
|
||||
experiment_id: string
|
||||
user_id: string
|
||||
status: 'draft' | 'submitted'
|
||||
entry_name?: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
submitted_at?: string | null
|
||||
}
|
||||
|
||||
interface DraftManagerProps {
|
||||
userDataEntries: LegacyDataEntry[]
|
||||
selectedDataEntry: LegacyDataEntry | null
|
||||
onSelectEntry: (entry: LegacyDataEntry) => void
|
||||
onDeleteDraft: (entryId: string) => void
|
||||
onCreateNew: () => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function DraftManager({
|
||||
userDataEntries,
|
||||
selectedDataEntry,
|
||||
onSelectEntry,
|
||||
onDeleteDraft,
|
||||
onCreateNew,
|
||||
onClose
|
||||
}: DraftManagerProps) {
|
||||
const drafts = userDataEntries.filter(entry => entry.status === 'draft')
|
||||
const submitted = userDataEntries.filter(entry => entry.status === 'submitted')
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-lg">
|
||||
<div className="px-6 py-4 border-b border-gray-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-medium text-gray-900">Draft Manager</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
{/* Draft Entries */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-md font-medium text-gray-900">
|
||||
Draft Entries ({drafts.length})
|
||||
</h3>
|
||||
<button
|
||||
onClick={onCreateNew}
|
||||
className="px-3 py-1 bg-green-600 text-white text-sm rounded-md hover:bg-green-700"
|
||||
>
|
||||
Create New Draft
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{drafts.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
<svg className="w-12 h-12 mx-auto mb-4 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
<p>No draft entries found</p>
|
||||
<p className="text-sm mt-1">Create a new draft to start entering data</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{drafts.map((entry) => (
|
||||
<div
|
||||
key={entry.id}
|
||||
className={`border rounded-lg p-4 ${selectedDataEntry?.id === entry.id
|
||||
? 'border-blue-500 bg-blue-50'
|
||||
: 'border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center">
|
||||
<h4 className="text-sm font-medium text-gray-900">
|
||||
{entry.entry_name || 'Untitled Draft'}
|
||||
</h4>
|
||||
{selectedDataEntry?.id === entry.id && (
|
||||
<span className="ml-2 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
|
||||
Current
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-gray-500">
|
||||
<div>Created: {new Date(entry.created_at).toLocaleString()}</div>
|
||||
<div>Last updated: {new Date(entry.updated_at).toLocaleString()}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={() => onSelectEntry(entry)}
|
||||
className="px-3 py-1 bg-blue-600 text-white text-sm rounded-md hover:bg-blue-700"
|
||||
>
|
||||
{selectedDataEntry?.id === entry.id ? 'Continue' : 'Select'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onDeleteDraft(entry.id)}
|
||||
className="px-3 py-1 bg-red-600 text-white text-sm rounded-md hover:bg-red-700"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Submitted Entries */}
|
||||
<div>
|
||||
<h3 className="text-md font-medium text-gray-900 mb-4">
|
||||
Submitted Entries ({submitted.length})
|
||||
</h3>
|
||||
|
||||
{submitted.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
<svg className="w-12 h-12 mx-auto mb-4 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<p>No submitted entries found</p>
|
||||
<p className="text-sm mt-1">Submit a draft to see it here</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{submitted.map((entry) => (
|
||||
<div
|
||||
key={entry.id}
|
||||
className="border border-green-200 bg-green-50 rounded-lg p-4"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center">
|
||||
<h4 className="text-sm font-medium text-gray-900">
|
||||
{entry.entry_name || 'Untitled Entry'}
|
||||
</h4>
|
||||
<span className="ml-2 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
||||
Submitted
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-gray-500">
|
||||
<div>Created: {new Date(entry.created_at).toLocaleString()}</div>
|
||||
{entry.submitted_at && (
|
||||
<div>Submitted: {new Date(entry.submitted_at).toLocaleString()}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={() => onSelectEntry(entry)}
|
||||
className="px-3 py-1 bg-gray-600 text-white text-sm rounded-md hover:bg-gray-700"
|
||||
>
|
||||
View
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-4 border-t border-gray-200 bg-gray-50 rounded-b-lg">
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 bg-gray-600 text-white rounded-md hover:bg-gray-700"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
370
management-dashboard-web-app/src/components/ExperimentForm.tsx
Normal file
370
management-dashboard-web-app/src/components/ExperimentForm.tsx
Normal file
@@ -0,0 +1,370 @@
|
||||
import { useState } from 'react'
|
||||
import type { CreateExperimentRequest, UpdateExperimentRequest, ScheduleStatus, ResultsStatus } from '../lib/supabase'
|
||||
|
||||
interface ExperimentFormProps {
|
||||
initialData?: Partial<CreateExperimentRequest & { schedule_status: ScheduleStatus; results_status: ResultsStatus; completion_status: boolean }>
|
||||
onSubmit: (data: CreateExperimentRequest | UpdateExperimentRequest) => Promise<void>
|
||||
onCancel: () => void
|
||||
isEditing?: boolean
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
export function ExperimentForm({ initialData, onSubmit, onCancel, isEditing = false, loading = false }: ExperimentFormProps) {
|
||||
const [formData, setFormData] = useState<CreateExperimentRequest & { schedule_status: ScheduleStatus; results_status: ResultsStatus; completion_status: boolean }>({
|
||||
experiment_number: initialData?.experiment_number || 0,
|
||||
reps_required: initialData?.reps_required || 1,
|
||||
soaking_duration_hr: initialData?.soaking_duration_hr || 0,
|
||||
air_drying_time_min: initialData?.air_drying_time_min || 0,
|
||||
plate_contact_frequency_hz: initialData?.plate_contact_frequency_hz || 1,
|
||||
throughput_rate_pecans_sec: initialData?.throughput_rate_pecans_sec || 1,
|
||||
crush_amount_in: initialData?.crush_amount_in || 0,
|
||||
entry_exit_height_diff_in: initialData?.entry_exit_height_diff_in || 0,
|
||||
schedule_status: initialData?.schedule_status || 'pending schedule',
|
||||
results_status: initialData?.results_status || 'valid',
|
||||
completion_status: initialData?.completion_status || false
|
||||
})
|
||||
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
|
||||
const validateForm = (): boolean => {
|
||||
const newErrors: Record<string, string> = {}
|
||||
|
||||
// Required field validation
|
||||
if (!formData.experiment_number || formData.experiment_number <= 0) {
|
||||
newErrors.experiment_number = 'Experiment number must be a positive integer'
|
||||
}
|
||||
|
||||
if (!formData.reps_required || formData.reps_required <= 0) {
|
||||
newErrors.reps_required = 'Repetitions required must be a positive integer'
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
if (formData.soaking_duration_hr < 0) {
|
||||
newErrors.soaking_duration_hr = 'Soaking duration cannot be negative'
|
||||
}
|
||||
|
||||
if (formData.air_drying_time_min < 0) {
|
||||
newErrors.air_drying_time_min = 'Air drying time cannot be negative'
|
||||
}
|
||||
|
||||
if (!formData.plate_contact_frequency_hz || formData.plate_contact_frequency_hz <= 0) {
|
||||
newErrors.plate_contact_frequency_hz = 'Plate contact frequency must be positive'
|
||||
}
|
||||
|
||||
if (!formData.throughput_rate_pecans_sec || formData.throughput_rate_pecans_sec <= 0) {
|
||||
newErrors.throughput_rate_pecans_sec = 'Throughput rate must be positive'
|
||||
}
|
||||
|
||||
if (formData.crush_amount_in < 0) {
|
||||
newErrors.crush_amount_in = 'Crush amount cannot be negative'
|
||||
}
|
||||
|
||||
setErrors(newErrors)
|
||||
return Object.keys(newErrors).length === 0
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (!validateForm()) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// Prepare data for submission
|
||||
const submitData = isEditing ? formData : {
|
||||
experiment_number: formData.experiment_number,
|
||||
reps_required: formData.reps_required,
|
||||
soaking_duration_hr: formData.soaking_duration_hr,
|
||||
air_drying_time_min: formData.air_drying_time_min,
|
||||
plate_contact_frequency_hz: formData.plate_contact_frequency_hz,
|
||||
throughput_rate_pecans_sec: formData.throughput_rate_pecans_sec,
|
||||
crush_amount_in: formData.crush_amount_in,
|
||||
entry_exit_height_diff_in: formData.entry_exit_height_diff_in,
|
||||
schedule_status: formData.schedule_status,
|
||||
results_status: formData.results_status
|
||||
}
|
||||
|
||||
await onSubmit(submitData)
|
||||
} catch (error) {
|
||||
console.error('Form submission error:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleInputChange = (field: keyof typeof formData, value: string | number | boolean) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[field]: value
|
||||
}))
|
||||
|
||||
// Clear error for this field when user starts typing
|
||||
if (errors[field]) {
|
||||
setErrors(prev => ({
|
||||
...prev,
|
||||
[field]: ''
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Basic Information */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label htmlFor="experiment_number" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Experiment Number *
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="experiment_number"
|
||||
value={formData.experiment_number}
|
||||
onChange={(e) => handleInputChange('experiment_number', parseInt(e.target.value) || 0)}
|
||||
className={`max-w-xs px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors text-sm ${errors.experiment_number ? 'border-red-300' : 'border-gray-300'
|
||||
}`}
|
||||
placeholder="Enter unique experiment number"
|
||||
min="1"
|
||||
step="1"
|
||||
required
|
||||
/>
|
||||
{errors.experiment_number && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.experiment_number}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="reps_required" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Repetitions Required *
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="reps_required"
|
||||
value={formData.reps_required}
|
||||
onChange={(e) => handleInputChange('reps_required', parseInt(e.target.value) || 1)}
|
||||
className={`max-w-xs px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors text-sm ${errors.reps_required ? 'border-red-300' : 'border-gray-300'
|
||||
}`}
|
||||
placeholder="Total repetitions needed"
|
||||
min="1"
|
||||
step="1"
|
||||
required
|
||||
/>
|
||||
{errors.reps_required && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.reps_required}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
{/* Experiment Parameters */}
|
||||
<div className="border-t pt-6">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">Experiment Parameters</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label htmlFor="soaking_duration_hr" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Soaking Duration (hours) *
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="soaking_duration_hr"
|
||||
value={formData.soaking_duration_hr}
|
||||
onChange={(e) => handleInputChange('soaking_duration_hr', parseFloat(e.target.value) || 0)}
|
||||
className={`max-w-xs px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors text-sm ${errors.soaking_duration_hr ? 'border-red-300' : 'border-gray-300'
|
||||
}`}
|
||||
placeholder="0.0"
|
||||
min="0"
|
||||
step="0.1"
|
||||
required
|
||||
/>
|
||||
{errors.soaking_duration_hr && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.soaking_duration_hr}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="air_drying_time_min" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Air Drying Time (minutes) *
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="air_drying_time_min"
|
||||
value={formData.air_drying_time_min}
|
||||
onChange={(e) => handleInputChange('air_drying_time_min', parseInt(e.target.value) || 0)}
|
||||
className={`max-w-xs px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors text-sm ${errors.air_drying_time_min ? 'border-red-300' : 'border-gray-300'
|
||||
}`}
|
||||
placeholder="0"
|
||||
min="0"
|
||||
step="1"
|
||||
required
|
||||
/>
|
||||
{errors.air_drying_time_min && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.air_drying_time_min}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="plate_contact_frequency_hz" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Plate Contact Frequency (Hz) *
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="plate_contact_frequency_hz"
|
||||
value={formData.plate_contact_frequency_hz}
|
||||
onChange={(e) => handleInputChange('plate_contact_frequency_hz', parseFloat(e.target.value) || 1)}
|
||||
className={`max-w-xs px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors text-sm ${errors.plate_contact_frequency_hz ? 'border-red-300' : 'border-gray-300'
|
||||
}`}
|
||||
placeholder="1.0"
|
||||
min="0.1"
|
||||
step="0.1"
|
||||
required
|
||||
/>
|
||||
{errors.plate_contact_frequency_hz && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.plate_contact_frequency_hz}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="throughput_rate_pecans_sec" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Throughput Rate (pecans/sec) *
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="throughput_rate_pecans_sec"
|
||||
value={formData.throughput_rate_pecans_sec}
|
||||
onChange={(e) => handleInputChange('throughput_rate_pecans_sec', parseFloat(e.target.value) || 1)}
|
||||
className={`max-w-xs px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors text-sm ${errors.throughput_rate_pecans_sec ? 'border-red-300' : 'border-gray-300'
|
||||
}`}
|
||||
placeholder="1.0"
|
||||
min="0.1"
|
||||
step="0.1"
|
||||
required
|
||||
/>
|
||||
{errors.throughput_rate_pecans_sec && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.throughput_rate_pecans_sec}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="crush_amount_in" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Crush Amount (thousandths of inch) *
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="crush_amount_in"
|
||||
value={formData.crush_amount_in}
|
||||
onChange={(e) => handleInputChange('crush_amount_in', parseFloat(e.target.value) || 0)}
|
||||
className={`max-w-xs px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors text-sm ${errors.crush_amount_in ? 'border-red-300' : 'border-gray-300'
|
||||
}`}
|
||||
placeholder="0.0"
|
||||
min="0"
|
||||
step="0.001"
|
||||
required
|
||||
/>
|
||||
{errors.crush_amount_in && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.crush_amount_in}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2">
|
||||
<label htmlFor="entry_exit_height_diff_in" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Entry/Exit Height Difference (inches) *
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="entry_exit_height_diff_in"
|
||||
value={formData.entry_exit_height_diff_in}
|
||||
onChange={(e) => handleInputChange('entry_exit_height_diff_in', parseFloat(e.target.value) || 0)}
|
||||
className={`max-w-sm px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors text-sm ${errors.entry_exit_height_diff_in ? 'border-red-300' : 'border-gray-300'
|
||||
}`}
|
||||
placeholder="0.0 (can be negative)"
|
||||
step="0.1"
|
||||
required
|
||||
/>
|
||||
{errors.entry_exit_height_diff_in && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.entry_exit_height_diff_in}</p>
|
||||
)}
|
||||
<p className="mt-1 text-sm text-gray-500">Positive values indicate entry is higher than exit</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status Fields (only show when editing) */}
|
||||
{isEditing && (
|
||||
<div className="border-t pt-6">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">Status</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label htmlFor="schedule_status" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Schedule Status
|
||||
</label>
|
||||
<select
|
||||
id="schedule_status"
|
||||
value={formData.schedule_status}
|
||||
onChange={(e) => handleInputChange('schedule_status', e.target.value as ScheduleStatus)}
|
||||
className="max-w-xs px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors text-sm"
|
||||
>
|
||||
<option value="pending schedule">Pending Schedule</option>
|
||||
<option value="scheduled">Scheduled</option>
|
||||
<option value="canceled">Canceled</option>
|
||||
<option value="aborted">Aborted</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="results_status" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Results Status
|
||||
</label>
|
||||
<select
|
||||
id="results_status"
|
||||
value={formData.results_status}
|
||||
onChange={(e) => handleInputChange('results_status', e.target.value as ResultsStatus)}
|
||||
className="max-w-xs px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors text-sm"
|
||||
>
|
||||
<option value="valid">Valid</option>
|
||||
<option value="invalid">Invalid</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="completion_status" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Completion Status
|
||||
</label>
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="completion_status"
|
||||
checked={formData.completion_status}
|
||||
onChange={(e) => handleInputChange('completion_status', e.target.checked)}
|
||||
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||||
/>
|
||||
<label htmlFor="completion_status" className="ml-2 text-sm text-gray-700">
|
||||
Mark as completed
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Form Actions */}
|
||||
<div className="flex justify-end space-x-4 pt-6 border-t">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="px-6 py-3 border border-gray-300 rounded-lg text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="px-6 py-3 border border-transparent rounded-lg text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{loading ? (isEditing ? 'Updating...' : 'Creating...') : (isEditing ? 'Update Experiment' : 'Create Experiment')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
127
management-dashboard-web-app/src/components/ExperimentModal.tsx
Normal file
127
management-dashboard-web-app/src/components/ExperimentModal.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import { useState } from 'react'
|
||||
import { ExperimentForm } from './ExperimentForm'
|
||||
import { experimentManagement } from '../lib/supabase'
|
||||
import type { Experiment, CreateExperimentRequest, UpdateExperimentRequest } from '../lib/supabase'
|
||||
|
||||
interface ExperimentModalProps {
|
||||
experiment?: Experiment
|
||||
onClose: () => void
|
||||
onExperimentSaved: (experiment: Experiment) => void
|
||||
}
|
||||
|
||||
export function ExperimentModal({ experiment, onClose, onExperimentSaved }: ExperimentModalProps) {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const isEditing = !!experiment
|
||||
|
||||
const handleSubmit = async (data: CreateExperimentRequest | UpdateExperimentRequest) => {
|
||||
setError(null)
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
let savedExperiment: Experiment
|
||||
|
||||
if (isEditing && experiment) {
|
||||
// Check if experiment number is unique (excluding current experiment)
|
||||
if ('experiment_number' in data && data.experiment_number !== undefined && data.experiment_number !== experiment.experiment_number) {
|
||||
const isUnique = await experimentManagement.isExperimentNumberUnique(data.experiment_number, experiment.id)
|
||||
if (!isUnique) {
|
||||
setError('Experiment number already exists. Please choose a different number.')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
savedExperiment = await experimentManagement.updateExperiment(experiment.id, data)
|
||||
} else {
|
||||
// Check if experiment number is unique for new experiments
|
||||
const createData = data as CreateExperimentRequest
|
||||
const isUnique = await experimentManagement.isExperimentNumberUnique(createData.experiment_number)
|
||||
if (!isUnique) {
|
||||
setError('Experiment number already exists. Please choose a different number.')
|
||||
return
|
||||
}
|
||||
|
||||
savedExperiment = await experimentManagement.createExperiment(createData)
|
||||
}
|
||||
|
||||
onExperimentSaved(savedExperiment)
|
||||
onClose()
|
||||
} catch (err: any) {
|
||||
setError(err.message || `Failed to ${isEditing ? 'update' : 'create'} experiment`)
|
||||
console.error('Experiment save error:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 flex items-center justify-center overflow-y-auto modal z-999999">
|
||||
<div
|
||||
className="fixed inset-0 h-full w-full bg-gray-400/50 backdrop-blur-[32px]"
|
||||
onClick={onClose}
|
||||
/>
|
||||
<div className="relative w-full rounded-2xl bg-white shadow-theme-xl dark:bg-gray-900 max-w-4xl mx-auto max-h-[90vh] overflow-y-auto p-4" onClick={(e) => e.stopPropagation()}>
|
||||
{/* Close Button */}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute right-3 top-3 z-999 flex h-9.5 w-9.5 items-center justify-center rounded-full bg-gray-100 text-gray-400 transition-colors hover:bg-gray-200 hover:text-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white sm:right-6 sm:top-6 sm:h-11 sm:w-11"
|
||||
>
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M6.04289 16.5413C5.65237 16.9318 5.65237 17.565 6.04289 17.9555C6.43342 18.346 7.06658 18.346 7.45711 17.9555L11.9987 13.4139L16.5408 17.956C16.9313 18.3466 17.5645 18.3466 17.955 17.956C18.3455 17.5655 18.3455 16.9323 17.955 16.5418L13.4129 11.9997L17.955 7.4576C18.3455 7.06707 18.3455 6.43391 17.955 6.04338C17.5645 5.65286 16.9313 5.65286 16.5408 6.04338L11.9987 10.5855L7.45711 6.0439C7.06658 5.65338 6.43342 5.65338 6.04289 6.0439C5.65237 6.43442 5.65237 7.06759 6.04289 7.45811L10.5845 11.9997L6.04289 16.5413Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Header */}
|
||||
<div className="sticky top-0 bg-white dark:bg-gray-900 flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-800 rounded-t-2xl">
|
||||
<h3 className="text-xl font-semibold text-gray-900 dark:text-white/90">
|
||||
{isEditing ? `Edit Experiment #${experiment.experiment_number}` : 'Create New Experiment'}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="mb-6 rounded-md bg-red-50 p-4">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<svg className="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-red-800">Error</h3>
|
||||
<div className="mt-2 text-sm text-red-700">{error}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Form */}
|
||||
<ExperimentForm
|
||||
initialData={experiment}
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={handleCancel}
|
||||
isEditing={isEditing}
|
||||
loading={loading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
445
management-dashboard-web-app/src/components/Experiments.tsx
Normal file
445
management-dashboard-web-app/src/components/Experiments.tsx
Normal file
@@ -0,0 +1,445 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { ExperimentModal } from './ExperimentModal'
|
||||
import { RepetitionScheduleModal } from './RepetitionScheduleModal'
|
||||
import { experimentManagement, repetitionManagement, userManagement } from '../lib/supabase'
|
||||
import type { Experiment, ExperimentRepetition, User, ScheduleStatus, ResultsStatus } from '../lib/supabase'
|
||||
|
||||
export function Experiments() {
|
||||
const [experiments, setExperiments] = useState<Experiment[]>([])
|
||||
const [experimentRepetitions, setExperimentRepetitions] = useState<Record<string, ExperimentRepetition[]>>({})
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const [editingExperiment, setEditingExperiment] = useState<Experiment | undefined>(undefined)
|
||||
const [currentUser, setCurrentUser] = useState<User | null>(null)
|
||||
|
||||
const [showRepetitionScheduleModal, setShowRepetitionScheduleModal] = useState(false)
|
||||
const [schedulingRepetition, setSchedulingRepetition] = useState<{ experiment: Experiment; repetition: ExperimentRepetition } | undefined>(undefined)
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
}, [])
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
const [experimentsData, userData] = await Promise.all([
|
||||
experimentManagement.getAllExperiments(),
|
||||
userManagement.getCurrentUser()
|
||||
])
|
||||
|
||||
setExperiments(experimentsData)
|
||||
setCurrentUser(userData)
|
||||
|
||||
// Load repetitions for each experiment
|
||||
const repetitionsMap: Record<string, ExperimentRepetition[]> = {}
|
||||
for (const experiment of experimentsData) {
|
||||
try {
|
||||
const repetitions = await repetitionManagement.getExperimentRepetitions(experiment.id)
|
||||
repetitionsMap[experiment.id] = repetitions
|
||||
} catch (err) {
|
||||
console.error(`Failed to load repetitions for experiment ${experiment.id}:`, err)
|
||||
repetitionsMap[experiment.id] = []
|
||||
}
|
||||
}
|
||||
setExperimentRepetitions(repetitionsMap)
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to load experiments')
|
||||
console.error('Load experiments error:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const canManageExperiments = currentUser?.roles.includes('admin') || currentUser?.roles.includes('conductor')
|
||||
|
||||
const handleCreateExperiment = () => {
|
||||
setEditingExperiment(undefined)
|
||||
setShowModal(true)
|
||||
}
|
||||
|
||||
const handleEditExperiment = (experiment: Experiment) => {
|
||||
setEditingExperiment(experiment)
|
||||
setShowModal(true)
|
||||
}
|
||||
|
||||
const handleExperimentSaved = async (experiment: Experiment) => {
|
||||
if (editingExperiment) {
|
||||
// Update existing experiment
|
||||
setExperiments(prev => prev.map(exp => exp.id === experiment.id ? experiment : exp))
|
||||
} else {
|
||||
// Add new experiment and create all its repetitions
|
||||
setExperiments(prev => [experiment, ...prev])
|
||||
|
||||
try {
|
||||
// Create all repetitions for the new experiment
|
||||
const repetitions = await repetitionManagement.createAllRepetitions(experiment.id)
|
||||
setExperimentRepetitions(prev => ({
|
||||
...prev,
|
||||
[experiment.id]: repetitions
|
||||
}))
|
||||
} catch (err) {
|
||||
console.error('Failed to create repetitions:', err)
|
||||
}
|
||||
}
|
||||
setShowModal(false)
|
||||
setEditingExperiment(undefined)
|
||||
}
|
||||
|
||||
const handleScheduleRepetition = (experiment: Experiment, repetition: ExperimentRepetition) => {
|
||||
setSchedulingRepetition({ experiment, repetition })
|
||||
setShowRepetitionScheduleModal(true)
|
||||
}
|
||||
|
||||
const handleRepetitionScheduleUpdated = (updatedRepetition: ExperimentRepetition) => {
|
||||
setExperimentRepetitions(prev => ({
|
||||
...prev,
|
||||
[updatedRepetition.experiment_id]: prev[updatedRepetition.experiment_id]?.map(rep =>
|
||||
rep.id === updatedRepetition.id ? updatedRepetition : rep
|
||||
) || []
|
||||
}))
|
||||
setShowRepetitionScheduleModal(false)
|
||||
setSchedulingRepetition(undefined)
|
||||
}
|
||||
|
||||
const handleCreateRepetition = async (experiment: Experiment, repetitionNumber: number) => {
|
||||
try {
|
||||
const newRepetition = await repetitionManagement.createRepetition({
|
||||
experiment_id: experiment.id,
|
||||
repetition_number: repetitionNumber,
|
||||
schedule_status: 'pending schedule'
|
||||
})
|
||||
|
||||
setExperimentRepetitions(prev => ({
|
||||
...prev,
|
||||
[experiment.id]: [...(prev[experiment.id] || []), newRepetition].sort((a, b) => a.repetition_number - b.repetition_number)
|
||||
}))
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to create repetition')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteExperiment = async (experiment: Experiment) => {
|
||||
if (!currentUser?.roles.includes('admin')) {
|
||||
alert('Only administrators can delete experiments.')
|
||||
return
|
||||
}
|
||||
|
||||
if (!confirm(`Are you sure you want to delete Experiment #${experiment.experiment_number}? This action cannot be undone.`)) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await experimentManagement.deleteExperiment(experiment.id)
|
||||
setExperiments(prev => prev.filter(exp => exp.id !== experiment.id))
|
||||
} catch (err: any) {
|
||||
alert(`Failed to delete experiment: ${err.message}`)
|
||||
console.error('Delete experiment error:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const getRepetitionStatusSummary = (repetitions: ExperimentRepetition[]) => {
|
||||
const scheduled = repetitions.filter(r => r.schedule_status === 'scheduled').length
|
||||
const pending = repetitions.filter(r => r.schedule_status === 'pending schedule').length
|
||||
const completed = repetitions.filter(r => r.completion_status).length
|
||||
|
||||
return { scheduled, pending, completed, total: repetitions.length }
|
||||
}
|
||||
|
||||
const getStatusBadgeColor = (status: ScheduleStatus | ResultsStatus) => {
|
||||
switch (status) {
|
||||
case 'pending schedule':
|
||||
return 'bg-yellow-100 text-yellow-800'
|
||||
case 'scheduled':
|
||||
return 'bg-blue-100 text-blue-800'
|
||||
case 'canceled':
|
||||
return 'bg-red-100 text-red-800'
|
||||
case 'aborted':
|
||||
return 'bg-red-100 text-red-800'
|
||||
case 'valid':
|
||||
return 'bg-green-100 text-green-800'
|
||||
case 'invalid':
|
||||
return 'bg-red-100 text-red-800'
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800'
|
||||
}
|
||||
}
|
||||
|
||||
// Remove filtering for now since experiments don't have schedule_status anymore
|
||||
const filteredExperiments = experiments
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Experiments</h1>
|
||||
<p className="mt-2 text-gray-600">Manage pecan processing experiment definitions</p>
|
||||
<p className="mt-2 text-gray-600">This is where you define the blueprint of an experiment with the required configurations and parameters, as well as the number of repetitions needed for that experiment.</p>
|
||||
</div>
|
||||
{canManageExperiments && (
|
||||
<button
|
||||
onClick={handleCreateExperiment}
|
||||
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
>
|
||||
➕ New Experiment
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="mb-6 rounded-md bg-red-50 p-4">
|
||||
<div className="text-sm text-red-700">{error}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
|
||||
{/* Experiments Table */}
|
||||
<div className="bg-white shadow overflow-hidden sm:rounded-md">
|
||||
<div className="px-4 py-5 sm:px-6">
|
||||
<h3 className="text-lg leading-6 font-medium text-gray-900">
|
||||
Experiments ({filteredExperiments.length})
|
||||
</h3>
|
||||
<p className="mt-1 max-w-2xl text-sm text-gray-500">
|
||||
{canManageExperiments ? 'Click on any experiment to edit details' : 'View experiment definitions and status'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Experiment #
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Reps Required
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Experiment Parameters
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Repetitions Status
|
||||
</th>
|
||||
{canManageExperiments && (
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Manage Repetitions
|
||||
</th>
|
||||
)}
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Results Status
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Completion
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Created
|
||||
</th>
|
||||
{canManageExperiments && (
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{filteredExperiments.map((experiment) => (
|
||||
<tr
|
||||
key={experiment.id}
|
||||
className={canManageExperiments ? "hover:bg-gray-50 cursor-pointer" : ""}
|
||||
onClick={canManageExperiments ? () => handleEditExperiment(experiment) : undefined}
|
||||
>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
||||
#{experiment.experiment_number}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{experiment.reps_required}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-500">
|
||||
<div className="space-y-1">
|
||||
<div>Soaking: {experiment.soaking_duration_hr}h</div>
|
||||
<div>Drying: {experiment.air_drying_time_min}min</div>
|
||||
<div>Frequency: {experiment.plate_contact_frequency_hz}Hz</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{(() => {
|
||||
const repetitions = experimentRepetitions[experiment.id] || []
|
||||
const summary = getRepetitionStatusSummary(repetitions)
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs text-gray-600">
|
||||
{summary.total} total • {summary.scheduled} scheduled • {summary.pending} pending
|
||||
</div>
|
||||
<div className="flex space-x-1">
|
||||
{summary.scheduled > 0 && (
|
||||
<span className="inline-flex px-2 py-1 text-xs font-semibold rounded-full bg-blue-100 text-blue-800">
|
||||
{summary.scheduled} scheduled
|
||||
</span>
|
||||
)}
|
||||
{summary.pending > 0 && (
|
||||
<span className="inline-flex px-2 py-1 text-xs font-semibold rounded-full bg-yellow-100 text-yellow-800">
|
||||
{summary.pending} pending
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</td>
|
||||
{canManageExperiments && (
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<div className="space-y-2">
|
||||
{(() => {
|
||||
const repetitions = experimentRepetitions[experiment.id] || []
|
||||
return repetitions.map((repetition) => (
|
||||
<div key={repetition.id} className="flex items-center justify-between p-2 bg-gray-50 rounded">
|
||||
<span className="text-sm font-medium">Rep #{repetition.repetition_number}</span>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${getStatusBadgeColor(repetition.schedule_status)}`}>
|
||||
{repetition.schedule_status === 'pending schedule' ? 'Pending' : repetition.schedule_status}
|
||||
</span>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleScheduleRepetition(experiment, repetition)
|
||||
}}
|
||||
className="text-blue-600 hover:text-blue-900 p-1 rounded hover:bg-blue-50 transition-colors"
|
||||
title={repetition.schedule_status === 'scheduled' ? 'Reschedule' : 'Schedule'}
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
})()}
|
||||
{(() => {
|
||||
const repetitions = experimentRepetitions[experiment.id] || []
|
||||
const missingReps = experiment.reps_required - repetitions.length
|
||||
if (missingReps > 0) {
|
||||
return (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleCreateRepetition(experiment, repetitions.length + 1)
|
||||
}}
|
||||
className="w-full text-sm text-blue-600 hover:text-blue-900 py-1 px-2 border border-blue-300 rounded hover:bg-blue-50 transition-colors"
|
||||
>
|
||||
+ Add Rep #{repetitions.length + 1}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
return null
|
||||
})()}
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${getStatusBadgeColor(experiment.results_status)}`}>
|
||||
{experiment.results_status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${experiment.completion_status
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-yellow-100 text-yellow-800'
|
||||
}`}>
|
||||
{experiment.completion_status ? 'Completed' : 'In Progress'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{new Date(experiment.created_at).toLocaleDateString()}
|
||||
</td>
|
||||
{canManageExperiments && (
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleEditExperiment(experiment)
|
||||
}}
|
||||
className="text-blue-600 hover:text-blue-900"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
{currentUser?.roles.includes('admin') && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleDeleteExperiment(experiment)
|
||||
}}
|
||||
className="text-red-600 hover:text-red-900"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{filteredExperiments.length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<svg className="mx-auto h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
|
||||
</svg>
|
||||
<h3 className="mt-2 text-sm font-medium text-gray-900">No experiments found</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Get started by creating your first experiment.
|
||||
</p>
|
||||
{canManageExperiments && (
|
||||
<div className="mt-6">
|
||||
<button
|
||||
onClick={handleCreateExperiment}
|
||||
className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
>
|
||||
➕ Create First Experiment
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Experiment Modal */}
|
||||
{showModal && (
|
||||
<ExperimentModal
|
||||
experiment={editingExperiment}
|
||||
onClose={() => setShowModal(false)}
|
||||
onExperimentSaved={handleExperimentSaved}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Repetition Schedule Modal */}
|
||||
{showRepetitionScheduleModal && schedulingRepetition && (
|
||||
<RepetitionScheduleModal
|
||||
experiment={schedulingRepetition.experiment}
|
||||
repetition={schedulingRepetition.repetition}
|
||||
onClose={() => setShowRepetitionScheduleModal(false)}
|
||||
onScheduleUpdated={handleRepetitionScheduleUpdated}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
104
management-dashboard-web-app/src/components/Login.tsx
Normal file
104
management-dashboard-web-app/src/components/Login.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import { useState } from 'react'
|
||||
import { supabase } from '../lib/supabase'
|
||||
|
||||
interface LoginProps {
|
||||
onLoginSuccess: () => void
|
||||
}
|
||||
|
||||
export function Login({ onLoginSuccess }: LoginProps) {
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const { data, error } = await supabase.auth.signInWithPassword({
|
||||
email,
|
||||
password,
|
||||
})
|
||||
|
||||
if (error) {
|
||||
setError(error.message)
|
||||
} else if (data.user) {
|
||||
onLoginSuccess()
|
||||
}
|
||||
} catch (err) {
|
||||
setError('An unexpected error occurred')
|
||||
console.error('Login error:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="max-w-md w-full space-y-8">
|
||||
<div>
|
||||
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
|
||||
Sign in to your account
|
||||
</h2>
|
||||
<p className="mt-2 text-center text-sm text-gray-600">
|
||||
RBAC Authentication System
|
||||
</p>
|
||||
</div>
|
||||
<form className="mt-8 space-y-6" onSubmit={handleLogin}>
|
||||
<div className="rounded-md shadow-sm -space-y-px">
|
||||
<div>
|
||||
<label htmlFor="email" className="sr-only">
|
||||
Email address
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
|
||||
placeholder="Email address"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="password" className="sr-only">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
|
||||
placeholder="Password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-md bg-red-50 p-4">
|
||||
<div className="text-sm text-red-700">{error}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? 'Signing in...' : 'Sign in'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
828
management-dashboard-web-app/src/components/PhaseDataEntry.tsx
Normal file
828
management-dashboard-web-app/src/components/PhaseDataEntry.tsx
Normal file
@@ -0,0 +1,828 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { phaseDraftManagement, type Experiment, type ExperimentPhaseDraft, type ExperimentPhase, type ExperimentPhaseData, type ExperimentRepetition, type User } from '../lib/supabase'
|
||||
import { PhaseDraftManager } from './PhaseDraftManager'
|
||||
|
||||
interface PhaseDataEntryProps {
|
||||
experiment: Experiment
|
||||
repetition: ExperimentRepetition
|
||||
phase: ExperimentPhase
|
||||
currentUser: User
|
||||
onBack: () => void
|
||||
onDataSaved: () => void
|
||||
}
|
||||
|
||||
export function PhaseDataEntry({ experiment, repetition, phase, currentUser, onBack, onDataSaved }: PhaseDataEntryProps) {
|
||||
const [selectedDraft, setSelectedDraft] = useState<ExperimentPhaseDraft | null>(null)
|
||||
const [phaseData, setPhaseData] = useState<Partial<ExperimentPhaseData>>({})
|
||||
const [diameterMeasurements, setDiameterMeasurements] = useState<number[]>(Array(10).fill(0))
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [lastSaved, setLastSaved] = useState<Date | null>(null)
|
||||
const [showDraftManager, setShowDraftManager] = useState(false)
|
||||
|
||||
// Auto-save interval (30 seconds)
|
||||
const AUTO_SAVE_INTERVAL = 30000
|
||||
|
||||
useEffect(() => {
|
||||
loadUserDrafts()
|
||||
}, [repetition.id, phase])
|
||||
|
||||
const loadUserDrafts = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
const drafts = await phaseDraftManagement.getUserPhaseDraftsForPhase(repetition.id, phase)
|
||||
|
||||
// Auto-select the most recent draft or show draft manager if none exist
|
||||
if (drafts.length > 0) {
|
||||
const mostRecentDraft = drafts[0] // Already sorted by created_at desc
|
||||
setSelectedDraft(mostRecentDraft)
|
||||
await loadPhaseDataForDraft(mostRecentDraft)
|
||||
} else {
|
||||
setShowDraftManager(true)
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to load drafts'
|
||||
setError(errorMessage)
|
||||
console.error('Load drafts error:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const loadPhaseDataForDraft = async (draft: ExperimentPhaseDraft) => {
|
||||
try {
|
||||
setError(null)
|
||||
|
||||
const existingData = await phaseDraftManagement.getPhaseDataForDraft(draft.id)
|
||||
|
||||
if (existingData) {
|
||||
setPhaseData(existingData)
|
||||
|
||||
// Load diameter measurements if they exist
|
||||
if (existingData.diameter_measurements) {
|
||||
const measurements = Array(10).fill(0)
|
||||
existingData.diameter_measurements.forEach(measurement => {
|
||||
if (measurement.measurement_number >= 1 && measurement.measurement_number <= 10) {
|
||||
measurements[measurement.measurement_number - 1] = measurement.diameter_in
|
||||
}
|
||||
})
|
||||
setDiameterMeasurements(measurements)
|
||||
}
|
||||
} else {
|
||||
// Initialize empty phase data
|
||||
setPhaseData({
|
||||
phase_draft_id: draft.id,
|
||||
phase_name: phase
|
||||
})
|
||||
setDiameterMeasurements(Array(10).fill(0))
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to load phase data'
|
||||
setError(errorMessage)
|
||||
console.error('Load phase data error:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const autoSave = useCallback(async () => {
|
||||
if (!selectedDraft || selectedDraft.status === 'submitted') return // Don't auto-save submitted drafts
|
||||
|
||||
try {
|
||||
await phaseDraftManagement.autoSaveDraft(selectedDraft.id, phaseData)
|
||||
|
||||
// Save diameter measurements if this is air-drying phase and we have measurements
|
||||
if (phase === 'air-drying' && phaseData.id && diameterMeasurements.some(m => m > 0)) {
|
||||
const validMeasurements = diameterMeasurements.filter(m => m > 0)
|
||||
if (validMeasurements.length > 0) {
|
||||
await phaseDraftManagement.saveDiameterMeasurements(phaseData.id, diameterMeasurements)
|
||||
|
||||
// Update average diameter
|
||||
const avgDiameter = phaseDraftManagement.calculateAverageDiameter(validMeasurements)
|
||||
setPhaseData(prev => ({ ...prev, avg_pecan_diameter_in: avgDiameter }))
|
||||
}
|
||||
}
|
||||
|
||||
setLastSaved(new Date())
|
||||
} catch (error) {
|
||||
console.warn('Auto-save failed:', error)
|
||||
}
|
||||
}, [selectedDraft, phase, phaseData, diameterMeasurements])
|
||||
|
||||
// Auto-save effect
|
||||
useEffect(() => {
|
||||
if (!loading && selectedDraft && phaseData.phase_draft_id) {
|
||||
const interval = setInterval(() => {
|
||||
autoSave()
|
||||
}, AUTO_SAVE_INTERVAL)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}
|
||||
}, [phaseData, diameterMeasurements, loading, autoSave, selectedDraft])
|
||||
|
||||
const handleInputChange = (field: string, value: unknown) => {
|
||||
setPhaseData(prev => ({
|
||||
...prev,
|
||||
[field]: value
|
||||
}))
|
||||
}
|
||||
|
||||
const handleDiameterChange = (index: number, value: number) => {
|
||||
const newMeasurements = [...diameterMeasurements]
|
||||
newMeasurements[index] = value
|
||||
setDiameterMeasurements(newMeasurements)
|
||||
|
||||
// Calculate and update average
|
||||
const validMeasurements = newMeasurements.filter(m => m > 0)
|
||||
if (validMeasurements.length > 0) {
|
||||
const avgDiameter = phaseDraftManagement.calculateAverageDiameter(validMeasurements)
|
||||
handleInputChange('avg_pecan_diameter_in', avgDiameter)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!selectedDraft) return
|
||||
|
||||
try {
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
|
||||
// Save phase data
|
||||
const savedData = await phaseDraftManagement.upsertPhaseData(selectedDraft.id, phaseData)
|
||||
setPhaseData(savedData)
|
||||
|
||||
// Save diameter measurements if this is air-drying phase
|
||||
if (phase === 'air-drying' && diameterMeasurements.some(m => m > 0)) {
|
||||
await phaseDraftManagement.saveDiameterMeasurements(savedData.id, diameterMeasurements)
|
||||
}
|
||||
|
||||
setLastSaved(new Date())
|
||||
onDataSaved()
|
||||
} catch (err: unknown) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to save data'
|
||||
setError(errorMessage)
|
||||
console.error('Save error:', err)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSelectDraft = (draft: ExperimentPhaseDraft) => {
|
||||
setSelectedDraft(draft)
|
||||
setShowDraftManager(false)
|
||||
loadPhaseDataForDraft(draft)
|
||||
}
|
||||
|
||||
const isFieldDisabled = () => {
|
||||
const isAdmin = currentUser.roles.includes('admin')
|
||||
return !selectedDraft ||
|
||||
selectedDraft.status === 'submitted' ||
|
||||
selectedDraft.status === 'withdrawn' ||
|
||||
(repetition.is_locked && !isAdmin)
|
||||
}
|
||||
|
||||
const getPhaseTitle = () => {
|
||||
switch (phase) {
|
||||
case 'pre-soaking': return 'Pre-Soaking Phase'
|
||||
case 'air-drying': return 'Air-Drying Phase'
|
||||
case 'cracking': return 'Cracking Phase'
|
||||
case 'shelling': return 'Shelling Phase'
|
||||
default: return 'Unknown Phase'
|
||||
}
|
||||
}
|
||||
|
||||
const calculateSoakingEndTime = () => {
|
||||
if (phaseData.soaking_start_time && experiment.soaking_duration_hr) {
|
||||
const startTime = new Date(phaseData.soaking_start_time)
|
||||
const endTime = new Date(startTime.getTime() + experiment.soaking_duration_hr * 60 * 60 * 1000)
|
||||
return endTime.toISOString().slice(0, 16) // Format for datetime-local input
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
const calculateAirDryingEndTime = () => {
|
||||
if (phaseData.airdrying_start_time && experiment.air_drying_time_min) {
|
||||
const startTime = new Date(phaseData.airdrying_start_time)
|
||||
const endTime = new Date(startTime.getTime() + experiment.air_drying_time_min * 60 * 1000)
|
||||
return endTime.toISOString().slice(0, 16) // Format for datetime-local input
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
|
||||
<p className="mt-4 text-gray-600">Loading phase data...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Draft Manager Modal */}
|
||||
{showDraftManager && (
|
||||
<PhaseDraftManager
|
||||
repetition={repetition}
|
||||
phase={phase}
|
||||
currentUser={currentUser}
|
||||
onSelectDraft={handleSelectDraft}
|
||||
onClose={() => setShowDraftManager(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="flex items-center text-gray-600 hover:text-gray-900 mb-2"
|
||||
>
|
||||
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Back to Phases
|
||||
</button>
|
||||
<h2 className="text-2xl font-bold text-gray-900">{getPhaseTitle()}</h2>
|
||||
{selectedDraft && (
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<span className="text-sm text-gray-600">
|
||||
Draft: {selectedDraft.draft_name || `Draft ${selectedDraft.id.slice(-8)}`}
|
||||
</span>
|
||||
<span className={`px-2 py-1 text-xs font-medium rounded-full ${selectedDraft.status === 'draft' ? 'bg-yellow-100 text-yellow-800' :
|
||||
selectedDraft.status === 'submitted' ? 'bg-green-100 text-green-800' :
|
||||
'bg-red-100 text-red-800'
|
||||
}`}>
|
||||
{selectedDraft.status}
|
||||
</span>
|
||||
{repetition.is_locked && (
|
||||
<span className="px-2 py-1 text-xs font-medium bg-red-100 text-red-800 rounded-full">
|
||||
🔒 Locked
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
<button
|
||||
onClick={() => setShowDraftManager(true)}
|
||||
className="px-3 py-2 text-sm bg-gray-100 text-gray-700 rounded-md hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
Manage Drafts
|
||||
</button>
|
||||
{lastSaved && (
|
||||
<span className="text-sm text-gray-500">
|
||||
Last saved: {lastSaved.toLocaleTimeString()}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving || !selectedDraft || selectedDraft.status === 'submitted'}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{saving ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mt-4 bg-red-50 border border-red-200 rounded-md p-4">
|
||||
<div className="text-sm text-red-700">{error}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedDraft?.status === 'submitted' && (
|
||||
<div className="mt-4 bg-yellow-50 border border-yellow-200 rounded-md p-4">
|
||||
<div className="text-sm text-yellow-700">
|
||||
This draft has been submitted and is read-only. Create a new draft to make changes.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedDraft?.status === 'withdrawn' && (
|
||||
<div className="mt-4 bg-red-50 border border-red-200 rounded-md p-4">
|
||||
<div className="text-sm text-red-700">
|
||||
This draft has been withdrawn. Create a new draft to make changes.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{repetition.is_locked && !currentUser.roles.includes('admin') && (
|
||||
<div className="mt-4 bg-red-50 border border-red-200 rounded-md p-4">
|
||||
<div className="text-sm text-red-700">
|
||||
This repetition has been locked by an admin. No changes can be made to drafts.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{repetition.is_locked && currentUser.roles.includes('admin') && (
|
||||
<div className="mt-4 bg-yellow-50 border border-yellow-200 rounded-md p-4">
|
||||
<div className="text-sm text-yellow-700">
|
||||
🔒 This repetition is locked, but you can still make changes as an admin.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!selectedDraft && (
|
||||
<div className="mt-4 bg-blue-50 border border-blue-200 rounded-md p-4">
|
||||
<div className="text-sm text-blue-700">
|
||||
No draft selected. Use "Manage Drafts" to create or select a draft for this phase.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Phase-specific forms */}
|
||||
<div className="bg-white rounded-lg shadow-md p-6">
|
||||
{phase === 'pre-soaking' && (
|
||||
<div className="space-y-6">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">Pre-Soaking Measurements</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Batch Initial Weight (lbs) *
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={phaseData.batch_initial_weight_lbs || ''}
|
||||
onChange={(e) => handleInputChange('batch_initial_weight_lbs', parseFloat(e.target.value) || null)}
|
||||
className="max-w-xs px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="0.00"
|
||||
disabled={isFieldDisabled()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Initial Shell Moisture (%)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0"
|
||||
max="100"
|
||||
value={phaseData.initial_shell_moisture_pct || ''}
|
||||
onChange={(e) => handleInputChange('initial_shell_moisture_pct', parseFloat(e.target.value) || null)}
|
||||
className="max-w-xs px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="0.0"
|
||||
disabled={isFieldDisabled()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Initial Kernel Moisture (%)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0"
|
||||
max="100"
|
||||
value={phaseData.initial_kernel_moisture_pct || ''}
|
||||
onChange={(e) => handleInputChange('initial_kernel_moisture_pct', parseFloat(e.target.value) || null)}
|
||||
className="max-w-xs px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="0.0"
|
||||
disabled={isFieldDisabled()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Soaking Start Time *
|
||||
</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={phaseData.soaking_start_time ? new Date(phaseData.soaking_start_time).toISOString().slice(0, 16) : ''}
|
||||
onChange={(e) => handleInputChange('soaking_start_time', e.target.value ? new Date(e.target.value).toISOString() : null)}
|
||||
className="max-w-xs px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
disabled={isFieldDisabled()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Calculated Soaking End Time */}
|
||||
{phaseData.soaking_start_time && (
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Soaking End Time (Calculated)
|
||||
</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={calculateSoakingEndTime()}
|
||||
className="max-w-xs px-3 py-2 border border-gray-300 rounded-lg bg-gray-100"
|
||||
disabled
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Automatically calculated based on soaking duration ({experiment.soaking_duration_hr}h)
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase === 'air-drying' && (
|
||||
<div className="space-y-6">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">Air-Drying Measurements</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Air-Drying Start Time *
|
||||
</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={phaseData.airdrying_start_time ? new Date(phaseData.airdrying_start_time).toISOString().slice(0, 16) : ''}
|
||||
onChange={(e) => handleInputChange('airdrying_start_time', e.target.value ? new Date(e.target.value).toISOString() : null)}
|
||||
className="max-w-xs px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
disabled={isFieldDisabled()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Post-Soak Weight (lbs)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={phaseData.post_soak_weight_lbs || ''}
|
||||
onChange={(e) => handleInputChange('post_soak_weight_lbs', parseFloat(e.target.value) || null)}
|
||||
className="max-w-xs px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="0.00"
|
||||
disabled={isFieldDisabled()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Post-Soak Kernel Moisture (%)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0"
|
||||
max="100"
|
||||
value={phaseData.post_soak_kernel_moisture_pct || ''}
|
||||
onChange={(e) => handleInputChange('post_soak_kernel_moisture_pct', parseFloat(e.target.value) || null)}
|
||||
className="max-w-xs px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="0.0"
|
||||
disabled={isFieldDisabled()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Post-Soak Shell Moisture (%)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0"
|
||||
max="100"
|
||||
value={phaseData.post_soak_shell_moisture_pct || ''}
|
||||
onChange={(e) => handleInputChange('post_soak_shell_moisture_pct', parseFloat(e.target.value) || null)}
|
||||
className="max-w-xs px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="0.0"
|
||||
disabled={isFieldDisabled()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Calculated Air-Drying End Time */}
|
||||
{phaseData.airdrying_start_time && (
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Air-Drying End Time (Calculated)
|
||||
</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={calculateAirDryingEndTime()}
|
||||
className="max-w-xs px-3 py-2 border border-gray-300 rounded-lg bg-gray-100"
|
||||
disabled
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Automatically calculated based on air-drying duration ({experiment.air_drying_time_min} minutes)
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pecan Diameter Measurements */}
|
||||
<div className="border-t pt-6">
|
||||
<h4 className="text-md font-medium text-gray-900 mb-4">Pecan Diameter Measurements (inches)</h4>
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
|
||||
{diameterMeasurements.map((measurement, index) => (
|
||||
<div key={index}>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Measurement {index + 1}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.001"
|
||||
min="0"
|
||||
value={measurement || ''}
|
||||
onChange={(e) => handleDiameterChange(index, parseFloat(e.target.value) || 0)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="0.000"
|
||||
disabled={isFieldDisabled()}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Average Diameter Display */}
|
||||
<div className="mt-4 bg-blue-50 rounded-lg p-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Average Pecan Diameter (Calculated)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.001"
|
||||
value={phaseData.avg_pecan_diameter_in || ''}
|
||||
onChange={(e) => handleInputChange('avg_pecan_diameter_in', parseFloat(e.target.value) || null)}
|
||||
className="max-w-xs px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="0.000"
|
||||
disabled={isFieldDisabled()}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Automatically calculated from individual measurements above
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase === 'cracking' && (
|
||||
<div className="space-y-6">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">Cracking Phase</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Cracking Start Time *
|
||||
</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={phaseData.cracking_start_time ? new Date(phaseData.cracking_start_time).toISOString().slice(0, 16) : ''}
|
||||
onChange={(e) => handleInputChange('cracking_start_time', e.target.value ? new Date(e.target.value).toISOString() : null)}
|
||||
className="max-w-xs px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
disabled={isFieldDisabled()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Machine Parameters Display */}
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<h4 className="text-md font-medium text-gray-700 mb-3">Cracker Machine Parameters (Read-Only)</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Plate Contact Frequency (Hz)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={experiment.plate_contact_frequency_hz}
|
||||
className="max-w-xs px-3 py-2 border border-gray-300 rounded-lg bg-gray-100"
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Throughput Rate (pecans/sec)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={experiment.throughput_rate_pecans_sec}
|
||||
className="max-w-xs px-3 py-2 border border-gray-300 rounded-lg bg-gray-100"
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Crush Amount (inches)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={experiment.crush_amount_in}
|
||||
className="max-w-xs px-3 py-2 border border-gray-300 rounded-lg bg-gray-100"
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Entry/Exit Height Difference (inches)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={experiment.entry_exit_height_diff_in}
|
||||
className="max-w-xs px-3 py-2 border border-gray-300 rounded-lg bg-gray-100"
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase === 'shelling' && (
|
||||
<div className="space-y-6">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">Shelling Phase</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Shelling Start Time *
|
||||
</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={phaseData.shelling_start_time ? new Date(phaseData.shelling_start_time).toISOString().slice(0, 16) : ''}
|
||||
onChange={(e) => handleInputChange('shelling_start_time', e.target.value ? new Date(e.target.value).toISOString() : null)}
|
||||
className="max-w-xs px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
disabled={isFieldDisabled()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bin Weights */}
|
||||
<div className="border-t pt-6">
|
||||
<h4 className="text-md font-medium text-gray-900 mb-4">Bin Weights (lbs)</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Bin 1 Weight
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={phaseData.bin_1_weight_lbs || ''}
|
||||
onChange={(e) => handleInputChange('bin_1_weight_lbs', parseFloat(e.target.value) || null)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="0.00"
|
||||
disabled={isFieldDisabled()}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Bin 2 Weight
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={phaseData.bin_2_weight_lbs || ''}
|
||||
onChange={(e) => handleInputChange('bin_2_weight_lbs', parseFloat(e.target.value) || null)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="0.00"
|
||||
disabled={isFieldDisabled()}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Bin 3 Weight
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={phaseData.bin_3_weight_lbs || ''}
|
||||
onChange={(e) => handleInputChange('bin_3_weight_lbs', parseFloat(e.target.value) || null)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="0.00"
|
||||
disabled={isFieldDisabled()}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Discharge Bin Weight
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={phaseData.discharge_bin_weight_lbs || ''}
|
||||
onChange={(e) => handleInputChange('discharge_bin_weight_lbs', parseFloat(e.target.value) || null)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="0.00"
|
||||
disabled={isFieldDisabled()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Full Yield Weights */}
|
||||
<div className="border-t pt-6">
|
||||
<h4 className="text-md font-medium text-gray-900 mb-4">Full Yield Weights (oz)</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Bin 1 Full Yield
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={phaseData.bin_1_full_yield_oz || ''}
|
||||
onChange={(e) => handleInputChange('bin_1_full_yield_oz', parseFloat(e.target.value) || null)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="0.00"
|
||||
disabled={isFieldDisabled()}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Bin 2 Full Yield
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={phaseData.bin_2_full_yield_oz || ''}
|
||||
onChange={(e) => handleInputChange('bin_2_full_yield_oz', parseFloat(e.target.value) || null)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="0.00"
|
||||
disabled={isFieldDisabled()}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Bin 3 Full Yield
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={phaseData.bin_3_full_yield_oz || ''}
|
||||
onChange={(e) => handleInputChange('bin_3_full_yield_oz', parseFloat(e.target.value) || null)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="0.00"
|
||||
disabled={isFieldDisabled()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Half Yield Weights */}
|
||||
<div className="border-t pt-6">
|
||||
<h4 className="text-md font-medium text-gray-900 mb-4">Half Yield Weights (oz)</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Bin 1 Half Yield
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={phaseData.bin_1_half_yield_oz || ''}
|
||||
onChange={(e) => handleInputChange('bin_1_half_yield_oz', parseFloat(e.target.value) || null)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="0.00"
|
||||
disabled={isFieldDisabled()}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Bin 2 Half Yield
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={phaseData.bin_2_half_yield_oz || ''}
|
||||
onChange={(e) => handleInputChange('bin_2_half_yield_oz', parseFloat(e.target.value) || null)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="0.00"
|
||||
disabled={isFieldDisabled()}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Bin 3 Half Yield
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={phaseData.bin_3_half_yield_oz || ''}
|
||||
onChange={(e) => handleInputChange('bin_3_half_yield_oz', parseFloat(e.target.value) || null)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="0.00"
|
||||
disabled={isFieldDisabled()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { phaseDraftManagement, type ExperimentPhaseDraft, type ExperimentPhase, type User, type ExperimentRepetition } from '../lib/supabase'
|
||||
|
||||
interface PhaseDraftManagerProps {
|
||||
repetition: ExperimentRepetition
|
||||
phase: ExperimentPhase
|
||||
currentUser: User
|
||||
onSelectDraft: (draft: ExperimentPhaseDraft) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function PhaseDraftManager({ repetition, phase, currentUser, onSelectDraft, onClose }: PhaseDraftManagerProps) {
|
||||
const [drafts, setDrafts] = useState<ExperimentPhaseDraft[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [newDraftName, setNewDraftName] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
loadDrafts()
|
||||
}, [repetition.id, phase])
|
||||
|
||||
const loadDrafts = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
const userDrafts = await phaseDraftManagement.getUserPhaseDraftsForPhase(repetition.id, phase)
|
||||
setDrafts(userDrafts)
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to load drafts')
|
||||
console.error('Load drafts error:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreateDraft = async () => {
|
||||
try {
|
||||
setCreating(true)
|
||||
setError(null)
|
||||
|
||||
const newDraft = await phaseDraftManagement.createPhaseDraft({
|
||||
experiment_id: repetition.experiment_id,
|
||||
repetition_id: repetition.id,
|
||||
phase_name: phase,
|
||||
draft_name: newDraftName || undefined,
|
||||
status: 'draft'
|
||||
})
|
||||
|
||||
setDrafts(prev => [newDraft, ...prev])
|
||||
setNewDraftName('')
|
||||
onSelectDraft(newDraft)
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to create draft')
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteDraft = async (draftId: string) => {
|
||||
if (!confirm('Are you sure you want to delete this draft? This action cannot be undone.')) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await phaseDraftManagement.deletePhaseDraft(draftId)
|
||||
setDrafts(prev => prev.filter(draft => draft.id !== draftId))
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to delete draft')
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmitDraft = async (draftId: string) => {
|
||||
if (!confirm('Are you sure you want to submit this draft? Once submitted, it can only be withdrawn by you or locked by an admin.')) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const submittedDraft = await phaseDraftManagement.submitPhaseDraft(draftId)
|
||||
setDrafts(prev => prev.map(draft =>
|
||||
draft.id === draftId ? submittedDraft : draft
|
||||
))
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to submit draft')
|
||||
}
|
||||
}
|
||||
|
||||
const handleWithdrawDraft = async (draftId: string) => {
|
||||
if (!confirm('Are you sure you want to withdraw this submitted draft? It will be marked as withdrawn.')) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const withdrawnDraft = await phaseDraftManagement.withdrawPhaseDraft(draftId)
|
||||
setDrafts(prev => prev.map(draft =>
|
||||
draft.id === draftId ? withdrawnDraft : draft
|
||||
))
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to withdraw draft')
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case 'draft':
|
||||
return <span className="px-2 py-1 text-xs font-medium bg-yellow-100 text-yellow-800 rounded-full">Draft</span>
|
||||
case 'submitted':
|
||||
return <span className="px-2 py-1 text-xs font-medium bg-green-100 text-green-800 rounded-full">Submitted</span>
|
||||
case 'withdrawn':
|
||||
return <span className="px-2 py-1 text-xs font-medium bg-red-100 text-red-800 rounded-full">Withdrawn</span>
|
||||
default:
|
||||
return <span className="px-2 py-1 text-xs font-medium bg-gray-100 text-gray-800 rounded-full">{status}</span>
|
||||
}
|
||||
}
|
||||
|
||||
const canDeleteDraft = (draft: ExperimentPhaseDraft) => {
|
||||
return draft.status === 'draft' && (!repetition.is_locked || currentUser.roles.includes('admin'))
|
||||
}
|
||||
|
||||
const canSubmitDraft = (draft: ExperimentPhaseDraft) => {
|
||||
return draft.status === 'draft' && (!repetition.is_locked || currentUser.roles.includes('admin'))
|
||||
}
|
||||
|
||||
const canWithdrawDraft = (draft: ExperimentPhaseDraft) => {
|
||||
return draft.status === 'submitted' && (!repetition.is_locked || currentUser.roles.includes('admin'))
|
||||
}
|
||||
|
||||
const canCreateDraft = () => {
|
||||
return !repetition.is_locked || currentUser.roles.includes('admin')
|
||||
}
|
||||
|
||||
const formatPhaseTitle = (phase: string) => {
|
||||
return phase.split('-').map(word =>
|
||||
word.charAt(0).toUpperCase() + word.slice(1)
|
||||
).join(' ')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg shadow-xl max-w-4xl w-full mx-4 max-h-[90vh] overflow-hidden">
|
||||
<div className="flex items-center justify-between p-6 border-b border-gray-200">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-900">
|
||||
{formatPhaseTitle(phase)} Phase Drafts
|
||||
</h2>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
Repetition {repetition.repetition_number}
|
||||
{repetition.is_locked && (
|
||||
<span className="ml-2 px-2 py-1 text-xs font-medium bg-red-100 text-red-800 rounded-full">
|
||||
🔒 Locked
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600 transition-colors"
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
{error && (
|
||||
<div className="mb-4 bg-red-50 border border-red-200 rounded-md p-4">
|
||||
<div className="text-sm text-red-700">{error}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create New Draft */}
|
||||
<div className="mb-6 p-4 bg-gray-50 rounded-lg">
|
||||
<h3 className="text-sm font-medium text-gray-900 mb-3">Create New Draft</h3>
|
||||
<div className="flex gap-3">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Draft name (optional)"
|
||||
value={newDraftName}
|
||||
onChange={(e) => setNewDraftName(e.target.value)}
|
||||
className="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
disabled={creating || repetition.is_locked}
|
||||
/>
|
||||
<button
|
||||
onClick={handleCreateDraft}
|
||||
disabled={creating || !canCreateDraft()}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{creating ? 'Creating...' : 'Create Draft'}
|
||||
</button>
|
||||
</div>
|
||||
{repetition.is_locked && !currentUser.roles.includes('admin') && (
|
||||
<p className="text-xs text-red-600 mt-2">
|
||||
Cannot create new drafts: repetition is locked by admin
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Drafts List */}
|
||||
<div className="space-y-4">
|
||||
{loading ? (
|
||||
<div className="text-center py-8">
|
||||
<div className="text-gray-500">Loading drafts...</div>
|
||||
</div>
|
||||
) : drafts.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<div className="text-gray-500">No drafts found for this phase</div>
|
||||
<p className="text-sm text-gray-400 mt-1">Create a new draft to get started</p>
|
||||
</div>
|
||||
) : (
|
||||
drafts.map((draft) => (
|
||||
<div key={draft.id} className="border border-gray-200 rounded-lg p-4 hover:bg-gray-50 transition-colors">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<h4 className="font-medium text-gray-900">
|
||||
{draft.draft_name || `Draft ${draft.id.slice(-8)}`}
|
||||
</h4>
|
||||
{getStatusBadge(draft.status)}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">
|
||||
<p>Created: {new Date(draft.created_at).toLocaleString()}</p>
|
||||
<p>Updated: {new Date(draft.updated_at).toLocaleString()}</p>
|
||||
{draft.submitted_at && (
|
||||
<p>Submitted: {new Date(draft.submitted_at).toLocaleString()}</p>
|
||||
)}
|
||||
{draft.withdrawn_at && (
|
||||
<p>Withdrawn: {new Date(draft.withdrawn_at).toLocaleString()}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => onSelectDraft(draft)}
|
||||
className="px-3 py-1 text-sm bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
{draft.status === 'draft' ? 'Edit' : 'View'}
|
||||
</button>
|
||||
|
||||
{canSubmitDraft(draft) && (
|
||||
<button
|
||||
onClick={() => handleSubmitDraft(draft.id)}
|
||||
className="px-3 py-1 text-sm bg-green-600 text-white rounded hover:bg-green-700 transition-colors"
|
||||
>
|
||||
Submit
|
||||
</button>
|
||||
)}
|
||||
|
||||
{canWithdrawDraft(draft) && (
|
||||
<button
|
||||
onClick={() => handleWithdrawDraft(draft.id)}
|
||||
className="px-3 py-1 text-sm bg-yellow-600 text-white rounded hover:bg-yellow-700 transition-colors"
|
||||
>
|
||||
Withdraw
|
||||
</button>
|
||||
)}
|
||||
|
||||
{canDeleteDraft(draft) && (
|
||||
<button
|
||||
onClick={() => handleDeleteDraft(draft.id)}
|
||||
className="px-3 py-1 text-sm bg-red-600 text-white rounded hover:bg-red-700 transition-colors"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
246
management-dashboard-web-app/src/components/PhaseSelector.tsx
Normal file
246
management-dashboard-web-app/src/components/PhaseSelector.tsx
Normal file
@@ -0,0 +1,246 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { type ExperimentPhase, type ExperimentPhaseData } from '../lib/supabase'
|
||||
|
||||
// DEPRECATED: This component is deprecated in favor of RepetitionPhaseSelector
|
||||
// which uses the new phase-specific draft system
|
||||
|
||||
// Temporary type for backward compatibility
|
||||
interface LegacyDataEntry {
|
||||
id: string
|
||||
experiment_id: string
|
||||
user_id: string
|
||||
status: 'draft' | 'submitted'
|
||||
entry_name?: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
submitted_at?: string | null
|
||||
}
|
||||
|
||||
interface PhaseSelectorProps {
|
||||
dataEntry: LegacyDataEntry
|
||||
onPhaseSelect: (phase: ExperimentPhase) => void
|
||||
}
|
||||
|
||||
interface PhaseInfo {
|
||||
name: ExperimentPhase
|
||||
title: string
|
||||
description: string
|
||||
icon: string
|
||||
color: string
|
||||
}
|
||||
|
||||
const phases: PhaseInfo[] = [
|
||||
{
|
||||
name: 'pre-soaking',
|
||||
title: 'Pre-Soaking',
|
||||
description: 'Initial measurements before soaking process',
|
||||
icon: '🌰',
|
||||
color: 'bg-blue-500'
|
||||
},
|
||||
{
|
||||
name: 'air-drying',
|
||||
title: 'Air-Drying',
|
||||
description: 'Post-soak measurements and air-drying data',
|
||||
icon: '💨',
|
||||
color: 'bg-green-500'
|
||||
},
|
||||
{
|
||||
name: 'cracking',
|
||||
title: 'Cracking',
|
||||
description: 'Cracking process timing and parameters',
|
||||
icon: '🔨',
|
||||
color: 'bg-yellow-500'
|
||||
},
|
||||
{
|
||||
name: 'shelling',
|
||||
title: 'Shelling',
|
||||
description: 'Final measurements and yield data',
|
||||
icon: '📊',
|
||||
color: 'bg-purple-500'
|
||||
}
|
||||
]
|
||||
|
||||
export function PhaseSelector({ dataEntry, onPhaseSelect }: PhaseSelectorProps) {
|
||||
const [phaseData, setPhaseData] = useState<Record<ExperimentPhase, ExperimentPhaseData | null>>({
|
||||
'pre-soaking': null,
|
||||
'air-drying': null,
|
||||
'cracking': null,
|
||||
'shelling': null
|
||||
})
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
loadPhaseData()
|
||||
}, [dataEntry.id])
|
||||
|
||||
const loadPhaseData = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
// DEPRECATED: Using empty array since this component is deprecated
|
||||
const allPhaseData: ExperimentPhaseData[] = []
|
||||
|
||||
const phaseDataMap: Record<ExperimentPhase, ExperimentPhaseData | null> = {
|
||||
'pre-soaking': null,
|
||||
'air-drying': null,
|
||||
'cracking': null,
|
||||
'shelling': null
|
||||
}
|
||||
|
||||
allPhaseData.forEach(data => {
|
||||
phaseDataMap[data.phase_name] = data
|
||||
})
|
||||
|
||||
setPhaseData(phaseDataMap)
|
||||
} catch (error) {
|
||||
console.error('Failed to load phase data:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const getPhaseCompletionStatus = (phaseName: ExperimentPhase): 'empty' | 'partial' | 'complete' => {
|
||||
const data = phaseData[phaseName]
|
||||
if (!data) return 'empty'
|
||||
|
||||
// Check if phase has any data
|
||||
const hasAnyData = Object.entries(data).some(([key, value]) => {
|
||||
if (['id', 'data_entry_id', 'phase_name', 'created_at', 'updated_at', 'diameter_measurements'].includes(key)) {
|
||||
return false
|
||||
}
|
||||
return value !== null && value !== undefined && value !== ''
|
||||
})
|
||||
|
||||
if (!hasAnyData) return 'empty'
|
||||
|
||||
// For now, consider any data as partial completion
|
||||
// You could implement more sophisticated completion logic here
|
||||
return 'partial'
|
||||
}
|
||||
|
||||
const getStatusIcon = (status: 'empty' | 'partial' | 'complete') => {
|
||||
switch (status) {
|
||||
case 'empty':
|
||||
return (
|
||||
<div className="w-6 h-6 rounded-full border-2 border-gray-300 bg-white"></div>
|
||||
)
|
||||
case 'partial':
|
||||
return (
|
||||
<div className="w-6 h-6 rounded-full bg-yellow-500 flex items-center justify-center">
|
||||
<svg className="w-4 h-4 text-white" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
case 'complete':
|
||||
return (
|
||||
<div className="w-6 h-6 rounded-full bg-green-500 flex items-center justify-center">
|
||||
<svg className="w-4 h-4 text-white" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const getLastUpdated = (phaseName: ExperimentPhase): string | null => {
|
||||
const data = phaseData[phaseName]
|
||||
if (!data) return null
|
||||
return new Date(data.updated_at).toLocaleString()
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
|
||||
<p className="mt-4 text-gray-600">Loading phase data...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-2">Select Experiment Phase</h2>
|
||||
<p className="text-gray-600">
|
||||
Click on any phase card to enter or edit data for that phase
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{phases.map((phase) => {
|
||||
const status = getPhaseCompletionStatus(phase.name)
|
||||
const lastUpdated = getLastUpdated(phase.name)
|
||||
|
||||
return (
|
||||
<button
|
||||
key={phase.name}
|
||||
onClick={() => onPhaseSelect(phase.name)}
|
||||
className="text-left p-6 bg-white rounded-lg shadow-md hover:shadow-lg transition-shadow border border-gray-200 hover:border-gray-300"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex items-center">
|
||||
<div className={`w-12 h-12 rounded-lg ${phase.color} flex items-center justify-center text-white text-xl mr-4`}>
|
||||
{phase.icon}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-gray-900">{phase.title}</h3>
|
||||
<p className="text-sm text-gray-500">{phase.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-end">
|
||||
{getStatusIcon(status)}
|
||||
<span className="text-xs text-gray-400 mt-1">
|
||||
{status === 'empty' ? 'No data' : status === 'partial' ? 'In progress' : 'Complete'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{lastUpdated && (
|
||||
<div className="text-xs text-gray-400">
|
||||
Last updated: {lastUpdated}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 flex items-center text-blue-600">
|
||||
<span className="text-sm font-medium">
|
||||
{status === 'empty' ? 'Start entering data' : 'Continue editing'}
|
||||
</span>
|
||||
<svg className="w-4 h-4 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Phase Navigation */}
|
||||
<div className="mt-8 bg-gray-50 rounded-lg p-4">
|
||||
<h3 className="text-sm font-medium text-gray-700 mb-3">Phase Progress</h3>
|
||||
<div className="flex items-center space-x-4">
|
||||
{phases.map((phase, index) => {
|
||||
const status = getPhaseCompletionStatus(phase.name)
|
||||
return (
|
||||
<div key={phase.name} className="flex items-center">
|
||||
<button
|
||||
onClick={() => onPhaseSelect(phase.name)}
|
||||
className="flex items-center space-x-2 px-3 py-2 rounded-md hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
{getStatusIcon(status)}
|
||||
<span className="text-sm text-gray-700">{phase.title}</span>
|
||||
</button>
|
||||
{index < phases.length - 1 && (
|
||||
<svg className="w-4 h-4 text-gray-400 mx-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { type Experiment, type ExperimentRepetition, type User, type ExperimentPhase } from '../lib/supabase'
|
||||
import { RepetitionPhaseSelector } from './RepetitionPhaseSelector'
|
||||
import { PhaseDataEntry } from './PhaseDataEntry'
|
||||
import { RepetitionLockManager } from './RepetitionLockManager'
|
||||
|
||||
interface RepetitionDataEntryInterfaceProps {
|
||||
experiment: Experiment
|
||||
repetition: ExperimentRepetition
|
||||
currentUser: User
|
||||
onBack: () => void
|
||||
}
|
||||
|
||||
export function RepetitionDataEntryInterface({ experiment, repetition, currentUser, onBack }: RepetitionDataEntryInterfaceProps) {
|
||||
const [selectedPhase, setSelectedPhase] = useState<ExperimentPhase | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [currentRepetition, setCurrentRepetition] = useState<ExperimentRepetition>(repetition)
|
||||
|
||||
useEffect(() => {
|
||||
// Skip loading old data entries - go directly to phase selection
|
||||
setLoading(false)
|
||||
}, [repetition.id, currentUser.id])
|
||||
|
||||
|
||||
|
||||
const handlePhaseSelect = (phase: ExperimentPhase) => {
|
||||
setSelectedPhase(phase)
|
||||
}
|
||||
|
||||
const handleBackToPhases = () => {
|
||||
setSelectedPhase(null)
|
||||
}
|
||||
|
||||
const handleRepetitionUpdated = (updatedRepetition: ExperimentRepetition) => {
|
||||
setCurrentRepetition(updatedRepetition)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
|
||||
<p className="mt-4 text-gray-600">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="flex items-center space-x-4">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="text-blue-600 hover:text-blue-800 flex items-center space-x-2"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
<span>Back to Repetitions</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<h1 className="text-3xl font-bold text-gray-900">
|
||||
Experiment #{experiment.experiment_number} - Repetition #{repetition.repetition_number}
|
||||
</h1>
|
||||
<div className="mt-2 text-sm text-gray-600 space-y-1">
|
||||
<div>Soaking: {experiment.soaking_duration_hr}h • Air Drying: {experiment.air_drying_time_min}min</div>
|
||||
<div>Frequency: {experiment.plate_contact_frequency_hz}Hz • Throughput: {experiment.throughput_rate_pecans_sec}/sec</div>
|
||||
{repetition.scheduled_date && (
|
||||
<div>Scheduled: {new Date(repetition.scheduled_date).toLocaleString()}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* No additional controls needed - phase-specific draft management is handled within each phase */}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Admin Controls */}
|
||||
<RepetitionLockManager
|
||||
repetition={currentRepetition}
|
||||
currentUser={currentUser}
|
||||
onRepetitionUpdated={handleRepetitionUpdated}
|
||||
/>
|
||||
|
||||
{/* Main Content */}
|
||||
{selectedPhase ? (
|
||||
<PhaseDataEntry
|
||||
experiment={experiment}
|
||||
repetition={currentRepetition}
|
||||
phase={selectedPhase}
|
||||
currentUser={currentUser}
|
||||
onBack={handleBackToPhases}
|
||||
onDataSaved={() => {
|
||||
// Data is automatically saved in the new phase-specific system
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<RepetitionPhaseSelector
|
||||
repetition={currentRepetition}
|
||||
currentUser={currentUser}
|
||||
onPhaseSelect={handlePhaseSelect}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { useState } from 'react'
|
||||
import { repetitionManagement, type ExperimentRepetition, type User } from '../lib/supabase'
|
||||
|
||||
interface RepetitionLockManagerProps {
|
||||
repetition: ExperimentRepetition
|
||||
currentUser: User
|
||||
onRepetitionUpdated: (updatedRepetition: ExperimentRepetition) => void
|
||||
}
|
||||
|
||||
export function RepetitionLockManager({ repetition, currentUser, onRepetitionUpdated }: RepetitionLockManagerProps) {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const isAdmin = currentUser.roles.includes('admin')
|
||||
|
||||
const handleLockRepetition = async () => {
|
||||
if (!confirm('Are you sure you want to lock this repetition? This will prevent users from modifying or withdrawing any submitted drafts.')) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
const updatedRepetition = await repetitionManagement.lockRepetition(repetition.id)
|
||||
onRepetitionUpdated(updatedRepetition)
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to lock repetition')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleUnlockRepetition = async () => {
|
||||
if (!confirm('Are you sure you want to unlock this repetition? This will allow users to modify and withdraw submitted drafts again.')) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
const updatedRepetition = await repetitionManagement.unlockRepetition(repetition.id)
|
||||
onRepetitionUpdated(updatedRepetition)
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to unlock repetition')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!isAdmin) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4">
|
||||
<h3 className="text-sm font-medium text-gray-900 mb-3">Admin Controls</h3>
|
||||
|
||||
{error && (
|
||||
<div className="mb-3 bg-red-50 border border-red-200 rounded-md p-3">
|
||||
<div className="text-sm text-red-700">{error}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-700">Repetition Status:</span>
|
||||
{repetition.is_locked ? (
|
||||
<span className="px-2 py-1 text-xs font-medium bg-red-100 text-red-800 rounded-full">
|
||||
🔒 Locked
|
||||
</span>
|
||||
) : (
|
||||
<span className="px-2 py-1 text-xs font-medium bg-green-100 text-green-800 rounded-full">
|
||||
🔓 Unlocked
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{repetition.is_locked && repetition.locked_at && (
|
||||
<div className="mt-1 text-xs text-gray-500">
|
||||
Locked: {new Date(repetition.locked_at).toLocaleString()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{repetition.is_locked ? (
|
||||
<button
|
||||
onClick={handleUnlockRepetition}
|
||||
disabled={loading}
|
||||
className="px-3 py-1 text-sm bg-green-600 text-white rounded hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{loading ? 'Unlocking...' : 'Unlock'}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleLockRepetition}
|
||||
disabled={loading}
|
||||
className="px-3 py-1 text-sm bg-red-600 text-white rounded hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{loading ? 'Locking...' : 'Lock'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 text-xs text-gray-600">
|
||||
{repetition.is_locked ? (
|
||||
<p>
|
||||
When locked, users cannot create new drafts, delete existing drafts, or withdraw submitted drafts.
|
||||
Only admins can modify the lock status.
|
||||
</p>
|
||||
) : (
|
||||
<p>
|
||||
When unlocked, users can freely create, edit, delete, submit, and withdraw drafts.
|
||||
Lock this repetition to prevent further changes to submitted data.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { phaseDraftManagement, type ExperimentRepetition, type ExperimentPhase, type ExperimentPhaseDraft, type User } from '../lib/supabase'
|
||||
|
||||
interface RepetitionPhaseSelectorProps {
|
||||
repetition: ExperimentRepetition
|
||||
currentUser: User
|
||||
onPhaseSelect: (phase: ExperimentPhase) => void
|
||||
}
|
||||
|
||||
interface PhaseInfo {
|
||||
name: ExperimentPhase
|
||||
title: string
|
||||
description: string
|
||||
icon: string
|
||||
color: string
|
||||
}
|
||||
|
||||
const phases: PhaseInfo[] = [
|
||||
{
|
||||
name: 'pre-soaking',
|
||||
title: 'Pre-Soaking',
|
||||
description: 'Initial measurements before soaking process',
|
||||
icon: '🌰',
|
||||
color: 'bg-blue-500'
|
||||
},
|
||||
{
|
||||
name: 'air-drying',
|
||||
title: 'Air-Drying',
|
||||
description: 'Post-soak measurements and air-drying data',
|
||||
icon: '💨',
|
||||
color: 'bg-green-500'
|
||||
},
|
||||
{
|
||||
name: 'cracking',
|
||||
title: 'Cracking',
|
||||
description: 'Cracking process timing and parameters',
|
||||
icon: '🔨',
|
||||
color: 'bg-yellow-500'
|
||||
},
|
||||
{
|
||||
name: 'shelling',
|
||||
title: 'Shelling',
|
||||
description: 'Final measurements and yield data',
|
||||
icon: '📊',
|
||||
color: 'bg-purple-500'
|
||||
}
|
||||
]
|
||||
|
||||
export function RepetitionPhaseSelector({ repetition, currentUser: _currentUser, onPhaseSelect }: RepetitionPhaseSelectorProps) {
|
||||
const [phaseDrafts, setPhaseDrafts] = useState<Record<ExperimentPhase, ExperimentPhaseDraft[]>>({
|
||||
'pre-soaking': [],
|
||||
'air-drying': [],
|
||||
'cracking': [],
|
||||
'shelling': []
|
||||
})
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
loadPhaseDrafts()
|
||||
}, [repetition.id])
|
||||
|
||||
const loadPhaseDrafts = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
const allDrafts = await phaseDraftManagement.getUserPhaseDraftsForRepetition(repetition.id)
|
||||
|
||||
// Group drafts by phase
|
||||
const groupedDrafts: Record<ExperimentPhase, ExperimentPhaseDraft[]> = {
|
||||
'pre-soaking': [],
|
||||
'air-drying': [],
|
||||
'cracking': [],
|
||||
'shelling': []
|
||||
}
|
||||
|
||||
allDrafts.forEach(draft => {
|
||||
groupedDrafts[draft.phase_name].push(draft)
|
||||
})
|
||||
|
||||
setPhaseDrafts(groupedDrafts)
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to load phase drafts')
|
||||
console.error('Load phase drafts error:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const getPhaseStatus = (phase: ExperimentPhase) => {
|
||||
const drafts = phaseDrafts[phase]
|
||||
if (drafts.length === 0) return 'empty'
|
||||
|
||||
const hasSubmitted = drafts.some(d => d.status === 'submitted')
|
||||
const hasDraft = drafts.some(d => d.status === 'draft')
|
||||
const hasWithdrawn = drafts.some(d => d.status === 'withdrawn')
|
||||
|
||||
if (hasSubmitted) return 'submitted'
|
||||
if (hasDraft) return 'draft'
|
||||
if (hasWithdrawn) return 'withdrawn'
|
||||
return 'empty'
|
||||
}
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case 'submitted':
|
||||
return <span className="px-2 py-1 text-xs font-medium bg-green-100 text-green-800 rounded-full">Submitted</span>
|
||||
case 'draft':
|
||||
return <span className="px-2 py-1 text-xs font-medium bg-yellow-100 text-yellow-800 rounded-full">Draft</span>
|
||||
case 'withdrawn':
|
||||
return <span className="px-2 py-1 text-xs font-medium bg-red-100 text-red-800 rounded-full">Withdrawn</span>
|
||||
case 'empty':
|
||||
return <span className="px-2 py-1 text-xs font-medium bg-gray-100 text-gray-800 rounded-full">No Data</span>
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const getDraftCount = (phase: ExperimentPhase) => {
|
||||
return phaseDrafts[phase].length
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
|
||||
<p className="mt-4 text-gray-600">Loading phases...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="bg-red-50 border border-red-200 rounded-md p-4">
|
||||
<div className="text-sm text-red-700">{error}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-2">Select Phase</h2>
|
||||
<p className="text-gray-600">
|
||||
Choose a phase to enter or view data. Each phase can have multiple drafts.
|
||||
</p>
|
||||
{repetition.is_locked && (
|
||||
<div className="mt-2 p-3 bg-red-50 border border-red-200 rounded-md">
|
||||
<div className="flex items-center">
|
||||
<span className="text-red-800 text-sm font-medium">🔒 This repetition is locked by an admin</span>
|
||||
</div>
|
||||
<p className="text-red-700 text-xs mt-1">
|
||||
You can view existing data but cannot create new drafts or modify existing ones.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{phases.map((phase) => {
|
||||
const status = getPhaseStatus(phase.name)
|
||||
const draftCount = getDraftCount(phase.name)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={phase.name}
|
||||
onClick={() => onPhaseSelect(phase.name)}
|
||||
className="bg-white rounded-lg shadow-md border border-gray-200 p-6 cursor-pointer hover:shadow-lg hover:border-blue-300 transition-all duration-200"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex items-center">
|
||||
<div className={`w-12 h-12 ${phase.color} rounded-lg flex items-center justify-center text-white text-xl mr-4`}>
|
||||
{phase.icon}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">{phase.title}</h3>
|
||||
<p className="text-sm text-gray-600">{phase.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
{getStatusBadge(status)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-sm text-gray-500">
|
||||
<span>
|
||||
{draftCount === 0 ? 'No drafts' : `${draftCount} draft${draftCount === 1 ? '' : 's'}`}
|
||||
</span>
|
||||
<svg className="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{draftCount > 0 && (
|
||||
<div className="mt-3 pt-3 border-t border-gray-100">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{phaseDrafts[phase.name].slice(0, 3).map((draft, index) => (
|
||||
<span
|
||||
key={draft.id}
|
||||
className={`px-2 py-1 text-xs rounded ${draft.status === 'submitted' ? 'bg-green-100 text-green-700' :
|
||||
draft.status === 'draft' ? 'bg-yellow-100 text-yellow-700' :
|
||||
'bg-red-100 text-red-700'
|
||||
}`}
|
||||
>
|
||||
{draft.draft_name || `Draft ${index + 1}`}
|
||||
</span>
|
||||
))}
|
||||
{draftCount > 3 && (
|
||||
<span className="px-2 py-1 text-xs bg-gray-100 text-gray-600 rounded">
|
||||
+{draftCount - 3} more
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
import { useState } from 'react'
|
||||
import { repetitionManagement } from '../lib/supabase'
|
||||
import type { Experiment, ExperimentRepetition } from '../lib/supabase'
|
||||
|
||||
interface RepetitionScheduleModalProps {
|
||||
experiment: Experiment
|
||||
repetition: ExperimentRepetition
|
||||
onClose: () => void
|
||||
onScheduleUpdated: (updatedRepetition: ExperimentRepetition) => void
|
||||
}
|
||||
|
||||
export function RepetitionScheduleModal({ experiment, repetition, onClose, onScheduleUpdated }: RepetitionScheduleModalProps) {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Initialize with existing scheduled date or current date/time
|
||||
const getInitialDateTime = () => {
|
||||
if (repetition.scheduled_date) {
|
||||
const date = new Date(repetition.scheduled_date)
|
||||
return {
|
||||
date: date.toISOString().split('T')[0],
|
||||
time: date.toTimeString().slice(0, 5)
|
||||
}
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
// Set to next hour by default
|
||||
now.setHours(now.getHours() + 1, 0, 0, 0)
|
||||
return {
|
||||
date: now.toISOString().split('T')[0],
|
||||
time: now.toTimeString().slice(0, 5)
|
||||
}
|
||||
}
|
||||
|
||||
const [dateTime, setDateTime] = useState(getInitialDateTime())
|
||||
const isScheduled = repetition.scheduled_date && repetition.schedule_status === 'scheduled'
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
// Validate date/time
|
||||
const selectedDateTime = new Date(`${dateTime.date}T${dateTime.time}`)
|
||||
const now = new Date()
|
||||
|
||||
if (selectedDateTime <= now) {
|
||||
setError('Scheduled date and time must be in the future')
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Schedule the repetition
|
||||
const updatedRepetition = await repetitionManagement.scheduleRepetition(
|
||||
repetition.id,
|
||||
selectedDateTime.toISOString()
|
||||
)
|
||||
|
||||
onScheduleUpdated(updatedRepetition)
|
||||
onClose()
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to schedule repetition')
|
||||
console.error('Schedule repetition error:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemoveSchedule = async () => {
|
||||
if (!confirm('Are you sure you want to remove the schedule for this repetition?')) {
|
||||
return
|
||||
}
|
||||
|
||||
setError(null)
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const updatedRepetition = await repetitionManagement.removeRepetitionSchedule(repetition.id)
|
||||
onScheduleUpdated(updatedRepetition)
|
||||
onClose()
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to remove schedule')
|
||||
console.error('Remove schedule error:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 flex items-center justify-center overflow-y-auto modal z-999999">
|
||||
<div
|
||||
className="fixed inset-0 h-full w-full bg-gray-400/50 backdrop-blur-[32px]"
|
||||
onClick={onClose}
|
||||
/>
|
||||
<div className="relative w-full rounded-2xl bg-white shadow-theme-xl dark:bg-gray-900 max-w-md mx-auto max-h-[90vh] overflow-y-auto p-4" onClick={(e) => e.stopPropagation()}>
|
||||
{/* Close Button */}
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
className="absolute right-3 top-3 z-999 flex h-9.5 w-9.5 items-center justify-center rounded-full bg-gray-100 text-gray-400 transition-colors hover:bg-gray-200 hover:text-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white sm:right-6 sm:top-6 sm:h-11 sm:w-11"
|
||||
>
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M6.04289 16.5413C5.65237 16.9318 5.65237 17.565 6.04289 17.9555C6.43342 18.346 7.06658 18.346 7.45711 17.9555L11.9987 13.4139L16.5408 17.956C16.9313 18.3466 17.5645 18.3466 17.955 17.956C18.3455 17.5655 18.3455 16.9323 17.955 16.5418L13.4129 11.9997L17.955 7.4576C18.3455 7.06707 18.3455 6.43391 17.955 6.04338C17.5645 5.65286 16.9313 5.65286 16.5408 6.04338L11.9987 10.5855L7.45711 6.0439C7.06658 5.65338 6.43342 5.65338 6.04289 6.0439C5.65237 6.43442 5.65237 7.06759 6.04289 7.45811L10.5845 11.9997L6.04289 16.5413Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-800">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white/90">
|
||||
Schedule Repetition
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
{/* Experiment and Repetition Info */}
|
||||
<div className="mb-6 p-4 bg-gray-50 rounded-lg">
|
||||
<h4 className="font-medium text-gray-900 mb-2">
|
||||
Experiment #{experiment.experiment_number} - Repetition #{repetition.repetition_number}
|
||||
</h4>
|
||||
<p className="text-sm text-gray-600">
|
||||
{experiment.reps_required} reps required • {experiment.soaking_duration_hr}h soaking
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="mb-4 rounded-md bg-red-50 p-4">
|
||||
<div className="text-sm text-red-700">{error}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Current Schedule (if exists) */}
|
||||
{isScheduled && (
|
||||
<div className="mb-6 p-4 bg-blue-50 rounded-lg">
|
||||
<h5 className="font-medium text-blue-900 mb-1">Currently Scheduled</h5>
|
||||
<p className="text-sm text-blue-700">
|
||||
{new Date(repetition.scheduled_date!).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Schedule Form */}
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="date" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Date *
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
id="date"
|
||||
value={dateTime.date}
|
||||
onChange={(e) => setDateTime({ ...dateTime, date: e.target.value })}
|
||||
className="max-w-xs px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors text-sm"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="time" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Time *
|
||||
</label>
|
||||
<input
|
||||
type="time"
|
||||
id="time"
|
||||
value={dateTime.time}
|
||||
onChange={(e) => setDateTime({ ...dateTime, time: e.target.value })}
|
||||
className="max-w-xs px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors text-sm"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex justify-between pt-4">
|
||||
<div>
|
||||
{isScheduled && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRemoveSchedule}
|
||||
disabled={loading}
|
||||
className="px-4 py-2 text-sm font-medium text-red-600 hover:text-red-700 hover:bg-red-50 rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
Remove Schedule
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancel}
|
||||
disabled={loading}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-lg hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{loading ? 'Scheduling...' : (isScheduled ? 'Update Schedule' : 'Schedule Repetition')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
237
management-dashboard-web-app/src/components/ScheduleModal.tsx
Normal file
237
management-dashboard-web-app/src/components/ScheduleModal.tsx
Normal file
@@ -0,0 +1,237 @@
|
||||
import { useState } from 'react'
|
||||
import { experimentManagement } from '../lib/supabase'
|
||||
import type { Experiment } from '../lib/supabase'
|
||||
|
||||
interface ScheduleModalProps {
|
||||
experiment: Experiment
|
||||
onClose: () => void
|
||||
onScheduleUpdated: (experiment: Experiment) => void
|
||||
}
|
||||
|
||||
export function ScheduleModal({ experiment, onClose, onScheduleUpdated }: ScheduleModalProps) {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Initialize with existing scheduled date or current date/time
|
||||
const getInitialDateTime = () => {
|
||||
if (experiment.scheduled_date) {
|
||||
const date = new Date(experiment.scheduled_date)
|
||||
return {
|
||||
date: date.toISOString().split('T')[0],
|
||||
time: date.toTimeString().slice(0, 5)
|
||||
}
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
// Set to next hour by default
|
||||
now.setHours(now.getHours() + 1, 0, 0, 0)
|
||||
return {
|
||||
date: now.toISOString().split('T')[0],
|
||||
time: now.toTimeString().slice(0, 5)
|
||||
}
|
||||
}
|
||||
|
||||
const [dateTime, setDateTime] = useState(getInitialDateTime())
|
||||
|
||||
const isScheduled = !!experiment.scheduled_date
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
// Validate date/time
|
||||
const selectedDateTime = new Date(`${dateTime.date}T${dateTime.time}`)
|
||||
const now = new Date()
|
||||
|
||||
if (selectedDateTime <= now) {
|
||||
setError('Scheduled date and time must be in the future')
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Schedule the experiment
|
||||
const updatedExperiment = await experimentManagement.scheduleExperiment(
|
||||
experiment.id,
|
||||
selectedDateTime.toISOString()
|
||||
)
|
||||
|
||||
onScheduleUpdated(updatedExperiment)
|
||||
onClose()
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to schedule experiment')
|
||||
console.error('Schedule experiment error:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemoveSchedule = async () => {
|
||||
if (!confirm('Are you sure you want to remove the schedule for this experiment?')) {
|
||||
return
|
||||
}
|
||||
|
||||
setError(null)
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const updatedExperiment = await experimentManagement.removeExperimentSchedule(experiment.id)
|
||||
onScheduleUpdated(updatedExperiment)
|
||||
onClose()
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to remove schedule')
|
||||
console.error('Remove schedule error:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 flex items-center justify-center overflow-y-auto modal z-999999">
|
||||
<div
|
||||
className="fixed inset-0 h-full w-full bg-gray-400/50 backdrop-blur-[32px]"
|
||||
onClick={onClose}
|
||||
/>
|
||||
<div className="relative w-full rounded-2xl bg-white shadow-theme-xl dark:bg-gray-900 max-w-md mx-auto p-4" onClick={(e) => e.stopPropagation()}>
|
||||
{/* Close Button */}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute right-3 top-3 z-999 flex h-9.5 w-9.5 items-center justify-center rounded-full bg-gray-100 text-gray-400 transition-colors hover:bg-gray-200 hover:text-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white sm:right-6 sm:top-6 sm:h-11 sm:w-11"
|
||||
>
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M6.04289 16.5413C5.65237 16.9318 5.65237 17.565 6.04289 17.9555C6.43342 18.346 7.06658 18.346 7.45711 17.9555L11.9987 13.4139L16.5408 17.956C16.9313 18.3466 17.5645 18.3466 17.955 17.956C18.3455 17.5655 18.3455 16.9323 17.955 16.5418L13.4129 11.9997L17.955 7.4576C18.3455 7.06707 18.3455 6.43391 17.955 6.04338C17.5645 5.65286 16.9313 5.65286 16.5408 6.04338L11.9987 10.5855L7.45711 6.0439C7.06658 5.65338 6.43342 5.65338 6.04289 6.0439C5.65237 6.43442 5.65237 7.06759 6.04289 7.45811L10.5845 11.9997L6.04289 16.5413Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-800">
|
||||
<h3 className="text-xl font-semibold text-gray-900 dark:text-white/90">
|
||||
{isScheduled ? 'Update Schedule' : 'Schedule Experiment'}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
{/* Experiment Info */}
|
||||
<div className="mb-6 p-4 bg-gray-50 rounded-lg">
|
||||
<h4 className="font-medium text-gray-900 mb-2">Experiment #{experiment.experiment_number}</h4>
|
||||
<p className="text-sm text-gray-600">
|
||||
{experiment.reps_required} reps required • {experiment.soaking_duration_hr}h soaking
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="mb-4 rounded-md bg-red-50 p-4">
|
||||
<div className="text-sm text-red-700">{error}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Current Schedule (if exists) */}
|
||||
{isScheduled && (
|
||||
<div className="mb-6 p-4 bg-blue-50 rounded-lg">
|
||||
<h5 className="font-medium text-blue-900 mb-1">Currently Scheduled</h5>
|
||||
<p className="text-sm text-blue-700">
|
||||
{new Date(experiment.scheduled_date!).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Schedule Form */}
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="date" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Date *
|
||||
</label>
|
||||
<div className="relative max-w-xs">
|
||||
<input
|
||||
type="date"
|
||||
id="date"
|
||||
value={dateTime.date}
|
||||
onChange={(e) => setDateTime({ ...dateTime, date: e.target.value })}
|
||||
className="h-11 w-full rounded-lg border appearance-none px-4 py-2.5 text-sm shadow-theme-xs placeholder:text-gray-400 focus:outline-hidden focus:ring-3 dark:bg-gray-900 dark:text-white/90 dark:placeholder:text-white/30 bg-transparent text-gray-800 border-gray-300 focus:border-brand-300 focus:ring-brand-500/20 dark:border-gray-700 dark:focus:border-brand-800"
|
||||
required
|
||||
/>
|
||||
<span className="absolute text-gray-500 -translate-y-1/2 pointer-events-none right-3 top-1/2 dark:text-gray-400">
|
||||
<svg className="size-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="time" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Time *
|
||||
</label>
|
||||
<div className="relative max-w-xs">
|
||||
<input
|
||||
type="time"
|
||||
id="time"
|
||||
value={dateTime.time}
|
||||
onChange={(e) => setDateTime({ ...dateTime, time: e.target.value })}
|
||||
className="h-11 w-full rounded-lg border appearance-none px-4 py-2.5 text-sm shadow-theme-xs placeholder:text-gray-400 focus:outline-hidden focus:ring-3 dark:bg-gray-900 dark:text-white/90 dark:placeholder:text-white/30 bg-transparent text-gray-800 border-gray-300 focus:border-brand-300 focus:ring-brand-500/20 dark:border-gray-700 dark:focus:border-brand-800"
|
||||
required
|
||||
/>
|
||||
<span className="absolute text-gray-500 -translate-y-1/2 pointer-events-none right-3 top-1/2 dark:text-gray-400">
|
||||
<svg className="size-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex justify-between pt-4">
|
||||
<div>
|
||||
{isScheduled && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRemoveSchedule}
|
||||
disabled={loading}
|
||||
className="px-4 py-2 text-sm font-medium text-error-600 hover:text-error-700 hover:bg-error-50 dark:text-error-500 dark:hover:bg-error-500/15 rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
Remove Schedule
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancel}
|
||||
disabled={loading}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-brand-500 hover:bg-brand-600 focus:outline-none focus:ring-3 focus:ring-brand-500/10 rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Saving...' : (isScheduled ? 'Update Schedule' : 'Schedule Experiment')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
311
management-dashboard-web-app/src/components/Sidebar.tsx
Normal file
311
management-dashboard-web-app/src/components/Sidebar.tsx
Normal file
@@ -0,0 +1,311 @@
|
||||
import { useState, useCallback, useEffect, useRef } from 'react'
|
||||
import type { User } from '../lib/supabase'
|
||||
|
||||
interface SidebarProps {
|
||||
user: User
|
||||
currentView: string
|
||||
onViewChange: (view: string) => void
|
||||
isExpanded?: boolean
|
||||
isMobileOpen?: boolean
|
||||
isHovered?: boolean
|
||||
setIsHovered?: (hovered: boolean) => void
|
||||
}
|
||||
|
||||
interface MenuItem {
|
||||
id: string
|
||||
name: string
|
||||
icon: React.ReactElement
|
||||
requiredRoles?: string[]
|
||||
subItems?: { name: string; id: string; requiredRoles?: string[] }[]
|
||||
}
|
||||
|
||||
export function Sidebar({
|
||||
user,
|
||||
currentView,
|
||||
onViewChange,
|
||||
isExpanded = true,
|
||||
isMobileOpen = false,
|
||||
isHovered = false,
|
||||
setIsHovered
|
||||
}: SidebarProps) {
|
||||
const [openSubmenu, setOpenSubmenu] = useState<number | null>(null)
|
||||
const [subMenuHeight, setSubMenuHeight] = useState<Record<string, number>>({})
|
||||
const subMenuRefs = useRef<Record<string, HTMLDivElement | null>>({})
|
||||
|
||||
const menuItems: MenuItem[] = [
|
||||
{
|
||||
id: 'dashboard',
|
||||
name: 'Dashboard',
|
||||
icon: (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2H5a2 2 0 00-2-2z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 5a2 2 0 012-2h4a2 2 0 012 2v6a2 2 0 01-2 2H10a2 2 0 01-2-2V5z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'user-management',
|
||||
name: 'User Management',
|
||||
icon: (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197m13.5-9a2.5 2.5 0 11-5 0 2.5 2.5 0 015 0z" />
|
||||
</svg>
|
||||
),
|
||||
requiredRoles: ['admin']
|
||||
},
|
||||
{
|
||||
id: 'experiments',
|
||||
name: 'Experiments',
|
||||
icon: (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z" />
|
||||
</svg>
|
||||
),
|
||||
requiredRoles: ['admin', 'conductor']
|
||||
},
|
||||
{
|
||||
id: 'video-library',
|
||||
name: 'Video Library',
|
||||
icon: (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'analytics',
|
||||
name: 'Analytics',
|
||||
icon: (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
),
|
||||
requiredRoles: ['admin', 'conductor', 'analyst']
|
||||
},
|
||||
{
|
||||
id: 'data-entry',
|
||||
name: 'Data Entry',
|
||||
icon: (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
),
|
||||
requiredRoles: ['admin', 'conductor', 'data recorder']
|
||||
},
|
||||
{
|
||||
id: 'vision-system',
|
||||
name: 'Vision System',
|
||||
icon: (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
),
|
||||
}
|
||||
]
|
||||
|
||||
// const isActive = (path: string) => location.pathname === path;
|
||||
const isActive = useCallback(
|
||||
(id: string) => currentView === id,
|
||||
[currentView]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
// Auto-open submenu if current view is in a submenu
|
||||
menuItems.forEach((nav, index) => {
|
||||
if (nav.subItems) {
|
||||
nav.subItems.forEach((subItem) => {
|
||||
if (isActive(subItem.id)) {
|
||||
setOpenSubmenu(index)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}, [currentView, isActive, menuItems])
|
||||
|
||||
useEffect(() => {
|
||||
if (openSubmenu !== null) {
|
||||
const key = `submenu-${openSubmenu}`
|
||||
if (subMenuRefs.current[key]) {
|
||||
setSubMenuHeight((prevHeights) => ({
|
||||
...prevHeights,
|
||||
[key]: subMenuRefs.current[key]?.scrollHeight || 0,
|
||||
}))
|
||||
}
|
||||
}
|
||||
}, [openSubmenu])
|
||||
|
||||
const handleSubmenuToggle = (index: number) => {
|
||||
setOpenSubmenu((prevOpenSubmenu) => {
|
||||
if (prevOpenSubmenu === index) {
|
||||
return null
|
||||
}
|
||||
return index
|
||||
})
|
||||
}
|
||||
|
||||
const hasAccess = (item: MenuItem): boolean => {
|
||||
if (!item.requiredRoles) return true
|
||||
return item.requiredRoles.some(role => user.roles.includes(role as any))
|
||||
}
|
||||
|
||||
const renderMenuItems = (items: MenuItem[]) => (
|
||||
<ul className="flex flex-col gap-4">
|
||||
{items.map((nav, index) => {
|
||||
if (!hasAccess(nav)) return null
|
||||
|
||||
return (
|
||||
<li key={nav.id}>
|
||||
{nav.subItems ? (
|
||||
<button
|
||||
onClick={() => handleSubmenuToggle(index)}
|
||||
className={`menu-item group ${openSubmenu === index
|
||||
? "menu-item-active"
|
||||
: "menu-item-inactive"
|
||||
} cursor-pointer ${!isExpanded && !isHovered
|
||||
? "lg:justify-center"
|
||||
: "lg:justify-start"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`menu-item-icon-size ${openSubmenu === index
|
||||
? "menu-item-icon-active"
|
||||
: "menu-item-icon-inactive"
|
||||
}`}
|
||||
>
|
||||
{nav.icon}
|
||||
</span>
|
||||
{(isExpanded || isHovered || isMobileOpen) && (
|
||||
<span className="menu-item-text">{nav.name}</span>
|
||||
)}
|
||||
{(isExpanded || isHovered || isMobileOpen) && (
|
||||
<svg
|
||||
className={`ml-auto w-5 h-5 transition-transform duration-200 ${openSubmenu === index
|
||||
? "rotate-180 text-brand-500"
|
||||
: ""
|
||||
}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => onViewChange(nav.id)}
|
||||
className={`menu-item group ${isActive(nav.id) ? "menu-item-active" : "menu-item-inactive"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`menu-item-icon-size ${isActive(nav.id)
|
||||
? "menu-item-icon-active"
|
||||
: "menu-item-icon-inactive"
|
||||
}`}
|
||||
>
|
||||
{nav.icon}
|
||||
</span>
|
||||
{(isExpanded || isHovered || isMobileOpen) && (
|
||||
<span className="menu-item-text">{nav.name}</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{nav.subItems && (isExpanded || isHovered || isMobileOpen) && (
|
||||
<div
|
||||
ref={(el) => {
|
||||
subMenuRefs.current[`submenu-${index}`] = el
|
||||
}}
|
||||
className="overflow-hidden transition-all duration-300"
|
||||
style={{
|
||||
height:
|
||||
openSubmenu === index
|
||||
? `${subMenuHeight[`submenu-${index}`]}px`
|
||||
: "0px",
|
||||
}}
|
||||
>
|
||||
<ul className="mt-2 space-y-1 ml-9">
|
||||
{nav.subItems.map((subItem) => {
|
||||
if (subItem.requiredRoles && !subItem.requiredRoles.some(role => user.roles.includes(role as any))) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<li key={subItem.id}>
|
||||
<button
|
||||
onClick={() => onViewChange(subItem.id)}
|
||||
className={`menu-dropdown-item ${isActive(subItem.id)
|
||||
? "menu-dropdown-item-active"
|
||||
: "menu-dropdown-item-inactive"
|
||||
}`}
|
||||
>
|
||||
{subItem.name}
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={`fixed mt-16 flex flex-col lg:mt-0 top-0 px-5 left-0 bg-white dark:bg-gray-900 dark:border-gray-800 text-gray-900 h-screen transition-all duration-300 ease-in-out z-50 border-r border-gray-200
|
||||
${isExpanded || isMobileOpen
|
||||
? "w-[290px]"
|
||||
: isHovered
|
||||
? "w-[290px]"
|
||||
: "w-[90px]"
|
||||
}
|
||||
${isMobileOpen ? "translate-x-0" : "-translate-x-full"}
|
||||
lg:translate-x-0`}
|
||||
onMouseEnter={() => !isExpanded && setIsHovered && setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered && setIsHovered(false)}
|
||||
>
|
||||
<div
|
||||
className={`py-8 flex ${!isExpanded && !isHovered ? "lg:justify-center" : "justify-start"
|
||||
}`}
|
||||
>
|
||||
<div>
|
||||
{isExpanded || isHovered || isMobileOpen ? (
|
||||
<>
|
||||
<h1 className="text-xl font-bold text-gray-800 dark:text-white/90">Pecan Experiments</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Research Dashboard</p>
|
||||
</>
|
||||
) : (
|
||||
<div className="w-8 h-8 bg-brand-500 rounded-lg flex items-center justify-center text-white font-bold text-lg">
|
||||
P
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col overflow-y-auto duration-300 ease-linear no-scrollbar">
|
||||
<nav className="mb-6">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<h2
|
||||
className={`mb-4 text-xs uppercase flex leading-[20px] text-gray-400 ${!isExpanded && !isHovered
|
||||
? "lg:justify-center"
|
||||
: "justify-start"
|
||||
}`}
|
||||
>
|
||||
{isExpanded || isHovered || isMobileOpen ? (
|
||||
"Menu"
|
||||
) : (
|
||||
<svg className="size-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 12h.01M12 12h.01M19 12h.01M6 12a1 1 0 11-2 0 1 1 0 012 0zm7 0a1 1 0 11-2 0 1 1 0 012 0zm7 0a1 1 0 11-2 0 1 1 0 012 0z" />
|
||||
</svg>
|
||||
)}
|
||||
</h2>
|
||||
{renderMenuItems(menuItems)}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
269
management-dashboard-web-app/src/components/TopNavbar.tsx
Normal file
269
management-dashboard-web-app/src/components/TopNavbar.tsx
Normal file
@@ -0,0 +1,269 @@
|
||||
import { useState } from 'react'
|
||||
import type { User } from '../lib/supabase'
|
||||
|
||||
interface TopNavbarProps {
|
||||
user: User
|
||||
onLogout: () => void
|
||||
currentView?: string
|
||||
onToggleSidebar?: () => void
|
||||
isSidebarOpen?: boolean
|
||||
}
|
||||
|
||||
export function TopNavbar({
|
||||
user,
|
||||
onLogout,
|
||||
currentView = 'dashboard',
|
||||
onToggleSidebar,
|
||||
isSidebarOpen = false
|
||||
}: TopNavbarProps) {
|
||||
const [isUserMenuOpen, setIsUserMenuOpen] = useState(false)
|
||||
|
||||
const getPageTitle = (view: string) => {
|
||||
switch (view) {
|
||||
case 'dashboard':
|
||||
return 'Dashboard'
|
||||
case 'user-management':
|
||||
return 'User Management'
|
||||
case 'experiments':
|
||||
return 'Experiments'
|
||||
case 'analytics':
|
||||
return 'Analytics'
|
||||
case 'data-entry':
|
||||
return 'Data Entry'
|
||||
case 'vision-system':
|
||||
return 'Vision System'
|
||||
case 'video-library':
|
||||
return 'Video Library'
|
||||
default:
|
||||
return 'Dashboard'
|
||||
}
|
||||
}
|
||||
|
||||
const getRoleBadgeColor = (role: string) => {
|
||||
switch (role) {
|
||||
case 'admin':
|
||||
return 'bg-error-50 text-error-600 dark:bg-error-500/15 dark:text-error-500'
|
||||
case 'conductor':
|
||||
return 'bg-brand-50 text-brand-500 dark:bg-brand-500/15 dark:text-brand-400'
|
||||
case 'analyst':
|
||||
return 'bg-success-50 text-success-600 dark:bg-success-500/15 dark:text-success-500'
|
||||
case 'data recorder':
|
||||
return 'bg-theme-purple-500/10 text-theme-purple-500'
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-700 dark:bg-white/5 dark:text-white/80'
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 flex w-full bg-white border-gray-200 z-99999 dark:border-gray-800 dark:bg-gray-900 lg:border-b">
|
||||
<div className="flex flex-col items-center justify-between grow lg:flex-row lg:px-6">
|
||||
<div className="flex items-center justify-between w-full gap-2 px-3 py-3 border-b border-gray-200 dark:border-gray-800 sm:gap-4 lg:justify-normal lg:border-b-0 lg:px-0 lg:py-4">
|
||||
<button
|
||||
className="items-center justify-center w-10 h-10 text-gray-500 border-gray-200 rounded-lg z-99999 dark:border-gray-800 lg:flex dark:text-gray-400 lg:h-11 lg:w-11 lg:border"
|
||||
onClick={onToggleSidebar}
|
||||
aria-label="Toggle Sidebar"
|
||||
>
|
||||
{isSidebarOpen ? (
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M6.21967 7.28131C5.92678 6.98841 5.92678 6.51354 6.21967 6.22065C6.51256 5.92775 6.98744 5.92775 7.28033 6.22065L11.999 10.9393L16.7176 6.22078C17.0105 5.92789 17.4854 5.92788 17.7782 6.22078C18.0711 6.51367 18.0711 6.98855 17.7782 7.28144L13.0597 12L17.7782 16.7186C18.0711 17.0115 18.0711 17.4863 17.7782 17.7792C17.4854 18.0721 17.0105 18.0721 16.7176 17.7792L11.999 13.0607L7.28033 17.7794C6.98744 18.0722 6.51256 18.0722 6.21967 17.7794C5.92678 17.4865 5.92678 17.0116 6.21967 16.7187L10.9384 12L6.21967 7.28131Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
width="16"
|
||||
height="12"
|
||||
viewBox="0 0 16 12"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M0.583252 1C0.583252 0.585788 0.919038 0.25 1.33325 0.25H14.6666C15.0808 0.25 15.4166 0.585786 15.4166 1C15.4166 1.41421 15.0808 1.75 14.6666 1.75L1.33325 1.75C0.919038 1.75 0.583252 1.41422 0.583252 1ZM0.583252 11C0.583252 10.5858 0.919038 10.25 1.33325 10.25L14.6666 10.25C15.0808 10.25 15.4166 10.5858 15.4166 11C15.4166 11.4142 15.0808 11.75 14.6666 11.75L1.33325 11.75C0.919038 11.75 0.583252 11.4142 0.583252 11ZM1.33325 5.25C0.919038 5.25 0.583252 5.58579 0.583252 6C0.583252 6.41421 0.919038 6.75 1.33325 6.75L7.99992 6.75C8.41413 6.75 8.74992 6.41421 8.74992 6C8.74992 5.58579 8.41413 5.25 7.99992 5.25L1.33325 5.25Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Page title */}
|
||||
<div className="flex items-center lg:hidden">
|
||||
<h1 className="text-lg font-medium text-gray-800 dark:text-white/90">{getPageTitle(currentView)}</h1>
|
||||
</div>
|
||||
|
||||
{/* Search bar - hidden on mobile, shown on desktop */}
|
||||
<div className="hidden lg:block">
|
||||
<form>
|
||||
<div className="relative">
|
||||
<span className="absolute -translate-y-1/2 pointer-events-none left-4 top-1/2">
|
||||
<svg
|
||||
className="fill-gray-500 dark:fill-gray-400"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M3.04175 9.37363C3.04175 5.87693 5.87711 3.04199 9.37508 3.04199C12.8731 3.04199 15.7084 5.87693 15.7084 9.37363C15.7084 12.8703 12.8731 15.7053 9.37508 15.7053C5.87711 15.7053 3.04175 12.8703 3.04175 9.37363ZM9.37508 1.54199C5.04902 1.54199 1.54175 5.04817 1.54175 9.37363C1.54175 13.6991 5.04902 17.2053 9.37508 17.2053C11.2674 17.2053 13.003 16.5344 14.357 15.4176L17.177 18.238C17.4699 18.5309 17.9448 18.5309 18.2377 18.238C18.5306 17.9451 18.5306 17.4703 18.2377 17.1774L15.418 14.3573C16.5365 13.0033 17.2084 11.2669 17.2084 9.37363C17.2084 5.04817 13.7011 1.54199 9.37508 1.54199Z"
|
||||
fill=""
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search or type command..."
|
||||
className="dark:bg-dark-900 h-11 w-full rounded-lg border border-gray-200 bg-transparent py-2.5 pl-12 pr-14 text-sm text-gray-800 shadow-theme-xs placeholder:text-gray-400 focus:border-brand-300 focus:outline-hidden focus:ring-3 focus:ring-brand-500/10 dark:border-gray-800 dark:bg-gray-900 dark:bg-white/[0.03] dark:text-white/90 dark:placeholder:text-white/30 dark:focus:border-brand-800 xl:w-[430px]"
|
||||
/>
|
||||
<button className="absolute right-2.5 top-1/2 inline-flex -translate-y-1/2 items-center gap-0.5 rounded-lg border border-gray-200 bg-gray-50 px-[7px] py-[4.5px] text-xs -tracking-[0.2px] text-gray-500 dark:border-gray-800 dark:bg-white/[0.03] dark:text-gray-400">
|
||||
<span> ⌘ </span>
|
||||
<span> K </span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between w-full gap-4 px-5 py-4 lg:flex shadow-theme-md lg:justify-end lg:px-0 lg:shadow-none">
|
||||
{/* User Area */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setIsUserMenuOpen(!isUserMenuOpen)}
|
||||
className="flex items-center text-gray-700 dropdown-toggle dark:text-gray-400"
|
||||
>
|
||||
<span className="mr-3 overflow-hidden rounded-full h-11 w-11">
|
||||
<div className="w-11 h-11 bg-brand-500 rounded-full flex items-center justify-center text-white text-sm font-medium">
|
||||
{user.email.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
</span>
|
||||
|
||||
<span className="block mr-1 font-medium text-theme-sm">{user.email.split('@')[0]}</span>
|
||||
<svg
|
||||
className={`stroke-gray-500 dark:stroke-gray-400 transition-transform duration-200 ${isUserMenuOpen ? "rotate-180" : ""
|
||||
}`}
|
||||
width="18"
|
||||
height="20"
|
||||
viewBox="0 0 18 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M4.3125 8.65625L9 13.3437L13.6875 8.65625"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Dropdown menu */}
|
||||
{isUserMenuOpen && (
|
||||
<div className="absolute right-0 mt-[17px] flex w-[260px] flex-col rounded-2xl border border-gray-200 bg-white p-3 shadow-theme-lg dark:border-gray-800 dark:bg-gray-dark">
|
||||
<div>
|
||||
<span className="block font-medium text-gray-700 text-theme-sm dark:text-gray-400">
|
||||
{user.email.split('@')[0]}
|
||||
</span>
|
||||
<span className="mt-0.5 block text-theme-xs text-gray-500 dark:text-gray-400">
|
||||
{user.email}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<ul className="flex flex-col gap-1 pt-4 pb-3 border-b border-gray-200 dark:border-gray-800">
|
||||
<li>
|
||||
<div className="flex items-center gap-3 px-3 py-2 font-medium text-gray-700 rounded-lg text-theme-sm dark:text-gray-400">
|
||||
<svg
|
||||
className="fill-gray-500 dark:fill-gray-400"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M12 3.5C7.30558 3.5 3.5 7.30558 3.5 12C3.5 14.1526 4.3002 16.1184 5.61936 17.616C6.17279 15.3096 8.24852 13.5955 10.7246 13.5955H13.2746C15.7509 13.5955 17.8268 15.31 18.38 17.6167C19.6996 16.119 20.5 14.153 20.5 12C20.5 7.30558 16.6944 3.5 12 3.5ZM17.0246 18.8566V18.8455C17.0246 16.7744 15.3457 15.0955 13.2746 15.0955H10.7246C8.65354 15.0955 6.97461 16.7744 6.97461 18.8455V18.856C8.38223 19.8895 10.1198 20.5 12 20.5C13.8798 20.5 15.6171 19.8898 17.0246 18.8566ZM2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12ZM11.9991 7.25C10.8847 7.25 9.98126 8.15342 9.98126 9.26784C9.98126 10.3823 10.8847 11.2857 11.9991 11.2857C13.1135 11.2857 14.0169 10.3823 14.0169 9.26784C14.0169 8.15342 13.1135 7.25 11.9991 7.25ZM8.48126 9.26784C8.48126 7.32499 10.0563 5.75 11.9991 5.75C13.9419 5.75 15.5169 7.32499 15.5169 9.26784C15.5169 11.2107 13.9419 12.7857 11.9991 12.7857C10.0563 12.7857 8.48126 11.2107 8.48126 9.26784Z"
|
||||
fill=""
|
||||
/>
|
||||
</svg>
|
||||
Profile
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<div className="flex items-center gap-3 px-3 py-2 font-medium text-gray-700 rounded-lg text-theme-sm dark:text-gray-400">
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">Status:</span>
|
||||
<span className={user.status === 'active' ? 'text-success-600 dark:text-success-500' : 'text-error-600 dark:text-error-500'}>
|
||||
{user.status}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<div className="px-3 py-2">
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mb-2">Roles:</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{user.roles.map((role) => (
|
||||
<span
|
||||
key={role}
|
||||
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getRoleBadgeColor(role)}`}
|
||||
>
|
||||
{role.charAt(0).toUpperCase() + role.slice(1)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsUserMenuOpen(false)
|
||||
onLogout()
|
||||
}}
|
||||
className="flex items-center gap-3 px-3 py-2 mt-3 font-medium text-gray-700 rounded-lg group text-theme-sm hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-gray-300"
|
||||
>
|
||||
<svg
|
||||
className="fill-gray-500 group-hover:fill-gray-700 dark:group-hover:fill-gray-300"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M15.1007 19.247C14.6865 19.247 14.3507 18.9112 14.3507 18.497L14.3507 14.245H12.8507V18.497C12.8507 19.7396 13.8581 20.747 15.1007 20.747H18.5007C19.7434 20.747 20.7507 19.7396 20.7507 18.497L20.7507 5.49609C20.7507 4.25345 19.7433 3.24609 18.5007 3.24609H15.1007C13.8581 3.24609 12.8507 4.25345 12.8507 5.49609V9.74501L14.3507 9.74501V5.49609C14.3507 5.08188 14.6865 4.74609 15.1007 4.74609L18.5007 4.74609C18.9149 4.74609 19.2507 5.08188 19.2507 5.49609L19.2507 18.497C19.2507 18.9112 18.9149 19.247 18.5007 19.247H15.1007ZM3.25073 11.9984C3.25073 12.2144 3.34204 12.4091 3.48817 12.546L8.09483 17.1556C8.38763 17.4485 8.86251 17.4487 9.15549 17.1559C9.44848 16.8631 9.44863 16.3882 9.15583 16.0952L5.81116 12.7484L16.0007 12.7484C16.4149 12.7484 16.7507 12.4127 16.7507 11.9984C16.7507 11.5842 16.4149 11.2484 16.0007 11.2484L5.81528 11.2484L9.15585 7.90554C9.44864 7.61255 9.44847 7.13767 9.15547 6.84488C8.86248 6.55209 8.3876 6.55226 8.09481 6.84525L3.52309 11.4202C3.35673 11.5577 3.25073 11.7657 3.25073 11.9984Z"
|
||||
fill=""
|
||||
/>
|
||||
</svg>
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Click outside to close dropdown */}
|
||||
{isUserMenuOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-40"
|
||||
onClick={() => setIsUserMenuOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</header>
|
||||
)
|
||||
}
|
||||
421
management-dashboard-web-app/src/components/UserManagement.tsx
Normal file
421
management-dashboard-web-app/src/components/UserManagement.tsx
Normal file
@@ -0,0 +1,421 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { userManagement, type User, type Role, type RoleName, type UserStatus } from '../lib/supabase'
|
||||
import { CreateUserModal } from './CreateUserModal'
|
||||
|
||||
export function UserManagement() {
|
||||
const [users, setUsers] = useState<User[]>([])
|
||||
const [roles, setRoles] = useState<Role[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [editingUser, setEditingUser] = useState<string | null>(null)
|
||||
const [showCreateModal, setShowCreateModal] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
}, [])
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
const [usersData, rolesData] = await Promise.all([
|
||||
userManagement.getAllUsers(),
|
||||
userManagement.getAllRoles()
|
||||
])
|
||||
|
||||
setUsers(usersData)
|
||||
setRoles(rolesData)
|
||||
} catch (err) {
|
||||
setError('Failed to load user data')
|
||||
console.error('Load data error:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleStatusToggle = async (userId: string, currentStatus: UserStatus) => {
|
||||
try {
|
||||
const newStatus: UserStatus = currentStatus === 'active' ? 'disabled' : 'active'
|
||||
await userManagement.updateUserStatus(userId, newStatus)
|
||||
|
||||
// Update local state
|
||||
setUsers(users.map(user =>
|
||||
user.id === userId ? { ...user, status: newStatus } : user
|
||||
))
|
||||
} catch (err) {
|
||||
console.error('Status update error:', err)
|
||||
alert('Failed to update user status')
|
||||
}
|
||||
}
|
||||
|
||||
const handleRoleUpdate = async (userId: string, newRoles: RoleName[]) => {
|
||||
try {
|
||||
await userManagement.updateUserRoles(userId, newRoles)
|
||||
|
||||
// Update local state
|
||||
setUsers(users.map(user =>
|
||||
user.id === userId ? { ...user, roles: newRoles } : user
|
||||
))
|
||||
|
||||
setEditingUser(null)
|
||||
} catch (err) {
|
||||
console.error('Role update error:', err)
|
||||
alert('Failed to update user roles')
|
||||
}
|
||||
}
|
||||
|
||||
const handleEmailUpdate = async (userId: string, newEmail: string) => {
|
||||
try {
|
||||
await userManagement.updateUserEmail(userId, newEmail)
|
||||
|
||||
// Update local state
|
||||
setUsers(users.map(user =>
|
||||
user.id === userId ? { ...user, email: newEmail } : user
|
||||
))
|
||||
|
||||
setEditingUser(null)
|
||||
} catch (err) {
|
||||
console.error('Email update error:', err)
|
||||
alert('Failed to update user email')
|
||||
}
|
||||
}
|
||||
|
||||
const handleUserCreated = (newUser: User) => {
|
||||
setUsers([...users, newUser])
|
||||
setShowCreateModal(false)
|
||||
}
|
||||
|
||||
const getRoleBadgeColor = (role: string) => {
|
||||
switch (role) {
|
||||
case 'admin':
|
||||
return 'bg-red-100 text-red-800'
|
||||
case 'conductor':
|
||||
return 'bg-blue-100 text-blue-800'
|
||||
case 'analyst':
|
||||
return 'bg-green-100 text-green-800'
|
||||
case 'data recorder':
|
||||
return 'bg-purple-100 text-purple-800'
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800'
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600 mx-auto"></div>
|
||||
<p className="mt-4 text-gray-600">Loading users...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="rounded-md bg-red-50 p-4">
|
||||
<div className="text-sm text-red-700">{error}</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={loadData}
|
||||
className="mt-4 px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">User Management</h1>
|
||||
<p className="mt-2 text-gray-600">Manage user accounts, roles, and permissions</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
>
|
||||
➕ Add New User
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
|
||||
<div className="bg-white overflow-hidden shadow rounded-lg">
|
||||
<div className="p-5">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0">
|
||||
<span className="text-2xl">👥</span>
|
||||
</div>
|
||||
<div className="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt className="text-sm font-medium text-gray-500 truncate">Total Users</dt>
|
||||
<dd className="text-lg font-medium text-gray-900">{users.length}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white overflow-hidden shadow rounded-lg">
|
||||
<div className="p-5">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0">
|
||||
<span className="text-2xl">✅</span>
|
||||
</div>
|
||||
<div className="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt className="text-sm font-medium text-gray-500 truncate">Active Users</dt>
|
||||
<dd className="text-lg font-medium text-gray-900">
|
||||
{users.filter(u => u.status === 'active').length}
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white overflow-hidden shadow rounded-lg">
|
||||
<div className="p-5">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0">
|
||||
<span className="text-2xl">🔴</span>
|
||||
</div>
|
||||
<div className="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt className="text-sm font-medium text-gray-500 truncate">Disabled Users</dt>
|
||||
<dd className="text-lg font-medium text-gray-900">
|
||||
{users.filter(u => u.status === 'disabled').length}
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white overflow-hidden shadow rounded-lg">
|
||||
<div className="p-5">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0">
|
||||
<span className="text-2xl">👑</span>
|
||||
</div>
|
||||
<div className="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt className="text-sm font-medium text-gray-500 truncate">Admins</dt>
|
||||
<dd className="text-lg font-medium text-gray-900">
|
||||
{users.filter(u => u.roles.includes('admin')).length}
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Users Table */}
|
||||
<div className="bg-white shadow overflow-hidden sm:rounded-md">
|
||||
<div className="px-4 py-5 sm:px-6">
|
||||
<h3 className="text-lg leading-6 font-medium text-gray-900">Users</h3>
|
||||
<p className="mt-1 max-w-2xl text-sm text-gray-500">
|
||||
Click on any field to edit user details
|
||||
</p>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Email
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Roles
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Created
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{users.map((user) => (
|
||||
<UserRow
|
||||
key={user.id}
|
||||
user={user}
|
||||
roles={roles}
|
||||
isEditing={editingUser === user.id}
|
||||
onEdit={() => setEditingUser(user.id)}
|
||||
onCancel={() => setEditingUser(null)}
|
||||
onStatusToggle={handleStatusToggle}
|
||||
onRoleUpdate={handleRoleUpdate}
|
||||
onEmailUpdate={handleEmailUpdate}
|
||||
getRoleBadgeColor={getRoleBadgeColor}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Create User Modal */}
|
||||
{showCreateModal && (
|
||||
<CreateUserModal
|
||||
roles={roles}
|
||||
onClose={() => setShowCreateModal(false)}
|
||||
onUserCreated={handleUserCreated}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// UserRow component for inline editing
|
||||
interface UserRowProps {
|
||||
user: User
|
||||
roles: Role[]
|
||||
isEditing: boolean
|
||||
onEdit: () => void
|
||||
onCancel: () => void
|
||||
onStatusToggle: (userId: string, currentStatus: UserStatus) => void
|
||||
onRoleUpdate: (userId: string, newRoles: RoleName[]) => void
|
||||
onEmailUpdate: (userId: string, newEmail: string) => void
|
||||
getRoleBadgeColor: (role: string) => string
|
||||
}
|
||||
|
||||
function UserRow({
|
||||
user,
|
||||
roles,
|
||||
isEditing,
|
||||
onEdit,
|
||||
onCancel,
|
||||
onStatusToggle,
|
||||
onRoleUpdate,
|
||||
onEmailUpdate,
|
||||
getRoleBadgeColor
|
||||
}: UserRowProps) {
|
||||
const [editEmail, setEditEmail] = useState(user.email)
|
||||
const [editRoles, setEditRoles] = useState<RoleName[]>(user.roles)
|
||||
|
||||
const handleSave = () => {
|
||||
if (editEmail !== user.email) {
|
||||
onEmailUpdate(user.id, editEmail)
|
||||
}
|
||||
if (JSON.stringify(editRoles.sort()) !== JSON.stringify(user.roles.sort())) {
|
||||
onRoleUpdate(user.id, editRoles)
|
||||
}
|
||||
if (editEmail === user.email && JSON.stringify(editRoles.sort()) === JSON.stringify(user.roles.sort())) {
|
||||
onCancel()
|
||||
}
|
||||
}
|
||||
|
||||
const handleRoleToggle = (roleName: RoleName) => {
|
||||
if (editRoles.includes(roleName)) {
|
||||
setEditRoles(editRoles.filter(r => r !== roleName))
|
||||
} else {
|
||||
setEditRoles([...editRoles, roleName])
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<tr>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="email"
|
||||
value={editEmail}
|
||||
onChange={(e) => setEditEmail(e.target.value)}
|
||||
className="block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="text-sm text-gray-900 cursor-pointer hover:text-blue-600"
|
||||
onClick={onEdit}
|
||||
>
|
||||
{user.email}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
{isEditing ? (
|
||||
<div className="space-y-2">
|
||||
{roles.map((role) => (
|
||||
<label key={role.id} className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editRoles.includes(role.name)}
|
||||
onChange={() => handleRoleToggle(role.name)}
|
||||
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||||
/>
|
||||
<span className="ml-2 text-sm text-gray-700">{role.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="flex flex-wrap gap-1 cursor-pointer"
|
||||
onClick={onEdit}
|
||||
>
|
||||
{user.roles.map((role) => (
|
||||
<span
|
||||
key={role}
|
||||
className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${getRoleBadgeColor(role)}`}
|
||||
>
|
||||
{role.charAt(0).toUpperCase() + role.slice(1)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<button
|
||||
onClick={() => onStatusToggle(user.id, user.status)}
|
||||
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${user.status === 'active'
|
||||
? 'bg-green-100 text-green-800 hover:bg-green-200'
|
||||
: 'bg-red-100 text-red-800 hover:bg-red-200'
|
||||
}`}
|
||||
>
|
||||
{user.status.charAt(0).toUpperCase() + user.status.slice(1)}
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{new Date(user.created_at).toLocaleDateString()}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||
{isEditing ? (
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className="text-blue-600 hover:text-blue-900"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="text-gray-600 hover:text-gray-900"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={onEdit}
|
||||
className="text-blue-600 hover:text-blue-900"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
963
management-dashboard-web-app/src/components/VisionSystem.tsx
Normal file
963
management-dashboard-web-app/src/components/VisionSystem.tsx
Normal file
@@ -0,0 +1,963 @@
|
||||
import { useState, useEffect, useRef, useCallback, useMemo, memo, startTransition } from 'react'
|
||||
import {
|
||||
visionApi,
|
||||
type SystemStatus,
|
||||
type CameraStatus,
|
||||
type MachineStatus,
|
||||
type StorageStats,
|
||||
type RecordingInfo,
|
||||
type MqttStatus,
|
||||
type MqttEventsResponse,
|
||||
type MqttEvent,
|
||||
formatBytes,
|
||||
formatDuration,
|
||||
formatUptime
|
||||
} from '../lib/visionApi'
|
||||
import { useAuth } from '../hooks/useAuth'
|
||||
import { CameraConfigModal } from './CameraConfigModal'
|
||||
import { CameraPreviewModal } from './CameraPreviewModal'
|
||||
|
||||
// Memoized components to prevent unnecessary re-renders
|
||||
const SystemOverview = memo(({ systemStatus }: { systemStatus: SystemStatus }) => (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<div className="bg-white overflow-hidden shadow rounded-lg">
|
||||
<div className="p-5">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0">
|
||||
<div className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${systemStatus.system_started ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'}`}>
|
||||
{systemStatus.system_started ? 'Online' : 'Offline'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<div className="text-2xl font-semibold text-gray-900">System Status</div>
|
||||
<div className="mt-1 text-sm text-gray-500">
|
||||
Uptime: {formatUptime(systemStatus.uptime_seconds)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white overflow-hidden shadow rounded-lg">
|
||||
<div className="p-5">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0">
|
||||
<div className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${systemStatus.mqtt_connected ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'}`}>
|
||||
{systemStatus.mqtt_connected ? 'Connected' : 'Disconnected'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<div className="text-2xl font-semibold text-gray-900">MQTT Status</div>
|
||||
<div className="mt-1 text-sm text-gray-500">
|
||||
Last message: {systemStatus.last_mqtt_message || 'Never'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white overflow-hidden shadow rounded-lg">
|
||||
<div className="p-5">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
|
||||
{systemStatus.active_recordings} Active
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<div className="text-2xl font-semibold text-gray-900">Recordings</div>
|
||||
<div className="mt-1 text-sm text-gray-500">
|
||||
Total: {systemStatus.total_recordings}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white overflow-hidden shadow rounded-lg">
|
||||
<div className="p-5">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800">
|
||||
{Object.keys(systemStatus.cameras).length} Cameras
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<div className="text-2xl font-semibold text-gray-900">Devices</div>
|
||||
<div className="mt-1 text-sm text-gray-500">
|
||||
{Object.keys(systemStatus.machines).length} Machines
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
|
||||
const StorageOverview = memo(({ storageStats }: { storageStats: StorageStats }) => (
|
||||
<div className="bg-white shadow rounded-lg">
|
||||
<div className="px-4 py-5 sm:px-6">
|
||||
<h3 className="text-lg leading-6 font-medium text-gray-900">Storage</h3>
|
||||
<p className="mt-1 max-w-2xl text-sm text-gray-500">
|
||||
Storage usage and file statistics
|
||||
</p>
|
||||
</div>
|
||||
<div className="border-t border-gray-200 p-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-6">
|
||||
<div className="text-center">
|
||||
<div className="text-3xl font-bold text-blue-600">{storageStats.total_files}</div>
|
||||
<div className="text-sm text-gray-500">Total Files</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-3xl font-bold text-green-600">{formatBytes(storageStats.total_size_bytes)}</div>
|
||||
<div className="text-sm text-gray-500">Total Size</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-3xl font-bold text-purple-600">{formatBytes(storageStats.disk_usage.free)}</div>
|
||||
<div className="text-sm text-gray-500">Free Space</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Disk Usage Bar */}
|
||||
<div className="mb-6">
|
||||
<div className="flex justify-between text-sm text-gray-600 mb-2">
|
||||
<span>Disk Usage</span>
|
||||
<span>{Math.round((storageStats.disk_usage.used / storageStats.disk_usage.total) * 100)}% used</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
className="bg-blue-600 h-2 rounded-full"
|
||||
style={{ width: `${(storageStats.disk_usage.used / storageStats.disk_usage.total) * 100}%` }}
|
||||
></div>
|
||||
</div>
|
||||
<div className="flex justify-between text-xs text-gray-500 mt-1">
|
||||
<span>{formatBytes(storageStats.disk_usage.used)} used</span>
|
||||
<span>{formatBytes(storageStats.disk_usage.total)} total</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Per-Camera Statistics */}
|
||||
{Object.keys(storageStats.cameras).length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-md font-medium text-gray-900 mb-4">Files by Camera</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{Object.entries(storageStats.cameras).map(([cameraName, stats]) => (
|
||||
<div key={cameraName} className="border border-gray-200 rounded-lg p-4">
|
||||
<h5 className="font-medium text-gray-900 mb-2">{cameraName}</h5>
|
||||
<div className="space-y-1 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Files:</span>
|
||||
<span className="text-gray-900">{stats.file_count}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Size:</span>
|
||||
<span className="text-gray-900">{formatBytes(stats.total_size_bytes)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
|
||||
const CamerasStatus = memo(({
|
||||
systemStatus,
|
||||
onConfigureCamera,
|
||||
onStartRecording,
|
||||
onStopRecording,
|
||||
onPreviewCamera,
|
||||
onStopStreaming
|
||||
}: {
|
||||
systemStatus: SystemStatus,
|
||||
onConfigureCamera: (cameraName: string) => void,
|
||||
onStartRecording: (cameraName: string) => Promise<void>,
|
||||
onStopRecording: (cameraName: string) => Promise<void>,
|
||||
onPreviewCamera: (cameraName: string) => void,
|
||||
onStopStreaming: (cameraName: string) => Promise<void>
|
||||
}) => {
|
||||
const { isAdmin } = useAuth()
|
||||
|
||||
return (
|
||||
<div className="bg-white shadow rounded-lg">
|
||||
<div className="px-4 py-5 sm:px-6">
|
||||
<h3 className="text-lg leading-6 font-medium text-gray-900">Cameras</h3>
|
||||
<p className="mt-1 max-w-2xl text-sm text-gray-500">
|
||||
Current status of all cameras in the system
|
||||
</p>
|
||||
</div>
|
||||
<div className="border-t border-gray-200">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 p-6">
|
||||
{Object.entries(systemStatus.cameras).map(([cameraName, camera]) => {
|
||||
const friendlyName = camera.device_info?.friendly_name
|
||||
const hasDeviceInfo = !!camera.device_info
|
||||
const hasSerial = !!camera.device_info?.serial_number
|
||||
|
||||
// Determine if camera is connected based on status
|
||||
const isConnected = camera.status === 'available' || camera.status === 'connected'
|
||||
const hasError = camera.status === 'error'
|
||||
const statusText = camera.status || 'unknown'
|
||||
|
||||
return (
|
||||
<div key={cameraName} className="border border-gray-200 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h4 className="text-lg font-medium text-gray-900">
|
||||
{friendlyName || cameraName}
|
||||
{friendlyName && (
|
||||
<span className="text-gray-500 text-sm font-normal ml-2">({cameraName})</span>
|
||||
)}
|
||||
</h4>
|
||||
<div className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${isConnected ? 'bg-green-100 text-green-800' :
|
||||
hasError ? 'bg-yellow-100 text-yellow-800' :
|
||||
'bg-red-100 text-red-800'
|
||||
}`}>
|
||||
{isConnected ? 'Connected' : hasError ? 'Error' : 'Disconnected'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Status:</span>
|
||||
<span className={`font-medium ${isConnected ? 'text-green-600' :
|
||||
hasError ? 'text-yellow-600' :
|
||||
'text-red-600'
|
||||
}`}>
|
||||
{statusText.charAt(0).toUpperCase() + statusText.slice(1)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{camera.is_recording && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Recording:</span>
|
||||
<span className="text-red-600 font-medium flex items-center">
|
||||
<div className="w-2 h-2 bg-red-500 rounded-full mr-2 animate-pulse"></div>
|
||||
Active
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasDeviceInfo && (
|
||||
<>
|
||||
{camera.device_info.model && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Model:</span>
|
||||
<span className="text-gray-900">{camera.device_info.model}</span>
|
||||
</div>
|
||||
)}
|
||||
{hasSerial && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Serial:</span>
|
||||
<span className="text-gray-900 font-mono text-xs">{camera.device_info.serial_number}</span>
|
||||
</div>
|
||||
)}
|
||||
{camera.device_info.firmware_version && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Firmware:</span>
|
||||
<span className="text-gray-900 font-mono text-xs">{camera.device_info.firmware_version}</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{camera.last_frame_time && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Last Frame:</span>
|
||||
<span className="text-gray-900">{new Date(camera.last_frame_time).toLocaleTimeString()}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{camera.frame_rate && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Frame Rate:</span>
|
||||
<span className="text-gray-900">{camera.frame_rate.toFixed(1)} fps</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{camera.last_checked && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Last Checked:</span>
|
||||
<span className="text-gray-900">{new Date(camera.last_checked).toLocaleTimeString()}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{camera.current_recording_file && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Recording File:</span>
|
||||
<span className="text-gray-900 truncate ml-2">{camera.current_recording_file}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{camera.last_error && (
|
||||
<div className="mt-2 p-2 bg-red-50 border border-red-200 rounded">
|
||||
<div className="text-red-800 text-xs">
|
||||
<strong>Error:</strong> {camera.last_error}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Camera Control Buttons */}
|
||||
<div className="mt-3 pt-3 border-t border-gray-200 space-y-2">
|
||||
{/* Recording Controls */}
|
||||
<div className="flex space-x-2">
|
||||
{!camera.is_recording ? (
|
||||
<button
|
||||
onClick={() => onStartRecording(cameraName)}
|
||||
disabled={!isConnected}
|
||||
className={`flex-1 px-3 py-2 text-sm font-medium rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 ${isConnected
|
||||
? 'text-green-600 bg-green-50 border border-green-200 hover:bg-green-100 focus:ring-green-500'
|
||||
: 'text-gray-400 bg-gray-50 border border-gray-200 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
<svg className="w-4 h-4 inline-block mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14.828 14.828a4 4 0 01-5.656 0M9 10h1m4 0h1m-6 4h8m-9-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
Start Recording
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => onStopRecording(cameraName)}
|
||||
className="flex-1 px-3 py-2 text-sm font-medium text-red-600 bg-red-50 border border-red-200 rounded-md hover:bg-red-100 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2"
|
||||
>
|
||||
<svg className="w-4 h-4 inline-block mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 9h6v6H9z" />
|
||||
</svg>
|
||||
Stop Recording
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Preview and Streaming Controls */}
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
onClick={() => onPreviewCamera(cameraName)}
|
||||
disabled={!isConnected}
|
||||
className={`flex-1 px-3 py-2 text-sm font-medium rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 ${isConnected
|
||||
? 'text-blue-600 bg-blue-50 border border-blue-200 hover:bg-blue-100 focus:ring-blue-500'
|
||||
: 'text-gray-400 bg-gray-50 border border-gray-200 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
<svg className="w-4 h-4 inline-block mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
Preview
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => onStopStreaming(cameraName)}
|
||||
disabled={!isConnected}
|
||||
className={`flex-1 px-3 py-2 text-sm font-medium rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 ${isConnected
|
||||
? 'text-orange-600 bg-orange-50 border border-orange-200 hover:bg-orange-100 focus:ring-orange-500'
|
||||
: 'text-gray-400 bg-gray-50 border border-gray-200 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
<svg className="w-4 h-4 inline-block mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
Stop Streaming
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Admin Configuration Button */}
|
||||
{isAdmin() && (
|
||||
<div className="mt-3 pt-3 border-t border-gray-200">
|
||||
<button
|
||||
onClick={() => onConfigureCamera(cameraName)}
|
||||
className="w-full px-3 py-2 text-sm font-medium text-indigo-600 bg-indigo-50 border border-indigo-200 rounded-md hover:bg-indigo-100 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
|
||||
>
|
||||
<svg className="w-4 h-4 inline-block mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
Configure Camera
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
const RecentRecordings = memo(({ recordings, systemStatus }: { recordings: Record<string, RecordingInfo>, systemStatus: SystemStatus | null }) => (
|
||||
<div className="bg-white shadow rounded-lg">
|
||||
<div className="px-4 py-5 sm:px-6">
|
||||
<h3 className="text-lg leading-6 font-medium text-gray-900">Recent Recordings</h3>
|
||||
<p className="mt-1 max-w-2xl text-sm text-gray-500">
|
||||
Latest recording sessions
|
||||
</p>
|
||||
</div>
|
||||
<div className="border-t border-gray-200">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Camera
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Filename
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Duration
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Size
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Started
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{Object.entries(recordings).slice(0, 10).map(([recordingId, recording]) => {
|
||||
const camera = systemStatus?.cameras[recording.camera_name]
|
||||
const displayName = camera?.device_info?.friendly_name || recording.camera_name
|
||||
|
||||
return (
|
||||
<tr key={recordingId}>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{displayName}
|
||||
{camera?.device_info?.friendly_name && (
|
||||
<div className="text-xs text-gray-500">({recording.camera_name})</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 font-mono">
|
||||
{recording.filename}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${recording.status === 'recording' ? 'bg-red-100 text-red-800' :
|
||||
recording.status === 'completed' ? 'bg-green-100 text-green-800' :
|
||||
'bg-yellow-100 text-yellow-800'
|
||||
}`}>
|
||||
{recording.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{recording.duration_seconds ? formatDuration(recording.duration_seconds) : '-'}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{recording.file_size_bytes ? formatBytes(recording.file_size_bytes) : '-'}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{new Date(recording.start_time).toLocaleString()}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
|
||||
export function VisionSystem() {
|
||||
const [systemStatus, setSystemStatus] = useState<SystemStatus | null>(null)
|
||||
const [storageStats, setStorageStats] = useState<StorageStats | null>(null)
|
||||
const [recordings, setRecordings] = useState<Record<string, RecordingInfo>>({})
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [autoRefreshEnabled, setAutoRefreshEnabled] = useState(true)
|
||||
const [refreshInterval, setRefreshInterval] = useState(5000) // 5 seconds default
|
||||
const [lastUpdateTime, setLastUpdateTime] = useState<Date | null>(null)
|
||||
const [mqttStatus, setMqttStatus] = useState<MqttStatus | null>(null)
|
||||
const [mqttEvents, setMqttEvents] = useState<MqttEvent[]>([])
|
||||
|
||||
// Camera configuration modal state
|
||||
const [configModalOpen, setConfigModalOpen] = useState(false)
|
||||
const [selectedCamera, setSelectedCamera] = useState<string | null>(null)
|
||||
const [notification, setNotification] = useState<{ type: 'success' | 'error', message: string } | null>(null)
|
||||
|
||||
// Camera preview modal state
|
||||
const [previewModalOpen, setPreviewModalOpen] = useState(false)
|
||||
const [previewCamera, setPreviewCamera] = useState<string | null>(null)
|
||||
|
||||
const intervalRef = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
const clearAutoRefresh = useCallback(() => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current)
|
||||
intervalRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
const startAutoRefresh = useCallback(() => {
|
||||
clearAutoRefresh()
|
||||
if (autoRefreshEnabled && refreshInterval > 0) {
|
||||
intervalRef.current = setInterval(fetchData, refreshInterval)
|
||||
}
|
||||
}, [autoRefreshEnabled, refreshInterval])
|
||||
|
||||
useEffect(() => {
|
||||
fetchData()
|
||||
startAutoRefresh()
|
||||
return clearAutoRefresh
|
||||
}, [startAutoRefresh])
|
||||
|
||||
useEffect(() => {
|
||||
startAutoRefresh()
|
||||
}, [autoRefreshEnabled, refreshInterval, startAutoRefresh])
|
||||
|
||||
const fetchData = useCallback(async (showRefreshIndicator = true) => {
|
||||
try {
|
||||
setError(null)
|
||||
if (!systemStatus) {
|
||||
setLoading(true)
|
||||
} else if (showRefreshIndicator) {
|
||||
setRefreshing(true)
|
||||
}
|
||||
|
||||
const [statusData, storageData, recordingsData, mqttStatusData, mqttEventsData] = await Promise.all([
|
||||
visionApi.getSystemStatus(),
|
||||
visionApi.getStorageStats(),
|
||||
visionApi.getRecordings(),
|
||||
visionApi.getMqttStatus().catch(err => {
|
||||
console.warn('Failed to fetch MQTT status:', err)
|
||||
return null
|
||||
}),
|
||||
visionApi.getMqttEvents(10).catch(err => {
|
||||
console.warn('Failed to fetch MQTT events:', err)
|
||||
return { events: [], total_events: 0, last_updated: '' }
|
||||
})
|
||||
])
|
||||
|
||||
// If cameras don't have device_info, try to fetch individual camera status
|
||||
if (statusData.cameras) {
|
||||
const camerasNeedingInfo = Object.entries(statusData.cameras)
|
||||
.filter(([_, camera]) => !camera.device_info?.friendly_name)
|
||||
.map(([cameraName, _]) => cameraName)
|
||||
|
||||
if (camerasNeedingInfo.length > 0) {
|
||||
console.log('Fetching individual camera info for:', camerasNeedingInfo)
|
||||
try {
|
||||
const individualCameraData = await Promise.all(
|
||||
camerasNeedingInfo.map(cameraName =>
|
||||
visionApi.getCameraStatus(cameraName).catch(err => {
|
||||
console.warn(`Failed to get individual status for ${cameraName}:`, err)
|
||||
return null
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
// Merge the individual camera data back into statusData
|
||||
camerasNeedingInfo.forEach((cameraName, index) => {
|
||||
const individualData = individualCameraData[index]
|
||||
if (individualData && individualData.device_info) {
|
||||
statusData.cameras[cameraName] = {
|
||||
...statusData.cameras[cameraName],
|
||||
device_info: individualData.device_info
|
||||
}
|
||||
}
|
||||
})
|
||||
} catch (err) {
|
||||
console.warn('Failed to fetch individual camera data:', err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Batch state updates to minimize re-renders using startTransition for non-urgent updates
|
||||
const updateTime = new Date()
|
||||
|
||||
// Use startTransition for non-urgent state updates to keep the UI responsive
|
||||
startTransition(() => {
|
||||
setSystemStatus(statusData)
|
||||
setStorageStats(storageData)
|
||||
setRecordings(recordingsData)
|
||||
setLastUpdateTime(updateTime)
|
||||
|
||||
// Update MQTT status and events
|
||||
if (mqttStatusData) {
|
||||
setMqttStatus(mqttStatusData)
|
||||
}
|
||||
|
||||
if (mqttEventsData && mqttEventsData.events) {
|
||||
setMqttEvents(mqttEventsData.events)
|
||||
}
|
||||
})
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch vision system data')
|
||||
console.error('Vision system fetch error:', err)
|
||||
// Don't disable auto-refresh on errors, just log them
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setRefreshing(false)
|
||||
}
|
||||
}, [systemStatus])
|
||||
|
||||
// Camera configuration handlers
|
||||
const handleConfigureCamera = (cameraName: string) => {
|
||||
setSelectedCamera(cameraName)
|
||||
setConfigModalOpen(true)
|
||||
}
|
||||
|
||||
const handleConfigSuccess = (message: string) => {
|
||||
setNotification({ type: 'success', message })
|
||||
setTimeout(() => setNotification(null), 5000)
|
||||
}
|
||||
|
||||
const handleConfigError = (message: string) => {
|
||||
setNotification({ type: 'error', message })
|
||||
setTimeout(() => setNotification(null), 5000)
|
||||
}
|
||||
|
||||
// Recording control handlers
|
||||
const handleStartRecording = async (cameraName: string) => {
|
||||
try {
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
|
||||
const filename = `manual_${cameraName}_${timestamp}.mp4`
|
||||
|
||||
const result = await visionApi.startRecording(cameraName, { filename })
|
||||
|
||||
if (result.success) {
|
||||
setNotification({ type: 'success', message: `Recording started: ${result.filename}` })
|
||||
// Refresh data to update recording status
|
||||
fetchData(false)
|
||||
} else {
|
||||
setNotification({ type: 'error', message: `Failed to start recording: ${result.message}` })
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
|
||||
setNotification({ type: 'error', message: `Error starting recording: ${errorMessage}` })
|
||||
}
|
||||
}
|
||||
|
||||
const handleStopRecording = async (cameraName: string) => {
|
||||
try {
|
||||
const result = await visionApi.stopRecording(cameraName)
|
||||
|
||||
if (result.success) {
|
||||
setNotification({ type: 'success', message: `Recording stopped: ${result.filename}` })
|
||||
// Refresh data to update recording status
|
||||
fetchData(false)
|
||||
} else {
|
||||
setNotification({ type: 'error', message: `Failed to stop recording: ${result.message}` })
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
|
||||
setNotification({ type: 'error', message: `Error stopping recording: ${errorMessage}` })
|
||||
}
|
||||
}
|
||||
|
||||
const handlePreviewCamera = (cameraName: string) => {
|
||||
setPreviewCamera(cameraName)
|
||||
setPreviewModalOpen(true)
|
||||
}
|
||||
|
||||
const handleStopStreaming = async (cameraName: string) => {
|
||||
try {
|
||||
const result = await visionApi.stopStream(cameraName)
|
||||
|
||||
if (result.success) {
|
||||
setNotification({ type: 'success', message: `Streaming stopped for ${cameraName}` })
|
||||
// Refresh data to update camera status
|
||||
fetchData(false)
|
||||
} else {
|
||||
setNotification({ type: 'error', message: `Failed to stop streaming: ${result.message}` })
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
|
||||
setNotification({ type: 'error', message: `Error stopping stream: ${errorMessage}` })
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusColor = (status: string, isRecording: boolean = false) => {
|
||||
// If camera is recording, always show red regardless of status
|
||||
if (isRecording) {
|
||||
return 'text-red-600 bg-red-100'
|
||||
}
|
||||
|
||||
switch (status.toLowerCase()) {
|
||||
case 'available':
|
||||
case 'connected':
|
||||
case 'healthy':
|
||||
case 'on':
|
||||
return 'text-green-600 bg-green-100'
|
||||
case 'disconnected':
|
||||
case 'off':
|
||||
case 'failed':
|
||||
return 'text-red-600 bg-red-100'
|
||||
case 'error':
|
||||
case 'warning':
|
||||
case 'degraded':
|
||||
return 'text-yellow-600 bg-yellow-100'
|
||||
default:
|
||||
return 'text-yellow-600 bg-yellow-100'
|
||||
}
|
||||
}
|
||||
|
||||
const getMachineStateColor = (state: string) => {
|
||||
switch (state.toLowerCase()) {
|
||||
case 'on':
|
||||
case 'running':
|
||||
return 'text-green-600 bg-green-100'
|
||||
case 'off':
|
||||
case 'stopped':
|
||||
return 'text-gray-600 bg-gray-100'
|
||||
default:
|
||||
return 'text-yellow-600 bg-yellow-100'
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600 mx-auto"></div>
|
||||
<p className="mt-4 text-gray-600">Loading vision system data...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="bg-red-50 border border-red-200 rounded-md p-4">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<svg className="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-red-800">Error loading vision system</h3>
|
||||
<div className="mt-2 text-sm text-red-700">
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<button
|
||||
onClick={() => fetchData(true)}
|
||||
disabled={refreshing}
|
||||
className="bg-red-100 px-3 py-2 rounded-md text-sm font-medium text-red-800 hover:bg-red-200 disabled:opacity-50"
|
||||
>
|
||||
{refreshing ? 'Retrying...' : 'Try Again'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Vision System</h1>
|
||||
<p className="mt-2 text-gray-600">Monitor cameras, machines, and recording status</p>
|
||||
{lastUpdateTime && (
|
||||
<p className={`mt-1 text-sm text-gray-500 flex items-center space-x-2 ${refreshing ? 'animate-pulse' : ''}`}>
|
||||
<span>Last updated: {lastUpdateTime.toLocaleTimeString()}</span>
|
||||
{refreshing && (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
|
||||
<span className="animate-spin rounded-full h-3 w-3 border-b border-blue-600 mr-1 inline-block"></span>
|
||||
Updating...
|
||||
</span>
|
||||
)}
|
||||
{autoRefreshEnabled && !refreshing && (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
||||
Auto-refresh: {refreshInterval / 1000}s
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
{/* Auto-refresh controls */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<label className="flex items-center space-x-2 text-sm text-gray-600">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={autoRefreshEnabled}
|
||||
onChange={(e) => setAutoRefreshEnabled(e.target.checked)}
|
||||
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
||||
/>
|
||||
<span>Auto-refresh</span>
|
||||
</label>
|
||||
{autoRefreshEnabled && (
|
||||
<select
|
||||
value={refreshInterval}
|
||||
onChange={(e) => setRefreshInterval(Number(e.target.value))}
|
||||
className="text-sm border-gray-300 rounded-md focus:ring-indigo-500 focus:border-indigo-500"
|
||||
>
|
||||
<option value={2000}>2s</option>
|
||||
<option value={5000}>5s</option>
|
||||
<option value={10000}>10s</option>
|
||||
<option value={30000}>30s</option>
|
||||
<option value={60000}>1m</option>
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Refresh indicator and button */}
|
||||
<div className="flex items-center space-x-2">
|
||||
{refreshing && (
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-indigo-600"></div>
|
||||
)}
|
||||
<button
|
||||
onClick={() => fetchData(true)}
|
||||
disabled={refreshing}
|
||||
className="bg-indigo-600 text-white px-4 py-2 rounded-md hover:bg-indigo-700 disabled:opacity-50"
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* System Overview */}
|
||||
{systemStatus && <SystemOverview systemStatus={systemStatus} />}
|
||||
|
||||
|
||||
|
||||
{/* Cameras Status */}
|
||||
{systemStatus && (
|
||||
<CamerasStatus
|
||||
systemStatus={systemStatus}
|
||||
onConfigureCamera={handleConfigureCamera}
|
||||
onStartRecording={handleStartRecording}
|
||||
onStopRecording={handleStopRecording}
|
||||
onPreviewCamera={handlePreviewCamera}
|
||||
onStopStreaming={handleStopStreaming}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Machines Status */}
|
||||
{systemStatus && Object.keys(systemStatus.machines).length > 0 && (
|
||||
<div className="bg-white shadow rounded-lg">
|
||||
<div className="px-4 py-5 sm:px-6">
|
||||
<h3 className="text-lg leading-6 font-medium text-gray-900">Machines</h3>
|
||||
<p className="mt-1 max-w-2xl text-sm text-gray-500">
|
||||
Current status of all machines in the system
|
||||
</p>
|
||||
</div>
|
||||
<div className="border-t border-gray-200">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 p-6">
|
||||
{Object.entries(systemStatus.machines).map(([machineName, machine]) => (
|
||||
<div key={machineName} className="border border-gray-200 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h4 className="text-lg font-medium text-gray-900 capitalize">
|
||||
{machineName.replace(/_/g, ' ')}
|
||||
</h4>
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getMachineStateColor(machine.state)}`}>
|
||||
{machine.state}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Last updated:</span>
|
||||
<span className="text-gray-900">{new Date(machine.last_updated).toLocaleTimeString()}</span>
|
||||
</div>
|
||||
|
||||
{machine.last_message && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Last message:</span>
|
||||
<span className="text-gray-900">{machine.last_message}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{machine.mqtt_topic && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">MQTT topic:</span>
|
||||
<span className="text-gray-900 text-xs font-mono">{machine.mqtt_topic}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Storage Statistics */}
|
||||
{storageStats && <StorageOverview storageStats={storageStats} />}
|
||||
|
||||
{/* Recent Recordings */}
|
||||
{Object.keys(recordings).length > 0 && <RecentRecordings recordings={recordings} systemStatus={systemStatus} />}
|
||||
|
||||
{/* Camera Configuration Modal */}
|
||||
{selectedCamera && (
|
||||
<CameraConfigModal
|
||||
cameraName={selectedCamera}
|
||||
isOpen={configModalOpen}
|
||||
onClose={() => {
|
||||
setConfigModalOpen(false)
|
||||
setSelectedCamera(null)
|
||||
}}
|
||||
onSuccess={handleConfigSuccess}
|
||||
onError={handleConfigError}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Camera Preview Modal */}
|
||||
{previewCamera && (
|
||||
<CameraPreviewModal
|
||||
cameraName={previewCamera}
|
||||
isOpen={previewModalOpen}
|
||||
onClose={() => {
|
||||
setPreviewModalOpen(false)
|
||||
setPreviewCamera(null)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Notification */}
|
||||
{notification && (
|
||||
<div className={`fixed top-4 right-4 z-50 p-4 rounded-md shadow-lg ${notification.type === 'success'
|
||||
? 'bg-green-50 border border-green-200 text-green-800'
|
||||
: 'bg-red-50 border border-red-200 text-red-800'
|
||||
}`}>
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0">
|
||||
{notification.type === 'success' ? (
|
||||
<svg className="h-5 w-5 text-green-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<p className="text-sm font-medium">{notification.message}</p>
|
||||
</div>
|
||||
<div className="ml-auto pl-3">
|
||||
<button
|
||||
onClick={() => setNotification(null)}
|
||||
className={`inline-flex rounded-md p-1.5 focus:outline-none focus:ring-2 focus:ring-offset-2 ${notification.type === 'success'
|
||||
? 'text-green-500 hover:bg-green-100 focus:ring-green-600'
|
||||
: 'text-red-500 hover:bg-red-100 focus:ring-red-600'
|
||||
}`}
|
||||
>
|
||||
<svg className="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* VideoStreamingPage Component
|
||||
*
|
||||
* Main page component for the video streaming feature.
|
||||
* Demonstrates how to compose the modular components together.
|
||||
*/
|
||||
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { VideoList, VideoModal, ApiStatusIndicator, VideoErrorBoundary, PerformanceDashboard } from './components';
|
||||
import { type VideoFile, type VideoListFilters, type VideoListSortOptions } from './types';
|
||||
|
||||
export const VideoStreamingPage: React.FC = () => {
|
||||
const [selectedVideo, setSelectedVideo] = useState<VideoFile | null>(null);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [filters, setFilters] = useState<VideoListFilters>({});
|
||||
const [sortOptions, setSortOptions] = useState<VideoListSortOptions>({
|
||||
field: 'created_at',
|
||||
direction: 'desc',
|
||||
});
|
||||
|
||||
// Available cameras for filtering (this could come from an API)
|
||||
const availableCameras = ['camera1', 'camera2', 'camera3']; // This should be fetched from your camera API
|
||||
|
||||
const handleVideoSelect = (video: VideoFile) => {
|
||||
setSelectedVideo(video);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleModalClose = () => {
|
||||
setIsModalOpen(false);
|
||||
setSelectedVideo(null);
|
||||
};
|
||||
|
||||
const handleCameraFilterChange = (cameraName: string) => {
|
||||
setFilters(prev => ({
|
||||
...prev,
|
||||
cameraName: cameraName === 'all' ? undefined : cameraName,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSortChange = (field: VideoListSortOptions['field'], direction: VideoListSortOptions['direction']) => {
|
||||
setSortOptions({ field, direction });
|
||||
};
|
||||
|
||||
const handleDateRangeChange = (start: string, end: string) => {
|
||||
setFilters(prev => ({
|
||||
...prev,
|
||||
dateRange: start && end ? { start, end } : undefined,
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<VideoErrorBoundary>
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Video Library</h1>
|
||||
<p className="mt-2 text-gray-600">
|
||||
Browse and view recorded videos from your camera system
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex-shrink-0">
|
||||
<ApiStatusIndicator showDetails={false} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters and Controls */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6 shadow-theme-sm">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
|
||||
{/* Camera Filter */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Camera
|
||||
</label>
|
||||
<div className="relative">
|
||||
<select
|
||||
value={filters.cameraName || 'all'}
|
||||
onChange={(e) => handleCameraFilterChange(e.target.value)}
|
||||
className="w-full px-3 py-2.5 border border-gray-300 rounded-lg bg-white text-gray-900 focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-brand-500 shadow-theme-xs transition-colors"
|
||||
>
|
||||
<option value="all">All Cameras</option>
|
||||
{availableCameras.map(camera => (
|
||||
<option key={camera} value={camera}>
|
||||
{camera}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none">
|
||||
<svg className="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sort Options */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Sort by
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<select
|
||||
value={sortOptions.field}
|
||||
onChange={(e) => handleSortChange(e.target.value as VideoListSortOptions['field'], sortOptions.direction)}
|
||||
className="w-full px-3 py-2.5 border border-gray-300 rounded-lg bg-white text-gray-900 focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-brand-500 shadow-theme-xs transition-colors"
|
||||
>
|
||||
<option value="created_at">Date Created</option>
|
||||
<option value="file_size_bytes">File Size</option>
|
||||
<option value="camera_name">Camera Name</option>
|
||||
<option value="filename">Filename</option>
|
||||
</select>
|
||||
<div className="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none">
|
||||
<svg className="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleSortChange(sortOptions.field, sortOptions.direction === 'asc' ? 'desc' : 'asc')}
|
||||
className="px-3 py-2.5 border border-gray-300 rounded-lg bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-brand-500 shadow-theme-xs transition-colors"
|
||||
title={`Sort ${sortOptions.direction === 'asc' ? 'Descending' : 'Ascending'}`}
|
||||
>
|
||||
{sortOptions.direction === 'asc' ? (
|
||||
<svg className="w-4 h-4 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 4h13M3 8h9m-9 4h6m4 0l4-4m0 0l4 4m-4-4v12" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-4 h-4 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 4h13M3 8h9m-9 4h9m5-4v12m0 0l-4-4m4 4l4-4" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Date Range Filter */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Start Date
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={filters.dateRange?.start || ''}
|
||||
onChange={(e) => handleDateRangeChange(e.target.value, filters.dateRange?.end || '')}
|
||||
className="w-full px-3 py-2.5 border border-gray-300 rounded-lg bg-white text-gray-900 focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-brand-500 shadow-theme-xs transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
End Date
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={filters.dateRange?.end || ''}
|
||||
onChange={(e) => handleDateRangeChange(filters.dateRange?.start || '', e.target.value)}
|
||||
className="w-full px-3 py-2.5 border border-gray-300 rounded-lg bg-white text-gray-900 focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-brand-500 shadow-theme-xs transition-colors"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Clear Filters */}
|
||||
{(filters.cameraName || filters.dateRange) && (
|
||||
<div className="mt-6 pt-6 border-t border-gray-200">
|
||||
<button
|
||||
onClick={() => setFilters({})}
|
||||
className="inline-flex items-center px-4 py-2.5 text-sm font-medium transition rounded-lg border bg-white text-gray-700 border-gray-300 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-brand-500 shadow-theme-xs"
|
||||
>
|
||||
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
Clear All Filters
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Video List */}
|
||||
<VideoList
|
||||
filters={filters}
|
||||
sortOptions={sortOptions}
|
||||
onVideoSelect={handleVideoSelect}
|
||||
limit={24}
|
||||
/>
|
||||
|
||||
{/* Video Modal */}
|
||||
<VideoModal
|
||||
video={selectedVideo}
|
||||
isOpen={isModalOpen}
|
||||
onClose={handleModalClose}
|
||||
/>
|
||||
|
||||
{/* Performance Dashboard (development only) */}
|
||||
<PerformanceDashboard />
|
||||
</div>
|
||||
</VideoErrorBoundary>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* ApiStatusIndicator Component
|
||||
*
|
||||
* A component that displays the connection status of the video streaming API
|
||||
* and provides helpful information when the API is not accessible.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { videoApiService } from '../services/videoApi';
|
||||
|
||||
interface ApiStatusIndicatorProps {
|
||||
className?: string;
|
||||
showDetails?: boolean;
|
||||
}
|
||||
|
||||
export const ApiStatusIndicator: React.FC<ApiStatusIndicatorProps> = ({
|
||||
className = '',
|
||||
showDetails = false,
|
||||
}) => {
|
||||
const [isOnline, setIsOnline] = useState<boolean | null>(null);
|
||||
const [isChecking, setIsChecking] = useState(false);
|
||||
const [lastChecked, setLastChecked] = useState<Date | null>(null);
|
||||
|
||||
const checkApiStatus = async () => {
|
||||
setIsChecking(true);
|
||||
try {
|
||||
const status = await videoApiService.healthCheck();
|
||||
setIsOnline(status);
|
||||
setLastChecked(new Date());
|
||||
} catch (error) {
|
||||
setIsOnline(false);
|
||||
setLastChecked(new Date());
|
||||
} finally {
|
||||
setIsChecking(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
checkApiStatus();
|
||||
|
||||
// Check status every 30 seconds
|
||||
const interval = setInterval(checkApiStatus, 30000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const getStatusColor = () => {
|
||||
if (isChecking) return 'bg-yellow-500';
|
||||
if (isOnline === null) return 'bg-gray-500';
|
||||
return isOnline ? 'bg-green-500' : 'bg-red-500';
|
||||
};
|
||||
|
||||
const getStatusText = () => {
|
||||
if (isChecking) return 'Checking...';
|
||||
if (isOnline === null) return 'Unknown';
|
||||
return isOnline ? 'Connected' : 'Disconnected';
|
||||
};
|
||||
|
||||
const getStatusIcon = () => {
|
||||
if (isChecking) {
|
||||
return (
|
||||
<div className="animate-spin rounded-full h-3 w-3 border-b-2 border-white"></div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isOnline) {
|
||||
return (
|
||||
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
if (!showDetails) {
|
||||
return (
|
||||
<div className={`inline-flex items-center ${className}`}>
|
||||
<div className={`w-2 h-2 rounded-full ${getStatusColor()} mr-2`}></div>
|
||||
<span className="text-sm text-gray-600">{getStatusText()}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`bg-white border border-gray-200 rounded-lg p-4 ${className}`}>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="text-sm font-medium text-gray-900">Video API Status</h3>
|
||||
<button
|
||||
onClick={checkApiStatus}
|
||||
disabled={isChecking}
|
||||
className="text-xs text-blue-600 hover:text-blue-800 disabled:opacity-50"
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center mb-2">
|
||||
<div className={`w-3 h-3 rounded-full ${getStatusColor()} mr-2 flex items-center justify-center text-white`}>
|
||||
{getStatusIcon()}
|
||||
</div>
|
||||
<span className="text-sm font-medium">{getStatusText()}</span>
|
||||
</div>
|
||||
|
||||
{lastChecked && (
|
||||
<div className="text-xs text-gray-500">
|
||||
Last checked: {lastChecked.toLocaleTimeString()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isOnline === false && (
|
||||
<div className="mt-3 p-3 bg-red-50 border border-red-200 rounded-md">
|
||||
<div className="text-sm text-red-800">
|
||||
<strong>Connection Failed</strong>
|
||||
<p className="mt-1">
|
||||
Cannot connect to the USDA Vision Camera System. Please ensure:
|
||||
</p>
|
||||
<ul className="mt-2 list-disc list-inside space-y-1">
|
||||
<li>The vision system is running</li>
|
||||
<li>The API is accessible at the configured URL</li>
|
||||
<li>Network connectivity is available</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* Pagination Component
|
||||
*
|
||||
* A reusable pagination component that matches the dashboard template's styling patterns.
|
||||
* Provides page navigation with first/last, previous/next, and numbered page buttons.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { type PaginationProps } from '../types';
|
||||
|
||||
export const Pagination: React.FC<PaginationProps> = ({
|
||||
currentPage,
|
||||
totalPages,
|
||||
onPageChange,
|
||||
showFirstLast = true,
|
||||
showPrevNext = true,
|
||||
maxVisiblePages = 5,
|
||||
className = '',
|
||||
}) => {
|
||||
// Don't render if there's only one page or no pages
|
||||
if (totalPages <= 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Calculate visible page numbers
|
||||
const getVisiblePages = (): number[] => {
|
||||
const pages: number[] = [];
|
||||
const halfVisible = Math.floor(maxVisiblePages / 2);
|
||||
|
||||
let startPage = Math.max(1, currentPage - halfVisible);
|
||||
let endPage = Math.min(totalPages, currentPage + halfVisible);
|
||||
|
||||
// Adjust if we're near the beginning or end
|
||||
if (endPage - startPage + 1 < maxVisiblePages) {
|
||||
if (startPage === 1) {
|
||||
endPage = Math.min(totalPages, startPage + maxVisiblePages - 1);
|
||||
} else {
|
||||
startPage = Math.max(1, endPage - maxVisiblePages + 1);
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = startPage; i <= endPage; i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
|
||||
return pages;
|
||||
};
|
||||
|
||||
const visiblePages = getVisiblePages();
|
||||
const isFirstPage = currentPage === 1;
|
||||
const isLastPage = currentPage === totalPages;
|
||||
|
||||
// Button base classes matching dashboard template
|
||||
const baseButtonClasses = "inline-flex items-center justify-center px-3 py-2 text-sm font-medium transition-all duration-200 rounded-lg border";
|
||||
|
||||
// Active page button classes
|
||||
const activeButtonClasses = "bg-brand-500 text-white border-brand-500 hover:bg-brand-600 shadow-theme-sm";
|
||||
|
||||
// Inactive page button classes
|
||||
const inactiveButtonClasses = "bg-white text-gray-700 border-gray-300 hover:bg-gray-50 hover:border-gray-400 shadow-theme-xs";
|
||||
|
||||
// Disabled button classes
|
||||
const disabledButtonClasses = "bg-gray-100 text-gray-400 border-gray-200 cursor-not-allowed opacity-50";
|
||||
|
||||
const handlePageClick = (page: number) => {
|
||||
if (page !== currentPage && page >= 1 && page <= totalPages) {
|
||||
onPageChange(page);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`flex items-center justify-center space-x-1 ${className}`}>
|
||||
{/* First Page Button */}
|
||||
{showFirstLast && !isFirstPage && (
|
||||
<button
|
||||
onClick={() => handlePageClick(1)}
|
||||
className={`${baseButtonClasses} ${inactiveButtonClasses}`}
|
||||
aria-label="Go to first page"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 19l-7-7 7-7m8 14l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Previous Page Button */}
|
||||
{showPrevNext && (
|
||||
<button
|
||||
onClick={() => handlePageClick(currentPage - 1)}
|
||||
disabled={isFirstPage}
|
||||
className={`${baseButtonClasses} ${isFirstPage ? disabledButtonClasses : inactiveButtonClasses}`}
|
||||
aria-label="Go to previous page"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Page Number Buttons */}
|
||||
{visiblePages.map((page) => (
|
||||
<button
|
||||
key={page}
|
||||
onClick={() => handlePageClick(page)}
|
||||
className={`${baseButtonClasses} ${page === currentPage ? activeButtonClasses : inactiveButtonClasses
|
||||
} min-w-[40px]`}
|
||||
aria-label={`Go to page ${page}`}
|
||||
aria-current={page === currentPage ? 'page' : undefined}
|
||||
>
|
||||
{page}
|
||||
</button>
|
||||
))}
|
||||
|
||||
{/* Next Page Button */}
|
||||
{showPrevNext && (
|
||||
<button
|
||||
onClick={() => handlePageClick(currentPage + 1)}
|
||||
disabled={isLastPage}
|
||||
className={`${baseButtonClasses} ${isLastPage ? disabledButtonClasses : inactiveButtonClasses}`}
|
||||
aria-label="Go to next page"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Last Page Button */}
|
||||
{showFirstLast && !isLastPage && (
|
||||
<button
|
||||
onClick={() => handlePageClick(totalPages)}
|
||||
className={`${baseButtonClasses} ${inactiveButtonClasses}`}
|
||||
aria-label="Go to last page"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 5l7 7-7 7M5 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Page info component to show current page and total
|
||||
export const PageInfo: React.FC<{
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
totalItems: number;
|
||||
itemsPerPage: number;
|
||||
className?: string;
|
||||
}> = ({ currentPage, totalPages, totalItems, itemsPerPage, className = '' }) => {
|
||||
const startItem = (currentPage - 1) * itemsPerPage + 1;
|
||||
const endItem = Math.min(currentPage * itemsPerPage, totalItems);
|
||||
|
||||
return (
|
||||
<div className={`text-sm text-gray-600 ${className}`}>
|
||||
Showing {startItem} to {endItem} of {totalItems} results (Page {currentPage} of {totalPages})
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* PerformanceDashboard Component
|
||||
*
|
||||
* A development tool for monitoring video streaming performance.
|
||||
* Only shown in development mode.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { performanceMonitor, thumbnailCache } from '../utils';
|
||||
|
||||
interface PerformanceDashboardProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const PerformanceDashboard: React.FC<PerformanceDashboardProps> = ({
|
||||
className = '',
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [stats, setStats] = useState<any>(null);
|
||||
const [cacheStats, setCacheStats] = useState<any>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
const updateStats = () => {
|
||||
setStats({
|
||||
overall: performanceMonitor.getStats(),
|
||||
getVideos: performanceMonitor.getStats('get_videos'),
|
||||
getThumbnail: performanceMonitor.getStats('get_thumbnail'),
|
||||
recentMetrics: performanceMonitor.getRecentMetrics(5),
|
||||
});
|
||||
setCacheStats(thumbnailCache.getStats());
|
||||
};
|
||||
|
||||
updateStats();
|
||||
const interval = setInterval(updateStats, 2000);
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Only show in development
|
||||
if (process.env.NODE_ENV !== 'development') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!isOpen) {
|
||||
return (
|
||||
<button
|
||||
onClick={() => setIsOpen(true)}
|
||||
className={`fixed bottom-4 right-4 bg-blue-600 text-white p-2 rounded-full shadow-lg hover:bg-blue-700 transition-colors z-50 ${className}`}
|
||||
title="Open Performance Dashboard"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M3 3a1 1 0 000 2v8a2 2 0 002 2h2.586l-1.293 1.293a1 1 0 101.414 1.414L10 15.414l2.293 2.293a1 1 0 001.414-1.414L12.414 15H15a2 2 0 002-2V5a1 1 0 100-2H3zm11.707 4.707a1 1 0 00-1.414-1.414L10 9.586 8.707 8.293a1 1 0 00-1.414 0l-2 2a1 1 0 101.414 1.414L8 10.414l1.293 1.293a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`fixed bottom-4 right-4 bg-white border border-gray-200 rounded-lg shadow-xl p-4 max-w-md w-80 max-h-96 overflow-y-auto z-50 ${className}`}>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900">Performance</h3>
|
||||
<button
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{stats && (
|
||||
<div className="space-y-4">
|
||||
{/* Overall Stats */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-2">Overall</h4>
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
<div>Operations: {stats.overall.totalOperations}</div>
|
||||
<div>Success: {(stats.overall.successRate * 100).toFixed(1)}%</div>
|
||||
<div>Avg: {stats.overall.averageDuration.toFixed(0)}ms</div>
|
||||
<div>Max: {stats.overall.maxDuration.toFixed(0)}ms</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Video Loading Stats */}
|
||||
{stats.getVideos.totalOperations > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-2">Video Loading</h4>
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
<div>Calls: {stats.getVideos.totalOperations}</div>
|
||||
<div>Success: {(stats.getVideos.successRate * 100).toFixed(1)}%</div>
|
||||
<div>Avg: {stats.getVideos.averageDuration.toFixed(0)}ms</div>
|
||||
<div>Max: {stats.getVideos.maxDuration.toFixed(0)}ms</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Thumbnail Stats */}
|
||||
{stats.getThumbnail.totalOperations > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-2">Thumbnails</h4>
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
<div>Calls: {stats.getThumbnail.totalOperations}</div>
|
||||
<div>Success: {(stats.getThumbnail.successRate * 100).toFixed(1)}%</div>
|
||||
<div>Avg: {stats.getThumbnail.averageDuration.toFixed(0)}ms</div>
|
||||
<div>Max: {stats.getThumbnail.maxDuration.toFixed(0)}ms</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Cache Stats */}
|
||||
{cacheStats && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-2">Cache</h4>
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
<div>Cached: {cacheStats.size}</div>
|
||||
<div>Memory: {(cacheStats.totalMemory / 1024 / 1024).toFixed(1)}MB</div>
|
||||
<div>Hits: {cacheStats.totalAccess}</div>
|
||||
<div>Avg Size: {(cacheStats.averageSize / 1024).toFixed(0)}KB</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recent Operations */}
|
||||
{stats.recentMetrics.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-2">Recent</h4>
|
||||
<div className="space-y-1">
|
||||
{stats.recentMetrics.map((metric: any, index: number) => (
|
||||
<div key={index} className="flex justify-between text-xs">
|
||||
<span className={metric.success ? 'text-green-600' : 'text-red-600'}>
|
||||
{metric.operation}
|
||||
</span>
|
||||
<span>{metric.duration?.toFixed(0)}ms</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
performanceMonitor.clear();
|
||||
thumbnailCache.clear();
|
||||
}}
|
||||
className="flex-1 px-2 py-1 text-xs bg-red-100 text-red-700 rounded hover:bg-red-200"
|
||||
>
|
||||
Clear All
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
console.log(performanceMonitor.getReport());
|
||||
console.log('Cache Stats:', thumbnailCache.getStats());
|
||||
}}
|
||||
className="flex-1 px-2 py-1 text-xs bg-blue-100 text-blue-700 rounded hover:bg-blue-200"
|
||||
>
|
||||
Log Report
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* VideoCard Component
|
||||
*
|
||||
* A reusable card component for displaying video information with thumbnail, metadata, and actions.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { type VideoCardProps } from '../types';
|
||||
import { VideoThumbnail } from './VideoThumbnail';
|
||||
import {
|
||||
formatFileSize,
|
||||
formatVideoDate,
|
||||
getRelativeTime,
|
||||
getFormatDisplayName,
|
||||
getStatusBadgeClass,
|
||||
getResolutionString,
|
||||
} from '../utils/videoUtils';
|
||||
|
||||
export const VideoCard: React.FC<VideoCardProps> = ({
|
||||
video,
|
||||
onClick,
|
||||
showMetadata = true,
|
||||
className = '',
|
||||
}) => {
|
||||
const handleClick = () => {
|
||||
if (onClick) {
|
||||
onClick(video);
|
||||
}
|
||||
};
|
||||
|
||||
const handleThumbnailClick = () => {
|
||||
handleClick();
|
||||
};
|
||||
|
||||
const cardClasses = [
|
||||
'bg-white rounded-xl border border-gray-200 overflow-hidden transition-all hover:shadow-theme-md',
|
||||
onClick ? 'cursor-pointer hover:border-gray-300' : '',
|
||||
className,
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
return (
|
||||
<div className={cardClasses} onClick={onClick ? handleClick : undefined}>
|
||||
{/* Thumbnail */}
|
||||
<div className="relative">
|
||||
<VideoThumbnail
|
||||
fileId={video.file_id}
|
||||
width={320}
|
||||
height={180}
|
||||
alt={`Thumbnail for ${video.filename}`}
|
||||
onClick={onClick ? handleThumbnailClick : undefined}
|
||||
className="w-full"
|
||||
/>
|
||||
|
||||
{/* Status Badge */}
|
||||
<div className="absolute top-2 left-2">
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getStatusBadgeClass(video.status)}`}>
|
||||
{video.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Format Badge */}
|
||||
<div className="absolute top-2 right-2">
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
|
||||
{getFormatDisplayName(video.format)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Streamable Indicator */}
|
||||
{video.is_streamable ? (
|
||||
<div className="absolute bottom-2 left-2">
|
||||
<div className="bg-green-500 text-white text-xs px-2 py-1 rounded flex items-center">
|
||||
<svg className="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z" clipRule="evenodd" />
|
||||
</svg>
|
||||
Streamable
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="absolute bottom-2 left-2">
|
||||
<div className="bg-yellow-500 text-white text-xs px-2 py-1 rounded flex items-center">
|
||||
<svg className="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
|
||||
</svg>
|
||||
Processing
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Conversion Needed Indicator */}
|
||||
{video.needs_conversion && (
|
||||
<div className="absolute bottom-2 right-2">
|
||||
<div className="bg-yellow-500 text-white text-xs px-2 py-1 rounded flex items-center">
|
||||
<svg className="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
|
||||
</svg>
|
||||
Needs Conversion
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-4">
|
||||
{/* Title */}
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2 truncate" title={video.filename}>
|
||||
{video.filename}
|
||||
</h3>
|
||||
|
||||
{/* Camera Name */}
|
||||
<div className="flex items-center text-sm text-gray-600 mb-2">
|
||||
<svg className="w-4 h-4 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M4 3a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V5a2 2 0 00-2-2H4zm12 12H4l4-8 3 6 2-4 3 6z" clipRule="evenodd" />
|
||||
</svg>
|
||||
{video.camera_name}
|
||||
</div>
|
||||
|
||||
{/* Basic Info */}
|
||||
<div className="grid grid-cols-2 gap-4 text-sm text-gray-600 mb-3">
|
||||
<div>
|
||||
<span className="font-medium">Size:</span> {formatFileSize(video.file_size_bytes)}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">Created:</span> {getRelativeTime(video.created_at)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metadata (if available and requested) */}
|
||||
{showMetadata && 'metadata' in video && video.metadata && (
|
||||
<div className="border-t pt-3 mt-3 border-gray-100">
|
||||
<div className="grid grid-cols-2 gap-4 text-sm text-gray-600">
|
||||
<div>
|
||||
<span className="font-medium">Duration:</span> {Math.round(video.metadata.duration_seconds)}s
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">Resolution:</span> {getResolutionString(video.metadata.width, video.metadata.height)}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">FPS:</span> {video.metadata.fps}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">Codec:</span> {video.metadata.codec}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-between items-center mt-4 pt-3 border-t border-gray-100">
|
||||
<div className="text-xs text-gray-500">
|
||||
{formatVideoDate(video.created_at)}
|
||||
</div>
|
||||
|
||||
{onClick && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleClick();
|
||||
}}
|
||||
className="inline-flex items-center px-3 py-1.5 text-xs font-medium transition rounded-lg border border-transparent bg-brand-500 text-white hover:bg-brand-600 shadow-theme-xs"
|
||||
>
|
||||
<svg className="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z" clipRule="evenodd" />
|
||||
</svg>
|
||||
Play
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* VideoDebugger Component
|
||||
*
|
||||
* A development tool for debugging video streaming issues.
|
||||
* Provides direct access to test video URLs and diagnose problems.
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { videoApiService } from '../services/videoApi';
|
||||
|
||||
interface VideoDebuggerProps {
|
||||
fileId: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const VideoDebugger: React.FC<VideoDebuggerProps> = ({
|
||||
fileId,
|
||||
className = '',
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [testResults, setTestResults] = useState<any>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const streamingUrl = videoApiService.getStreamingUrl(fileId);
|
||||
const thumbnailUrl = videoApiService.getThumbnailUrl(fileId);
|
||||
|
||||
const runDiagnostics = async () => {
|
||||
setIsLoading(true);
|
||||
const results: any = {
|
||||
timestamp: new Date().toISOString(),
|
||||
fileId,
|
||||
streamingUrl,
|
||||
thumbnailUrl,
|
||||
tests: {}
|
||||
};
|
||||
|
||||
try {
|
||||
// Test 1: Video Info
|
||||
try {
|
||||
const videoInfo = await videoApiService.getVideoInfo(fileId);
|
||||
results.tests.videoInfo = { success: true, data: videoInfo };
|
||||
} catch (error) {
|
||||
results.tests.videoInfo = { success: false, error: error instanceof Error ? error.message : 'Unknown error' };
|
||||
}
|
||||
|
||||
// Test 2: Streaming Info
|
||||
try {
|
||||
const streamingInfo = await videoApiService.getStreamingInfo(fileId);
|
||||
results.tests.streamingInfo = { success: true, data: streamingInfo };
|
||||
} catch (error) {
|
||||
results.tests.streamingInfo = { success: false, error: error instanceof Error ? error.message : 'Unknown error' };
|
||||
}
|
||||
|
||||
// Test 3: HEAD request to streaming URL
|
||||
try {
|
||||
const response = await fetch(streamingUrl, { method: 'HEAD' });
|
||||
results.tests.streamingHead = {
|
||||
success: response.ok,
|
||||
status: response.status,
|
||||
headers: Object.fromEntries(response.headers.entries())
|
||||
};
|
||||
} catch (error) {
|
||||
results.tests.streamingHead = { success: false, error: error instanceof Error ? error.message : 'Unknown error' };
|
||||
}
|
||||
|
||||
// Test 4: Range request test
|
||||
try {
|
||||
const response = await fetch(streamingUrl, {
|
||||
headers: { 'Range': 'bytes=0-1023' }
|
||||
});
|
||||
results.tests.rangeRequest = {
|
||||
success: response.ok,
|
||||
status: response.status,
|
||||
supportsRanges: response.headers.get('accept-ranges') === 'bytes',
|
||||
contentRange: response.headers.get('content-range')
|
||||
};
|
||||
} catch (error) {
|
||||
results.tests.rangeRequest = { success: false, error: error instanceof Error ? error.message : 'Unknown error' };
|
||||
}
|
||||
|
||||
// Test 5: Thumbnail test
|
||||
try {
|
||||
const response = await fetch(thumbnailUrl, { method: 'HEAD' });
|
||||
results.tests.thumbnail = {
|
||||
success: response.ok,
|
||||
status: response.status,
|
||||
contentType: response.headers.get('content-type')
|
||||
};
|
||||
} catch (error) {
|
||||
results.tests.thumbnail = { success: false, error: error instanceof Error ? error.message : 'Unknown error' };
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
results.error = error instanceof Error ? error.message : 'Unknown error';
|
||||
}
|
||||
|
||||
setTestResults(results);
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
// Only show in development
|
||||
if (process.env.NODE_ENV !== 'development') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!isOpen) {
|
||||
return (
|
||||
<button
|
||||
onClick={() => setIsOpen(true)}
|
||||
className={`px-3 py-1 text-xs bg-yellow-100 text-yellow-800 rounded hover:bg-yellow-200 transition-colors ${className}`}
|
||||
title="Open Video Debugger"
|
||||
>
|
||||
🔧 Debug
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`bg-white border border-gray-200 rounded-lg shadow-lg p-4 max-w-2xl ${className}`}>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900">Video Debugger</h3>
|
||||
<button
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Basic Info */}
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-700 mb-2">Basic Info</h4>
|
||||
<div className="text-sm space-y-1">
|
||||
<div><strong>File ID:</strong> {fileId}</div>
|
||||
<div><strong>Streaming URL:</strong> <a href={streamingUrl} target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline">{streamingUrl}</a></div>
|
||||
<div><strong>Thumbnail URL:</strong> <a href={thumbnailUrl} target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline">{thumbnailUrl}</a></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-700 mb-2">Quick Actions</h4>
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
onClick={runDiagnostics}
|
||||
disabled={isLoading}
|
||||
className="px-3 py-1 text-sm bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? 'Running...' : 'Run Diagnostics'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => window.open(streamingUrl, '_blank')}
|
||||
className="px-3 py-1 text-sm bg-green-600 text-white rounded hover:bg-green-700"
|
||||
>
|
||||
Open Video
|
||||
</button>
|
||||
<button
|
||||
onClick={() => window.open(thumbnailUrl, '_blank')}
|
||||
className="px-3 py-1 text-sm bg-purple-600 text-white rounded hover:bg-purple-700"
|
||||
>
|
||||
Open Thumbnail
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Test Results */}
|
||||
{testResults && (
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-700 mb-2">Diagnostic Results</h4>
|
||||
<div className="bg-gray-50 rounded p-3 text-xs font-mono max-h-64 overflow-y-auto">
|
||||
<pre>{JSON.stringify(testResults, null, 2)}</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Native Video Test */}
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-700 mb-2">Native Video Test</h4>
|
||||
<video
|
||||
controls
|
||||
width="100%"
|
||||
height="200"
|
||||
className="border rounded"
|
||||
onLoadStart={() => console.log('Native video load started')}
|
||||
onLoadedData={() => console.log('Native video data loaded')}
|
||||
onError={(e) => console.error('Native video error:', e)}
|
||||
>
|
||||
<source src={streamingUrl} type="video/mp4" />
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* VideoErrorBoundary Component
|
||||
*
|
||||
* Error boundary specifically designed for video streaming components.
|
||||
* Provides user-friendly error messages and recovery options.
|
||||
*/
|
||||
|
||||
import React, { Component, ReactNode } from 'react';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
fallback?: ReactNode;
|
||||
onError?: (error: Error, errorInfo: React.ErrorInfo) => void;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
errorInfo: React.ErrorInfo | null;
|
||||
}
|
||||
|
||||
export class VideoErrorBoundary extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
hasError: false,
|
||||
error: null,
|
||||
errorInfo: null,
|
||||
};
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return {
|
||||
hasError: true,
|
||||
error,
|
||||
errorInfo: null,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||
this.setState({
|
||||
error,
|
||||
errorInfo,
|
||||
});
|
||||
|
||||
// Call the onError callback if provided
|
||||
if (this.props.onError) {
|
||||
this.props.onError(error, errorInfo);
|
||||
}
|
||||
|
||||
// Log error for debugging
|
||||
console.error('Video streaming error:', error, errorInfo);
|
||||
}
|
||||
|
||||
handleRetry = () => {
|
||||
this.setState({
|
||||
hasError: false,
|
||||
error: null,
|
||||
errorInfo: null,
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
// Use custom fallback if provided
|
||||
if (this.props.fallback) {
|
||||
return this.props.fallback;
|
||||
}
|
||||
|
||||
// Default error UI
|
||||
return (
|
||||
<div className="min-h-[400px] flex items-center justify-center bg-gray-50 rounded-lg border border-gray-200">
|
||||
<div className="text-center max-w-md mx-auto p-6">
|
||||
<div className="w-16 h-16 mx-auto mb-4 bg-red-100 rounded-full flex items-center justify-center">
|
||||
<svg className="w-8 h-8 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
Video System Error
|
||||
</h3>
|
||||
|
||||
<p className="text-gray-600 mb-4">
|
||||
Something went wrong with the video streaming component. This might be due to:
|
||||
</p>
|
||||
|
||||
<ul className="text-sm text-gray-500 text-left mb-6 space-y-1">
|
||||
<li>• Network connectivity issues</li>
|
||||
<li>• Video API server problems</li>
|
||||
<li>• Corrupted video files</li>
|
||||
<li>• Browser compatibility issues</li>
|
||||
</ul>
|
||||
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
onClick={this.handleRetry}
|
||||
className="w-full px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="w-full px-4 py-2 bg-gray-200 text-gray-800 rounded-lg hover:bg-gray-300 transition-colors"
|
||||
>
|
||||
Reload Page
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Error details for debugging (only in development) */}
|
||||
{process.env.NODE_ENV === 'development' && this.state.error && (
|
||||
<details className="mt-6 text-left">
|
||||
<summary className="cursor-pointer text-sm text-gray-500 hover:text-gray-700">
|
||||
Show Error Details
|
||||
</summary>
|
||||
<div className="mt-2 p-3 bg-gray-100 rounded text-xs font-mono text-gray-700 overflow-auto max-h-32">
|
||||
<div className="font-semibold mb-1">Error:</div>
|
||||
<div className="mb-2">{this.state.error.message}</div>
|
||||
<div className="font-semibold mb-1">Stack:</div>
|
||||
<div>{this.state.error.stack}</div>
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
// Higher-order component for easier usage
|
||||
export function withVideoErrorBoundary<P extends object>(
|
||||
Component: React.ComponentType<P>,
|
||||
fallback?: ReactNode
|
||||
) {
|
||||
return function WrappedComponent(props: P) {
|
||||
return (
|
||||
<VideoErrorBoundary fallback={fallback}>
|
||||
<Component {...props} />
|
||||
</VideoErrorBoundary>
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
/**
|
||||
* VideoList Component
|
||||
*
|
||||
* A reusable component for displaying a list/grid of videos with filtering, sorting, and pagination.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { type VideoListProps, type VideoListFilters, type VideoListSortOptions } from '../types';
|
||||
import { useVideoList } from '../hooks/useVideoList';
|
||||
import { VideoCard } from './VideoCard';
|
||||
import { Pagination, PageInfo } from './Pagination';
|
||||
|
||||
export const VideoList: React.FC<VideoListProps> = ({
|
||||
filters,
|
||||
sortOptions,
|
||||
limit = 20,
|
||||
onVideoSelect,
|
||||
className = '',
|
||||
}) => {
|
||||
const [localFilters, setLocalFilters] = useState<VideoListFilters>(filters || {});
|
||||
const [localSort, setLocalSort] = useState<VideoListSortOptions>(
|
||||
sortOptions || { field: 'created_at', direction: 'desc' }
|
||||
);
|
||||
|
||||
const {
|
||||
videos,
|
||||
totalCount,
|
||||
currentPage,
|
||||
totalPages,
|
||||
loading,
|
||||
error,
|
||||
refetch,
|
||||
loadMore,
|
||||
hasMore,
|
||||
goToPage,
|
||||
nextPage,
|
||||
previousPage,
|
||||
updateFilters,
|
||||
updateSort,
|
||||
} = useVideoList({
|
||||
initialParams: {
|
||||
camera_name: localFilters.cameraName,
|
||||
start_date: localFilters.dateRange?.start,
|
||||
end_date: localFilters.dateRange?.end,
|
||||
limit,
|
||||
include_metadata: true,
|
||||
page: 1, // Start with page 1
|
||||
},
|
||||
autoFetch: true,
|
||||
});
|
||||
|
||||
// Update filters when props change (without causing infinite loops)
|
||||
useEffect(() => {
|
||||
if (filters) {
|
||||
setLocalFilters(filters);
|
||||
}
|
||||
}, [filters]);
|
||||
|
||||
// Update sort when props change (without causing infinite loops)
|
||||
useEffect(() => {
|
||||
if (sortOptions) {
|
||||
setLocalSort(sortOptions);
|
||||
}
|
||||
}, [sortOptions]);
|
||||
|
||||
const handleVideoClick = (video: any) => {
|
||||
if (onVideoSelect) {
|
||||
onVideoSelect(video);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLoadMore = () => {
|
||||
if (hasMore && loading !== 'loading') {
|
||||
loadMore();
|
||||
}
|
||||
};
|
||||
|
||||
const containerClasses = [
|
||||
'video-list',
|
||||
className,
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
if (loading === 'loading' && videos.length === 0) {
|
||||
return (
|
||||
<div className={containerClasses}>
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mx-auto mb-4"></div>
|
||||
<p className="text-gray-600">Loading videos...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className={containerClasses}>
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="text-center">
|
||||
<svg className="w-12 h-12 text-red-400 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">Error Loading Videos</h3>
|
||||
<p className="text-gray-600 mb-4">{error.message}</p>
|
||||
<button
|
||||
onClick={refetch}
|
||||
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (videos.length === 0) {
|
||||
return (
|
||||
<div className={containerClasses}>
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="text-center">
|
||||
<svg className="w-12 h-12 text-gray-400 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">No Videos Found</h3>
|
||||
<p className="text-gray-600">No videos match your current filters.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={containerClasses}>
|
||||
{/* Top Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="mb-6 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 p-4 bg-white rounded-xl border border-gray-200 shadow-theme-sm">
|
||||
{/* Page Info */}
|
||||
<PageInfo
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
totalItems={totalCount}
|
||||
itemsPerPage={limit}
|
||||
className="text-sm text-gray-600"
|
||||
/>
|
||||
|
||||
{/* Pagination Controls */}
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={goToPage}
|
||||
showFirstLast={true}
|
||||
showPrevNext={true}
|
||||
maxVisiblePages={5}
|
||||
className="justify-center sm:justify-end"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Results Summary */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="text-sm text-gray-600">
|
||||
{totalPages > 0 ? (
|
||||
<>Showing page {currentPage} of {totalPages} ({totalCount} total videos)</>
|
||||
) : (
|
||||
<>Showing {videos.length} of {totalCount} videos</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={refetch}
|
||||
disabled={loading === 'loading'}
|
||||
className="inline-flex items-center px-3 py-2 text-sm font-medium transition rounded-lg border bg-white text-gray-700 border-gray-300 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed shadow-theme-xs"
|
||||
>
|
||||
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
{loading === 'loading' ? 'Refreshing...' : 'Refresh'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Video Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||
{videos.map((video) => (
|
||||
<VideoCard
|
||||
key={video.file_id}
|
||||
video={video}
|
||||
onClick={onVideoSelect ? handleVideoClick : undefined}
|
||||
showMetadata={true}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Bottom Pagination */}
|
||||
{totalPages > 1 && videos.length > 0 && (
|
||||
<div className="mt-8 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 p-4 bg-white rounded-xl border border-gray-200 shadow-theme-sm">
|
||||
{/* Page Info */}
|
||||
<PageInfo
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
totalItems={totalCount}
|
||||
itemsPerPage={limit}
|
||||
className="text-sm text-gray-600"
|
||||
/>
|
||||
|
||||
{/* Pagination Controls */}
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={goToPage}
|
||||
showFirstLast={true}
|
||||
showPrevNext={true}
|
||||
maxVisiblePages={5}
|
||||
className="justify-center sm:justify-end"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading Indicator */}
|
||||
{loading === 'loading' && (
|
||||
<div className="flex justify-center mt-8">
|
||||
<div className="text-sm text-gray-600 flex items-center">
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-brand-500 mr-2"></div>
|
||||
Loading videos...
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,234 @@
|
||||
/**
|
||||
* VideoModal Component
|
||||
*
|
||||
* A modal component for displaying videos in fullscreen with detailed information.
|
||||
*/
|
||||
|
||||
import React, { useEffect } from 'react';
|
||||
import { type VideoFile } from '../types';
|
||||
import { VideoPlayer } from './VideoPlayer';
|
||||
import { VideoDebugger } from './VideoDebugger';
|
||||
import { useVideoInfo } from '../hooks/useVideoInfo';
|
||||
import {
|
||||
formatFileSize,
|
||||
formatVideoDate,
|
||||
getFormatDisplayName,
|
||||
getStatusBadgeClass,
|
||||
getResolutionString,
|
||||
formatDuration,
|
||||
isWebCompatible,
|
||||
} from '../utils/videoUtils';
|
||||
|
||||
interface VideoModalProps {
|
||||
video: VideoFile | null;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const VideoModal: React.FC<VideoModalProps> = ({
|
||||
video,
|
||||
isOpen,
|
||||
onClose,
|
||||
}) => {
|
||||
const { videoInfo, streamingInfo, loading, error } = useVideoInfo(
|
||||
video?.file_id || null,
|
||||
{ autoFetch: isOpen && !!video }
|
||||
);
|
||||
|
||||
// Handle escape key
|
||||
useEffect(() => {
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleEscape);
|
||||
document.body.style.overflow = 'unset';
|
||||
};
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
if (!isOpen || !video) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleBackdropClick = (e: React.MouseEvent) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[999999] overflow-y-auto">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 bg-black bg-opacity-75 transition-opacity"
|
||||
onClick={handleBackdropClick}
|
||||
/>
|
||||
|
||||
{/* Modal */}
|
||||
<div className="flex min-h-full items-center justify-center p-4">
|
||||
<div className="relative bg-white rounded-lg shadow-xl max-w-6xl w-full max-h-[90vh] overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b">
|
||||
<h2 className="text-xl font-semibold text-gray-900 truncate pr-4">
|
||||
{video.filename}
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 rounded p-1"
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex flex-col lg:flex-row max-h-[calc(90vh-80px)]">
|
||||
{/* Video Player */}
|
||||
<div className="flex-1 bg-black">
|
||||
<VideoPlayer
|
||||
fileId={video.file_id}
|
||||
controls={true}
|
||||
className="w-full h-full min-h-[300px] lg:min-h-[400px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Sidebar with Video Info */}
|
||||
<div className="w-full lg:w-80 bg-gray-50 overflow-y-auto">
|
||||
<div className="p-4 space-y-4">
|
||||
{/* Status and Format */}
|
||||
<div className="flex items-center space-x-2 flex-wrap">
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getStatusBadgeClass(video.status)}`}>
|
||||
{video.status}
|
||||
</span>
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${isWebCompatible(video.format)
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-orange-100 text-orange-800'
|
||||
}`}>
|
||||
{getFormatDisplayName(video.format)}
|
||||
</span>
|
||||
{isWebCompatible(video.format) && (
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
|
||||
Web Compatible
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Basic Info */}
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-900 mb-2">Basic Information</h3>
|
||||
<dl className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-gray-500">Camera:</dt>
|
||||
<dd className="text-gray-900">{video.camera_name}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-gray-500">File Size:</dt>
|
||||
<dd className="text-gray-900">{formatFileSize(video.file_size_bytes)}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-gray-500">Created:</dt>
|
||||
<dd className="text-gray-900">{formatVideoDate(video.created_at)}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-gray-500">Streamable:</dt>
|
||||
<dd className="text-gray-900">{video.is_streamable ? 'Yes' : 'No'}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{/* Video Metadata */}
|
||||
{videoInfo?.metadata && (
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-900 mb-2">Video Details</h3>
|
||||
<dl className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-gray-500">Duration:</dt>
|
||||
<dd className="text-gray-900">{formatDuration(videoInfo.metadata.duration_seconds)}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-gray-500">Resolution:</dt>
|
||||
<dd className="text-gray-900">
|
||||
{getResolutionString(videoInfo.metadata.width, videoInfo.metadata.height)}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-gray-500">Frame Rate:</dt>
|
||||
<dd className="text-gray-900">{videoInfo.metadata.fps} fps</dd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-gray-500">Codec:</dt>
|
||||
<dd className="text-gray-900">{videoInfo.metadata.codec}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-gray-500">Aspect Ratio:</dt>
|
||||
<dd className="text-gray-900">{videoInfo.metadata.aspect_ratio.toFixed(2)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Streaming Info */}
|
||||
{streamingInfo && (
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-900 mb-2">Streaming Details</h3>
|
||||
<dl className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-gray-500">Content Type:</dt>
|
||||
<dd className="text-gray-900">{streamingInfo.content_type}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-gray-500">Range Requests:</dt>
|
||||
<dd className="text-gray-900">{streamingInfo.supports_range_requests ? 'Supported' : 'Not Supported'}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-gray-500">Chunk Size:</dt>
|
||||
<dd className="text-gray-900">{formatFileSize(streamingInfo.chunk_size_bytes)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading State */}
|
||||
{loading === 'loading' && (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-500"></div>
|
||||
<span className="ml-2 text-sm text-gray-600">Loading video details...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error State */}
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-md p-3">
|
||||
<div className="flex">
|
||||
<svg className="w-5 h-5 text-red-400" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-red-800">Error loading video details</h3>
|
||||
<p className="text-sm text-red-700 mt-1">{error.message}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Video Debugger (development only) */}
|
||||
<VideoDebugger fileId={video.file_id} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,242 @@
|
||||
/**
|
||||
* VideoPlayer Component
|
||||
*
|
||||
* A reusable video player component with full controls and customization options.
|
||||
* Uses the useVideoPlayer hook for state management and provides a clean interface.
|
||||
*/
|
||||
|
||||
import React, { forwardRef, useState, useEffect } from 'react';
|
||||
import { useVideoPlayer } from '../hooks/useVideoPlayer';
|
||||
import { videoApiService } from '../services/videoApi';
|
||||
import { type VideoPlayerProps } from '../types';
|
||||
import { formatDuration, getVideoMimeType } from '../utils/videoUtils';
|
||||
|
||||
export const VideoPlayer = forwardRef<HTMLVideoElement, VideoPlayerProps>(({
|
||||
fileId,
|
||||
autoPlay = false,
|
||||
controls = true,
|
||||
width = '100%',
|
||||
height = 'auto',
|
||||
className = '',
|
||||
onPlay,
|
||||
onPause,
|
||||
onEnded,
|
||||
onError,
|
||||
}, forwardedRef) => {
|
||||
const [videoInfo, setVideoInfo] = useState<{ filename?: string; mimeType: string; isStreamable?: boolean }>({
|
||||
mimeType: 'video/mp4' // Default to MP4
|
||||
});
|
||||
|
||||
const { state, actions, ref } = useVideoPlayer({
|
||||
autoPlay,
|
||||
onPlay,
|
||||
onPause,
|
||||
onEnded,
|
||||
onError,
|
||||
});
|
||||
|
||||
// Combine refs
|
||||
React.useImperativeHandle(forwardedRef, () => ref.current!, [ref]);
|
||||
|
||||
const streamingUrl = videoApiService.getStreamingUrl(fileId);
|
||||
|
||||
// Fetch video info to determine MIME type and streamability
|
||||
useEffect(() => {
|
||||
const fetchVideoInfo = async () => {
|
||||
try {
|
||||
const info = await videoApiService.getVideoInfo(fileId);
|
||||
if (info.file_id) {
|
||||
// Extract filename from file_id or use a default pattern
|
||||
const filename = info.file_id.includes('.') ? info.file_id : `${info.file_id}.mp4`;
|
||||
const mimeType = getVideoMimeType(filename);
|
||||
setVideoInfo({
|
||||
filename,
|
||||
mimeType,
|
||||
isStreamable: info.is_streamable
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Could not fetch video info, using default MIME type:', error);
|
||||
// Keep default MP4 MIME type, assume not streamable
|
||||
setVideoInfo(prev => ({ ...prev, isStreamable: false }));
|
||||
}
|
||||
};
|
||||
|
||||
fetchVideoInfo();
|
||||
}, [fileId]);
|
||||
|
||||
const handleSeek = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!ref.current) return;
|
||||
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const clickX = e.clientX - rect.left;
|
||||
const percentage = clickX / rect.width;
|
||||
const newTime = percentage * state.duration;
|
||||
|
||||
actions.seek(newTime);
|
||||
};
|
||||
|
||||
const handleVolumeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
actions.setVolume(parseFloat(e.target.value));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`video-player relative ${className}`} style={{ width, height }}>
|
||||
{/* Video Element */}
|
||||
<video
|
||||
ref={ref}
|
||||
className="w-full h-full bg-black"
|
||||
controls={!controls || state.error} // Use native controls if custom controls are disabled or there's an error
|
||||
style={{ width, height }}
|
||||
playsInline // Important for iOS compatibility
|
||||
preload="metadata" // Load metadata first for better UX
|
||||
>
|
||||
<source src={streamingUrl} type={videoInfo.mimeType} />
|
||||
{/* Fallback for MP4 if original format fails */}
|
||||
{videoInfo.mimeType !== 'video/mp4' && (
|
||||
<source src={streamingUrl} type="video/mp4" />
|
||||
)}
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
|
||||
{/* Loading Overlay */}
|
||||
{state.isLoading && (
|
||||
<div className="absolute inset-0 bg-black bg-opacity-50 flex items-center justify-center">
|
||||
<div className="text-white text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white mx-auto mb-2"></div>
|
||||
<div className="text-lg">Loading video...</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error Overlay */}
|
||||
{state.error && (
|
||||
<div className="absolute inset-0 bg-black bg-opacity-75 flex items-center justify-center">
|
||||
<div className="text-red-400 text-center">
|
||||
<div className="text-lg mb-2">Playback Error</div>
|
||||
<div className="text-sm">{state.error}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Custom Controls */}
|
||||
{controls && (
|
||||
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black to-transparent p-4">
|
||||
{/* Progress Bar */}
|
||||
<div className="mb-3">
|
||||
<div
|
||||
className="w-full h-2 bg-gray-600 rounded cursor-pointer"
|
||||
onClick={handleSeek}
|
||||
>
|
||||
<div
|
||||
className="h-full bg-blue-500 rounded"
|
||||
style={{
|
||||
width: `${state.duration > 0 ? (state.currentTime / state.duration) * 100 : 0}%`
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Control Bar */}
|
||||
<div className="flex items-center justify-between text-white">
|
||||
{/* Left Controls */}
|
||||
<div className="flex items-center space-x-3">
|
||||
{/* Play/Pause Button */}
|
||||
<button
|
||||
onClick={actions.togglePlay}
|
||||
className="p-2 hover:bg-white hover:bg-opacity-20 rounded"
|
||||
disabled={state.isLoading}
|
||||
>
|
||||
{state.isPlaying ? (
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zM7 8a1 1 0 012 0v4a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v4a1 1 0 102 0V8a1 1 0 00-1-1z" clipRule="evenodd" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z" clipRule="evenodd" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Skip Backward */}
|
||||
<button
|
||||
onClick={() => actions.skip(-10)}
|
||||
className="p-2 hover:bg-white hover:bg-opacity-20 rounded"
|
||||
title="Skip backward 10s"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M15.707 15.707a1 1 0 01-1.414 0l-5-5a1 1 0 010-1.414l5-5a1 1 0 111.414 1.414L11.414 9H17a1 1 0 110 2h-5.586l3.293 3.293a1 1 0 010 1.414z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Skip Forward */}
|
||||
<button
|
||||
onClick={() => actions.skip(10)}
|
||||
className="p-2 hover:bg-white hover:bg-opacity-20 rounded"
|
||||
title="Skip forward 10s"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0l5 5a1 1 0 010 1.414l-5 5a1 1 0 01-1.414-1.414L8.586 11H3a1 1 0 110-2h5.586L4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Time Display */}
|
||||
<div className="text-sm">
|
||||
{formatDuration(state.currentTime)} / {formatDuration(state.duration)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Controls */}
|
||||
<div className="flex items-center space-x-3">
|
||||
{/* Volume Control */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={actions.toggleMute}
|
||||
className="p-2 hover:bg-white hover:bg-opacity-20 rounded"
|
||||
>
|
||||
{state.isMuted || state.volume === 0 ? (
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M9.383 3.076A1 1 0 0110 4v12a1 1 0 01-1.617.776L4.83 13H2a1 1 0 01-1-1V8a1 1 0 011-1h2.83l3.553-3.776a1 1 0 011.617.776zM14.657 2.929a1 1 0 011.414 0A9.972 9.972 0 0119 10a9.972 9.972 0 01-2.929 7.071 1 1 0 11-1.414-1.414A7.971 7.971 0 0017 10c0-2.21-.894-4.208-2.343-5.657a1 1 0 010-1.414zm-2.829 2.828a1 1 0 011.415 0A5.983 5.983 0 0115 10a5.984 5.984 0 01-1.757 4.243 1 1 0 01-1.415-1.415A3.984 3.984 0 0013 10a3.983 3.983 0 00-1.172-2.828 1 1 0 010-1.415z" clipRule="evenodd" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M9.383 3.076A1 1 0 0110 4v12a1 1 0 01-1.617.776L4.83 13H2a1 1 0 01-1-1V8a1 1 0 011-1h2.83l3.553-3.776a1 1 0 011.617.776zM14.657 2.929a1 1 0 011.414 0A9.972 9.972 0 0119 10a9.972 9.972 0 01-2.929 7.071 1 1 0 11-1.414-1.414A7.971 7.971 0 0017 10c0-2.21-.894-4.208-2.343-5.657a1 1 0 010-1.414zm-2.829 2.828a1 1 0 011.415 0A5.983 5.983 0 0115 10a5.984 5.984 0 01-1.757 4.243 1 1 0 01-1.415-1.415A3.984 3.984 0 0013 10a3.983 3.983 0 00-1.172-2.828 1 1 0 010-1.415z" clipRule="evenodd" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.1"
|
||||
value={state.volume}
|
||||
onChange={handleVolumeChange}
|
||||
className="w-20 h-1 bg-gray-600 rounded-lg appearance-none cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Fullscreen Button */}
|
||||
<button
|
||||
onClick={actions.toggleFullscreen}
|
||||
className="p-2 hover:bg-white hover:bg-opacity-20 rounded"
|
||||
>
|
||||
{state.isFullscreen ? (
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M3 4a1 1 0 011-1h4a1 1 0 010 2H6.414l2.293 2.293a1 1 0 11-1.414 1.414L5 6.414V8a1 1 0 01-2 0V4zm9 1a1 1 0 010-2h4a1 1 0 011 1v4a1 1 0 01-2 0V6.414l-2.293 2.293a1 1 0 11-1.414-1.414L13.586 5H12zm-9 7a1 1 0 012 0v1.586l2.293-2.293a1 1 0 111.414 1.414L6.414 15H8a1 1 0 010 2H4a1 1 0 01-1-1v-4zm13-1a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 010-2h1.586l-2.293-2.293a1 1 0 111.414-1.414L15 13.586V12a1 1 0 011-1z" clipRule="evenodd" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M3 4a1 1 0 011-1h4a1 1 0 010 2H6.414l2.293 2.293a1 1 0 11-1.414 1.414L5 6.414V8a1 1 0 01-2 0V4zm9 1a1 1 0 010-2h4a1 1 0 011 1v4a1 1 0 01-2 0V6.414l-2.293 2.293a1 1 0 11-1.414-1.414L13.586 5H12zm-9 7a1 1 0 012 0v1.586l2.293-2.293a1 1 0 111.414 1.414L6.414 15H8a1 1 0 010 2H4a1 1 0 01-1-1v-4zm13-1a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 010-2h1.586l-2.293-2.293a1 1 0 111.414-1.414L15 13.586V12a1 1 0 011-1z" clipRule="evenodd" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
VideoPlayer.displayName = 'VideoPlayer';
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* VideoThumbnail Component
|
||||
*
|
||||
* A reusable component for displaying video thumbnails with loading states and error handling.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { videoApiService } from '../services/videoApi';
|
||||
import { thumbnailCache } from '../utils/thumbnailCache';
|
||||
import { type VideoThumbnailProps } from '../types';
|
||||
|
||||
export const VideoThumbnail: React.FC<VideoThumbnailProps> = ({
|
||||
fileId,
|
||||
timestamp = 0,
|
||||
width = 320,
|
||||
height = 240,
|
||||
alt = 'Video thumbnail',
|
||||
className = '',
|
||||
onClick,
|
||||
}) => {
|
||||
const [thumbnailUrl, setThumbnailUrl] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
const loadThumbnail = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
// Check cache first
|
||||
const cachedUrl = thumbnailCache.get(fileId, timestamp, width, height);
|
||||
if (cachedUrl && isMounted) {
|
||||
setThumbnailUrl(cachedUrl);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch from API if not cached
|
||||
const blob = await videoApiService.getThumbnailBlob(fileId, {
|
||||
timestamp,
|
||||
width,
|
||||
height,
|
||||
});
|
||||
|
||||
if (isMounted) {
|
||||
// Store in cache and get URL
|
||||
const url = thumbnailCache.set(fileId, timestamp, width, height, blob);
|
||||
setThumbnailUrl(url);
|
||||
setIsLoading(false);
|
||||
}
|
||||
} catch (err) {
|
||||
if (isMounted) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load thumbnail');
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadThumbnail();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
// Note: We don't revoke the URL here since it's managed by the cache
|
||||
};
|
||||
}, [fileId, timestamp, width, height]);
|
||||
|
||||
// Note: URL cleanup is now handled by the thumbnail cache
|
||||
|
||||
const handleClick = () => {
|
||||
if (onClick && !isLoading && !error) {
|
||||
onClick();
|
||||
}
|
||||
};
|
||||
|
||||
const containerClasses = [
|
||||
'relative overflow-hidden bg-gray-200 rounded',
|
||||
onClick && !isLoading && !error ? 'cursor-pointer hover:opacity-80 transition-opacity' : '',
|
||||
className,
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
return (
|
||||
<div
|
||||
className={containerClasses}
|
||||
style={{ width, height }}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{/* Loading State */}
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-gray-100">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error State */}
|
||||
{error && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-gray-100 text-gray-500 text-sm p-2 text-center">
|
||||
<div>
|
||||
<svg className="w-8 h-8 mx-auto mb-2 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div>Failed to load thumbnail</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Thumbnail Image */}
|
||||
{thumbnailUrl && !isLoading && !error && (
|
||||
<img
|
||||
src={thumbnailUrl}
|
||||
alt={alt}
|
||||
className="w-full h-full object-cover"
|
||||
onError={() => setError('Failed to display thumbnail')}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Play Overlay */}
|
||||
{onClick && !isLoading && !error && (
|
||||
<div className="absolute inset-0 flex items-center justify-center opacity-0 hover:opacity-100 transition-opacity bg-black bg-opacity-30">
|
||||
<div className="bg-white bg-opacity-90 rounded-full p-3">
|
||||
<svg className="w-6 h-6 text-gray-800" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Timestamp Badge */}
|
||||
{timestamp > 0 && !isLoading && !error && (
|
||||
<div className="absolute bottom-2 right-2 bg-black bg-opacity-75 text-white text-xs px-2 py-1 rounded">
|
||||
{Math.floor(timestamp / 60)}:{(timestamp % 60).toString().padStart(2, '0')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Video Streaming Components - Index
|
||||
*
|
||||
* Centralized export for all video streaming components.
|
||||
* This makes it easy to import components from a single location.
|
||||
*/
|
||||
|
||||
export { VideoPlayer } from './VideoPlayer';
|
||||
export { VideoThumbnail } from './VideoThumbnail';
|
||||
export { VideoCard } from './VideoCard';
|
||||
export { VideoList } from './VideoList';
|
||||
export { VideoModal } from './VideoModal';
|
||||
export { Pagination, PageInfo } from './Pagination';
|
||||
export { ApiStatusIndicator } from './ApiStatusIndicator';
|
||||
export { VideoErrorBoundary, withVideoErrorBoundary } from './VideoErrorBoundary';
|
||||
export { PerformanceDashboard } from './PerformanceDashboard';
|
||||
export { VideoDebugger } from './VideoDebugger';
|
||||
|
||||
// Re-export component prop types for convenience
|
||||
export type {
|
||||
VideoPlayerProps,
|
||||
VideoThumbnailProps,
|
||||
VideoCardProps,
|
||||
VideoListProps,
|
||||
PaginationProps,
|
||||
} from '../types';
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Video Streaming Hooks - Index
|
||||
*
|
||||
* Centralized export for all video streaming hooks.
|
||||
* This makes it easy to import hooks from a single location.
|
||||
*/
|
||||
|
||||
export { useVideoList, type UseVideoListReturn } from './useVideoList';
|
||||
export { useVideoPlayer, type UseVideoPlayerReturn, type VideoPlayerState } from './useVideoPlayer';
|
||||
export { useVideoInfo, type UseVideoInfoReturn } from './useVideoInfo';
|
||||
|
||||
// Re-export types that are commonly used with hooks
|
||||
export type {
|
||||
VideoListFilters,
|
||||
VideoListSortOptions,
|
||||
} from '../types';
|
||||
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* useVideoInfo Hook
|
||||
*
|
||||
* Custom React hook for fetching and managing video metadata and streaming information.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { videoApiService } from '../services/videoApi';
|
||||
import {
|
||||
type VideoInfoResponse,
|
||||
type VideoStreamingInfo,
|
||||
type VideoError,
|
||||
type LoadingState
|
||||
} from '../types';
|
||||
|
||||
export interface UseVideoInfoReturn {
|
||||
videoInfo: VideoInfoResponse | null;
|
||||
streamingInfo: VideoStreamingInfo | null;
|
||||
loading: LoadingState;
|
||||
error: VideoError | null;
|
||||
refetch: () => Promise<void>;
|
||||
clearCache: () => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
interface UseVideoInfoOptions {
|
||||
autoFetch?: boolean;
|
||||
cacheKey?: string;
|
||||
}
|
||||
|
||||
export function useVideoInfo(
|
||||
fileId: string | null,
|
||||
options: UseVideoInfoOptions = {}
|
||||
) {
|
||||
const { autoFetch = true, cacheKey = 'default' } = options;
|
||||
|
||||
// State
|
||||
const [videoInfo, setVideoInfo] = useState<VideoInfoResponse | null>(null);
|
||||
const [streamingInfo, setStreamingInfo] = useState<VideoStreamingInfo | null>(null);
|
||||
const [loading, setLoading] = useState<LoadingState>('idle');
|
||||
const [error, setError] = useState<VideoError | null>(null);
|
||||
|
||||
// Refs for cleanup and caching
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
const cacheRef = useRef<Map<string, {
|
||||
videoInfo: VideoInfoResponse;
|
||||
streamingInfo: VideoStreamingInfo;
|
||||
timestamp: number;
|
||||
}>>(new Map());
|
||||
|
||||
const CACHE_DURATION = 10 * 60 * 1000; // 10 minutes
|
||||
|
||||
/**
|
||||
* Check if cached data is still valid
|
||||
*/
|
||||
const isCacheValid = useCallback((timestamp: number): boolean => {
|
||||
return Date.now() - timestamp < CACHE_DURATION;
|
||||
}, [CACHE_DURATION]);
|
||||
|
||||
/**
|
||||
* Fetch video information
|
||||
*/
|
||||
const fetchVideoInfo = useCallback(async (id: string): Promise<void> => {
|
||||
// Cancel any ongoing request
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
abortControllerRef.current = controller;
|
||||
|
||||
try {
|
||||
setLoading('loading');
|
||||
setError(null);
|
||||
|
||||
// Check cache first
|
||||
const key = `${cacheKey}_${id}`;
|
||||
const cached = cacheRef.current.get(key);
|
||||
|
||||
if (cached && isCacheValid(cached.timestamp)) {
|
||||
setVideoInfo(cached.videoInfo);
|
||||
setStreamingInfo(cached.streamingInfo);
|
||||
setLoading('success');
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch both video info and streaming info in parallel
|
||||
const [videoInfoResponse, streamingInfoResponse] = await Promise.all([
|
||||
videoApiService.getVideoInfo(id),
|
||||
videoApiService.getStreamingInfo(id)
|
||||
]);
|
||||
|
||||
// Check if request was aborted
|
||||
if (controller.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Update cache
|
||||
cacheRef.current.set(key, {
|
||||
videoInfo: videoInfoResponse,
|
||||
streamingInfo: streamingInfoResponse,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
|
||||
// Update state
|
||||
setVideoInfo(videoInfoResponse);
|
||||
setStreamingInfo(streamingInfoResponse);
|
||||
setLoading('success');
|
||||
|
||||
} catch (err) {
|
||||
if (controller.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
const videoError: VideoError = err instanceof Error
|
||||
? { code: 'FETCH_ERROR', message: err.message, details: err }
|
||||
: { code: 'UNKNOWN_ERROR', message: 'An unknown error occurred' };
|
||||
|
||||
setError(videoError);
|
||||
setLoading('error');
|
||||
} finally {
|
||||
abortControllerRef.current = null;
|
||||
}
|
||||
}, [cacheKey, isCacheValid]);
|
||||
|
||||
/**
|
||||
* Refetch video information
|
||||
*/
|
||||
const refetch = useCallback(async (): Promise<void> => {
|
||||
if (!fileId) return;
|
||||
await fetchVideoInfo(fileId);
|
||||
}, [fileId, fetchVideoInfo]);
|
||||
|
||||
/**
|
||||
* Clear cache for current video
|
||||
*/
|
||||
const clearCache = useCallback((): void => {
|
||||
if (!fileId) return;
|
||||
const key = `${cacheKey}_${fileId}`;
|
||||
cacheRef.current.delete(key);
|
||||
}, [fileId, cacheKey]);
|
||||
|
||||
/**
|
||||
* Reset state
|
||||
*/
|
||||
const reset = useCallback((): void => {
|
||||
setVideoInfo(null);
|
||||
setStreamingInfo(null);
|
||||
setLoading('idle');
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
// Auto-fetch when fileId changes
|
||||
useEffect(() => {
|
||||
if (fileId && autoFetch) {
|
||||
fetchVideoInfo(fileId);
|
||||
} else if (!fileId) {
|
||||
reset();
|
||||
}
|
||||
|
||||
// Cleanup on unmount or fileId change
|
||||
return () => {
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
};
|
||||
}, [fileId, autoFetch, fetchVideoInfo, reset]);
|
||||
|
||||
// Cleanup cache periodically
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
for (const [key, value] of cacheRef.current.entries()) {
|
||||
if (!isCacheValid(value.timestamp)) {
|
||||
cacheRef.current.delete(key);
|
||||
}
|
||||
}
|
||||
}, CACHE_DURATION);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [isCacheValid, CACHE_DURATION]);
|
||||
|
||||
return {
|
||||
videoInfo,
|
||||
streamingInfo,
|
||||
loading,
|
||||
error,
|
||||
refetch,
|
||||
clearCache,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
/**
|
||||
* useVideoList Hook
|
||||
*
|
||||
* Custom React hook for managing video list state, fetching, filtering, and pagination.
|
||||
* Provides a clean interface for components to interact with video data.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { videoApiService } from '../services/videoApi';
|
||||
import {
|
||||
type VideoFile,
|
||||
type VideoListParams,
|
||||
type VideoError,
|
||||
type LoadingState,
|
||||
type VideoListFilters,
|
||||
type VideoListSortOptions
|
||||
} from '../types';
|
||||
|
||||
export interface UseVideoListReturn {
|
||||
videos: VideoFile[];
|
||||
totalCount: number;
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
loading: LoadingState;
|
||||
error: VideoError | null;
|
||||
refetch: () => Promise<void>;
|
||||
loadMore: () => Promise<void>;
|
||||
hasMore: boolean;
|
||||
goToPage: (page: number) => Promise<void>;
|
||||
nextPage: () => Promise<void>;
|
||||
previousPage: () => Promise<void>;
|
||||
updateFilters: (filters: VideoListFilters) => void;
|
||||
updateSort: (sortOptions: VideoListSortOptions) => void;
|
||||
clearCache: () => void;
|
||||
reset: () => void;
|
||||
}
|
||||
import { filterVideos, sortVideos } from '../utils/videoUtils';
|
||||
|
||||
interface UseVideoListOptions {
|
||||
initialParams?: VideoListParams;
|
||||
autoFetch?: boolean;
|
||||
cacheKey?: string;
|
||||
}
|
||||
|
||||
export function useVideoList(options: UseVideoListOptions = {}) {
|
||||
const {
|
||||
initialParams = {},
|
||||
autoFetch = true,
|
||||
cacheKey = 'default'
|
||||
} = options;
|
||||
|
||||
// State
|
||||
const [videos, setVideos] = useState<VideoFile[]>([]);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(0);
|
||||
const [loading, setLoading] = useState<LoadingState>('idle');
|
||||
const [error, setError] = useState<VideoError | null>(null);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [currentParams, setCurrentParams] = useState<VideoListParams>(initialParams);
|
||||
|
||||
// Refs for cleanup and caching
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
const debounceTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
/**
|
||||
* Fetch videos from API
|
||||
*/
|
||||
const fetchVideos = useCallback(async (
|
||||
params: VideoListParams = initialParams,
|
||||
append: boolean = false
|
||||
): Promise<void> => {
|
||||
// Cancel any ongoing request
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
abortControllerRef.current = controller;
|
||||
|
||||
try {
|
||||
setLoading('loading');
|
||||
setError(null);
|
||||
|
||||
// Fetch from API
|
||||
const response = await videoApiService.getVideos(params);
|
||||
|
||||
// Check if request was aborted
|
||||
if (controller.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Update state
|
||||
setVideos(append ? prev => [...prev, ...response.videos] : response.videos);
|
||||
setTotalCount(response.total_count);
|
||||
|
||||
// Update pagination state
|
||||
if (response.page && response.total_pages) {
|
||||
setCurrentPage(response.page);
|
||||
setTotalPages(response.total_pages);
|
||||
setHasMore(response.has_next || false);
|
||||
} else {
|
||||
// Fallback for offset-based pagination
|
||||
setHasMore(response.videos.length === (params.limit || 50));
|
||||
}
|
||||
|
||||
setLoading('success');
|
||||
|
||||
} catch (err) {
|
||||
if (controller.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
const videoError: VideoError = err instanceof Error
|
||||
? { code: 'FETCH_ERROR', message: err.message, details: err }
|
||||
: { code: 'UNKNOWN_ERROR', message: 'An unknown error occurred' };
|
||||
|
||||
setError(videoError);
|
||||
setLoading('error');
|
||||
} finally {
|
||||
abortControllerRef.current = null;
|
||||
}
|
||||
}, [initialParams]);
|
||||
|
||||
/**
|
||||
* Refetch videos with current page
|
||||
*/
|
||||
const refetch = useCallback(async (): Promise<void> => {
|
||||
const currentParams = {
|
||||
...initialParams,
|
||||
page: currentPage,
|
||||
limit: initialParams.limit || 20,
|
||||
};
|
||||
await fetchVideos(currentParams, false);
|
||||
}, [fetchVideos, initialParams, currentPage]);
|
||||
|
||||
/**
|
||||
* Load more videos (pagination) - for backward compatibility
|
||||
*/
|
||||
const loadMore = useCallback(async (): Promise<void> => {
|
||||
if (!hasMore || loading === 'loading') {
|
||||
return;
|
||||
}
|
||||
|
||||
const offset = videos.length;
|
||||
const params = { ...initialParams, offset };
|
||||
await fetchVideos(params, true);
|
||||
}, [hasMore, loading, videos.length, initialParams, fetchVideos]);
|
||||
|
||||
/**
|
||||
* Go to specific page
|
||||
*/
|
||||
const goToPage = useCallback(async (page: number): Promise<void> => {
|
||||
if (page < 1 || (totalPages > 0 && page > totalPages) || loading === 'loading') {
|
||||
return;
|
||||
}
|
||||
|
||||
const params = { ...currentParams, page, limit: currentParams.limit || 20 };
|
||||
setCurrentParams(params);
|
||||
await fetchVideos(params, false);
|
||||
}, [currentParams, totalPages, loading, fetchVideos]);
|
||||
|
||||
/**
|
||||
* Go to next page
|
||||
*/
|
||||
const nextPage = useCallback(async (): Promise<void> => {
|
||||
if (currentPage < totalPages) {
|
||||
await goToPage(currentPage + 1);
|
||||
}
|
||||
}, [currentPage, totalPages, goToPage]);
|
||||
|
||||
/**
|
||||
* Go to previous page
|
||||
*/
|
||||
const previousPage = useCallback(async (): Promise<void> => {
|
||||
if (currentPage > 1) {
|
||||
await goToPage(currentPage - 1);
|
||||
}
|
||||
}, [currentPage, goToPage]);
|
||||
|
||||
/**
|
||||
* Update filters and refetch
|
||||
*/
|
||||
const updateFilters = useCallback((filters: VideoListFilters): void => {
|
||||
const newParams: VideoListParams = {
|
||||
...initialParams,
|
||||
camera_name: filters.cameraName,
|
||||
start_date: filters.dateRange?.start,
|
||||
end_date: filters.dateRange?.end,
|
||||
page: 1, // Reset to first page when filters change
|
||||
limit: initialParams.limit || 20,
|
||||
};
|
||||
|
||||
setCurrentParams(newParams);
|
||||
fetchVideos(newParams, false);
|
||||
}, [initialParams, fetchVideos]);
|
||||
|
||||
/**
|
||||
* Update sort options and refetch
|
||||
*/
|
||||
const updateSort = useCallback((sortOptions: VideoListSortOptions): void => {
|
||||
// Since the API doesn't support sorting, we'll sort locally
|
||||
setVideos(prev => sortVideos(prev, sortOptions.field, sortOptions.direction));
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Clear cache (placeholder for future caching implementation)
|
||||
*/
|
||||
const clearCache = useCallback((): void => {
|
||||
// TODO: Implement cache clearing when caching is added
|
||||
console.log('Cache cleared');
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Reset to initial state
|
||||
*/
|
||||
const reset = useCallback((): void => {
|
||||
setVideos([]);
|
||||
setTotalCount(0);
|
||||
setCurrentPage(1);
|
||||
setTotalPages(0);
|
||||
setLoading('idle');
|
||||
setError(null);
|
||||
setHasMore(true);
|
||||
}, []);
|
||||
|
||||
// Auto-fetch on mount only
|
||||
useEffect(() => {
|
||||
if (autoFetch) {
|
||||
fetchVideos(initialParams, false);
|
||||
}
|
||||
|
||||
// Cleanup on unmount
|
||||
return () => {
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
};
|
||||
}, []); // Empty dependency array - only run once on mount
|
||||
|
||||
return {
|
||||
videos,
|
||||
totalCount,
|
||||
currentPage,
|
||||
totalPages,
|
||||
loading,
|
||||
error,
|
||||
refetch,
|
||||
loadMore,
|
||||
hasMore,
|
||||
// Pagination methods
|
||||
goToPage,
|
||||
nextPage,
|
||||
previousPage,
|
||||
// Additional utility methods
|
||||
updateFilters,
|
||||
updateSort,
|
||||
clearCache,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
/**
|
||||
* useVideoPlayer Hook
|
||||
*
|
||||
* Custom React hook for managing video player state and controls.
|
||||
* Provides a comprehensive interface for video playback functionality.
|
||||
*/
|
||||
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
|
||||
// Video player state interface
|
||||
export interface VideoPlayerState {
|
||||
isPlaying: boolean;
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
volume: number;
|
||||
isMuted: boolean;
|
||||
isFullscreen: boolean;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface UseVideoPlayerReturn {
|
||||
state: VideoPlayerState;
|
||||
actions: {
|
||||
play: () => void;
|
||||
pause: () => void;
|
||||
togglePlay: () => void;
|
||||
seek: (time: number) => void;
|
||||
setVolume: (volume: number) => void;
|
||||
toggleMute: () => void;
|
||||
toggleFullscreen: () => void;
|
||||
skip: (seconds: number) => void;
|
||||
setPlaybackRate: (rate: number) => void;
|
||||
reset: () => void;
|
||||
};
|
||||
ref: React.RefObject<HTMLVideoElement>;
|
||||
}
|
||||
|
||||
interface UseVideoPlayerOptions {
|
||||
autoPlay?: boolean;
|
||||
loop?: boolean;
|
||||
muted?: boolean;
|
||||
volume?: number;
|
||||
onPlay?: () => void;
|
||||
onPause?: () => void;
|
||||
onEnded?: () => void;
|
||||
onError?: (error: string) => void;
|
||||
onTimeUpdate?: (currentTime: number) => void;
|
||||
onDurationChange?: (duration: number) => void;
|
||||
}
|
||||
|
||||
export function useVideoPlayer(options: UseVideoPlayerOptions = {}) {
|
||||
const {
|
||||
autoPlay = false,
|
||||
loop = false,
|
||||
muted = false,
|
||||
volume = 1,
|
||||
onPlay,
|
||||
onPause,
|
||||
onEnded,
|
||||
onError,
|
||||
onTimeUpdate,
|
||||
onDurationChange,
|
||||
} = options;
|
||||
|
||||
// Video element ref
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
|
||||
// Player state
|
||||
const [state, setState] = useState<VideoPlayerState>({
|
||||
isPlaying: false,
|
||||
currentTime: 0,
|
||||
duration: 0,
|
||||
volume: volume,
|
||||
isMuted: muted,
|
||||
isFullscreen: false,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
/**
|
||||
* Update state helper
|
||||
*/
|
||||
const updateState = useCallback((updates: Partial<VideoPlayerState>) => {
|
||||
setState(prev => ({ ...prev, ...updates }));
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Play video
|
||||
*/
|
||||
const play = useCallback(async () => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
try {
|
||||
updateState({ isLoading: true, error: null });
|
||||
await video.play();
|
||||
updateState({ isPlaying: true, isLoading: false });
|
||||
onPlay?.();
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Failed to play video';
|
||||
updateState({ isLoading: false, error: errorMessage });
|
||||
onError?.(errorMessage);
|
||||
}
|
||||
}, [updateState, onPlay, onError]);
|
||||
|
||||
/**
|
||||
* Pause video
|
||||
*/
|
||||
const pause = useCallback(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
video.pause();
|
||||
updateState({ isPlaying: false });
|
||||
onPause?.();
|
||||
}, [updateState, onPause]);
|
||||
|
||||
/**
|
||||
* Toggle play/pause
|
||||
*/
|
||||
const togglePlay = useCallback(() => {
|
||||
if (state.isPlaying) {
|
||||
pause();
|
||||
} else {
|
||||
play();
|
||||
}
|
||||
}, [state.isPlaying, play, pause]);
|
||||
|
||||
/**
|
||||
* Seek to specific time
|
||||
*/
|
||||
const seek = useCallback((time: number) => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
video.currentTime = Math.max(0, Math.min(time, video.duration || 0));
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Set volume (0-1)
|
||||
*/
|
||||
const setVolume = useCallback((newVolume: number) => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
const clampedVolume = Math.max(0, Math.min(1, newVolume));
|
||||
video.volume = clampedVolume;
|
||||
updateState({ volume: clampedVolume });
|
||||
}, [updateState]);
|
||||
|
||||
/**
|
||||
* Toggle mute
|
||||
*/
|
||||
const toggleMute = useCallback(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
video.muted = !video.muted;
|
||||
updateState({ isMuted: video.muted });
|
||||
}, [updateState]);
|
||||
|
||||
/**
|
||||
* Enter/exit fullscreen
|
||||
*/
|
||||
const toggleFullscreen = useCallback(async () => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
try {
|
||||
if (!document.fullscreenElement) {
|
||||
await video.requestFullscreen();
|
||||
updateState({ isFullscreen: true });
|
||||
} else {
|
||||
await document.exitFullscreen();
|
||||
updateState({ isFullscreen: false });
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Fullscreen not supported or failed:', error);
|
||||
}
|
||||
}, [updateState]);
|
||||
|
||||
/**
|
||||
* Skip forward/backward
|
||||
*/
|
||||
const skip = useCallback((seconds: number) => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
const newTime = video.currentTime + seconds;
|
||||
seek(newTime);
|
||||
}, [seek]);
|
||||
|
||||
/**
|
||||
* Set playback rate
|
||||
*/
|
||||
const setPlaybackRate = useCallback((rate: number) => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
video.playbackRate = Math.max(0.25, Math.min(4, rate));
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Reset video to beginning
|
||||
*/
|
||||
const reset = useCallback(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
video.currentTime = 0;
|
||||
pause();
|
||||
}, [pause]);
|
||||
|
||||
// Event handlers
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
const handleLoadStart = () => {
|
||||
updateState({ isLoading: true, error: null });
|
||||
|
||||
// Set a timeout to detect if loading takes too long
|
||||
const loadTimeout = setTimeout(() => {
|
||||
if (video && video.readyState < 2) { // HAVE_CURRENT_DATA
|
||||
updateState({
|
||||
isLoading: false,
|
||||
error: 'Video loading timeout. The video may not be accessible or there may be a network issue.'
|
||||
});
|
||||
}
|
||||
}, 30000); // 30 second timeout
|
||||
|
||||
// Store timeout ID to clear it later
|
||||
(video as any)._loadTimeout = loadTimeout;
|
||||
};
|
||||
|
||||
const handleLoadedData = () => {
|
||||
updateState({ isLoading: false });
|
||||
|
||||
// Clear the loading timeout
|
||||
if ((video as any)._loadTimeout) {
|
||||
clearTimeout((video as any)._loadTimeout);
|
||||
(video as any)._loadTimeout = null;
|
||||
}
|
||||
};
|
||||
|
||||
const handleTimeUpdate = () => {
|
||||
updateState({ currentTime: video.currentTime });
|
||||
onTimeUpdate?.(video.currentTime);
|
||||
};
|
||||
|
||||
const handleDurationChange = () => {
|
||||
updateState({ duration: video.duration });
|
||||
onDurationChange?.(video.duration);
|
||||
};
|
||||
|
||||
const handlePlay = () => {
|
||||
updateState({ isPlaying: true });
|
||||
};
|
||||
|
||||
const handlePause = () => {
|
||||
updateState({ isPlaying: false });
|
||||
};
|
||||
|
||||
const handleEnded = () => {
|
||||
updateState({ isPlaying: false });
|
||||
onEnded?.();
|
||||
};
|
||||
|
||||
const handleError = () => {
|
||||
const errorMessage = video.error?.message || 'Video playback error';
|
||||
updateState({ isLoading: false, error: errorMessage, isPlaying: false });
|
||||
onError?.(errorMessage);
|
||||
|
||||
// Clear the loading timeout
|
||||
if ((video as any)._loadTimeout) {
|
||||
clearTimeout((video as any)._loadTimeout);
|
||||
(video as any)._loadTimeout = null;
|
||||
}
|
||||
};
|
||||
|
||||
const handleVolumeChange = () => {
|
||||
updateState({
|
||||
volume: video.volume,
|
||||
isMuted: video.muted
|
||||
});
|
||||
};
|
||||
|
||||
const handleFullscreenChange = () => {
|
||||
updateState({ isFullscreen: !!document.fullscreenElement });
|
||||
};
|
||||
|
||||
// Add event listeners
|
||||
video.addEventListener('loadstart', handleLoadStart);
|
||||
video.addEventListener('loadeddata', handleLoadedData);
|
||||
video.addEventListener('timeupdate', handleTimeUpdate);
|
||||
video.addEventListener('durationchange', handleDurationChange);
|
||||
video.addEventListener('play', handlePlay);
|
||||
video.addEventListener('pause', handlePause);
|
||||
video.addEventListener('ended', handleEnded);
|
||||
video.addEventListener('error', handleError);
|
||||
video.addEventListener('volumechange', handleVolumeChange);
|
||||
document.addEventListener('fullscreenchange', handleFullscreenChange);
|
||||
|
||||
// Set initial properties
|
||||
video.autoplay = autoPlay;
|
||||
video.loop = loop;
|
||||
video.muted = muted;
|
||||
video.volume = volume;
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
video.removeEventListener('loadstart', handleLoadStart);
|
||||
video.removeEventListener('loadeddata', handleLoadedData);
|
||||
video.removeEventListener('timeupdate', handleTimeUpdate);
|
||||
video.removeEventListener('durationchange', handleDurationChange);
|
||||
video.removeEventListener('play', handlePlay);
|
||||
video.removeEventListener('pause', handlePause);
|
||||
video.removeEventListener('ended', handleEnded);
|
||||
video.removeEventListener('error', handleError);
|
||||
video.removeEventListener('volumechange', handleVolumeChange);
|
||||
document.removeEventListener('fullscreenchange', handleFullscreenChange);
|
||||
};
|
||||
}, [autoPlay, loop, muted, volume, updateState, onTimeUpdate, onDurationChange, onEnded, onError]);
|
||||
|
||||
return {
|
||||
state,
|
||||
actions: {
|
||||
play,
|
||||
pause,
|
||||
togglePlay,
|
||||
seek,
|
||||
setVolume,
|
||||
toggleMute,
|
||||
toggleFullscreen,
|
||||
skip,
|
||||
setPlaybackRate,
|
||||
reset,
|
||||
},
|
||||
ref: videoRef,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Video Streaming Feature - Main Export
|
||||
*
|
||||
* This is the main entry point for the video streaming feature.
|
||||
* It exports all the public APIs that other parts of the application can use.
|
||||
*/
|
||||
|
||||
// Components
|
||||
export * from './components';
|
||||
|
||||
// Hooks
|
||||
export * from './hooks';
|
||||
|
||||
// Services
|
||||
export { videoApiService, VideoApiService } from './services/videoApi';
|
||||
|
||||
// Types
|
||||
export * from './types';
|
||||
|
||||
// Utils
|
||||
export * from './utils/videoUtils';
|
||||
|
||||
// Main feature component
|
||||
export { VideoStreamingPage } from './VideoStreamingPage';
|
||||
@@ -0,0 +1,283 @@
|
||||
/**
|
||||
* Video Streaming API Service
|
||||
*
|
||||
* This service handles all API interactions for the video streaming feature.
|
||||
* It provides a clean interface for components to interact with the video API
|
||||
* without knowing the implementation details.
|
||||
*/
|
||||
|
||||
import {
|
||||
type VideoListResponse,
|
||||
type VideoInfoResponse,
|
||||
type VideoStreamingInfo,
|
||||
type VideoListParams,
|
||||
type ThumbnailParams,
|
||||
} from '../types';
|
||||
import { performanceMonitor } from '../utils/performanceMonitor';
|
||||
|
||||
// Configuration - Use environment variable or default to vision container
|
||||
// The API is accessible at vision:8000 in the current setup
|
||||
const API_BASE_URL = import.meta.env.VITE_VISION_API_URL || 'http://vision:8000';
|
||||
|
||||
/**
|
||||
* Custom error class for video API errors
|
||||
*/
|
||||
export class VideoApiError extends Error {
|
||||
public code: string;
|
||||
public details?: unknown;
|
||||
|
||||
constructor(
|
||||
code: string,
|
||||
message: string,
|
||||
details?: unknown
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'VideoApiError';
|
||||
this.code = code;
|
||||
this.details = details;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to handle API responses
|
||||
*/
|
||||
async function handleApiResponse<T>(response: Response): Promise<T> {
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new VideoApiError(
|
||||
`HTTP_${response.status}`,
|
||||
`API request failed: ${response.statusText}`,
|
||||
{ status: response.status, body: errorText }
|
||||
);
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('content-type');
|
||||
if (contentType && contentType.includes('application/json')) {
|
||||
return response.json();
|
||||
}
|
||||
|
||||
throw new VideoApiError(
|
||||
'INVALID_RESPONSE',
|
||||
'Expected JSON response from API'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build query string from parameters
|
||||
*/
|
||||
function buildQueryString(params: VideoListParams | ThumbnailParams): string {
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null) {
|
||||
searchParams.append(key, String(value));
|
||||
}
|
||||
});
|
||||
|
||||
return searchParams.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Video API Service Class
|
||||
*/
|
||||
export class VideoApiService {
|
||||
private baseUrl: string;
|
||||
|
||||
constructor(baseUrl: string = API_BASE_URL) {
|
||||
this.baseUrl = baseUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get total count of videos with filters (without pagination)
|
||||
*/
|
||||
private totalCountCache = new Map<string, { count: number; timestamp: number }>();
|
||||
private readonly CACHE_DURATION = 30000; // 30 seconds cache
|
||||
|
||||
private async getTotalCount(params: Omit<VideoListParams, 'limit' | 'offset' | 'page'>): Promise<number> {
|
||||
// Create cache key from params
|
||||
const cacheKey = JSON.stringify(params);
|
||||
const cached = this.totalCountCache.get(cacheKey);
|
||||
|
||||
// Return cached result if still valid
|
||||
if (cached && Date.now() - cached.timestamp < this.CACHE_DURATION) {
|
||||
return cached.count;
|
||||
}
|
||||
|
||||
const queryString = buildQueryString({ ...params, limit: 1000 }); // Use high limit to get accurate total
|
||||
const url = `${this.baseUrl}/videos/${queryString ? `?${queryString}` : ''}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
const result = await handleApiResponse<VideoListResponse>(response);
|
||||
const count = result.videos.length; // Since backend returns wrong total_count, count the actual videos
|
||||
|
||||
// Cache the result
|
||||
this.totalCountCache.set(cacheKey, { count, timestamp: Date.now() });
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of videos with optional filtering
|
||||
*/
|
||||
async getVideos(params: VideoListParams = {}): Promise<VideoListResponse> {
|
||||
return performanceMonitor.trackOperation('get_videos', async () => {
|
||||
// Convert page-based params to offset-based for API compatibility
|
||||
const apiParams = { ...params };
|
||||
|
||||
// If page is provided, convert to offset
|
||||
if (params.page && params.limit) {
|
||||
apiParams.offset = (params.page - 1) * params.limit;
|
||||
delete apiParams.page; // Remove page param as API expects offset
|
||||
}
|
||||
|
||||
const queryString = buildQueryString(apiParams);
|
||||
const url = `${this.baseUrl}/videos/${queryString ? `?${queryString}` : ''}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
const result = await handleApiResponse<VideoListResponse>(response);
|
||||
|
||||
// Add pagination metadata if page was requested
|
||||
if (params.page && params.limit) {
|
||||
// Get accurate total count by calling without pagination
|
||||
const totalCount = await this.getTotalCount({
|
||||
camera_name: params.camera_name,
|
||||
start_date: params.start_date,
|
||||
end_date: params.end_date,
|
||||
include_metadata: params.include_metadata,
|
||||
});
|
||||
|
||||
const totalPages = Math.ceil(totalCount / params.limit);
|
||||
return {
|
||||
...result,
|
||||
total_count: totalCount, // Use accurate total count
|
||||
page: params.page,
|
||||
total_pages: totalPages,
|
||||
has_next: params.page < totalPages,
|
||||
has_previous: params.page > 1,
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
}, { params });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get detailed information about a specific video
|
||||
*/
|
||||
async getVideoInfo(fileId: string): Promise<VideoInfoResponse> {
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}/videos/${fileId}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
return await handleApiResponse<VideoInfoResponse>(response);
|
||||
} catch (error) {
|
||||
if (error instanceof VideoApiError) {
|
||||
throw error;
|
||||
}
|
||||
throw new VideoApiError(
|
||||
'NETWORK_ERROR',
|
||||
`Failed to fetch video info for ${fileId}`,
|
||||
{ originalError: error, fileId }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get streaming information for a video
|
||||
*/
|
||||
async getStreamingInfo(fileId: string): Promise<VideoStreamingInfo> {
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}/videos/${fileId}/info`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
return await handleApiResponse<VideoStreamingInfo>(response);
|
||||
} catch (error) {
|
||||
if (error instanceof VideoApiError) {
|
||||
throw error;
|
||||
}
|
||||
throw new VideoApiError(
|
||||
'NETWORK_ERROR',
|
||||
`Failed to fetch streaming info for ${fileId}`,
|
||||
{ originalError: error, fileId }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the streaming URL for a video
|
||||
*/
|
||||
getStreamingUrl(fileId: string): string {
|
||||
return `${this.baseUrl}/videos/${fileId}/stream`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the thumbnail URL for a video
|
||||
*/
|
||||
getThumbnailUrl(fileId: string, params: ThumbnailParams = {}): string {
|
||||
const queryString = buildQueryString(params);
|
||||
return `${this.baseUrl}/videos/${fileId}/thumbnail${queryString ? `?${queryString}` : ''}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Download thumbnail as blob
|
||||
*/
|
||||
async getThumbnailBlob(fileId: string, params: ThumbnailParams = {}): Promise<Blob> {
|
||||
return performanceMonitor.trackOperation('get_thumbnail', async () => {
|
||||
const url = this.getThumbnailUrl(fileId, params);
|
||||
const response = await fetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new VideoApiError(
|
||||
`HTTP_${response.status}`,
|
||||
`Failed to fetch thumbnail: ${response.statusText}`,
|
||||
{ status: response.status, fileId }
|
||||
);
|
||||
}
|
||||
|
||||
return await response.blob();
|
||||
}, { fileId, params });
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the video API is available
|
||||
*/
|
||||
async healthCheck(): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}/videos/`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
});
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Export a default instance
|
||||
export const videoApiService = new VideoApiService();
|
||||
|
||||
// Export utility functions
|
||||
export { buildQueryString, handleApiResponse };
|
||||
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* Video Streaming Feature Types
|
||||
*
|
||||
* This file contains all TypeScript type definitions for the video streaming feature.
|
||||
* Following the modular architecture pattern where types are centralized and reusable.
|
||||
* Updated to fix import issues.
|
||||
*/
|
||||
|
||||
// Base video information from the API
|
||||
export interface VideoFile {
|
||||
file_id: string;
|
||||
camera_name: string;
|
||||
filename: string;
|
||||
file_size_bytes: number;
|
||||
format: string;
|
||||
status: 'completed' | 'processing' | 'failed';
|
||||
created_at: string;
|
||||
is_streamable: boolean;
|
||||
needs_conversion: boolean;
|
||||
}
|
||||
|
||||
// Extended video information with metadata
|
||||
export interface VideoWithMetadata extends VideoFile {
|
||||
metadata?: {
|
||||
duration_seconds: number;
|
||||
width: number;
|
||||
height: number;
|
||||
fps: number;
|
||||
codec: string;
|
||||
aspect_ratio: number;
|
||||
};
|
||||
}
|
||||
|
||||
// API response for video list
|
||||
export interface VideoListResponse {
|
||||
videos: VideoFile[];
|
||||
total_count: number;
|
||||
page?: number;
|
||||
total_pages?: number;
|
||||
has_next?: boolean;
|
||||
has_previous?: boolean;
|
||||
}
|
||||
|
||||
// API response for video info
|
||||
export interface VideoInfoResponse {
|
||||
file_id: string;
|
||||
metadata: {
|
||||
duration_seconds: number;
|
||||
width: number;
|
||||
height: number;
|
||||
fps: number;
|
||||
codec: string;
|
||||
aspect_ratio: number;
|
||||
};
|
||||
}
|
||||
|
||||
// Streaming technical information
|
||||
export interface VideoStreamingInfo {
|
||||
file_id: string;
|
||||
file_size_bytes: number;
|
||||
content_type: string;
|
||||
supports_range_requests: boolean;
|
||||
chunk_size_bytes: number;
|
||||
}
|
||||
|
||||
// Query parameters for video list API
|
||||
export interface VideoListParams {
|
||||
camera_name?: string;
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
limit?: number;
|
||||
include_metadata?: boolean;
|
||||
page?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
// Thumbnail request parameters
|
||||
export interface ThumbnailParams {
|
||||
timestamp?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
// Video player state is now defined in useVideoPlayer hook to avoid circular imports
|
||||
|
||||
// Video list filter and sort options
|
||||
export interface VideoListFilters {
|
||||
cameraName?: string;
|
||||
dateRange?: {
|
||||
start: string;
|
||||
end: string;
|
||||
};
|
||||
status?: VideoFile['status'];
|
||||
format?: string;
|
||||
}
|
||||
|
||||
export interface VideoListSortOptions {
|
||||
field: 'created_at' | 'file_size_bytes' | 'camera_name' | 'filename';
|
||||
direction: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
// Component props interfaces
|
||||
export interface VideoPlayerProps {
|
||||
fileId: string;
|
||||
autoPlay?: boolean;
|
||||
controls?: boolean;
|
||||
width?: string | number;
|
||||
height?: string | number;
|
||||
className?: string;
|
||||
onPlay?: () => void;
|
||||
onPause?: () => void;
|
||||
onEnded?: () => void;
|
||||
onError?: (error: string) => void;
|
||||
}
|
||||
|
||||
export interface VideoCardProps {
|
||||
video: VideoFile;
|
||||
onClick?: (video: VideoFile) => void;
|
||||
showMetadata?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export interface VideoListProps {
|
||||
filters?: VideoListFilters;
|
||||
sortOptions?: VideoListSortOptions;
|
||||
limit?: number;
|
||||
onVideoSelect?: (video: VideoFile) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// Pagination component props
|
||||
export interface PaginationProps {
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
onPageChange: (page: number) => void;
|
||||
showFirstLast?: boolean;
|
||||
showPrevNext?: boolean;
|
||||
maxVisiblePages?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export interface VideoThumbnailProps {
|
||||
fileId: string;
|
||||
timestamp?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
alt?: string;
|
||||
className?: string;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
// Error types
|
||||
export interface VideoError {
|
||||
code: string;
|
||||
message: string;
|
||||
details?: any;
|
||||
}
|
||||
|
||||
// Loading states
|
||||
export type LoadingState = 'idle' | 'loading' | 'success' | 'error';
|
||||
|
||||
// Hook return types are exported from their respective hook files
|
||||
// This avoids circular import issues
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Video Streaming Utils - Index
|
||||
*
|
||||
* Centralized export for all video streaming utilities.
|
||||
*/
|
||||
|
||||
export * from './videoUtils';
|
||||
export * from './thumbnailCache';
|
||||
export * from './performanceMonitor';
|
||||
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* Performance Monitor for Video Streaming
|
||||
*
|
||||
* Tracks and reports performance metrics for video streaming operations.
|
||||
*/
|
||||
|
||||
interface PerformanceMetric {
|
||||
operation: string;
|
||||
startTime: number;
|
||||
endTime?: number;
|
||||
duration?: number;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
interface PerformanceStats {
|
||||
totalOperations: number;
|
||||
successfulOperations: number;
|
||||
failedOperations: number;
|
||||
averageDuration: number;
|
||||
minDuration: number;
|
||||
maxDuration: number;
|
||||
successRate: number;
|
||||
}
|
||||
|
||||
export class PerformanceMonitor {
|
||||
private metrics: PerformanceMetric[] = [];
|
||||
private maxMetrics: number;
|
||||
|
||||
constructor(maxMetrics: number = 1000) {
|
||||
this.maxMetrics = maxMetrics;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start tracking an operation
|
||||
*/
|
||||
startOperation(operation: string, metadata?: Record<string, any>): string {
|
||||
const id = `${operation}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
const metric: PerformanceMetric = {
|
||||
operation,
|
||||
startTime: performance.now(),
|
||||
success: false,
|
||||
metadata,
|
||||
};
|
||||
|
||||
this.metrics.push(metric);
|
||||
|
||||
// Keep only the most recent metrics
|
||||
if (this.metrics.length > this.maxMetrics) {
|
||||
this.metrics.shift();
|
||||
}
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* End tracking an operation
|
||||
*/
|
||||
endOperation(operation: string, success: boolean, error?: string): void {
|
||||
const metric = this.metrics
|
||||
.slice()
|
||||
.reverse()
|
||||
.find(m => m.operation === operation && !m.endTime);
|
||||
|
||||
if (metric) {
|
||||
metric.endTime = performance.now();
|
||||
metric.duration = metric.endTime - metric.startTime;
|
||||
metric.success = success;
|
||||
metric.error = error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Track a complete operation
|
||||
*/
|
||||
async trackOperation<T>(
|
||||
operation: string,
|
||||
fn: () => Promise<T>,
|
||||
metadata?: Record<string, any>
|
||||
): Promise<T> {
|
||||
this.startOperation(operation, metadata);
|
||||
|
||||
try {
|
||||
const result = await fn();
|
||||
this.endOperation(operation, true);
|
||||
return result;
|
||||
} catch (error) {
|
||||
this.endOperation(operation, false, error instanceof Error ? error.message : 'Unknown error');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get performance statistics for a specific operation
|
||||
*/
|
||||
getStats(operation?: string): PerformanceStats {
|
||||
const filteredMetrics = operation
|
||||
? this.metrics.filter(m => m.operation === operation && m.duration !== undefined)
|
||||
: this.metrics.filter(m => m.duration !== undefined);
|
||||
|
||||
if (filteredMetrics.length === 0) {
|
||||
return {
|
||||
totalOperations: 0,
|
||||
successfulOperations: 0,
|
||||
failedOperations: 0,
|
||||
averageDuration: 0,
|
||||
minDuration: 0,
|
||||
maxDuration: 0,
|
||||
successRate: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const durations = filteredMetrics.map(m => m.duration!);
|
||||
const successfulOps = filteredMetrics.filter(m => m.success).length;
|
||||
const failedOps = filteredMetrics.length - successfulOps;
|
||||
|
||||
return {
|
||||
totalOperations: filteredMetrics.length,
|
||||
successfulOperations: successfulOps,
|
||||
failedOperations: failedOps,
|
||||
averageDuration: durations.reduce((sum, d) => sum + d, 0) / durations.length,
|
||||
minDuration: Math.min(...durations),
|
||||
maxDuration: Math.max(...durations),
|
||||
successRate: successfulOps / filteredMetrics.length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recent metrics
|
||||
*/
|
||||
getRecentMetrics(count: number = 10): PerformanceMetric[] {
|
||||
return this.metrics
|
||||
.filter(m => m.duration !== undefined)
|
||||
.slice(-count)
|
||||
.reverse();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all metrics
|
||||
*/
|
||||
clear(): void {
|
||||
this.metrics = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Export metrics for analysis
|
||||
*/
|
||||
exportMetrics(): PerformanceMetric[] {
|
||||
return [...this.metrics];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a performance report
|
||||
*/
|
||||
getReport(): string {
|
||||
const operations = [...new Set(this.metrics.map(m => m.operation))];
|
||||
let report = 'Video Streaming Performance Report\n';
|
||||
report += '=====================================\n\n';
|
||||
|
||||
for (const operation of operations) {
|
||||
const stats = this.getStats(operation);
|
||||
report += `${operation}:\n`;
|
||||
report += ` Total Operations: ${stats.totalOperations}\n`;
|
||||
report += ` Success Rate: ${(stats.successRate * 100).toFixed(1)}%\n`;
|
||||
report += ` Average Duration: ${stats.averageDuration.toFixed(2)}ms\n`;
|
||||
report += ` Min Duration: ${stats.minDuration.toFixed(2)}ms\n`;
|
||||
report += ` Max Duration: ${stats.maxDuration.toFixed(2)}ms\n\n`;
|
||||
}
|
||||
|
||||
return report;
|
||||
}
|
||||
}
|
||||
|
||||
// Create a singleton instance
|
||||
export const performanceMonitor = new PerformanceMonitor();
|
||||
|
||||
// Helper functions for common operations
|
||||
export const trackVideoLoad = (fileId: string) =>
|
||||
performanceMonitor.startOperation('video_load', { fileId });
|
||||
|
||||
export const trackThumbnailLoad = (fileId: string, width: number, height: number) =>
|
||||
performanceMonitor.startOperation('thumbnail_load', { fileId, width, height });
|
||||
|
||||
export const trackApiCall = (endpoint: string) =>
|
||||
performanceMonitor.startOperation('api_call', { endpoint });
|
||||
|
||||
// Log performance stats periodically in development
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
setInterval(() => {
|
||||
const stats = performanceMonitor.getStats();
|
||||
if (stats.totalOperations > 0) {
|
||||
console.log('Video Streaming Performance:', stats);
|
||||
}
|
||||
}, 60000); // Every minute
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* Thumbnail Cache Utility
|
||||
*
|
||||
* Provides efficient caching for video thumbnails to improve performance
|
||||
* and reduce API calls.
|
||||
*/
|
||||
|
||||
interface CacheEntry {
|
||||
blob: Blob;
|
||||
url: string;
|
||||
timestamp: number;
|
||||
accessCount: number;
|
||||
lastAccessed: number;
|
||||
}
|
||||
|
||||
interface ThumbnailCacheOptions {
|
||||
maxSize: number; // Maximum number of cached thumbnails
|
||||
maxAge: number; // Maximum age in milliseconds
|
||||
maxMemory: number; // Maximum memory usage in bytes
|
||||
}
|
||||
|
||||
export class ThumbnailCache {
|
||||
private cache = new Map<string, CacheEntry>();
|
||||
private options: ThumbnailCacheOptions;
|
||||
|
||||
constructor(options: Partial<ThumbnailCacheOptions> = {}) {
|
||||
this.options = {
|
||||
maxSize: options.maxSize || 100,
|
||||
maxAge: options.maxAge || 30 * 60 * 1000, // 30 minutes
|
||||
maxMemory: options.maxMemory || 50 * 1024 * 1024, // 50MB
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate cache key for a thumbnail
|
||||
*/
|
||||
private generateKey(fileId: string, timestamp: number, width: number, height: number): string {
|
||||
return `${fileId}_${timestamp}_${width}x${height}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get thumbnail from cache
|
||||
*/
|
||||
get(fileId: string, timestamp: number, width: number, height: number): string | null {
|
||||
const key = this.generateKey(fileId, timestamp, width, height);
|
||||
const entry = this.cache.get(key);
|
||||
|
||||
if (!entry) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if entry is expired
|
||||
const now = Date.now();
|
||||
if (now - entry.timestamp > this.options.maxAge) {
|
||||
this.delete(key);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Update access statistics
|
||||
entry.accessCount++;
|
||||
entry.lastAccessed = now;
|
||||
|
||||
return entry.url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Store thumbnail in cache
|
||||
*/
|
||||
set(fileId: string, timestamp: number, width: number, height: number, blob: Blob): string {
|
||||
const key = this.generateKey(fileId, timestamp, width, height);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const now = Date.now();
|
||||
|
||||
// Clean up existing entry if it exists
|
||||
const existingEntry = this.cache.get(key);
|
||||
if (existingEntry) {
|
||||
URL.revokeObjectURL(existingEntry.url);
|
||||
}
|
||||
|
||||
// Create new entry
|
||||
const entry: CacheEntry = {
|
||||
blob,
|
||||
url,
|
||||
timestamp: now,
|
||||
accessCount: 1,
|
||||
lastAccessed: now,
|
||||
};
|
||||
|
||||
this.cache.set(key, entry);
|
||||
|
||||
// Cleanup if necessary
|
||||
this.cleanup();
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete specific entry from cache
|
||||
*/
|
||||
delete(key: string): boolean {
|
||||
const entry = this.cache.get(key);
|
||||
if (entry) {
|
||||
URL.revokeObjectURL(entry.url);
|
||||
return this.cache.delete(key);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all cached thumbnails
|
||||
*/
|
||||
clear(): void {
|
||||
for (const entry of this.cache.values()) {
|
||||
URL.revokeObjectURL(entry.url);
|
||||
}
|
||||
this.cache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cache statistics
|
||||
*/
|
||||
getStats() {
|
||||
const entries = Array.from(this.cache.values());
|
||||
const totalSize = entries.reduce((sum, entry) => sum + entry.blob.size, 0);
|
||||
const totalAccess = entries.reduce((sum, entry) => sum + entry.accessCount, 0);
|
||||
|
||||
return {
|
||||
size: this.cache.size,
|
||||
totalMemory: totalSize,
|
||||
totalAccess,
|
||||
averageSize: entries.length > 0 ? totalSize / entries.length : 0,
|
||||
averageAccess: entries.length > 0 ? totalAccess / entries.length : 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup expired and least used entries
|
||||
*/
|
||||
private cleanup(): void {
|
||||
const now = Date.now();
|
||||
const entries = Array.from(this.cache.entries());
|
||||
|
||||
// Remove expired entries
|
||||
for (const [key, entry] of entries) {
|
||||
if (now - entry.timestamp > this.options.maxAge) {
|
||||
this.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we need to remove more entries
|
||||
if (this.cache.size <= this.options.maxSize) {
|
||||
const stats = this.getStats();
|
||||
if (stats.totalMemory <= this.options.maxMemory) {
|
||||
return; // No cleanup needed
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by access frequency and recency (LRU with access count)
|
||||
const sortedEntries = Array.from(this.cache.entries()).sort(([, a], [, b]) => {
|
||||
// Prioritize by access count, then by last accessed time
|
||||
const scoreA = a.accessCount * 1000 + (a.lastAccessed / 1000);
|
||||
const scoreB = b.accessCount * 1000 + (b.lastAccessed / 1000);
|
||||
return scoreA - scoreB; // Ascending order (least valuable first)
|
||||
});
|
||||
|
||||
// Remove least valuable entries until we're under limits
|
||||
while (
|
||||
(this.cache.size > this.options.maxSize ||
|
||||
this.getStats().totalMemory > this.options.maxMemory) &&
|
||||
sortedEntries.length > 0
|
||||
) {
|
||||
const [key] = sortedEntries.shift()!;
|
||||
this.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Preload thumbnails for a list of videos
|
||||
*/
|
||||
async preload(
|
||||
videos: Array<{ file_id: string }>,
|
||||
getThumbnailBlob: (fileId: string, params: any) => Promise<Blob>,
|
||||
options: { timestamp?: number; width?: number; height?: number } = {}
|
||||
): Promise<void> {
|
||||
const { timestamp = 1.0, width = 320, height = 240 } = options;
|
||||
|
||||
const promises = videos.slice(0, 10).map(async (video) => {
|
||||
const key = this.generateKey(video.file_id, timestamp, width, height);
|
||||
|
||||
// Skip if already cached
|
||||
if (this.cache.has(key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const blob = await getThumbnailBlob(video.file_id, {
|
||||
timestamp,
|
||||
width,
|
||||
height,
|
||||
});
|
||||
this.set(video.file_id, timestamp, width, height, blob);
|
||||
} catch (error) {
|
||||
// Silently fail for preloading
|
||||
console.warn(`Failed to preload thumbnail for ${video.file_id}:`, error);
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.allSettled(promises);
|
||||
}
|
||||
}
|
||||
|
||||
// Create a singleton instance
|
||||
export const thumbnailCache = new ThumbnailCache({
|
||||
maxSize: 100,
|
||||
maxAge: 30 * 60 * 1000, // 30 minutes
|
||||
maxMemory: 50 * 1024 * 1024, // 50MB
|
||||
});
|
||||
|
||||
// Cleanup on page unload
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('beforeunload', () => {
|
||||
thumbnailCache.clear();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
/**
|
||||
* Video Streaming Utilities
|
||||
*
|
||||
* Pure utility functions for video operations, formatting, and data processing.
|
||||
* These functions have no side effects and can be easily tested.
|
||||
* Enhanced with MP4 format support and improved file handling.
|
||||
*/
|
||||
|
||||
import { type VideoFile, type VideoWithMetadata } from '../types';
|
||||
import {
|
||||
isVideoFile as isVideoFileUtil,
|
||||
getVideoMimeType as getVideoMimeTypeUtil,
|
||||
getVideoFormat,
|
||||
isWebCompatibleFormat,
|
||||
getFormatDisplayName as getFormatDisplayNameUtil
|
||||
} from '../../../utils/videoFileUtils';
|
||||
|
||||
/**
|
||||
* Format file size in bytes to human readable format
|
||||
*/
|
||||
export function formatFileSize(bytes: number): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
|
||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format duration in seconds to human readable format (HH:MM:SS or MM:SS)
|
||||
*/
|
||||
export function formatDuration(seconds: number): string {
|
||||
if (isNaN(seconds) || seconds < 0) return '00:00';
|
||||
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
return `${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format date string to human readable format
|
||||
*/
|
||||
export function formatVideoDate(dateString: string): string {
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString();
|
||||
} catch {
|
||||
return dateString;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get relative time string (e.g., "2 hours ago")
|
||||
*/
|
||||
export function getRelativeTime(dateString: string): string {
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMinutes = Math.floor(diffMs / (1000 * 60));
|
||||
const diffHours = Math.floor(diffMinutes / 60);
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
|
||||
if (diffMinutes < 1) return 'Just now';
|
||||
if (diffMinutes < 60) return `${diffMinutes} minute${diffMinutes === 1 ? '' : 's'} ago`;
|
||||
if (diffHours < 24) return `${diffHours} hour${diffHours === 1 ? '' : 's'} ago`;
|
||||
if (diffDays < 7) return `${diffDays} day${diffDays === 1 ? '' : 's'} ago`;
|
||||
|
||||
return formatVideoDate(dateString);
|
||||
} catch {
|
||||
return dateString;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a filename is a video file (supports MP4, AVI, and other formats)
|
||||
*/
|
||||
export function isVideoFile(filename: string): boolean {
|
||||
return isVideoFileUtil(filename);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get MIME type for video file based on filename
|
||||
*/
|
||||
export function getVideoMimeType(filename: string): string {
|
||||
return getVideoMimeTypeUtil(filename);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract camera name from filename if not provided
|
||||
*/
|
||||
export function extractCameraName(filename: string): string {
|
||||
// Try to extract camera name from filename pattern like "camera1_recording_20250804_143022.avi"
|
||||
const match = filename.match(/^([^_]+)_/);
|
||||
return match ? match[1] : 'Unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get video format display name
|
||||
*/
|
||||
export function getFormatDisplayName(format: string): string {
|
||||
return getFormatDisplayNameUtil(format);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if video format is web-compatible
|
||||
*/
|
||||
export function isWebCompatible(format: string): boolean {
|
||||
return isWebCompatibleFormat(format);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get status badge color class
|
||||
*/
|
||||
export function getStatusBadgeClass(status: VideoFile['status']): string {
|
||||
const statusClasses = {
|
||||
'completed': 'bg-green-100 text-green-800',
|
||||
'processing': 'bg-yellow-100 text-yellow-800',
|
||||
'failed': 'bg-red-100 text-red-800',
|
||||
};
|
||||
|
||||
return statusClasses[status] || 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get video resolution display string
|
||||
*/
|
||||
export function getResolutionString(width?: number, height?: number): string {
|
||||
if (!width || !height) return 'Unknown';
|
||||
|
||||
// Common resolution names
|
||||
const resolutions: Record<string, string> = {
|
||||
'1920x1080': '1080p',
|
||||
'1280x720': '720p',
|
||||
'854x480': '480p',
|
||||
'640x360': '360p',
|
||||
'426x240': '240p',
|
||||
};
|
||||
|
||||
const key = `${width}x${height}`;
|
||||
return resolutions[key] || `${width}×${height}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate aspect ratio string
|
||||
*/
|
||||
export function getAspectRatioString(aspectRatio: number): string {
|
||||
if (!aspectRatio || aspectRatio <= 0) return 'Unknown';
|
||||
|
||||
// Common aspect ratios
|
||||
const ratios: Array<[number, string]> = [
|
||||
[16/9, '16:9'],
|
||||
[4/3, '4:3'],
|
||||
[21/9, '21:9'],
|
||||
[1, '1:1'],
|
||||
];
|
||||
|
||||
// Find closest match (within 0.1 tolerance)
|
||||
for (const [ratio, display] of ratios) {
|
||||
if (Math.abs(aspectRatio - ratio) < 0.1) {
|
||||
return display;
|
||||
}
|
||||
}
|
||||
|
||||
// Return calculated ratio
|
||||
const gcd = (a: number, b: number): number => b === 0 ? a : gcd(b, a % b);
|
||||
const width = Math.round(aspectRatio * 100);
|
||||
const height = 100;
|
||||
const divisor = gcd(width, height);
|
||||
|
||||
return `${width / divisor}:${height / divisor}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort videos by different criteria
|
||||
*/
|
||||
export function sortVideos(
|
||||
videos: VideoFile[],
|
||||
field: 'created_at' | 'file_size_bytes' | 'camera_name' | 'filename',
|
||||
direction: 'asc' | 'desc' = 'desc'
|
||||
): VideoFile[] {
|
||||
return [...videos].sort((a, b) => {
|
||||
let aValue: any = a[field];
|
||||
let bValue: any = b[field];
|
||||
|
||||
// Handle date strings
|
||||
if (field === 'created_at') {
|
||||
aValue = new Date(aValue).getTime();
|
||||
bValue = new Date(bValue).getTime();
|
||||
}
|
||||
|
||||
// Handle string comparison
|
||||
if (typeof aValue === 'string' && typeof bValue === 'string') {
|
||||
aValue = aValue.toLowerCase();
|
||||
bValue = bValue.toLowerCase();
|
||||
}
|
||||
|
||||
let result = 0;
|
||||
if (aValue < bValue) result = -1;
|
||||
else if (aValue > bValue) result = 1;
|
||||
|
||||
return direction === 'desc' ? -result : result;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter videos by criteria
|
||||
*/
|
||||
export function filterVideos(
|
||||
videos: VideoFile[],
|
||||
filters: {
|
||||
cameraName?: string;
|
||||
status?: VideoFile['status'];
|
||||
format?: string;
|
||||
dateRange?: { start: string; end: string };
|
||||
}
|
||||
): VideoFile[] {
|
||||
return videos.filter(video => {
|
||||
// Filter by camera name
|
||||
if (filters.cameraName && video.camera_name !== filters.cameraName) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Filter by status
|
||||
if (filters.status && video.status !== filters.status) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Filter by format
|
||||
if (filters.format && video.format !== filters.format) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Filter by date range
|
||||
if (filters.dateRange) {
|
||||
const videoDate = new Date(video.created_at);
|
||||
const startDate = new Date(filters.dateRange.start);
|
||||
const endDate = new Date(filters.dateRange.end);
|
||||
|
||||
if (videoDate < startDate || videoDate > endDate) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique key for video caching
|
||||
*/
|
||||
export function generateVideoKey(fileId: string, params?: Record<string, any>): string {
|
||||
if (!params || Object.keys(params).length === 0) {
|
||||
return fileId;
|
||||
}
|
||||
|
||||
const sortedParams = Object.keys(params)
|
||||
.sort()
|
||||
.map(key => `${key}=${params[key]}`)
|
||||
.join('&');
|
||||
|
||||
return `${fileId}?${sortedParams}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate video file ID format
|
||||
*/
|
||||
export function isValidFileId(fileId: string): boolean {
|
||||
// Basic validation - adjust based on your file ID format
|
||||
return typeof fileId === 'string' && fileId.length > 0 && !fileId.includes('/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get video thumbnail timestamp suggestions
|
||||
*/
|
||||
export function getThumbnailTimestamps(duration: number): number[] {
|
||||
if (duration <= 0) return [0];
|
||||
|
||||
// Generate timestamps at 10%, 25%, 50%, 75%, 90% of video duration
|
||||
return [
|
||||
Math.floor(duration * 0.1),
|
||||
Math.floor(duration * 0.25),
|
||||
Math.floor(duration * 0.5),
|
||||
Math.floor(duration * 0.75),
|
||||
Math.floor(duration * 0.9),
|
||||
].filter(t => t >= 0 && t < duration);
|
||||
}
|
||||
48
management-dashboard-web-app/src/hooks/useAuth.ts
Normal file
48
management-dashboard-web-app/src/hooks/useAuth.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { userManagement, type User } from '../lib/supabase'
|
||||
|
||||
export function useAuth() {
|
||||
const [user, setUser] = useState<User | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
loadUser()
|
||||
}, [])
|
||||
|
||||
const loadUser = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
const currentUser = await userManagement.getCurrentUser()
|
||||
setUser(currentUser)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load user')
|
||||
setUser(null)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const isAdmin = () => {
|
||||
return user?.roles.includes('admin') ?? false
|
||||
}
|
||||
|
||||
const hasRole = (role: string) => {
|
||||
return user?.roles.includes(role as any) ?? false
|
||||
}
|
||||
|
||||
const hasAnyRole = (roles: string[]) => {
|
||||
return roles.some(role => user?.roles.includes(role as any)) ?? false
|
||||
}
|
||||
|
||||
return {
|
||||
user,
|
||||
loading,
|
||||
error,
|
||||
isAdmin,
|
||||
hasRole,
|
||||
hasAnyRole,
|
||||
refreshUser: loadUser
|
||||
}
|
||||
}
|
||||
81
management-dashboard-web-app/src/hooks/useAutoRecording.ts
Normal file
81
management-dashboard-web-app/src/hooks/useAutoRecording.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* React hook for managing auto-recording functionality
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { autoRecordingManager, type AutoRecordingState } from '../lib/autoRecordingManager'
|
||||
|
||||
export interface UseAutoRecordingResult {
|
||||
isRunning: boolean
|
||||
states: AutoRecordingState[]
|
||||
error: string | null
|
||||
start: () => Promise<void>
|
||||
stop: () => void
|
||||
refresh: () => Promise<void>
|
||||
}
|
||||
|
||||
export function useAutoRecording(): UseAutoRecordingResult {
|
||||
const [isRunning, setIsRunning] = useState(false)
|
||||
const [states, setStates] = useState<AutoRecordingState[]>([])
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Update states periodically
|
||||
useEffect(() => {
|
||||
if (!isRunning) {
|
||||
return
|
||||
}
|
||||
|
||||
const interval = setInterval(() => {
|
||||
setStates(autoRecordingManager.getStates())
|
||||
}, 1000)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [isRunning])
|
||||
|
||||
const start = useCallback(async () => {
|
||||
try {
|
||||
setError(null)
|
||||
await autoRecordingManager.start()
|
||||
setIsRunning(true)
|
||||
setStates(autoRecordingManager.getStates())
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to start auto-recording'
|
||||
setError(errorMessage)
|
||||
console.error('Failed to start auto-recording:', err)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const stop = useCallback(() => {
|
||||
try {
|
||||
autoRecordingManager.stop()
|
||||
setIsRunning(false)
|
||||
setStates([])
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to stop auto-recording'
|
||||
setError(errorMessage)
|
||||
console.error('Failed to stop auto-recording:', err)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
setError(null)
|
||||
await autoRecordingManager.refreshConfigurations()
|
||||
setStates(autoRecordingManager.getStates())
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to refresh configurations'
|
||||
setError(errorMessage)
|
||||
console.error('Failed to refresh auto-recording configurations:', err)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return {
|
||||
isRunning,
|
||||
states,
|
||||
error,
|
||||
start,
|
||||
stop,
|
||||
refresh
|
||||
}
|
||||
}
|
||||
290
management-dashboard-web-app/src/index.css
Normal file
290
management-dashboard-web-app/src/index.css
Normal file
@@ -0,0 +1,290 @@
|
||||
@import url("https://fonts.googleapis.com/css2?family=Outfit:wght@100..900&display=swap") layer(base);
|
||||
|
||||
@import "tailwindcss";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme {
|
||||
--font-*: initial;
|
||||
--font-outfit: Outfit, sans-serif;
|
||||
|
||||
--breakpoint-*: initial;
|
||||
--breakpoint-2xsm: 375px;
|
||||
--breakpoint-xsm: 425px;
|
||||
--breakpoint-3xl: 2000px;
|
||||
--breakpoint-sm: 640px;
|
||||
--breakpoint-md: 768px;
|
||||
--breakpoint-lg: 1024px;
|
||||
--breakpoint-xl: 1280px;
|
||||
--breakpoint-2xl: 1536px;
|
||||
|
||||
--text-title-2xl: 72px;
|
||||
--text-title-2xl--line-height: 90px;
|
||||
--text-title-xl: 60px;
|
||||
--text-title-xl--line-height: 72px;
|
||||
--text-title-lg: 48px;
|
||||
--text-title-lg--line-height: 60px;
|
||||
--text-title-md: 36px;
|
||||
--text-title-md--line-height: 44px;
|
||||
--text-title-sm: 30px;
|
||||
--text-title-sm--line-height: 38px;
|
||||
--text-theme-xl: 20px;
|
||||
--text-theme-xl--line-height: 30px;
|
||||
--text-theme-sm: 14px;
|
||||
--text-theme-sm--line-height: 20px;
|
||||
--text-theme-xs: 12px;
|
||||
--text-theme-xs--line-height: 18px;
|
||||
|
||||
--color-current: currentColor;
|
||||
--color-transparent: transparent;
|
||||
--color-white: #ffffff;
|
||||
--color-black: #101828;
|
||||
|
||||
--color-brand-25: #f2f7ff;
|
||||
--color-brand-50: #ecf3ff;
|
||||
--color-brand-100: #dde9ff;
|
||||
--color-brand-200: #c2d6ff;
|
||||
--color-brand-300: #9cb9ff;
|
||||
--color-brand-400: #7592ff;
|
||||
--color-brand-500: #465fff;
|
||||
--color-brand-600: #3641f5;
|
||||
--color-brand-700: #2a31d8;
|
||||
--color-brand-800: #252dae;
|
||||
--color-brand-900: #262e89;
|
||||
--color-brand-950: #161950;
|
||||
|
||||
--color-blue-light-25: #f5fbff;
|
||||
--color-blue-light-50: #f0f9ff;
|
||||
--color-blue-light-100: #e0f2fe;
|
||||
--color-blue-light-200: #b9e6fe;
|
||||
--color-blue-light-300: #7cd4fd;
|
||||
--color-blue-light-400: #36bffa;
|
||||
--color-blue-light-500: #0ba5ec;
|
||||
--color-blue-light-600: #0086c9;
|
||||
--color-blue-light-700: #026aa2;
|
||||
--color-blue-light-800: #065986;
|
||||
--color-blue-light-900: #0b4a6f;
|
||||
--color-blue-light-950: #062c41;
|
||||
|
||||
--color-gray-25: #fcfcfd;
|
||||
--color-gray-50: #f9fafb;
|
||||
--color-gray-100: #f2f4f7;
|
||||
--color-gray-200: #e4e7ec;
|
||||
--color-gray-300: #d0d5dd;
|
||||
--color-gray-400: #98a2b3;
|
||||
--color-gray-500: #667085;
|
||||
--color-gray-600: #475467;
|
||||
--color-gray-700: #344054;
|
||||
--color-gray-800: #1d2939;
|
||||
--color-gray-900: #101828;
|
||||
--color-gray-950: #0c111d;
|
||||
--color-gray-dark: #1a2231;
|
||||
|
||||
--color-orange-25: #fffaf5;
|
||||
--color-orange-50: #fff6ed;
|
||||
--color-orange-100: #ffead5;
|
||||
--color-orange-200: #fddcab;
|
||||
--color-orange-300: #feb273;
|
||||
--color-orange-400: #fd853a;
|
||||
--color-orange-500: #fb6514;
|
||||
--color-orange-600: #ec4a0a;
|
||||
--color-orange-700: #c4320a;
|
||||
--color-orange-800: #9c2a10;
|
||||
--color-orange-900: #7e2410;
|
||||
--color-orange-950: #511c10;
|
||||
|
||||
--color-success-25: #f6fef9;
|
||||
--color-success-50: #ecfdf3;
|
||||
--color-success-100: #d1fadf;
|
||||
--color-success-200: #a6f4c5;
|
||||
--color-success-300: #6ce9a6;
|
||||
--color-success-400: #32d583;
|
||||
--color-success-500: #12b76a;
|
||||
--color-success-600: #039855;
|
||||
--color-success-700: #027a48;
|
||||
--color-success-800: #05603a;
|
||||
--color-success-900: #054f31;
|
||||
--color-success-950: #053321;
|
||||
|
||||
--color-error-25: #fffbfa;
|
||||
--color-error-50: #fef3f2;
|
||||
--color-error-100: #fee4e2;
|
||||
--color-error-200: #fecdca;
|
||||
--color-error-300: #fda29b;
|
||||
--color-error-400: #f97066;
|
||||
--color-error-500: #f04438;
|
||||
--color-error-600: #d92d20;
|
||||
--color-error-700: #b42318;
|
||||
--color-error-800: #912018;
|
||||
--color-error-900: #7a271a;
|
||||
--color-error-950: #55160c;
|
||||
|
||||
--color-warning-25: #fffcf5;
|
||||
--color-warning-50: #fffaeb;
|
||||
--color-warning-100: #fef0c7;
|
||||
--color-warning-200: #fedf89;
|
||||
--color-warning-300: #fec84b;
|
||||
--color-warning-400: #fdb022;
|
||||
--color-warning-500: #f79009;
|
||||
--color-warning-600: #dc6803;
|
||||
--color-warning-700: #b54708;
|
||||
--color-warning-800: #93370d;
|
||||
--color-warning-900: #7a2e0e;
|
||||
--color-warning-950: #4e1d09;
|
||||
|
||||
--color-theme-pink-500: #ee46bc;
|
||||
|
||||
--color-theme-purple-500: #7a5af8;
|
||||
|
||||
--shadow-theme-md: 0px 4px 8px -2px rgba(16, 24, 40, 0.1),
|
||||
0px 2px 4px -2px rgba(16, 24, 40, 0.06);
|
||||
--shadow-theme-lg: 0px 12px 16px -4px rgba(16, 24, 40, 0.08),
|
||||
0px 4px 6px -2px rgba(16, 24, 40, 0.03);
|
||||
--shadow-theme-sm: 0px 1px 3px 0px rgba(16, 24, 40, 0.1),
|
||||
0px 1px 2px 0px rgba(16, 24, 40, 0.06);
|
||||
--shadow-theme-xs: 0px 1px 2px 0px rgba(16, 24, 40, 0.05);
|
||||
--shadow-theme-xl: 0px 20px 24px -4px rgba(16, 24, 40, 0.08),
|
||||
0px 8px 8px -4px rgba(16, 24, 40, 0.03);
|
||||
--shadow-datepicker: -5px 0 0 #262d3c, 5px 0 0 #262d3c;
|
||||
--shadow-focus-ring: 0px 0px 0px 4px rgba(70, 95, 255, 0.12);
|
||||
--shadow-slider-navigation: 0px 1px 2px 0px rgba(16, 24, 40, 0.1),
|
||||
0px 1px 3px 0px rgba(16, 24, 40, 0.1);
|
||||
--shadow-tooltip: 0px 4px 6px -2px rgba(16, 24, 40, 0.05),
|
||||
-8px 0px 20px 8px rgba(16, 24, 40, 0.05);
|
||||
|
||||
--drop-shadow-4xl: 0 35px 35px rgba(0, 0, 0, 0.25),
|
||||
0 45px 65px rgba(0, 0, 0, 0.15);
|
||||
|
||||
--z-index-1: 1;
|
||||
--z-index-9: 9;
|
||||
--z-index-99: 99;
|
||||
--z-index-999: 999;
|
||||
--z-index-9999: 9999;
|
||||
--z-index-99999: 99999;
|
||||
--z-index-999999: 999999;
|
||||
}
|
||||
|
||||
/*
|
||||
The default border color has changed to `currentColor` in Tailwind CSS v4,
|
||||
so we've added these compatibility styles to make sure everything still
|
||||
looks the same as it did with Tailwind CSS v3.
|
||||
|
||||
If we ever want to remove these styles, we need to add an explicit border
|
||||
color utility to any element that depends on these defaults.
|
||||
*/
|
||||
@layer base {
|
||||
|
||||
*,
|
||||
::after,
|
||||
::before,
|
||||
::backdrop,
|
||||
::file-selector-button {
|
||||
border-color: var(--color-gray-200, currentColor);
|
||||
}
|
||||
|
||||
button:not(:disabled),
|
||||
[role="button"]:not(:disabled) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply relative font-normal font-outfit z-1 bg-gray-50;
|
||||
}
|
||||
}
|
||||
|
||||
@utility menu-item {
|
||||
@apply relative flex items-center w-full gap-3 px-3 py-2 font-medium rounded-lg text-theme-sm;
|
||||
}
|
||||
|
||||
@utility menu-item-active {
|
||||
@apply bg-brand-50 text-brand-500 dark:bg-brand-500/[0.12] dark:text-brand-400;
|
||||
}
|
||||
|
||||
@utility menu-item-inactive {
|
||||
@apply text-gray-700 hover:bg-gray-100 group-hover:text-gray-700 dark:text-gray-300 dark:hover:bg-white/5 dark:hover:text-gray-300;
|
||||
}
|
||||
|
||||
@utility menu-item-icon {
|
||||
@apply text-gray-500 group-hover:text-gray-700 dark:text-gray-400;
|
||||
}
|
||||
|
||||
@utility menu-item-icon-active {
|
||||
@apply text-brand-500 dark:text-brand-400;
|
||||
}
|
||||
|
||||
@utility menu-item-icon-size {
|
||||
& svg {
|
||||
@apply !size-6;
|
||||
}
|
||||
}
|
||||
|
||||
@utility menu-item-icon-inactive {
|
||||
@apply text-gray-500 group-hover:text-gray-700 dark:text-gray-400 dark:group-hover:text-gray-300;
|
||||
}
|
||||
|
||||
@utility menu-item-arrow {
|
||||
@apply relative;
|
||||
}
|
||||
|
||||
@utility menu-item-arrow-active {
|
||||
@apply rotate-180 text-brand-500 dark:text-brand-400;
|
||||
}
|
||||
|
||||
@utility menu-item-arrow-inactive {
|
||||
@apply text-gray-500 group-hover:text-gray-700 dark:text-gray-400 dark:group-hover:text-gray-300;
|
||||
}
|
||||
|
||||
@utility menu-dropdown-item {
|
||||
@apply relative flex items-center gap-3 rounded-lg px-3 py-2.5 text-theme-sm font-medium;
|
||||
}
|
||||
|
||||
@utility menu-dropdown-item-active {
|
||||
@apply bg-brand-50 text-brand-500 dark:bg-brand-500/[0.12] dark:text-brand-400;
|
||||
}
|
||||
|
||||
@utility menu-dropdown-item-inactive {
|
||||
@apply text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-white/5;
|
||||
}
|
||||
|
||||
@utility menu-dropdown-badge {
|
||||
@apply block rounded-full px-2.5 py-0.5 text-xs font-medium uppercase text-brand-500 dark:text-brand-400;
|
||||
}
|
||||
|
||||
@utility menu-dropdown-badge-active {
|
||||
@apply bg-brand-100 dark:bg-brand-500/20;
|
||||
}
|
||||
|
||||
@utility menu-dropdown-badge-inactive {
|
||||
@apply bg-brand-50 group-hover:bg-brand-100 dark:bg-brand-500/15 dark:group-hover:bg-brand-500/20;
|
||||
}
|
||||
|
||||
@utility no-scrollbar {
|
||||
|
||||
/* Chrome, Safari and Opera */
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
-ms-overflow-style: none;
|
||||
/* IE and Edge */
|
||||
scrollbar-width: none;
|
||||
/* Firefox */
|
||||
}
|
||||
|
||||
@utility custom-scrollbar {
|
||||
&::-webkit-scrollbar {
|
||||
@apply size-1.5;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
@apply rounded-full;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
@apply bg-gray-200 rounded-full dark:bg-gray-700;
|
||||
}
|
||||
}
|
||||
|
||||
.dark .custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background-color: #344054;
|
||||
}
|
||||
286
management-dashboard-web-app/src/lib/autoRecordingManager.ts
Normal file
286
management-dashboard-web-app/src/lib/autoRecordingManager.ts
Normal file
@@ -0,0 +1,286 @@
|
||||
/**
|
||||
* Auto-Recording Manager
|
||||
*
|
||||
* This module handles automatic recording start/stop based on MQTT machine state changes.
|
||||
* It monitors MQTT events and triggers camera recording when machines turn on/off.
|
||||
*/
|
||||
|
||||
import { visionApi, type MqttEvent, type CameraConfig } from './visionApi'
|
||||
|
||||
export interface AutoRecordingState {
|
||||
cameraName: string
|
||||
machineState: 'on' | 'off'
|
||||
isRecording: boolean
|
||||
autoRecordEnabled: boolean
|
||||
lastStateChange: Date
|
||||
}
|
||||
|
||||
export class AutoRecordingManager {
|
||||
private cameras: Map<string, AutoRecordingState> = new Map()
|
||||
private mqttPollingInterval: NodeJS.Timeout | null = null
|
||||
private lastProcessedEventNumber = 0
|
||||
private isRunning = false
|
||||
|
||||
constructor(private pollingIntervalMs: number = 2000) {}
|
||||
|
||||
/**
|
||||
* Start the auto-recording manager
|
||||
*/
|
||||
async start(): Promise<void> {
|
||||
if (this.isRunning) {
|
||||
console.warn('Auto-recording manager is already running')
|
||||
return
|
||||
}
|
||||
|
||||
console.log('Starting auto-recording manager...')
|
||||
this.isRunning = true
|
||||
|
||||
// Initialize camera configurations
|
||||
await this.initializeCameras()
|
||||
|
||||
// Start polling for MQTT events
|
||||
this.startMqttPolling()
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the auto-recording manager
|
||||
*/
|
||||
stop(): void {
|
||||
if (!this.isRunning) {
|
||||
return
|
||||
}
|
||||
|
||||
console.log('Stopping auto-recording manager...')
|
||||
this.isRunning = false
|
||||
|
||||
if (this.mqttPollingInterval) {
|
||||
clearInterval(this.mqttPollingInterval)
|
||||
this.mqttPollingInterval = null
|
||||
}
|
||||
|
||||
this.cameras.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize camera configurations and states
|
||||
*/
|
||||
private async initializeCameras(): Promise<void> {
|
||||
try {
|
||||
const cameras = await visionApi.getCameras()
|
||||
|
||||
for (const [cameraName, cameraStatus] of Object.entries(cameras)) {
|
||||
try {
|
||||
const config = await visionApi.getCameraConfig(cameraName)
|
||||
|
||||
this.cameras.set(cameraName, {
|
||||
cameraName,
|
||||
machineState: 'off', // Default to off
|
||||
isRecording: cameraStatus.is_recording,
|
||||
autoRecordEnabled: config.auto_record_on_machine_start,
|
||||
lastStateChange: new Date()
|
||||
})
|
||||
|
||||
console.log(`Initialized camera ${cameraName}: auto-record=${config.auto_record_on_machine_start}, machine=${config.machine_topic}`)
|
||||
} catch (error) {
|
||||
console.error(`Failed to initialize camera ${cameraName}:`, error)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize cameras:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start polling for MQTT events
|
||||
*/
|
||||
private startMqttPolling(): void {
|
||||
this.mqttPollingInterval = setInterval(async () => {
|
||||
if (!this.isRunning) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await this.processMqttEvents()
|
||||
} catch (error) {
|
||||
console.error('Error processing MQTT events:', error)
|
||||
}
|
||||
}, this.pollingIntervalMs)
|
||||
}
|
||||
|
||||
/**
|
||||
* Process new MQTT events and trigger recording actions
|
||||
*/
|
||||
private async processMqttEvents(): Promise<void> {
|
||||
try {
|
||||
const mqttResponse = await visionApi.getMqttEvents(50) // Get recent events
|
||||
|
||||
// Filter for new events we haven't processed yet
|
||||
const newEvents = mqttResponse.events.filter(
|
||||
event => event.message_number > this.lastProcessedEventNumber
|
||||
)
|
||||
|
||||
if (newEvents.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
// Update last processed event number
|
||||
this.lastProcessedEventNumber = Math.max(
|
||||
...newEvents.map(event => event.message_number)
|
||||
)
|
||||
|
||||
// Process each new event
|
||||
for (const event of newEvents) {
|
||||
await this.handleMqttEvent(event)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch MQTT events:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a single MQTT event and trigger recording if needed
|
||||
*/
|
||||
private async handleMqttEvent(event: MqttEvent): Promise<void> {
|
||||
const { machine_name, normalized_state } = event
|
||||
|
||||
// Find cameras that are configured for this machine
|
||||
const affectedCameras = await this.getCamerasForMachine(machine_name)
|
||||
|
||||
for (const cameraName of affectedCameras) {
|
||||
const cameraState = this.cameras.get(cameraName)
|
||||
|
||||
if (!cameraState || !cameraState.autoRecordEnabled) {
|
||||
continue
|
||||
}
|
||||
|
||||
const newMachineState = normalized_state as 'on' | 'off'
|
||||
|
||||
// Skip if state hasn't changed
|
||||
if (cameraState.machineState === newMachineState) {
|
||||
continue
|
||||
}
|
||||
|
||||
console.log(`Machine ${machine_name} changed from ${cameraState.machineState} to ${newMachineState} - Camera: ${cameraName}`)
|
||||
|
||||
// Update camera state
|
||||
cameraState.machineState = newMachineState
|
||||
cameraState.lastStateChange = new Date()
|
||||
|
||||
// Trigger recording action
|
||||
if (newMachineState === 'on' && !cameraState.isRecording) {
|
||||
await this.startAutoRecording(cameraName, machine_name)
|
||||
} else if (newMachineState === 'off' && cameraState.isRecording) {
|
||||
await this.stopAutoRecording(cameraName, machine_name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cameras that are configured for a specific machine
|
||||
*/
|
||||
private async getCamerasForMachine(machineName: string): Promise<string[]> {
|
||||
const cameras: string[] = []
|
||||
|
||||
// Define the correct machine-to-camera mapping
|
||||
const machineToCamera: Record<string, string> = {
|
||||
'blower_separator': 'camera1', // camera1 is for blower separator
|
||||
'vibratory_conveyor': 'camera2' // camera2 is for conveyor
|
||||
}
|
||||
|
||||
const expectedCamera = machineToCamera[machineName]
|
||||
if (!expectedCamera) {
|
||||
console.warn(`No camera mapping found for machine: ${machineName}`)
|
||||
return cameras
|
||||
}
|
||||
|
||||
try {
|
||||
const allCameras = await visionApi.getCameras()
|
||||
|
||||
// Check if the expected camera exists and has auto-recording enabled
|
||||
if (allCameras[expectedCamera]) {
|
||||
try {
|
||||
const config = await visionApi.getCameraConfig(expectedCamera)
|
||||
|
||||
if (config.auto_record_on_machine_start) {
|
||||
cameras.push(expectedCamera)
|
||||
console.log(`Found camera ${expectedCamera} configured for machine ${machineName}`)
|
||||
} else {
|
||||
console.log(`Camera ${expectedCamera} exists but auto-recording is disabled`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to get config for camera ${expectedCamera}:`, error)
|
||||
}
|
||||
} else {
|
||||
console.warn(`Expected camera ${expectedCamera} not found for machine ${machineName}`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to get cameras for machine:', error)
|
||||
}
|
||||
|
||||
return cameras
|
||||
}
|
||||
|
||||
/**
|
||||
* Start auto-recording for a camera
|
||||
*/
|
||||
private async startAutoRecording(cameraName: string, machineName: string): Promise<void> {
|
||||
try {
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
|
||||
const filename = `auto_${machineName}_${timestamp}.mp4`
|
||||
|
||||
const result = await visionApi.startRecording(cameraName, { filename })
|
||||
|
||||
if (result.success) {
|
||||
const cameraState = this.cameras.get(cameraName)
|
||||
if (cameraState) {
|
||||
cameraState.isRecording = true
|
||||
}
|
||||
|
||||
console.log(`✅ Auto-recording started for ${cameraName}: ${result.filename}`)
|
||||
} else {
|
||||
console.error(`❌ Failed to start auto-recording for ${cameraName}:`, result.message)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`❌ Error starting auto-recording for ${cameraName}:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop auto-recording for a camera
|
||||
*/
|
||||
private async stopAutoRecording(cameraName: string, machineName: string): Promise<void> {
|
||||
try {
|
||||
const result = await visionApi.stopRecording(cameraName)
|
||||
|
||||
if (result.success) {
|
||||
const cameraState = this.cameras.get(cameraName)
|
||||
if (cameraState) {
|
||||
cameraState.isRecording = false
|
||||
}
|
||||
|
||||
console.log(`⏹️ Auto-recording stopped for ${cameraName} (${result.duration_seconds}s)`)
|
||||
} else {
|
||||
console.error(`❌ Failed to stop auto-recording for ${cameraName}:`, result.message)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`❌ Error stopping auto-recording for ${cameraName}:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current auto-recording states for all cameras
|
||||
*/
|
||||
getStates(): AutoRecordingState[] {
|
||||
return Array.from(this.cameras.values())
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh camera configurations (call when configs are updated)
|
||||
*/
|
||||
async refreshConfigurations(): Promise<void> {
|
||||
await this.initializeCameras()
|
||||
}
|
||||
}
|
||||
|
||||
// Global instance
|
||||
export const autoRecordingManager = new AutoRecordingManager()
|
||||
843
management-dashboard-web-app/src/lib/supabase.ts
Normal file
843
management-dashboard-web-app/src/lib/supabase.ts
Normal file
@@ -0,0 +1,843 @@
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
|
||||
// Local development configuration
|
||||
const supabaseUrl = 'http://127.0.0.1:54321'
|
||||
const supabaseAnonKey = '[REDACTED]'
|
||||
|
||||
export const supabase = createClient(supabaseUrl, supabaseAnonKey)
|
||||
|
||||
// Database types for TypeScript
|
||||
export type RoleName = 'admin' | 'conductor' | 'analyst' | 'data recorder'
|
||||
export type UserStatus = 'active' | 'disabled'
|
||||
export type ScheduleStatus = 'pending schedule' | 'scheduled' | 'canceled' | 'aborted'
|
||||
export type ResultsStatus = 'valid' | 'invalid'
|
||||
|
||||
export interface User {
|
||||
id: string
|
||||
email: string
|
||||
roles: RoleName[]
|
||||
status: UserStatus
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface Role {
|
||||
id: string
|
||||
name: RoleName
|
||||
description: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface Experiment {
|
||||
id: string
|
||||
experiment_number: number
|
||||
reps_required: number
|
||||
soaking_duration_hr: number
|
||||
air_drying_time_min: number
|
||||
plate_contact_frequency_hz: number
|
||||
throughput_rate_pecans_sec: number
|
||||
crush_amount_in: number
|
||||
entry_exit_height_diff_in: number
|
||||
results_status: ResultsStatus
|
||||
completion_status: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
created_by: string
|
||||
}
|
||||
|
||||
|
||||
|
||||
export interface CreateExperimentRequest {
|
||||
experiment_number: number
|
||||
reps_required: number
|
||||
soaking_duration_hr: number
|
||||
air_drying_time_min: number
|
||||
plate_contact_frequency_hz: number
|
||||
throughput_rate_pecans_sec: number
|
||||
crush_amount_in: number
|
||||
entry_exit_height_diff_in: number
|
||||
results_status?: ResultsStatus
|
||||
completion_status?: boolean
|
||||
}
|
||||
|
||||
export interface UpdateExperimentRequest {
|
||||
experiment_number?: number
|
||||
reps_required?: number
|
||||
soaking_duration_hr?: number
|
||||
air_drying_time_min?: number
|
||||
plate_contact_frequency_hz?: number
|
||||
throughput_rate_pecans_sec?: number
|
||||
crush_amount_in?: number
|
||||
entry_exit_height_diff_in?: number
|
||||
results_status?: ResultsStatus
|
||||
completion_status?: boolean
|
||||
}
|
||||
|
||||
export interface CreateRepetitionRequest {
|
||||
experiment_id: string
|
||||
repetition_number: number
|
||||
scheduled_date?: string | null
|
||||
schedule_status?: ScheduleStatus
|
||||
}
|
||||
|
||||
export interface UpdateRepetitionRequest {
|
||||
scheduled_date?: string | null
|
||||
schedule_status?: ScheduleStatus
|
||||
completion_status?: boolean
|
||||
}
|
||||
|
||||
// Data Entry System Interfaces
|
||||
export type PhaseDraftStatus = 'draft' | 'submitted' | 'withdrawn'
|
||||
export type ExperimentPhase = 'pre-soaking' | 'air-drying' | 'cracking' | 'shelling'
|
||||
|
||||
export interface ExperimentPhaseDraft {
|
||||
id: string
|
||||
experiment_id: string
|
||||
repetition_id: string
|
||||
user_id: string
|
||||
phase_name: ExperimentPhase
|
||||
status: PhaseDraftStatus
|
||||
draft_name?: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
submitted_at?: string | null
|
||||
withdrawn_at?: string | null
|
||||
}
|
||||
|
||||
export interface ExperimentRepetition {
|
||||
id: string
|
||||
experiment_id: string
|
||||
repetition_number: number
|
||||
scheduled_date?: string | null
|
||||
schedule_status: ScheduleStatus
|
||||
completion_status: boolean
|
||||
is_locked: boolean
|
||||
locked_at?: string | null
|
||||
locked_by?: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
created_by: string
|
||||
}
|
||||
|
||||
export interface PecanDiameterMeasurement {
|
||||
id: string
|
||||
phase_data_id: string
|
||||
measurement_number: number
|
||||
diameter_in: number
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface ExperimentPhaseData {
|
||||
id: string
|
||||
phase_draft_id: string
|
||||
phase_name: ExperimentPhase
|
||||
|
||||
// Pre-soaking phase
|
||||
batch_initial_weight_lbs?: number | null
|
||||
initial_shell_moisture_pct?: number | null
|
||||
initial_kernel_moisture_pct?: number | null
|
||||
soaking_start_time?: string | null
|
||||
|
||||
// Air-drying phase
|
||||
airdrying_start_time?: string | null
|
||||
post_soak_weight_lbs?: number | null
|
||||
post_soak_kernel_moisture_pct?: number | null
|
||||
post_soak_shell_moisture_pct?: number | null
|
||||
avg_pecan_diameter_in?: number | null
|
||||
|
||||
// Cracking phase
|
||||
cracking_start_time?: string | null
|
||||
|
||||
// Shelling phase
|
||||
shelling_start_time?: string | null
|
||||
bin_1_weight_lbs?: number | null
|
||||
bin_2_weight_lbs?: number | null
|
||||
bin_3_weight_lbs?: number | null
|
||||
discharge_bin_weight_lbs?: number | null
|
||||
bin_1_full_yield_oz?: number | null
|
||||
bin_2_full_yield_oz?: number | null
|
||||
bin_3_full_yield_oz?: number | null
|
||||
bin_1_half_yield_oz?: number | null
|
||||
bin_2_half_yield_oz?: number | null
|
||||
bin_3_half_yield_oz?: number | null
|
||||
|
||||
created_at: string
|
||||
updated_at: string
|
||||
|
||||
// Related data
|
||||
diameter_measurements?: PecanDiameterMeasurement[]
|
||||
}
|
||||
|
||||
export interface CreatePhaseDraftRequest {
|
||||
experiment_id: string
|
||||
repetition_id: string
|
||||
phase_name: ExperimentPhase
|
||||
draft_name?: string
|
||||
status?: PhaseDraftStatus
|
||||
}
|
||||
|
||||
export interface UpdatePhaseDraftRequest {
|
||||
draft_name?: string
|
||||
status?: PhaseDraftStatus
|
||||
}
|
||||
|
||||
export interface CreatePhaseDataRequest {
|
||||
data_entry_id: string
|
||||
phase_name: ExperimentPhase
|
||||
[key: string]: any // For phase-specific data fields
|
||||
}
|
||||
|
||||
export interface UpdatePhaseDataRequest {
|
||||
[key: string]: any // For phase-specific data fields
|
||||
}
|
||||
|
||||
export interface UserRole {
|
||||
id: string
|
||||
user_id: string
|
||||
role_id: string
|
||||
assigned_at: string
|
||||
assigned_by?: string
|
||||
}
|
||||
|
||||
export interface UserProfile {
|
||||
id: string
|
||||
email: string
|
||||
status: UserStatus
|
||||
created_at: string
|
||||
updated_at: string
|
||||
role_id?: string // Legacy field, will be deprecated
|
||||
}
|
||||
|
||||
export interface CreateUserRequest {
|
||||
email: string
|
||||
roles: RoleName[]
|
||||
tempPassword?: string
|
||||
}
|
||||
|
||||
export interface CreateUserResponse {
|
||||
user_id: string
|
||||
email: string
|
||||
temp_password: string
|
||||
roles: RoleName[]
|
||||
status: UserStatus
|
||||
}
|
||||
|
||||
// User management utility functions
|
||||
export const userManagement = {
|
||||
// Get all users with their roles
|
||||
async getAllUsers(): Promise<User[]> {
|
||||
const { data: profiles, error: profilesError } = await supabase
|
||||
.from('user_profiles')
|
||||
.select(`
|
||||
id,
|
||||
email,
|
||||
status,
|
||||
created_at,
|
||||
updated_at
|
||||
`)
|
||||
|
||||
if (profilesError) throw profilesError
|
||||
|
||||
// Get roles for each user
|
||||
const usersWithRoles = await Promise.all(
|
||||
profiles.map(async (profile) => {
|
||||
const { data: userRoles, error: rolesError } = await supabase
|
||||
.from('user_roles')
|
||||
.select(`
|
||||
roles!inner (
|
||||
name
|
||||
)
|
||||
`)
|
||||
.eq('user_id', profile.id)
|
||||
|
||||
if (rolesError) throw rolesError
|
||||
|
||||
return {
|
||||
...profile,
|
||||
roles: userRoles.map(ur => (ur.roles as any).name as RoleName)
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
return usersWithRoles
|
||||
},
|
||||
|
||||
// Get all available roles
|
||||
async getAllRoles(): Promise<Role[]> {
|
||||
const { data, error } = await supabase
|
||||
.from('roles')
|
||||
.select('*')
|
||||
.order('name')
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
},
|
||||
|
||||
// Create a new user with roles
|
||||
async createUser(userData: CreateUserRequest): Promise<CreateUserResponse> {
|
||||
const { data, error } = await supabase.rpc('create_user_with_roles', {
|
||||
user_email: userData.email,
|
||||
role_names: userData.roles,
|
||||
temp_password: userData.tempPassword
|
||||
})
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
},
|
||||
|
||||
// Update user status (enable/disable)
|
||||
async updateUserStatus(userId: string, status: UserStatus): Promise<void> {
|
||||
const { error } = await supabase
|
||||
.from('user_profiles')
|
||||
.update({ status })
|
||||
.eq('id', userId)
|
||||
|
||||
if (error) throw error
|
||||
},
|
||||
|
||||
// Update user roles
|
||||
async updateUserRoles(userId: string, roleNames: RoleName[]): Promise<void> {
|
||||
// First, remove all existing roles for the user
|
||||
const { error: deleteError } = await supabase
|
||||
.from('user_roles')
|
||||
.delete()
|
||||
.eq('user_id', userId)
|
||||
|
||||
if (deleteError) throw deleteError
|
||||
|
||||
// Get role IDs for the new roles
|
||||
const { data: roles, error: rolesError } = await supabase
|
||||
.from('roles')
|
||||
.select('id, name')
|
||||
.in('name', roleNames)
|
||||
|
||||
if (rolesError) throw rolesError
|
||||
|
||||
// Insert new role assignments
|
||||
const roleAssignments = roles.map(role => ({
|
||||
user_id: userId,
|
||||
role_id: role.id
|
||||
}))
|
||||
|
||||
const { error: insertError } = await supabase
|
||||
.from('user_roles')
|
||||
.insert(roleAssignments)
|
||||
|
||||
if (insertError) throw insertError
|
||||
},
|
||||
|
||||
// Update user email
|
||||
async updateUserEmail(userId: string, email: string): Promise<void> {
|
||||
const { error } = await supabase
|
||||
.from('user_profiles')
|
||||
.update({ email })
|
||||
.eq('id', userId)
|
||||
|
||||
if (error) throw error
|
||||
},
|
||||
|
||||
// Get current user with roles
|
||||
async getCurrentUser(): Promise<User | null> {
|
||||
const { data: { user: authUser }, error: authError } = await supabase.auth.getUser()
|
||||
|
||||
if (authError || !authUser) return null
|
||||
|
||||
const { data: profile, error: profileError } = await supabase
|
||||
.from('user_profiles')
|
||||
.select(`
|
||||
id,
|
||||
email,
|
||||
status,
|
||||
created_at,
|
||||
updated_at
|
||||
`)
|
||||
.eq('id', authUser.id)
|
||||
.single()
|
||||
|
||||
if (profileError) throw profileError
|
||||
|
||||
const { data: userRoles, error: rolesError } = await supabase
|
||||
.from('user_roles')
|
||||
.select(`
|
||||
roles!inner (
|
||||
name
|
||||
)
|
||||
`)
|
||||
.eq('user_id', authUser.id)
|
||||
|
||||
if (rolesError) throw rolesError
|
||||
|
||||
return {
|
||||
...profile,
|
||||
roles: userRoles.map(ur => (ur.roles as any).name as RoleName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Experiment management utility functions
|
||||
export const experimentManagement = {
|
||||
// Get all experiments
|
||||
async getAllExperiments(): Promise<Experiment[]> {
|
||||
const { data, error } = await supabase
|
||||
.from('experiments')
|
||||
.select('*')
|
||||
.order('created_at', { ascending: false })
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
},
|
||||
|
||||
// Get experiment by ID
|
||||
async getExperimentById(id: string): Promise<Experiment | null> {
|
||||
const { data, error } = await supabase
|
||||
.from('experiments')
|
||||
.select('*')
|
||||
.eq('id', id)
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
if (error.code === 'PGRST116') return null // Not found
|
||||
throw error
|
||||
}
|
||||
return data
|
||||
},
|
||||
|
||||
// Create a new experiment
|
||||
async createExperiment(experimentData: CreateExperimentRequest): Promise<Experiment> {
|
||||
const { data: { user }, error: authError } = await supabase.auth.getUser()
|
||||
if (authError || !user) throw new Error('User not authenticated')
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('experiments')
|
||||
.insert({
|
||||
...experimentData,
|
||||
created_by: user.id
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
},
|
||||
|
||||
// Update an experiment
|
||||
async updateExperiment(id: string, updates: UpdateExperimentRequest): Promise<Experiment> {
|
||||
const { data, error } = await supabase
|
||||
.from('experiments')
|
||||
.update(updates)
|
||||
.eq('id', id)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
},
|
||||
|
||||
// Delete an experiment (admin only)
|
||||
async deleteExperiment(id: string): Promise<void> {
|
||||
const { error } = await supabase
|
||||
.from('experiments')
|
||||
.delete()
|
||||
.eq('id', id)
|
||||
|
||||
if (error) throw error
|
||||
},
|
||||
|
||||
// Update experiment status
|
||||
async updateExperimentStatus(id: string, scheduleStatus?: ScheduleStatus, resultsStatus?: ResultsStatus): Promise<Experiment> {
|
||||
const updates: Partial<UpdateExperimentRequest> = {}
|
||||
if (scheduleStatus) updates.schedule_status = scheduleStatus
|
||||
if (resultsStatus) updates.results_status = resultsStatus
|
||||
|
||||
return this.updateExperiment(id, updates)
|
||||
},
|
||||
|
||||
// Get experiments by status
|
||||
async getExperimentsByStatus(scheduleStatus?: ScheduleStatus, resultsStatus?: ResultsStatus): Promise<Experiment[]> {
|
||||
let query = supabase.from('experiments').select('*')
|
||||
|
||||
if (scheduleStatus) {
|
||||
query = query.eq('schedule_status', scheduleStatus)
|
||||
}
|
||||
if (resultsStatus) {
|
||||
query = query.eq('results_status', resultsStatus)
|
||||
}
|
||||
|
||||
const { data, error } = await query.order('created_at', { ascending: false })
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
},
|
||||
|
||||
|
||||
|
||||
// Check if experiment number is unique
|
||||
async isExperimentNumberUnique(experimentNumber: number, excludeId?: string): Promise<boolean> {
|
||||
let query = supabase
|
||||
.from('experiments')
|
||||
.select('id')
|
||||
.eq('experiment_number', experimentNumber)
|
||||
|
||||
if (excludeId) {
|
||||
query = query.neq('id', excludeId)
|
||||
}
|
||||
|
||||
const { data, error } = await query
|
||||
|
||||
if (error) throw error
|
||||
return data.length === 0
|
||||
}
|
||||
}
|
||||
|
||||
// Experiment Repetitions Management
|
||||
export const repetitionManagement = {
|
||||
// Get all repetitions for an experiment
|
||||
async getExperimentRepetitions(experimentId: string): Promise<ExperimentRepetition[]> {
|
||||
const { data, error } = await supabase
|
||||
.from('experiment_repetitions')
|
||||
.select('*')
|
||||
.eq('experiment_id', experimentId)
|
||||
.order('repetition_number', { ascending: true })
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
},
|
||||
|
||||
// Create a new repetition
|
||||
async createRepetition(repetitionData: CreateRepetitionRequest): Promise<ExperimentRepetition> {
|
||||
const { data: { user }, error: authError } = await supabase.auth.getUser()
|
||||
if (authError || !user) throw new Error('User not authenticated')
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('experiment_repetitions')
|
||||
.insert({
|
||||
...repetitionData,
|
||||
created_by: user.id
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
},
|
||||
|
||||
// Update a repetition
|
||||
async updateRepetition(id: string, updates: UpdateRepetitionRequest): Promise<ExperimentRepetition> {
|
||||
const { data, error } = await supabase
|
||||
.from('experiment_repetitions')
|
||||
.update(updates)
|
||||
.eq('id', id)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
},
|
||||
|
||||
// Schedule a repetition
|
||||
async scheduleRepetition(id: string, scheduledDate: string): Promise<ExperimentRepetition> {
|
||||
const updates: UpdateRepetitionRequest = {
|
||||
scheduled_date: scheduledDate,
|
||||
schedule_status: 'scheduled'
|
||||
}
|
||||
|
||||
return this.updateRepetition(id, updates)
|
||||
},
|
||||
|
||||
// Remove repetition schedule
|
||||
async removeRepetitionSchedule(id: string): Promise<ExperimentRepetition> {
|
||||
const updates: UpdateRepetitionRequest = {
|
||||
scheduled_date: null,
|
||||
schedule_status: 'pending schedule'
|
||||
}
|
||||
|
||||
return this.updateRepetition(id, updates)
|
||||
},
|
||||
|
||||
// Delete a repetition
|
||||
async deleteRepetition(id: string): Promise<void> {
|
||||
const { error } = await supabase
|
||||
.from('experiment_repetitions')
|
||||
.delete()
|
||||
.eq('id', id)
|
||||
|
||||
if (error) throw error
|
||||
},
|
||||
|
||||
// Get repetitions by status
|
||||
async getRepetitionsByStatus(scheduleStatus?: ScheduleStatus): Promise<ExperimentRepetition[]> {
|
||||
let query = supabase.from('experiment_repetitions').select('*')
|
||||
|
||||
if (scheduleStatus) {
|
||||
query = query.eq('schedule_status', scheduleStatus)
|
||||
}
|
||||
|
||||
const { data, error } = await query.order('created_at', { ascending: false })
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
},
|
||||
|
||||
// Get repetitions with experiment details
|
||||
async getRepetitionsWithExperiments(): Promise<(ExperimentRepetition & { experiment: Experiment })[]> {
|
||||
const { data, error } = await supabase
|
||||
.from('experiment_repetitions')
|
||||
.select(`
|
||||
*,
|
||||
experiment:experiments(*)
|
||||
`)
|
||||
.order('created_at', { ascending: false })
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
},
|
||||
|
||||
// Create all repetitions for an experiment
|
||||
async createAllRepetitions(experimentId: string): Promise<ExperimentRepetition[]> {
|
||||
// First get the experiment to know how many reps are required
|
||||
const { data: experiment, error: expError } = await supabase
|
||||
.from('experiments')
|
||||
.select('reps_required')
|
||||
.eq('id', experimentId)
|
||||
.single()
|
||||
|
||||
if (expError) throw expError
|
||||
|
||||
// Create repetitions for each required rep
|
||||
const repetitions: CreateRepetitionRequest[] = []
|
||||
for (let i = 1; i <= experiment.reps_required; i++) {
|
||||
repetitions.push({
|
||||
experiment_id: experimentId,
|
||||
repetition_number: i,
|
||||
schedule_status: 'pending schedule'
|
||||
})
|
||||
}
|
||||
|
||||
const { data: { user }, error: authError } = await supabase.auth.getUser()
|
||||
if (authError || !user) throw new Error('User not authenticated')
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('experiment_repetitions')
|
||||
.insert(repetitions.map(rep => ({
|
||||
...rep,
|
||||
created_by: user.id
|
||||
})))
|
||||
.select()
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
},
|
||||
|
||||
// Lock a repetition (admin only)
|
||||
async lockRepetition(repetitionId: string): Promise<ExperimentRepetition> {
|
||||
const { data: { user }, error: authError } = await supabase.auth.getUser()
|
||||
if (authError || !user) throw new Error('User not authenticated')
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('experiment_repetitions')
|
||||
.update({
|
||||
is_locked: true,
|
||||
locked_at: new Date().toISOString(),
|
||||
locked_by: user.id
|
||||
})
|
||||
.eq('id', repetitionId)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
},
|
||||
|
||||
// Unlock a repetition (admin only)
|
||||
async unlockRepetition(repetitionId: string): Promise<ExperimentRepetition> {
|
||||
const { data, error } = await supabase
|
||||
.from('experiment_repetitions')
|
||||
.update({
|
||||
is_locked: false,
|
||||
locked_at: null,
|
||||
locked_by: null
|
||||
})
|
||||
.eq('id', repetitionId)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
}
|
||||
}
|
||||
|
||||
// Phase Draft Management
|
||||
export const phaseDraftManagement = {
|
||||
// Get all phase drafts for a repetition
|
||||
async getPhaseDraftsForRepetition(repetitionId: string): Promise<ExperimentPhaseDraft[]> {
|
||||
const { data, error } = await supabase
|
||||
.from('experiment_phase_drafts')
|
||||
.select('*')
|
||||
.eq('repetition_id', repetitionId)
|
||||
.order('created_at', { ascending: false })
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
},
|
||||
|
||||
// Get user's phase drafts for a repetition
|
||||
async getUserPhaseDraftsForRepetition(repetitionId: string): Promise<ExperimentPhaseDraft[]> {
|
||||
const { data: { user }, error: authError } = await supabase.auth.getUser()
|
||||
if (authError || !user) throw new Error('User not authenticated')
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('experiment_phase_drafts')
|
||||
.select('*')
|
||||
.eq('repetition_id', repetitionId)
|
||||
.eq('user_id', user.id)
|
||||
.order('created_at', { ascending: false })
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
},
|
||||
|
||||
// Get user's phase drafts for a specific phase and repetition
|
||||
async getUserPhaseDraftsForPhase(repetitionId: string, phase: ExperimentPhase): Promise<ExperimentPhaseDraft[]> {
|
||||
const { data: { user }, error: authError } = await supabase.auth.getUser()
|
||||
if (authError || !user) throw new Error('User not authenticated')
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('experiment_phase_drafts')
|
||||
.select('*')
|
||||
.eq('repetition_id', repetitionId)
|
||||
.eq('user_id', user.id)
|
||||
.eq('phase_name', phase)
|
||||
.order('created_at', { ascending: false })
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
},
|
||||
|
||||
// Create a new phase draft
|
||||
async createPhaseDraft(request: CreatePhaseDraftRequest): Promise<ExperimentPhaseDraft> {
|
||||
const { data: { user }, error: authError } = await supabase.auth.getUser()
|
||||
if (authError || !user) throw new Error('User not authenticated')
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('experiment_phase_drafts')
|
||||
.insert({
|
||||
...request,
|
||||
user_id: user.id
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
},
|
||||
|
||||
// Update a phase draft
|
||||
async updatePhaseDraft(id: string, updates: UpdatePhaseDraftRequest): Promise<ExperimentPhaseDraft> {
|
||||
const { data, error } = await supabase
|
||||
.from('experiment_phase_drafts')
|
||||
.update(updates)
|
||||
.eq('id', id)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
},
|
||||
|
||||
// Delete a phase draft (only drafts)
|
||||
async deletePhaseDraft(id: string): Promise<void> {
|
||||
const { error } = await supabase
|
||||
.from('experiment_phase_drafts')
|
||||
.delete()
|
||||
.eq('id', id)
|
||||
|
||||
if (error) throw error
|
||||
},
|
||||
|
||||
// Submit a phase draft (change status from draft to submitted)
|
||||
async submitPhaseDraft(id: string): Promise<ExperimentPhaseDraft> {
|
||||
return this.updatePhaseDraft(id, { status: 'submitted' })
|
||||
},
|
||||
|
||||
// Withdraw a phase draft (change status from submitted to withdrawn)
|
||||
async withdrawPhaseDraft(id: string): Promise<ExperimentPhaseDraft> {
|
||||
return this.updatePhaseDraft(id, { status: 'withdrawn' })
|
||||
},
|
||||
|
||||
// Get phase data for a phase draft
|
||||
async getPhaseDataForDraft(phaseDraftId: string): Promise<ExperimentPhaseData | null> {
|
||||
const { data, error } = await supabase
|
||||
.from('experiment_phase_data')
|
||||
.select(`
|
||||
*,
|
||||
diameter_measurements:pecan_diameter_measurements(*)
|
||||
`)
|
||||
.eq('phase_draft_id', phaseDraftId)
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
if (error.code === 'PGRST116') return null // No rows found
|
||||
throw error
|
||||
}
|
||||
return data
|
||||
},
|
||||
|
||||
// Create or update phase data for a draft
|
||||
async upsertPhaseData(phaseDraftId: string, phaseData: Partial<ExperimentPhaseData>): Promise<ExperimentPhaseData> {
|
||||
const { data, error } = await supabase
|
||||
.from('experiment_phase_data')
|
||||
.upsert({
|
||||
phase_draft_id: phaseDraftId,
|
||||
...phaseData
|
||||
}, {
|
||||
onConflict: 'phase_draft_id,phase_name'
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
},
|
||||
|
||||
// Save diameter measurements
|
||||
async saveDiameterMeasurements(phaseDataId: string, measurements: number[]): Promise<PecanDiameterMeasurement[]> {
|
||||
// First, delete existing measurements
|
||||
await supabase
|
||||
.from('pecan_diameter_measurements')
|
||||
.delete()
|
||||
.eq('phase_data_id', phaseDataId)
|
||||
|
||||
// Then insert new measurements
|
||||
const measurementData = measurements.map((diameter, index) => ({
|
||||
phase_data_id: phaseDataId,
|
||||
measurement_number: index + 1,
|
||||
diameter_in: diameter
|
||||
}))
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('pecan_diameter_measurements')
|
||||
.insert(measurementData)
|
||||
.select()
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
},
|
||||
|
||||
// Calculate average diameter from measurements
|
||||
calculateAverageDiameter(measurements: number[]): number {
|
||||
if (measurements.length === 0) return 0
|
||||
const validMeasurements = measurements.filter(m => m > 0)
|
||||
if (validMeasurements.length === 0) return 0
|
||||
return validMeasurements.reduce((sum, m) => sum + m, 0) / validMeasurements.length
|
||||
},
|
||||
|
||||
// Auto-save draft data (for periodic saves)
|
||||
async autoSaveDraft(phaseDraftId: string, phaseData: Partial<ExperimentPhaseData>): Promise<void> {
|
||||
try {
|
||||
await this.upsertPhaseData(phaseDraftId, phaseData)
|
||||
} catch (error) {
|
||||
console.warn('Auto-save failed:', error)
|
||||
// Don't throw error for auto-save failures
|
||||
}
|
||||
}
|
||||
}
|
||||
565
management-dashboard-web-app/src/lib/visionApi.ts
Normal file
565
management-dashboard-web-app/src/lib/visionApi.ts
Normal file
@@ -0,0 +1,565 @@
|
||||
// Vision System API Client
|
||||
// Base URL for the vision system API - Use environment variable or default to vision container
|
||||
// The API is accessible at vision:8000 in the current setup
|
||||
const VISION_API_BASE_URL = import.meta.env.VITE_VISION_API_URL || 'http://vision:8000'
|
||||
|
||||
// Types based on the API documentation
|
||||
export interface SystemStatus {
|
||||
system_started: boolean
|
||||
mqtt_connected: boolean
|
||||
last_mqtt_message: string
|
||||
machines: Record<string, MachineStatus>
|
||||
cameras: Record<string, CameraStatus>
|
||||
active_recordings: number
|
||||
total_recordings: number
|
||||
uptime_seconds: number
|
||||
}
|
||||
|
||||
export interface MachineStatus {
|
||||
name: string
|
||||
state: string
|
||||
last_updated: string
|
||||
last_message?: string
|
||||
mqtt_topic?: string
|
||||
}
|
||||
|
||||
export interface CameraStatus {
|
||||
name?: string
|
||||
status: string
|
||||
is_recording: boolean
|
||||
last_checked: string
|
||||
last_error?: string | null
|
||||
device_info?: {
|
||||
friendly_name?: string
|
||||
serial_number?: string
|
||||
port_type?: string
|
||||
model?: string
|
||||
firmware_version?: string
|
||||
last_checked?: number
|
||||
}
|
||||
current_recording_file?: string | null
|
||||
recording_start_time?: string | null
|
||||
last_frame_time?: string
|
||||
frame_rate?: number
|
||||
// NEW AUTO-RECORDING FIELDS
|
||||
auto_recording_enabled: boolean
|
||||
auto_recording_active: boolean
|
||||
auto_recording_failure_count: number
|
||||
auto_recording_last_attempt?: string
|
||||
auto_recording_last_error?: string
|
||||
}
|
||||
|
||||
export interface RecordingInfo {
|
||||
camera_name: string
|
||||
filename: string
|
||||
start_time: string
|
||||
state: string
|
||||
end_time?: string
|
||||
file_size_bytes?: number
|
||||
frame_count?: number
|
||||
duration_seconds?: number
|
||||
error_message?: string | null
|
||||
}
|
||||
|
||||
export interface StorageStats {
|
||||
base_path: string
|
||||
total_files: number
|
||||
total_size_bytes: number
|
||||
cameras: Record<string, {
|
||||
file_count: number
|
||||
total_size_bytes: number
|
||||
}>
|
||||
disk_usage: {
|
||||
total: number
|
||||
used: number
|
||||
free: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface RecordingFile {
|
||||
filename: string
|
||||
camera_name: string
|
||||
file_size_bytes: number
|
||||
created_date: string
|
||||
duration_seconds?: number
|
||||
}
|
||||
|
||||
export interface StartRecordingRequest {
|
||||
filename?: string
|
||||
exposure_ms?: number
|
||||
gain?: number
|
||||
fps?: number
|
||||
}
|
||||
|
||||
export interface StartRecordingResponse {
|
||||
success: boolean
|
||||
message: string
|
||||
filename: string
|
||||
}
|
||||
|
||||
export interface StopRecordingResponse {
|
||||
success: boolean
|
||||
message: string
|
||||
duration_seconds: number
|
||||
}
|
||||
|
||||
export interface StreamStartResponse {
|
||||
success: boolean
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface StreamStopResponse {
|
||||
success: boolean
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface CameraTestResponse {
|
||||
success: boolean
|
||||
message: string
|
||||
camera_name: string
|
||||
timestamp: string
|
||||
}
|
||||
|
||||
export interface CameraRecoveryResponse {
|
||||
success: boolean
|
||||
message: string
|
||||
camera_name: string
|
||||
operation: string
|
||||
timestamp: string
|
||||
}
|
||||
|
||||
// Auto-Recording Response Types
|
||||
export interface AutoRecordingConfigResponse {
|
||||
success: boolean
|
||||
message: string
|
||||
camera_name: string
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export interface AutoRecordingStatusResponse {
|
||||
running: boolean
|
||||
auto_recording_enabled: boolean
|
||||
retry_queue: Record<string, any>
|
||||
enabled_cameras: string[]
|
||||
}
|
||||
|
||||
// Camera Configuration Types
|
||||
export interface CameraConfig {
|
||||
// READ-ONLY SYSTEM FIELDS
|
||||
name: string
|
||||
machine_topic: string
|
||||
storage_path: string
|
||||
enabled: boolean
|
||||
// READ-ONLY AUTO-RECORDING FIELDS
|
||||
auto_start_recording_enabled: boolean
|
||||
auto_recording_max_retries: number
|
||||
auto_recording_retry_delay_seconds: number
|
||||
// BASIC SETTINGS (real-time configurable)
|
||||
exposure_ms: number
|
||||
gain: number
|
||||
target_fps: number
|
||||
// VIDEO RECORDING SETTINGS (restart required)
|
||||
video_format: string // 'mp4' or 'avi'
|
||||
video_codec: string // 'mp4v', 'XVID', 'MJPG'
|
||||
video_quality: number // 0-100 (higher = better quality)
|
||||
// IMAGE QUALITY SETTINGS (real-time configurable)
|
||||
sharpness: number
|
||||
contrast: number
|
||||
saturation: number
|
||||
gamma: number
|
||||
// COLOR SETTINGS (real-time configurable)
|
||||
auto_white_balance: boolean
|
||||
color_temperature_preset: number
|
||||
// WHITE BALANCE RGB GAINS (real-time configurable)
|
||||
wb_red_gain: number
|
||||
wb_green_gain: number
|
||||
wb_blue_gain: number
|
||||
// ADVANCED SETTINGS
|
||||
anti_flicker_enabled: boolean
|
||||
light_frequency: number
|
||||
// NOISE REDUCTION (restart required)
|
||||
noise_filter_enabled: boolean
|
||||
denoise_3d_enabled: boolean
|
||||
// SYSTEM SETTINGS (restart required)
|
||||
bit_depth: number
|
||||
// HDR SETTINGS (real-time configurable)
|
||||
hdr_enabled: boolean
|
||||
hdr_gain_mode: number
|
||||
}
|
||||
|
||||
export interface CameraConfigUpdate {
|
||||
// BASIC SETTINGS (real-time configurable)
|
||||
exposure_ms?: number
|
||||
gain?: number
|
||||
target_fps?: number
|
||||
// IMAGE QUALITY SETTINGS (real-time configurable)
|
||||
sharpness?: number
|
||||
contrast?: number
|
||||
saturation?: number
|
||||
gamma?: number
|
||||
// COLOR SETTINGS (real-time configurable)
|
||||
auto_white_balance?: boolean
|
||||
color_temperature_preset?: number
|
||||
// WHITE BALANCE RGB GAINS (real-time configurable)
|
||||
wb_red_gain?: number
|
||||
wb_green_gain?: number
|
||||
wb_blue_gain?: number
|
||||
// ADVANCED SETTINGS (real-time configurable)
|
||||
anti_flicker_enabled?: boolean
|
||||
light_frequency?: number
|
||||
// HDR SETTINGS (real-time configurable)
|
||||
hdr_enabled?: boolean
|
||||
hdr_gain_mode?: number
|
||||
// NOTE: Video format settings and noise reduction settings are not included
|
||||
// as they are either read-only or require restart via apply-config endpoint
|
||||
}
|
||||
|
||||
export interface CameraConfigUpdateResponse {
|
||||
success: boolean
|
||||
message: string
|
||||
updated_settings: string[]
|
||||
}
|
||||
|
||||
export interface CameraConfigApplyResponse {
|
||||
success: boolean
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface MqttMessage {
|
||||
timestamp: string
|
||||
topic: string
|
||||
message: string
|
||||
source: string
|
||||
}
|
||||
|
||||
export interface MqttStatus {
|
||||
connected: boolean
|
||||
broker_host: string
|
||||
broker_port: number
|
||||
subscribed_topics: string[]
|
||||
last_message_time: string
|
||||
message_count: number
|
||||
error_count: number
|
||||
uptime_seconds: number
|
||||
}
|
||||
|
||||
export interface MqttEvent {
|
||||
machine_name: string
|
||||
topic: string
|
||||
payload: string
|
||||
normalized_state: string
|
||||
timestamp: string
|
||||
message_number: number
|
||||
}
|
||||
|
||||
export interface MqttEventsResponse {
|
||||
events: MqttEvent[]
|
||||
total_events: number
|
||||
last_updated: string
|
||||
}
|
||||
|
||||
export interface FileListRequest {
|
||||
camera_name?: string
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
limit?: number
|
||||
}
|
||||
|
||||
export interface FileListResponse {
|
||||
files: RecordingFile[]
|
||||
total_count: number
|
||||
}
|
||||
|
||||
export interface CleanupRequest {
|
||||
max_age_days?: number
|
||||
}
|
||||
|
||||
export interface CleanupResponse {
|
||||
files_removed: number
|
||||
bytes_freed: number
|
||||
errors: string[]
|
||||
}
|
||||
|
||||
// API Client Class
|
||||
class VisionApiClient {
|
||||
private baseUrl: string
|
||||
|
||||
constructor(baseUrl: string = VISION_API_BASE_URL) {
|
||||
this.baseUrl = baseUrl
|
||||
}
|
||||
|
||||
private async request<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
|
||||
const url = `${this.baseUrl}${endpoint}`
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
},
|
||||
...options,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
throw new Error(`API request failed: ${response.status} ${response.statusText} - ${errorText}`)
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
// System endpoints
|
||||
async getHealth(): Promise<{ status: string; timestamp: string }> {
|
||||
return this.request('/health')
|
||||
}
|
||||
|
||||
async getSystemStatus(): Promise<SystemStatus> {
|
||||
return this.request('/system/status')
|
||||
}
|
||||
|
||||
// Machine endpoints
|
||||
async getMachines(): Promise<Record<string, MachineStatus>> {
|
||||
return this.request('/machines')
|
||||
}
|
||||
|
||||
// MQTT endpoints
|
||||
async getMqttStatus(): Promise<MqttStatus> {
|
||||
return this.request('/mqtt/status')
|
||||
}
|
||||
|
||||
async getMqttEvents(limit: number = 10): Promise<MqttEventsResponse> {
|
||||
return this.request(`/mqtt/events?limit=${limit}`)
|
||||
}
|
||||
|
||||
// Camera endpoints
|
||||
async getCameras(): Promise<Record<string, CameraStatus>> {
|
||||
return this.request('/cameras')
|
||||
}
|
||||
|
||||
async getCameraStatus(cameraName: string): Promise<CameraStatus> {
|
||||
return this.request(`/cameras/${cameraName}/status`)
|
||||
}
|
||||
|
||||
// Recording control
|
||||
async startRecording(cameraName: string, params: StartRecordingRequest = {}): Promise<StartRecordingResponse> {
|
||||
return this.request(`/cameras/${cameraName}/start-recording`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(params),
|
||||
})
|
||||
}
|
||||
|
||||
async stopRecording(cameraName: string): Promise<StopRecordingResponse> {
|
||||
return this.request(`/cameras/${cameraName}/stop-recording`, {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
// Streaming control
|
||||
async startStream(cameraName: string): Promise<StreamStartResponse> {
|
||||
return this.request(`/cameras/${cameraName}/start-stream`, {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
async stopStream(cameraName: string): Promise<StreamStopResponse> {
|
||||
return this.request(`/cameras/${cameraName}/stop-stream`, {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
getStreamUrl(cameraName: string): string {
|
||||
return `${this.baseUrl}/cameras/${cameraName}/stream`
|
||||
}
|
||||
|
||||
// Camera diagnostics
|
||||
async testCameraConnection(cameraName: string): Promise<CameraTestResponse> {
|
||||
return this.request(`/cameras/${cameraName}/test-connection`, {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
async reconnectCamera(cameraName: string): Promise<CameraRecoveryResponse> {
|
||||
return this.request(`/cameras/${cameraName}/reconnect`, {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
async restartCameraGrab(cameraName: string): Promise<CameraRecoveryResponse> {
|
||||
return this.request(`/cameras/${cameraName}/restart-grab`, {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
async resetCameraTimestamp(cameraName: string): Promise<CameraRecoveryResponse> {
|
||||
return this.request(`/cameras/${cameraName}/reset-timestamp`, {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
async fullCameraReset(cameraName: string): Promise<CameraRecoveryResponse> {
|
||||
return this.request(`/cameras/${cameraName}/full-reset`, {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
async reinitializeCamera(cameraName: string): Promise<CameraRecoveryResponse> {
|
||||
return this.request(`/cameras/${cameraName}/reinitialize`, {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
// Camera configuration
|
||||
async getCameraConfig(cameraName: string): Promise<CameraConfig> {
|
||||
try {
|
||||
const config = await this.request(`/cameras/${cameraName}/config`) as any
|
||||
|
||||
// Map API field names to UI expected field names and ensure auto-recording fields have default values if missing
|
||||
return {
|
||||
...config,
|
||||
// Map auto_start_recording_enabled from API to auto_record_on_machine_start for UI
|
||||
auto_record_on_machine_start: config.auto_start_recording_enabled ?? false,
|
||||
auto_start_recording_enabled: config.auto_start_recording_enabled ?? false,
|
||||
auto_recording_max_retries: config.auto_recording_max_retries ?? 3,
|
||||
auto_recording_retry_delay_seconds: config.auto_recording_retry_delay_seconds ?? 5
|
||||
}
|
||||
} catch (error: any) {
|
||||
// If the error is related to missing auto-recording fields, try to handle it gracefully
|
||||
if (error.message?.includes('auto_start_recording_enabled') ||
|
||||
error.message?.includes('auto_recording_max_retries') ||
|
||||
error.message?.includes('auto_recording_retry_delay_seconds')) {
|
||||
|
||||
// Try to get the raw camera data and add default auto-recording fields
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}/cameras/${cameraName}/config`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const rawConfig = await response.json()
|
||||
|
||||
// Add missing auto-recording fields with defaults and map field names
|
||||
return {
|
||||
...rawConfig,
|
||||
// Map auto_start_recording_enabled from API to auto_record_on_machine_start for UI
|
||||
auto_record_on_machine_start: rawConfig.auto_start_recording_enabled ?? false,
|
||||
auto_start_recording_enabled: rawConfig.auto_start_recording_enabled ?? false,
|
||||
auto_recording_max_retries: rawConfig.auto_recording_max_retries ?? 3,
|
||||
auto_recording_retry_delay_seconds: rawConfig.auto_recording_retry_delay_seconds ?? 5
|
||||
}
|
||||
} catch (fallbackError) {
|
||||
throw new Error(`Failed to load camera configuration: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async updateCameraConfig(cameraName: string, config: CameraConfigUpdate): Promise<CameraConfigUpdateResponse> {
|
||||
// Map UI field names to API field names
|
||||
const apiConfig = { ...config }
|
||||
|
||||
// If auto_record_on_machine_start is present, map it to auto_start_recording_enabled for the API
|
||||
if ('auto_record_on_machine_start' in config) {
|
||||
apiConfig.auto_start_recording_enabled = config.auto_record_on_machine_start
|
||||
// Remove the UI field name to avoid confusion
|
||||
delete apiConfig.auto_record_on_machine_start
|
||||
}
|
||||
|
||||
return this.request(`/cameras/${cameraName}/config`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(apiConfig),
|
||||
})
|
||||
}
|
||||
|
||||
async applyCameraConfig(cameraName: string): Promise<CameraConfigApplyResponse> {
|
||||
return this.request(`/cameras/${cameraName}/apply-config`, {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
// Auto-Recording endpoints
|
||||
async enableAutoRecording(cameraName: string): Promise<AutoRecordingConfigResponse> {
|
||||
return this.request(`/cameras/${cameraName}/auto-recording/enable`, {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
async disableAutoRecording(cameraName: string): Promise<AutoRecordingConfigResponse> {
|
||||
return this.request(`/cameras/${cameraName}/auto-recording/disable`, {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
async getAutoRecordingStatus(): Promise<AutoRecordingStatusResponse> {
|
||||
return this.request('/auto-recording/status')
|
||||
}
|
||||
|
||||
// Recording sessions
|
||||
async getRecordings(): Promise<Record<string, RecordingInfo>> {
|
||||
return this.request('/recordings')
|
||||
}
|
||||
|
||||
// Storage endpoints
|
||||
async getStorageStats(): Promise<StorageStats> {
|
||||
return this.request('/storage/stats')
|
||||
}
|
||||
|
||||
async getFiles(params: FileListRequest = {}): Promise<FileListResponse> {
|
||||
return this.request('/storage/files', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(params),
|
||||
})
|
||||
}
|
||||
|
||||
async cleanupStorage(params: CleanupRequest = {}): Promise<CleanupResponse> {
|
||||
return this.request('/storage/cleanup', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(params),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Export a singleton instance
|
||||
export const visionApi = new VisionApiClient()
|
||||
|
||||
// Utility functions
|
||||
export const formatBytes = (bytes: number): string => {
|
||||
if (bytes === 0) return '0 Bytes'
|
||||
const k = 1024
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
export const formatDuration = (seconds: number): string => {
|
||||
const hours = Math.floor(seconds / 3600)
|
||||
const minutes = Math.floor((seconds % 3600) / 60)
|
||||
const secs = Math.floor(seconds % 60)
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes}m ${secs}s`
|
||||
} else if (minutes > 0) {
|
||||
return `${minutes}m ${secs}s`
|
||||
} else {
|
||||
return `${secs}s`
|
||||
}
|
||||
}
|
||||
|
||||
export const formatUptime = (seconds: number): string => {
|
||||
const days = Math.floor(seconds / 86400)
|
||||
const hours = Math.floor((seconds % 86400) / 3600)
|
||||
const minutes = Math.floor((seconds % 3600) / 60)
|
||||
|
||||
if (days > 0) {
|
||||
return `${days}d ${hours}h ${minutes}m`
|
||||
} else if (hours > 0) {
|
||||
return `${hours}h ${minutes}m`
|
||||
} else {
|
||||
return `${minutes}m`
|
||||
}
|
||||
}
|
||||
10
management-dashboard-web-app/src/main.tsx
Normal file
10
management-dashboard-web-app/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
156
management-dashboard-web-app/src/test/videoStreamingTest.ts
Normal file
156
management-dashboard-web-app/src/test/videoStreamingTest.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* Video Streaming API Test
|
||||
*
|
||||
* This test script verifies the video streaming functionality
|
||||
* and API connectivity with the USDA Vision Camera System.
|
||||
*/
|
||||
|
||||
import { videoApiService } from '../features/video-streaming/services/videoApi';
|
||||
|
||||
export interface TestResult {
|
||||
test: string;
|
||||
success: boolean;
|
||||
message: string;
|
||||
data?: any;
|
||||
}
|
||||
|
||||
export class VideoStreamingTester {
|
||||
private results: TestResult[] = [];
|
||||
|
||||
async runAllTests(): Promise<TestResult[]> {
|
||||
this.results = [];
|
||||
|
||||
console.log('🧪 Starting Video Streaming API Tests');
|
||||
console.log('=====================================');
|
||||
|
||||
await this.testApiConnectivity();
|
||||
await this.testVideoList();
|
||||
await this.testVideoInfo();
|
||||
await this.testStreamingUrls();
|
||||
|
||||
console.log('\n📊 Test Results Summary:');
|
||||
console.log('========================');
|
||||
|
||||
const passed = this.results.filter(r => r.success).length;
|
||||
const total = this.results.length;
|
||||
|
||||
this.results.forEach(result => {
|
||||
const icon = result.success ? '✅' : '❌';
|
||||
console.log(`${icon} ${result.test}: ${result.message}`);
|
||||
});
|
||||
|
||||
console.log(`\n🎯 Tests Passed: ${passed}/${total}`);
|
||||
|
||||
return this.results;
|
||||
}
|
||||
|
||||
private async testApiConnectivity(): Promise<void> {
|
||||
try {
|
||||
console.log('\n🔗 Testing API Connectivity...');
|
||||
|
||||
const isHealthy = await videoApiService.healthCheck();
|
||||
|
||||
if (isHealthy) {
|
||||
this.addResult('API Connectivity', true, 'Successfully connected to video API');
|
||||
} else {
|
||||
this.addResult('API Connectivity', false, 'API is not responding');
|
||||
}
|
||||
} catch (error) {
|
||||
this.addResult('API Connectivity', false, `Connection failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async testVideoList(): Promise<void> {
|
||||
try {
|
||||
console.log('\n📋 Testing Video List...');
|
||||
|
||||
const response = await videoApiService.getVideos({
|
||||
limit: 5,
|
||||
include_metadata: true
|
||||
});
|
||||
|
||||
if (response && typeof response.total_count === 'number') {
|
||||
this.addResult('Video List', true, `Found ${response.total_count} videos, retrieved ${response.videos.length} items`, response);
|
||||
} else {
|
||||
this.addResult('Video List', false, 'Invalid response format');
|
||||
}
|
||||
} catch (error) {
|
||||
this.addResult('Video List', false, `Failed to fetch videos: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async testVideoInfo(): Promise<void> {
|
||||
try {
|
||||
console.log('\n📹 Testing Video Info...');
|
||||
|
||||
// First get a video list to test with
|
||||
const videoList = await videoApiService.getVideos({ limit: 1 });
|
||||
|
||||
if (videoList.videos.length === 0) {
|
||||
this.addResult('Video Info', false, 'No videos available to test with');
|
||||
return;
|
||||
}
|
||||
|
||||
const firstVideo = videoList.videos[0];
|
||||
const videoInfo = await videoApiService.getVideoInfo(firstVideo.file_id);
|
||||
|
||||
if (videoInfo && videoInfo.file_id === firstVideo.file_id) {
|
||||
this.addResult('Video Info', true, `Successfully retrieved info for ${firstVideo.file_id}`, videoInfo);
|
||||
} else {
|
||||
this.addResult('Video Info', false, 'Invalid video info response');
|
||||
}
|
||||
} catch (error) {
|
||||
this.addResult('Video Info', false, `Failed to fetch video info: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async testStreamingUrls(): Promise<void> {
|
||||
try {
|
||||
console.log('\n🎬 Testing Streaming URLs...');
|
||||
|
||||
// Get a video to test with
|
||||
const videoList = await videoApiService.getVideos({ limit: 1 });
|
||||
|
||||
if (videoList.videos.length === 0) {
|
||||
this.addResult('Streaming URLs', false, 'No videos available to test with');
|
||||
return;
|
||||
}
|
||||
|
||||
const firstVideo = videoList.videos[0];
|
||||
|
||||
// Test streaming URL generation
|
||||
const streamingUrl = videoApiService.getStreamingUrl(firstVideo.file_id);
|
||||
const thumbnailUrl = videoApiService.getThumbnailUrl(firstVideo.file_id, {
|
||||
timestamp: 1.0,
|
||||
width: 320,
|
||||
height: 240
|
||||
});
|
||||
|
||||
if (streamingUrl && thumbnailUrl) {
|
||||
this.addResult('Streaming URLs', true, `Generated URLs for ${firstVideo.file_id}`, {
|
||||
streamingUrl,
|
||||
thumbnailUrl
|
||||
});
|
||||
} else {
|
||||
this.addResult('Streaming URLs', false, 'Failed to generate URLs');
|
||||
}
|
||||
} catch (error) {
|
||||
this.addResult('Streaming URLs', false, `Failed to test URLs: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
private addResult(test: string, success: boolean, message: string, data?: any): void {
|
||||
this.results.push({ test, success, message, data });
|
||||
}
|
||||
}
|
||||
|
||||
// Export for use in browser console
|
||||
if (typeof window !== 'undefined') {
|
||||
(window as any).VideoStreamingTester = VideoStreamingTester;
|
||||
(window as any).runVideoStreamingTests = async () => {
|
||||
const tester = new VideoStreamingTester();
|
||||
return await tester.runAllTests();
|
||||
};
|
||||
}
|
||||
|
||||
export default VideoStreamingTester;
|
||||
51
management-dashboard-web-app/src/test/visionApi.test.ts
Normal file
51
management-dashboard-web-app/src/test/visionApi.test.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
// Simple test file to verify vision API client functionality
|
||||
// This is not a formal test suite, just a manual verification script
|
||||
|
||||
import { visionApi, formatBytes, formatDuration, formatUptime } from '../lib/visionApi'
|
||||
|
||||
// Test utility functions
|
||||
console.log('Testing utility functions:')
|
||||
console.log('formatBytes(1024):', formatBytes(1024)) // Should be "1 KB"
|
||||
console.log('formatBytes(1048576):', formatBytes(1048576)) // Should be "1 MB"
|
||||
console.log('formatDuration(65):', formatDuration(65)) // Should be "1m 5s"
|
||||
console.log('formatUptime(3661):', formatUptime(3661)) // Should be "1h 1m"
|
||||
|
||||
// Test API endpoints (these will fail if vision system is not running)
|
||||
export async function testVisionApi() {
|
||||
try {
|
||||
console.log('Testing vision API endpoints...')
|
||||
|
||||
// Test health endpoint
|
||||
const health = await visionApi.getHealth()
|
||||
console.log('Health check:', health)
|
||||
|
||||
// Test system status
|
||||
const status = await visionApi.getSystemStatus()
|
||||
console.log('System status:', status)
|
||||
|
||||
// Test cameras
|
||||
const cameras = await visionApi.getCameras()
|
||||
console.log('Cameras:', cameras)
|
||||
|
||||
// Test machines
|
||||
const machines = await visionApi.getMachines()
|
||||
console.log('Machines:', machines)
|
||||
|
||||
// Test storage stats
|
||||
const storage = await visionApi.getStorageStats()
|
||||
console.log('Storage stats:', storage)
|
||||
|
||||
// Test recordings
|
||||
const recordings = await visionApi.getRecordings()
|
||||
console.log('Recordings:', recordings)
|
||||
|
||||
console.log('All API tests passed!')
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('API test failed:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Uncomment the line below to run the test when this file is imported
|
||||
// testVisionApi()
|
||||
302
management-dashboard-web-app/src/utils/videoFileUtils.ts
Normal file
302
management-dashboard-web-app/src/utils/videoFileUtils.ts
Normal file
@@ -0,0 +1,302 @@
|
||||
/**
|
||||
* Video File Utilities
|
||||
*
|
||||
* Utility functions for handling video files, extensions, MIME types, and format validation.
|
||||
* Supports both MP4 and AVI formats with backward compatibility.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Supported video file extensions
|
||||
*/
|
||||
export const VIDEO_EXTENSIONS = ['.mp4', '.avi', '.webm', '.mov', '.mkv'] as const;
|
||||
|
||||
/**
|
||||
* Video format to MIME type mapping
|
||||
*/
|
||||
export const VIDEO_MIME_TYPES: Record<string, string> = {
|
||||
'mp4': 'video/mp4',
|
||||
'avi': 'video/x-msvideo',
|
||||
'webm': 'video/webm',
|
||||
'mov': 'video/quicktime',
|
||||
'mkv': 'video/x-matroska',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Video codec options for each format
|
||||
*/
|
||||
export const VIDEO_CODECS: Record<string, string[]> = {
|
||||
'mp4': ['mp4v', 'h264', 'h265'],
|
||||
'avi': ['XVID', 'MJPG', 'h264'],
|
||||
'webm': ['vp8', 'vp9'],
|
||||
'mov': ['h264', 'h265', 'prores'],
|
||||
'mkv': ['h264', 'h265', 'vp9'],
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Check if a filename has a video file extension
|
||||
*/
|
||||
export function isVideoFile(filename: string): boolean {
|
||||
if (!filename || typeof filename !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const lowerFilename = filename.toLowerCase();
|
||||
return VIDEO_EXTENSIONS.some(ext => lowerFilename.endsWith(ext));
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract file extension from filename (without the dot)
|
||||
*/
|
||||
export function getFileExtension(filename: string): string {
|
||||
if (!filename || typeof filename !== 'string') {
|
||||
return '';
|
||||
}
|
||||
|
||||
const lastDotIndex = filename.lastIndexOf('.');
|
||||
if (lastDotIndex === -1 || lastDotIndex === filename.length - 1) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return filename.substring(lastDotIndex + 1).toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get video format from filename
|
||||
*/
|
||||
export function getVideoFormat(filename: string): string {
|
||||
const extension = getFileExtension(filename);
|
||||
return extension || 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get MIME type for a video file based on filename
|
||||
*/
|
||||
export function getVideoMimeType(filename: string): string {
|
||||
const format = getVideoFormat(filename);
|
||||
return VIDEO_MIME_TYPES[format] || 'video/mp4'; // Default to MP4 for new files
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a video format is web-compatible (can be played in browsers)
|
||||
*/
|
||||
export function isWebCompatibleFormat(format: string): boolean {
|
||||
const webCompatibleFormats = ['mp4', 'webm', 'ogg'];
|
||||
return webCompatibleFormats.includes(format.toLowerCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get display name for video format
|
||||
*/
|
||||
export function getFormatDisplayName(format: string): string {
|
||||
const formatNames: Record<string, string> = {
|
||||
'mp4': 'MP4',
|
||||
'avi': 'AVI',
|
||||
'webm': 'WebM',
|
||||
'mov': 'QuickTime',
|
||||
'mkv': 'Matroska',
|
||||
};
|
||||
|
||||
return formatNames[format.toLowerCase()] || format.toUpperCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate video format setting
|
||||
*/
|
||||
export function isValidVideoFormat(format: string): boolean {
|
||||
const validFormats = ['mp4', 'avi', 'webm', 'mov', 'mkv'];
|
||||
return validFormats.includes(format.toLowerCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate video codec for a given format
|
||||
*/
|
||||
export function isValidCodecForFormat(codec: string, format: string): boolean {
|
||||
const validCodecs = VIDEO_CODECS[format.toLowerCase()];
|
||||
return validCodecs ? validCodecs.includes(codec) : false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available codecs for a video format
|
||||
*/
|
||||
export function getAvailableCodecs(format: string): string[] {
|
||||
return VIDEO_CODECS[format.toLowerCase()] || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate video quality setting (0-100)
|
||||
*/
|
||||
export function isValidVideoQuality(quality: number): boolean {
|
||||
return typeof quality === 'number' && quality >= 0 && quality <= 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recommended video settings for different use cases
|
||||
*/
|
||||
export function getRecommendedVideoSettings(useCase: 'production' | 'storage-optimized' | 'legacy') {
|
||||
const settings = {
|
||||
production: {
|
||||
video_format: 'mp4',
|
||||
video_codec: 'h264',
|
||||
video_quality: 95,
|
||||
},
|
||||
'storage-optimized': {
|
||||
video_format: 'mp4',
|
||||
video_codec: 'h264',
|
||||
video_quality: 85,
|
||||
},
|
||||
legacy: {
|
||||
video_format: 'avi',
|
||||
video_codec: 'XVID',
|
||||
video_quality: 95,
|
||||
},
|
||||
};
|
||||
|
||||
return settings[useCase];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if video format change requires camera restart
|
||||
*/
|
||||
export function requiresRestart(currentFormat: string, newFormat: string): boolean {
|
||||
// Format changes always require restart
|
||||
return currentFormat !== newFormat;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get format-specific file size estimation factor
|
||||
* (relative to AVI baseline)
|
||||
*/
|
||||
export function getFileSizeFactor(format: string): number {
|
||||
const factors: Record<string, number> = {
|
||||
'mp4': 0.6, // ~40% smaller than AVI
|
||||
'avi': 1.0, // baseline
|
||||
'webm': 0.5, // even smaller
|
||||
'mov': 0.8, // slightly smaller
|
||||
'mkv': 0.7, // moderately smaller
|
||||
};
|
||||
|
||||
return factors[format.toLowerCase()] || 1.0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimate file size for a video recording
|
||||
*/
|
||||
export function estimateFileSize(
|
||||
durationSeconds: number,
|
||||
format: string,
|
||||
quality: number,
|
||||
baselineMBPerMinute: number = 30
|
||||
): number {
|
||||
const durationMinutes = durationSeconds / 60;
|
||||
const qualityFactor = quality / 100;
|
||||
const formatFactor = getFileSizeFactor(format);
|
||||
|
||||
return durationMinutes * baselineMBPerMinute * qualityFactor * formatFactor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate video filename with proper extension
|
||||
*/
|
||||
export function generateVideoFilename(
|
||||
cameraName: string,
|
||||
format: string,
|
||||
timestamp?: Date
|
||||
): string {
|
||||
const date = timestamp || new Date();
|
||||
const dateStr = date.toISOString().slice(0, 19).replace(/[-:]/g, '').replace('T', '_');
|
||||
const extension = format.toLowerCase();
|
||||
|
||||
return `${cameraName}_recording_${dateStr}.${extension}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse video filename to extract metadata
|
||||
*/
|
||||
export function parseVideoFilename(filename: string): {
|
||||
cameraName?: string;
|
||||
timestamp?: Date;
|
||||
format: string;
|
||||
isValid: boolean;
|
||||
} {
|
||||
const format = getVideoFormat(filename);
|
||||
|
||||
// Try to match pattern: cameraName_recording_YYYYMMDD_HHMMSS.ext
|
||||
const match = filename.match(/^([^_]+)_recording_(\d{8})_(\d{6})\./);
|
||||
|
||||
if (match) {
|
||||
const [, cameraName, dateStr, timeStr] = match;
|
||||
const year = parseInt(dateStr.slice(0, 4));
|
||||
const month = parseInt(dateStr.slice(4, 6)) - 1; // Month is 0-indexed
|
||||
const day = parseInt(dateStr.slice(6, 8));
|
||||
const hour = parseInt(timeStr.slice(0, 2));
|
||||
const minute = parseInt(timeStr.slice(2, 4));
|
||||
const second = parseInt(timeStr.slice(4, 6));
|
||||
|
||||
const timestamp = new Date(year, month, day, hour, minute, second);
|
||||
|
||||
return {
|
||||
cameraName,
|
||||
timestamp,
|
||||
format,
|
||||
isValid: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
format,
|
||||
isValid: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Video format configuration validation
|
||||
*/
|
||||
export interface VideoFormatValidationResult {
|
||||
isValid: boolean;
|
||||
errors: string[];
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate complete video format configuration
|
||||
*/
|
||||
export function validateVideoFormatConfig(config: {
|
||||
video_format?: string;
|
||||
video_codec?: string;
|
||||
video_quality?: number;
|
||||
}): VideoFormatValidationResult {
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
// Validate format
|
||||
if (config.video_format && !isValidVideoFormat(config.video_format)) {
|
||||
errors.push(`Invalid video format: ${config.video_format}`);
|
||||
}
|
||||
|
||||
// Validate codec
|
||||
if (config.video_format && config.video_codec) {
|
||||
if (!isValidCodecForFormat(config.video_codec, config.video_format)) {
|
||||
errors.push(`Codec ${config.video_codec} is not valid for format ${config.video_format}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate quality
|
||||
if (config.video_quality !== undefined && !isValidVideoQuality(config.video_quality)) {
|
||||
errors.push(`Video quality must be between 0 and 100, got: ${config.video_quality}`);
|
||||
}
|
||||
|
||||
// Add warnings
|
||||
if (config.video_format === 'avi') {
|
||||
warnings.push('AVI format has limited web compatibility. Consider using MP4 for better browser support.');
|
||||
}
|
||||
|
||||
if (config.video_quality && config.video_quality < 70) {
|
||||
warnings.push('Low video quality may affect analysis accuracy.');
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: errors.length === 0,
|
||||
errors,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
10
management-dashboard-web-app/src/vite-env.d.ts
vendored
Normal file
10
management-dashboard-web-app/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_SUPABASE_URL: string;
|
||||
readonly VITE_SUPABASE_ANON_KEY: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
Reference in New Issue
Block a user