Chore: rename api->camera-management-api and web->management-dashboard-web-app; update compose, ignore, README references

This commit is contained in:
Alireza Vaezi
2025-08-07 22:07:25 -04:00
parent 28dab3a366
commit fc2da16728
281 changed files with 19 additions and 19 deletions

View 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;
}
}

View 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

View 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

View File

@@ -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 }

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View 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>
)
}

View File

@@ -0,0 +1,9 @@
import { DashboardLayout } from "./DashboardLayout"
interface DashboardProps {
onLogout: () => void
}
export function Dashboard({ onLogout }: DashboardProps) {
return <DashboardLayout onLogout={onLogout} />
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View File

@@ -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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View File

@@ -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>
)
}

View 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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};
}

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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';

View File

@@ -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>
);
};

View File

@@ -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';

View File

@@ -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';

View File

@@ -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,
};
}

View File

@@ -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,
};
}

View File

@@ -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,
};
}

View File

@@ -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';

View File

@@ -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 };

View File

@@ -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

View File

@@ -0,0 +1,9 @@
/**
* Video Streaming Utils - Index
*
* Centralized export for all video streaming utilities.
*/
export * from './videoUtils';
export * from './thumbnailCache';
export * from './performanceMonitor';

View File

@@ -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
}

View File

@@ -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();
});
}

View File

@@ -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);
}

View 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
}
}

View 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
}
}

View 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;
}

View 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()

View 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
}
}
}

View 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`
}
}

View 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>,
)

View 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;

View 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()

View 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,
};
}

View 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;
}