Add scheduling-remote service to docker-compose and enhance camera error handling
- Introduced a new service for scheduling-remote in docker-compose.yml, allowing for better management of scheduling functionalities. - Enhanced error handling in CameraMonitor and CameraStreamer classes to improve robustness during camera initialization and streaming processes. - Updated various components in the management dashboard to support dark mode and improve user experience with consistent styling. - Implemented feature flags for enabling/disabling modules, including the new scheduling module.
This commit is contained in:
@@ -196,7 +196,13 @@ class CameraMonitor:
|
||||
self.logger.info(f"Camera {camera_name} initialized successfully, starting test capture...")
|
||||
except mvsdk.CameraException as init_e:
|
||||
self.logger.warning(f"CameraInit failed for {camera_name}: {init_e.message} (error_code: {init_e.error_code})")
|
||||
return "error", f"Camera initialization failed: {init_e.message}", self._get_device_info_dict(device_info)
|
||||
# Get device info dict before returning - wrap in try/except in case device_info is corrupted
|
||||
try:
|
||||
device_info_dict = self._get_device_info_dict(device_info)
|
||||
except Exception as dev_info_e:
|
||||
self.logger.warning(f"Failed to get device info dict after CameraInit failure: {dev_info_e}")
|
||||
device_info_dict = None
|
||||
return "error", f"Camera initialization failed: {init_e.message}", device_info_dict
|
||||
|
||||
# Quick test - try to get one frame
|
||||
try:
|
||||
@@ -232,10 +238,38 @@ class CameraMonitor:
|
||||
|
||||
def _get_device_info_dict(self, device_info) -> Dict[str, Any]:
|
||||
"""Convert device info to dictionary"""
|
||||
if device_info is None:
|
||||
return {"error": "device_info is None"}
|
||||
|
||||
try:
|
||||
return {"friendly_name": device_info.GetFriendlyName(), "port_type": device_info.GetPortType(), "serial_number": getattr(device_info, "acSn", "Unknown"), "last_checked": time.time()}
|
||||
# Safely access device info methods - wrap each in try/except to prevent segfaults
|
||||
friendly_name = "Unknown"
|
||||
port_type = "Unknown"
|
||||
serial_number = "Unknown"
|
||||
|
||||
try:
|
||||
friendly_name = device_info.GetFriendlyName()
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Failed to get friendly name: {e}")
|
||||
|
||||
try:
|
||||
port_type = device_info.GetPortType()
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Failed to get port type: {e}")
|
||||
|
||||
try:
|
||||
serial_number = getattr(device_info, "acSn", "Unknown")
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Failed to get serial number: {e}")
|
||||
|
||||
return {
|
||||
"friendly_name": friendly_name,
|
||||
"port_type": port_type,
|
||||
"serial_number": serial_number,
|
||||
"last_checked": time.time()
|
||||
}
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error getting device info: {e}")
|
||||
self.logger.error(f"Error getting device info: {e}", exc_info=True)
|
||||
return {"error": str(e)}
|
||||
|
||||
def check_camera_now(self, camera_name: str) -> Dict[str, Any]:
|
||||
|
||||
@@ -273,33 +273,99 @@ class CameraStreamer:
|
||||
return False
|
||||
|
||||
# Initialize camera (suppress output to avoid MVCAMAPI error messages)
|
||||
with suppress_camera_errors():
|
||||
self.hCamera = mvsdk.CameraInit(self.device_info, -1, -1)
|
||||
self.logger.info("Camera initialized successfully for streaming")
|
||||
try:
|
||||
with suppress_camera_errors():
|
||||
self.hCamera = mvsdk.CameraInit(self.device_info, -1, -1)
|
||||
self.logger.info("Camera initialized successfully for streaming")
|
||||
except mvsdk.CameraException as init_e:
|
||||
self.logger.error(f"CameraInit failed for streaming {self.camera_config.name}: {init_e.message} (error_code: {init_e.error_code})")
|
||||
self.hCamera = None
|
||||
return False
|
||||
|
||||
# Ensure hCamera is valid before proceeding
|
||||
if self.hCamera is None:
|
||||
self.logger.error("Camera initialization returned None handle")
|
||||
return False
|
||||
|
||||
# Get camera capabilities
|
||||
self.cap = mvsdk.CameraGetCapability(self.hCamera)
|
||||
try:
|
||||
self.cap = mvsdk.CameraGetCapability(self.hCamera)
|
||||
except mvsdk.CameraException as cap_e:
|
||||
self.logger.error(f"CameraGetCapability failed for {self.camera_config.name}: {cap_e.message} (error_code: {cap_e.error_code})")
|
||||
if self.hCamera:
|
||||
try:
|
||||
mvsdk.CameraUnInit(self.hCamera)
|
||||
except:
|
||||
pass
|
||||
self.hCamera = None
|
||||
return False
|
||||
|
||||
# Determine if camera is monochrome
|
||||
self.monoCamera = self.cap.sIspCapacity.bMonoSensor != 0
|
||||
|
||||
# Set output format based on camera type and bit depth
|
||||
if self.monoCamera:
|
||||
mvsdk.CameraSetIspOutFormat(self.hCamera, mvsdk.CAMERA_MEDIA_TYPE_MONO8)
|
||||
else:
|
||||
mvsdk.CameraSetIspOutFormat(self.hCamera, mvsdk.CAMERA_MEDIA_TYPE_BGR8)
|
||||
try:
|
||||
if self.monoCamera:
|
||||
mvsdk.CameraSetIspOutFormat(self.hCamera, mvsdk.CAMERA_MEDIA_TYPE_MONO8)
|
||||
else:
|
||||
mvsdk.CameraSetIspOutFormat(self.hCamera, mvsdk.CAMERA_MEDIA_TYPE_BGR8)
|
||||
except mvsdk.CameraException as fmt_e:
|
||||
self.logger.error(f"CameraSetIspOutFormat failed for {self.camera_config.name}: {fmt_e.message} (error_code: {fmt_e.error_code})")
|
||||
if self.hCamera:
|
||||
try:
|
||||
mvsdk.CameraUnInit(self.hCamera)
|
||||
except:
|
||||
pass
|
||||
self.hCamera = None
|
||||
return False
|
||||
|
||||
# Configure camera settings for streaming (optimized for preview)
|
||||
self._configure_streaming_settings()
|
||||
try:
|
||||
self._configure_streaming_settings()
|
||||
except Exception as config_e:
|
||||
self.logger.error(f"Failed to configure streaming settings for {self.camera_config.name}: {config_e}")
|
||||
if self.hCamera:
|
||||
try:
|
||||
mvsdk.CameraUnInit(self.hCamera)
|
||||
except:
|
||||
pass
|
||||
self.hCamera = None
|
||||
return False
|
||||
|
||||
# Allocate frame buffer
|
||||
bytes_per_pixel = 1 if self.monoCamera else 3
|
||||
self.frame_buffer_size = self.cap.sResolutionRange.iWidthMax * self.cap.sResolutionRange.iHeightMax * bytes_per_pixel
|
||||
self.frame_buffer = mvsdk.CameraAlignMalloc(self.frame_buffer_size, 16)
|
||||
try:
|
||||
bytes_per_pixel = 1 if self.monoCamera else 3
|
||||
self.frame_buffer_size = self.cap.sResolutionRange.iWidthMax * self.cap.sResolutionRange.iHeightMax * bytes_per_pixel
|
||||
self.frame_buffer = mvsdk.CameraAlignMalloc(self.frame_buffer_size, 16)
|
||||
except Exception as buf_e:
|
||||
self.logger.error(f"Failed to allocate frame buffer for {self.camera_config.name}: {buf_e}")
|
||||
if self.hCamera:
|
||||
try:
|
||||
mvsdk.CameraUnInit(self.hCamera)
|
||||
except:
|
||||
pass
|
||||
self.hCamera = None
|
||||
return False
|
||||
|
||||
# Start camera
|
||||
mvsdk.CameraPlay(self.hCamera)
|
||||
self.logger.info("Camera started successfully for streaming")
|
||||
try:
|
||||
mvsdk.CameraPlay(self.hCamera)
|
||||
self.logger.info("Camera started successfully for streaming")
|
||||
except mvsdk.CameraException as play_e:
|
||||
self.logger.error(f"CameraPlay failed for {self.camera_config.name}: {play_e.message} (error_code: {play_e.error_code})")
|
||||
if self.frame_buffer:
|
||||
try:
|
||||
mvsdk.CameraAlignFree(self.frame_buffer)
|
||||
except:
|
||||
pass
|
||||
self.frame_buffer = None
|
||||
if self.hCamera:
|
||||
try:
|
||||
mvsdk.CameraUnInit(self.hCamera)
|
||||
except:
|
||||
pass
|
||||
self.hCamera = None
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@@ -104,6 +104,26 @@ services:
|
||||
ports:
|
||||
- "3002:3002"
|
||||
|
||||
scheduling-remote:
|
||||
image: node:20-alpine
|
||||
working_dir: /app
|
||||
env_file:
|
||||
- ./management-dashboard-web-app/.env
|
||||
environment:
|
||||
- CHOKIDAR_USEPOLLING=true
|
||||
- TZ=America/New_York
|
||||
volumes:
|
||||
- ./scheduling-remote:/app
|
||||
command: >
|
||||
sh -lc "
|
||||
npm install;
|
||||
npm run dev:watch
|
||||
"
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
ports:
|
||||
- "3003:3003"
|
||||
|
||||
media-api:
|
||||
build:
|
||||
context: ./media-api
|
||||
|
||||
@@ -5,6 +5,15 @@
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Experiments Dashboard</title>
|
||||
<script>
|
||||
// Prevent flash of unstyled content by applying theme immediately
|
||||
(function() {
|
||||
const stored = localStorage.getItem('theme');
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
const theme = stored || (prefersDark ? 'dark' : 'light');
|
||||
document.documentElement.classList.add(theme);
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -115,7 +115,7 @@ function App() {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900">
|
||||
<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>
|
||||
@@ -127,7 +127,7 @@ function App() {
|
||||
// Handle signout route
|
||||
if (currentRoute === '/signout') {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900">
|
||||
<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>
|
||||
|
||||
@@ -7,7 +7,8 @@ import { ExperimentManagement } from './ExperimentManagement'
|
||||
import { DataEntry } from './DataEntry'
|
||||
// VisionSystem is now loaded as a microfrontend - see RemoteVisionSystem below
|
||||
// import { VisionSystem } from './VisionSystem'
|
||||
import { Scheduling } from './Scheduling'
|
||||
// Scheduling is now loaded as a microfrontend - see RemoteScheduling below
|
||||
// import { Scheduling } from './Scheduling'
|
||||
import React, { Suspense } from 'react'
|
||||
import { loadRemoteComponent } from '../lib/loadRemote'
|
||||
import { ErrorBoundary } from './ErrorBoundary'
|
||||
@@ -172,6 +173,13 @@ export function DashboardLayout({ onLogout, currentRoute }: DashboardLayoutProps
|
||||
LocalVisionSystemPlaceholder as any
|
||||
) as unknown as React.ComponentType
|
||||
|
||||
const LocalSchedulingPlaceholder = () => (<div className="p-6">Scheduling module not enabled.</div>)
|
||||
const RemoteScheduling = loadRemoteComponent(
|
||||
isFeatureEnabled('enableSchedulingModule'),
|
||||
() => import('schedulingRemote/App'),
|
||||
LocalSchedulingPlaceholder as any
|
||||
) as unknown as React.ComponentType<{ user: User; currentRoute: string }>
|
||||
|
||||
const renderCurrentView = () => {
|
||||
if (!user) return null
|
||||
|
||||
@@ -216,7 +224,13 @@ export function DashboardLayout({ onLogout, currentRoute }: DashboardLayoutProps
|
||||
</ErrorBoundary>
|
||||
)
|
||||
case 'scheduling':
|
||||
return <Scheduling user={user} currentRoute={currentRoute} />
|
||||
return (
|
||||
<ErrorBoundary fallback={<div className="p-6">Failed to load scheduling module. Please try again.</div>}>
|
||||
<Suspense fallback={<div className="p-6">Loading scheduling module...</div>}>
|
||||
<RemoteScheduling user={user} currentRoute={currentRoute} />
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
)
|
||||
case 'video-library':
|
||||
return (
|
||||
<ErrorBoundary fallback={<div className="p-6">Failed to load video module. Please try again.</div>}>
|
||||
@@ -234,7 +248,7 @@ export function DashboardLayout({ onLogout, currentRoute }: DashboardLayoutProps
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900">
|
||||
<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>
|
||||
@@ -245,7 +259,7 @@ export function DashboardLayout({ onLogout, currentRoute }: DashboardLayoutProps
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900">
|
||||
<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>
|
||||
@@ -263,7 +277,7 @@ export function DashboardLayout({ onLogout, currentRoute }: DashboardLayoutProps
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900">
|
||||
<div className="text-center">
|
||||
<div className="text-gray-600 dark:text-gray-400">No user data available</div>
|
||||
<button
|
||||
@@ -298,7 +312,7 @@ export function DashboardLayout({ onLogout, currentRoute }: DashboardLayoutProps
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={`flex-1 transition-all duration-300 ease-in-out ${isExpanded || isHovered ? "lg:ml-[290px]" : "lg:ml-[90px]"
|
||||
className={`flex-1 transition-all duration-300 ease-in-out bg-gray-50 dark:bg-gray-900 ${isExpanded || isHovered ? "lg:ml-[290px]" : "lg:ml-[90px]"
|
||||
} ${isMobileOpen ? "ml-0" : ""}`}
|
||||
>
|
||||
<TopNavbar
|
||||
|
||||
@@ -98,7 +98,7 @@ export function DataEntry() {
|
||||
<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>
|
||||
<p className="mt-4 text-gray-600 dark:text-gray-400">Loading experiments...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -108,8 +108,8 @@ export function DataEntry() {
|
||||
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 className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-md p-4">
|
||||
<div className="text-sm text-red-700 dark:text-red-400">{error}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -129,8 +129,8 @@ export function DataEntry() {
|
||||
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">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Data Entry</h1>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Select a repetition to enter measurement data
|
||||
</p>
|
||||
</div>
|
||||
@@ -142,13 +142,13 @@ export function DataEntry() {
|
||||
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">
|
||||
<div className="bg-white dark:bg-gray-800 shadow overflow-hidden sm:rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<div className="px-4 py-5 sm:px-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 className="text-xl font-bold text-gray-900 dark:text-white 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">
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Completed or past scheduled repetitions
|
||||
</p>
|
||||
</div>
|
||||
@@ -164,7 +164,7 @@ export function DataEntry() {
|
||||
/>
|
||||
))}
|
||||
{pastRepetitions.length === 0 && (
|
||||
<p className="text-sm text-gray-500 italic text-center py-8">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 italic text-center py-8">
|
||||
No completed repetitions
|
||||
</p>
|
||||
)}
|
||||
@@ -173,13 +173,13 @@ export function DataEntry() {
|
||||
</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">
|
||||
<div className="bg-white dark:bg-gray-800 shadow overflow-hidden sm:rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<div className="px-4 py-5 sm:px-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 className="text-xl font-bold text-gray-900 dark:text-white 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">
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Currently scheduled or active repetitions
|
||||
</p>
|
||||
</div>
|
||||
@@ -195,7 +195,7 @@ export function DataEntry() {
|
||||
/>
|
||||
))}
|
||||
{inProgressRepetitions.length === 0 && (
|
||||
<p className="text-sm text-gray-500 italic text-center py-8">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 italic text-center py-8">
|
||||
No repetitions in progress
|
||||
</p>
|
||||
)}
|
||||
@@ -204,13 +204,13 @@ export function DataEntry() {
|
||||
</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">
|
||||
<div className="bg-white dark:bg-gray-800 shadow overflow-hidden sm:rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<div className="px-4 py-5 sm:px-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 className="text-xl font-bold text-gray-900 dark:text-white 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">
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Future scheduled repetitions
|
||||
</p>
|
||||
</div>
|
||||
@@ -226,7 +226,7 @@ export function DataEntry() {
|
||||
/>
|
||||
))}
|
||||
{upcomingRepetitions.length === 0 && (
|
||||
<p className="text-sm text-gray-500 italic text-center py-8">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 italic text-center py-8">
|
||||
No upcoming repetitions
|
||||
</p>
|
||||
)}
|
||||
@@ -239,7 +239,7 @@ export function DataEntry() {
|
||||
|
||||
{experiments.length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<div className="text-gray-500">
|
||||
<div className="text-gray-500 dark:text-gray-400">
|
||||
No experiments available for data entry
|
||||
</div>
|
||||
</div>
|
||||
@@ -291,35 +291,35 @@ function RepetitionCard({ experiment, repetition, onSelect, status }: Repetition
|
||||
<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">
|
||||
<span className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
#{experiment.experiment_number}
|
||||
</span>
|
||||
{/* Smaller repetition number */}
|
||||
<span className="text-lg font-semibold text-gray-700">
|
||||
<span className="text-lg font-semibold text-gray-700 dark:text-gray-300">
|
||||
Rep #{repetition.repetition_number}
|
||||
</span>
|
||||
<span className="text-lg">{getStatusIcon()}</span>
|
||||
<span className="text-lg dark:text-white">{getStatusIcon()}</span>
|
||||
</div>
|
||||
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${repetition.scheduled_date
|
||||
? 'bg-blue-100 text-blue-800'
|
||||
: 'bg-yellow-100 text-yellow-800'
|
||||
? 'bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300'
|
||||
: 'bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-300'
|
||||
}`}>
|
||||
{repetition.scheduled_date ? 'scheduled' : 'pending'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Experiment details */}
|
||||
<div className="text-sm text-gray-600 mb-2">
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400 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 className="text-sm text-gray-600 dark:text-gray-400 mb-2">
|
||||
<strong className="dark:text-white">Scheduled:</strong> {new Date(repetition.scheduled_date).toLocaleString()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="text-xs text-gray-500">
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Click to enter data for this repetition
|
||||
</div>
|
||||
</button>
|
||||
|
||||
@@ -61,9 +61,9 @@ export function ExperimentPhases({ onPhaseSelect }: ExperimentPhasesProps) {
|
||||
<div className="mb-8">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Experiment Phases</h1>
|
||||
<p className="mt-2 text-gray-600">Select an experiment phase to view and manage its experiments</p>
|
||||
<p className="mt-2 text-gray-600">Experiment phases help organize experiments into logical groups for easier navigation and management.</p>
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">Experiment Phases</h1>
|
||||
<p className="mt-2 text-gray-600 dark:text-gray-400">Select an experiment phase to view and manage its experiments</p>
|
||||
<p className="mt-2 text-gray-600 dark:text-gray-400">Experiment phases help organize experiments into logical groups for easier navigation and management.</p>
|
||||
</div>
|
||||
{canManagePhases && (
|
||||
<button
|
||||
@@ -78,8 +78,8 @@ export function ExperimentPhases({ onPhaseSelect }: ExperimentPhasesProps) {
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="mb-6 rounded-md bg-red-50 p-4">
|
||||
<div className="text-sm text-red-700">{error}</div>
|
||||
<div className="mb-6 rounded-md bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 p-4">
|
||||
<div className="text-sm text-red-700 dark:text-red-400">{error}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -89,30 +89,30 @@ export function ExperimentPhases({ onPhaseSelect }: ExperimentPhasesProps) {
|
||||
<div
|
||||
key={phase.id}
|
||||
onClick={() => onPhaseSelect(phase)}
|
||||
className="bg-white rounded-lg shadow-md hover:shadow-lg transition-shadow duration-200 cursor-pointer border border-gray-200 hover:border-blue-300"
|
||||
className="bg-white dark:bg-gray-800 rounded-lg shadow-md hover:shadow-lg transition-shadow duration-200 cursor-pointer border border-gray-200 dark:border-gray-700 hover:border-blue-300 dark:hover:border-blue-600"
|
||||
>
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center">
|
||||
<div className="w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<div className="w-12 h-12 bg-blue-100 dark:bg-blue-900/20 rounded-lg flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-blue-600 dark:text-blue-400" 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>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300">
|
||||
Active
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
|
||||
{phase.name}
|
||||
</h3>
|
||||
|
||||
{phase.description && (
|
||||
<p className="text-sm text-gray-600 mb-4 line-clamp-2">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4 line-clamp-2">
|
||||
{phase.description}
|
||||
</p>
|
||||
)}
|
||||
@@ -121,31 +121,31 @@ export function ExperimentPhases({ onPhaseSelect }: ExperimentPhasesProps) {
|
||||
<div className="mb-4">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{phase.has_soaking && (
|
||||
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
|
||||
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300">
|
||||
🌰 Soaking
|
||||
</span>
|
||||
)}
|
||||
{phase.has_airdrying && (
|
||||
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
||||
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300">
|
||||
💨 Air-Drying
|
||||
</span>
|
||||
)}
|
||||
{phase.has_cracking && (
|
||||
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">
|
||||
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-300">
|
||||
🔨 Cracking
|
||||
</span>
|
||||
)}
|
||||
{phase.has_shelling && (
|
||||
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-purple-100 text-purple-800">
|
||||
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-300">
|
||||
📊 Shelling
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-sm text-gray-500">
|
||||
<div className="flex items-center justify-between text-sm text-gray-500 dark:text-gray-400">
|
||||
<span>Created {new Date(phase.created_at).toLocaleDateString()}</span>
|
||||
<div className="flex items-center text-blue-600 hover:text-blue-800">
|
||||
<div className="flex items-center text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300">
|
||||
<span className="mr-1">View Experiments</span>
|
||||
<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" />
|
||||
@@ -159,11 +159,11 @@ export function ExperimentPhases({ onPhaseSelect }: ExperimentPhasesProps) {
|
||||
|
||||
{phases.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">
|
||||
<svg className="mx-auto h-12 w-12 text-gray-400 dark:text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<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>
|
||||
<h3 className="mt-2 text-sm font-medium text-gray-900">No experiment phases found</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
<h3 className="mt-2 text-sm font-medium text-gray-900 dark:text-white">No experiment phases found</h3>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Get started by creating your first experiment phase.
|
||||
</p>
|
||||
{canManagePhases && (
|
||||
|
||||
@@ -36,7 +36,7 @@ export function Login({ onLoginSuccess }: LoginProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900">
|
||||
<div className="max-w-md w-full space-y-8">
|
||||
<div>
|
||||
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from 'react'
|
||||
import type { User } from '../lib/supabase'
|
||||
import { useTheme } from '../hooks/useTheme'
|
||||
|
||||
interface TopNavbarProps {
|
||||
user: User
|
||||
@@ -19,6 +20,7 @@ export function TopNavbar({
|
||||
onNavigateToProfile
|
||||
}: TopNavbarProps) {
|
||||
const [isUserMenuOpen, setIsUserMenuOpen] = useState(false)
|
||||
const { theme, toggleTheme } = useTheme()
|
||||
|
||||
const getPageTitle = (view: string) => {
|
||||
switch (view) {
|
||||
@@ -139,6 +141,48 @@ export function TopNavbar({
|
||||
</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">
|
||||
{/* Theme Toggle Button */}
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="flex items-center justify-center w-10 h-10 text-gray-500 border border-gray-200 rounded-lg hover:bg-gray-100 dark:border-gray-800 dark:text-gray-400 dark:hover:bg-white/5 transition-colors"
|
||||
aria-label="Toggle theme"
|
||||
title={theme === 'light' ? 'Switch to dark mode' : 'Switch to light mode'}
|
||||
>
|
||||
{theme === 'light' ? (
|
||||
// Moon icon for dark mode
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
// Sun icon for light mode
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* User Area */}
|
||||
<div className="relative">
|
||||
<button
|
||||
|
||||
@@ -103,15 +103,15 @@ export function UserManagement() {
|
||||
const getRoleBadgeColor = (role: string) => {
|
||||
switch (role) {
|
||||
case 'admin':
|
||||
return 'bg-red-100 text-red-800'
|
||||
return 'bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-300'
|
||||
case 'conductor':
|
||||
return 'bg-blue-100 text-blue-800'
|
||||
return 'bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300'
|
||||
case 'analyst':
|
||||
return 'bg-green-100 text-green-800'
|
||||
return 'bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300'
|
||||
case 'data recorder':
|
||||
return 'bg-purple-100 text-purple-800'
|
||||
return 'bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-300'
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800'
|
||||
return 'bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-300'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,7 +120,7 @@ export function UserManagement() {
|
||||
<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>
|
||||
<p className="mt-4 text-gray-600 dark:text-gray-400">Loading users...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -129,8 +129,8 @@ export function UserManagement() {
|
||||
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 className="rounded-md bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 p-4">
|
||||
<div className="text-sm text-red-700 dark:text-red-400">{error}</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={loadData}
|
||||
@@ -148,8 +148,8 @@ export function UserManagement() {
|
||||
<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>
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">User Management</h1>
|
||||
<p className="mt-2 text-gray-600 dark:text-gray-400">Manage user accounts, roles, and permissions</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
@@ -162,7 +162,7 @@ export function UserManagement() {
|
||||
|
||||
{/* 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="bg-white dark:bg-gray-800 overflow-hidden shadow rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<div className="p-5">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0">
|
||||
@@ -170,15 +170,15 @@ export function UserManagement() {
|
||||
</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>
|
||||
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400 truncate">Total Users</dt>
|
||||
<dd className="text-lg font-medium text-gray-900 dark:text-white">{users.length}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white overflow-hidden shadow rounded-lg">
|
||||
<div className="bg-white dark:bg-gray-800 overflow-hidden shadow rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<div className="p-5">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0">
|
||||
@@ -186,8 +186,8 @@ export function UserManagement() {
|
||||
</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">
|
||||
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400 truncate">Active Users</dt>
|
||||
<dd className="text-lg font-medium text-gray-900 dark:text-white">
|
||||
{users.filter(u => u.status === 'active').length}
|
||||
</dd>
|
||||
</dl>
|
||||
@@ -196,7 +196,7 @@ export function UserManagement() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white overflow-hidden shadow rounded-lg">
|
||||
<div className="bg-white dark:bg-gray-800 overflow-hidden shadow rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<div className="p-5">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0">
|
||||
@@ -204,8 +204,8 @@ export function UserManagement() {
|
||||
</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">
|
||||
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400 truncate">Disabled Users</dt>
|
||||
<dd className="text-lg font-medium text-gray-900 dark:text-white">
|
||||
{users.filter(u => u.status === 'disabled').length}
|
||||
</dd>
|
||||
</dl>
|
||||
@@ -214,7 +214,7 @@ export function UserManagement() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white overflow-hidden shadow rounded-lg">
|
||||
<div className="bg-white dark:bg-gray-800 overflow-hidden shadow rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<div className="p-5">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0">
|
||||
@@ -222,8 +222,8 @@ export function UserManagement() {
|
||||
</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">
|
||||
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400 truncate">Admins</dt>
|
||||
<dd className="text-lg font-medium text-gray-900 dark:text-white">
|
||||
{users.filter(u => u.roles.includes('admin')).length}
|
||||
</dd>
|
||||
</dl>
|
||||
@@ -234,35 +234,35 @@ export function UserManagement() {
|
||||
</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">
|
||||
<div className="bg-white dark:bg-gray-800 shadow overflow-hidden sm:rounded-md border border-gray-200 dark:border-gray-700">
|
||||
<div className="px-4 py-5 sm:px-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 className="text-lg leading-6 font-medium text-gray-900 dark:text-white">Users</h3>
|
||||
<p className="mt-1 max-w-2xl text-sm text-gray-500 dark:text-gray-400">
|
||||
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">
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead className="bg-gray-50 dark:bg-gray-900">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Email
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Roles
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Created
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{users.map((user) => (
|
||||
<UserRow
|
||||
key={user.id}
|
||||
@@ -352,11 +352,11 @@ function UserRow({
|
||||
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"
|
||||
className="block w-full border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="text-sm text-gray-900 cursor-pointer hover:text-blue-600"
|
||||
className="text-sm text-gray-900 dark:text-white cursor-pointer hover:text-blue-600 dark:hover:text-blue-400"
|
||||
onClick={onEdit}
|
||||
>
|
||||
{user.email}
|
||||
@@ -374,7 +374,7 @@ function UserRow({
|
||||
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>
|
||||
<span className="ml-2 text-sm text-gray-700 dark:text-gray-300">{role.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
@@ -398,14 +398,14 @@ function UserRow({
|
||||
<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'
|
||||
? 'bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300 hover:bg-green-200 dark:hover:bg-green-900/40'
|
||||
: 'bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-300 hover:bg-red-200 dark:hover:bg-red-900/40'
|
||||
}`}
|
||||
>
|
||||
{user.status.charAt(0).toUpperCase() + user.status.slice(1)}
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
|
||||
{new Date(user.created_at).toLocaleDateString()}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||
@@ -413,13 +413,13 @@ function UserRow({
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className="text-blue-600 hover:text-blue-900"
|
||||
className="text-blue-600 dark:text-blue-400 hover:text-blue-900 dark:hover:text-blue-300"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="text-gray-600 hover:text-gray-900"
|
||||
className="text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
@@ -428,7 +428,7 @@ function UserRow({
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
onClick={onEdit}
|
||||
className="p-1 text-blue-600 hover:text-blue-900 hover:bg-blue-50 rounded"
|
||||
className="p-1 text-blue-600 dark:text-blue-400 hover:text-blue-900 dark:hover:text-blue-300 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded"
|
||||
title="Edit user"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -437,7 +437,7 @@ function UserRow({
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onPasswordReset(user.id, user.email)}
|
||||
className="p-1 text-orange-600 hover:text-orange-900 hover:bg-orange-50 rounded"
|
||||
className="p-1 text-orange-600 dark:text-orange-400 hover:text-orange-900 dark:hover:text-orange-300 hover:bg-orange-50 dark:hover:bg-orange-900/20 rounded"
|
||||
title="Reset password to 'password123'"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
|
||||
44
management-dashboard-web-app/src/hooks/useTheme.ts
Normal file
44
management-dashboard-web-app/src/hooks/useTheme.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
type Theme = 'light' | 'dark'
|
||||
|
||||
// Initialize theme on script load (before React renders)
|
||||
function initializeTheme(): Theme {
|
||||
const stored = localStorage.getItem('theme') as Theme | null
|
||||
if (stored) {
|
||||
return stored
|
||||
}
|
||||
// Check system preference
|
||||
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||
return 'dark'
|
||||
}
|
||||
return 'light'
|
||||
}
|
||||
|
||||
// Apply theme class to document root
|
||||
function applyTheme(theme: Theme) {
|
||||
const root = window.document.documentElement
|
||||
root.classList.remove('light', 'dark')
|
||||
root.classList.add(theme)
|
||||
}
|
||||
|
||||
// Initialize theme immediately on module load
|
||||
const initialTheme = initializeTheme()
|
||||
applyTheme(initialTheme)
|
||||
|
||||
export function useTheme() {
|
||||
const [theme, setTheme] = useState<Theme>(initialTheme)
|
||||
|
||||
useEffect(() => {
|
||||
// Apply theme whenever it changes
|
||||
applyTheme(theme)
|
||||
localStorage.setItem('theme', theme)
|
||||
}, [theme])
|
||||
|
||||
const toggleTheme = () => {
|
||||
setTheme((prev) => (prev === 'light' ? 'dark' : 'light'))
|
||||
}
|
||||
|
||||
return { theme, toggleTheme }
|
||||
}
|
||||
|
||||
@@ -188,7 +188,7 @@
|
||||
}
|
||||
|
||||
body {
|
||||
@apply relative font-normal font-outfit z-1 bg-gray-50;
|
||||
@apply relative font-normal font-outfit z-1 bg-gray-50 dark:bg-gray-900;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ export type FeatureFlags = {
|
||||
enableExperimentModule: boolean
|
||||
enableCameraModule: boolean
|
||||
enableVisionSystemModule: boolean
|
||||
enableSchedulingModule: boolean
|
||||
}
|
||||
|
||||
const toBool = (v: unknown, fallback = false): boolean => {
|
||||
@@ -21,6 +22,7 @@ export const featureFlags: FeatureFlags = {
|
||||
enableExperimentModule: toBool(import.meta.env.VITE_ENABLE_EXPERIMENT_MODULE ?? false),
|
||||
enableCameraModule: toBool(import.meta.env.VITE_ENABLE_CAMERA_MODULE ?? false),
|
||||
enableVisionSystemModule: toBool(import.meta.env.VITE_ENABLE_VISION_SYSTEM_MODULE ?? false),
|
||||
enableSchedulingModule: toBool(import.meta.env.VITE_ENABLE_SCHEDULING_MODULE ?? false),
|
||||
}
|
||||
|
||||
export const isFeatureEnabled = (flag: keyof FeatureFlags): boolean => featureFlags[flag]
|
||||
|
||||
@@ -20,7 +20,8 @@ export default defineConfig({
|
||||
remotes: {
|
||||
// Allow overriding by env; default to localhost for dev
|
||||
videoRemote: process.env.VITE_VIDEO_REMOTE_URL || 'http://localhost:3001/assets/remoteEntry.js',
|
||||
visionSystemRemote: process.env.VITE_VISION_SYSTEM_REMOTE_URL || 'http://localhost:3002/assets/remoteEntry.js'
|
||||
visionSystemRemote: process.env.VITE_VISION_SYSTEM_REMOTE_URL || 'http://localhost:3002/assets/remoteEntry.js',
|
||||
schedulingRemote: process.env.VITE_SCHEDULING_REMOTE_URL || 'http://localhost:3003/assets/remoteEntry.js'
|
||||
},
|
||||
shared: {
|
||||
react: { singleton: true, eager: true },
|
||||
|
||||
14
scheduling-remote/index.html
Normal file
14
scheduling-remote/index.html
Normal file
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Scheduling Module</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
4494
scheduling-remote/package-lock.json
generated
Normal file
4494
scheduling-remote/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
36
scheduling-remote/package.json
Normal file
36
scheduling-remote/package.json
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "scheduling-remote",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"build:watch": "vite build --watch",
|
||||
"serve:dist": "serve -s dist -l 3003",
|
||||
"preview": "vite preview --port 3003",
|
||||
"dev:watch": "npm run build && (npm run build:watch &) && sleep 1 && npx http-server dist -p 3003 --cors -c-1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@supabase/supabase-js": "^2.52.0",
|
||||
"moment": "^2.30.1",
|
||||
"react": "^19.1.0",
|
||||
"react-big-calendar": "^1.19.4",
|
||||
"react-dnd": "^16.0.1",
|
||||
"react-dnd-html5-backend": "^16.0.1",
|
||||
"react-dom": "^19.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@originjs/vite-plugin-federation": "^1.3.3",
|
||||
"@tailwindcss/vite": "^4.1.11",
|
||||
"@types/react": "^19.2.2",
|
||||
"@types/react-dom": "^19.2.2",
|
||||
"@vitejs/plugin-react": "^4.6.0",
|
||||
"http-server": "^14.1.1",
|
||||
"serve": "^14.2.3",
|
||||
"tailwindcss": "^4.1.11",
|
||||
"typescript": "~5.8.3",
|
||||
"vite": "^7.0.4"
|
||||
}
|
||||
}
|
||||
|
||||
29
scheduling-remote/src/App.tsx
Normal file
29
scheduling-remote/src/App.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import React from 'react'
|
||||
import { Scheduling } from './components/Scheduling'
|
||||
import type { User } from './services/supabase'
|
||||
|
||||
interface AppProps {
|
||||
user?: User
|
||||
currentRoute?: string
|
||||
}
|
||||
|
||||
export default function App(props: AppProps) {
|
||||
// Get user and route from props or try to get from window (for standalone testing)
|
||||
const user = props.user || (window as any).__SCHEDULING_USER__
|
||||
const currentRoute = props.currentRoute || window.location.pathname
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-md p-4">
|
||||
<div className="text-sm text-yellow-700">
|
||||
User information is required to use the scheduling module. Please ensure you are logged in.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return <Scheduling user={user} currentRoute={currentRoute} />
|
||||
}
|
||||
|
||||
250
scheduling-remote/src/components/CalendarStyles.css
Normal file
250
scheduling-remote/src/components/CalendarStyles.css
Normal file
@@ -0,0 +1,250 @@
|
||||
/* Custom styles for React Big Calendar to match dashboard theme */
|
||||
|
||||
.rbc-calendar {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
/* Dark mode support */
|
||||
.dark .rbc-calendar {
|
||||
background: #1f2937;
|
||||
color: #f9fafb;
|
||||
}
|
||||
|
||||
/* Header styling */
|
||||
.rbc-header {
|
||||
background: #f8fafc;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
color: #374151;
|
||||
font-weight: 600;
|
||||
padding: 12px 8px;
|
||||
}
|
||||
|
||||
.dark .rbc-header {
|
||||
background: #374151;
|
||||
border-bottom: 1px solid #4b5563;
|
||||
color: #f9fafb;
|
||||
}
|
||||
|
||||
/* Today styling */
|
||||
.rbc-today {
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
.dark .rbc-today {
|
||||
background: #1e3a8a;
|
||||
}
|
||||
|
||||
/* Date cells */
|
||||
.rbc-date-cell {
|
||||
color: #374151;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.dark .rbc-date-cell {
|
||||
color: #f9fafb;
|
||||
}
|
||||
|
||||
/* Event styling */
|
||||
.rbc-event {
|
||||
border-radius: 4px;
|
||||
border: none;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
padding: 2px 4px;
|
||||
}
|
||||
|
||||
.rbc-event-content {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Month view specific */
|
||||
.rbc-month-view {
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.dark .rbc-month-view {
|
||||
border: 1px solid #4b5563;
|
||||
}
|
||||
|
||||
.rbc-month-row {
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.dark .rbc-month-row {
|
||||
border-bottom: 1px solid #4b5563;
|
||||
}
|
||||
|
||||
.rbc-date-cell {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
/* Week and day view specific */
|
||||
.rbc-time-view {
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.dark .rbc-time-view {
|
||||
border: 1px solid #4b5563;
|
||||
}
|
||||
|
||||
.rbc-time-header {
|
||||
background: #f8fafc;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.dark .rbc-time-header {
|
||||
background: #374151;
|
||||
border-bottom: 1px solid #4b5563;
|
||||
}
|
||||
|
||||
.rbc-time-content {
|
||||
background: white;
|
||||
}
|
||||
|
||||
.dark .rbc-time-content {
|
||||
background: #1f2937;
|
||||
}
|
||||
|
||||
/* Time slots */
|
||||
.rbc-time-slot {
|
||||
border-top: 1px solid #f1f5f9;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.dark .rbc-time-slot {
|
||||
border-top: 1px solid #374151;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
/* Toolbar */
|
||||
.rbc-toolbar {
|
||||
background: #f8fafc;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
padding: 12px 16px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.dark .rbc-toolbar {
|
||||
background: #374151;
|
||||
border-bottom: 1px solid #4b5563;
|
||||
}
|
||||
|
||||
.rbc-toolbar button {
|
||||
background: white;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
color: #374151;
|
||||
font-weight: 500;
|
||||
padding: 6px 12px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.rbc-toolbar button:hover {
|
||||
background: #f3f4f6;
|
||||
border-color: #9ca3af;
|
||||
}
|
||||
|
||||
.rbc-toolbar button:active,
|
||||
.rbc-toolbar button.rbc-active {
|
||||
background: #3b82f6;
|
||||
border-color: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.dark .rbc-toolbar button {
|
||||
background: #1f2937;
|
||||
border: 1px solid #4b5563;
|
||||
color: #f9fafb;
|
||||
}
|
||||
|
||||
.dark .rbc-toolbar button:hover {
|
||||
background: #374151;
|
||||
border-color: #6b7280;
|
||||
}
|
||||
|
||||
.dark .rbc-toolbar button:active,
|
||||
.dark .rbc-toolbar button.rbc-active {
|
||||
background: #3b82f6;
|
||||
border-color: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Labels */
|
||||
.rbc-toolbar-label {
|
||||
color: #111827;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.dark .rbc-toolbar-label {
|
||||
color: #f9fafb;
|
||||
}
|
||||
|
||||
/* Drag and drop improvements */
|
||||
.rbc-event {
|
||||
cursor: grab !important;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.rbc-event:active {
|
||||
cursor: grabbing !important;
|
||||
transform: scale(1.05);
|
||||
z-index: 1000 !important;
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3) !important;
|
||||
}
|
||||
|
||||
/* Improve event spacing and visibility */
|
||||
.rbc-event-content {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Better visual feedback for dragging */
|
||||
.rbc-addons-dnd-dragging {
|
||||
opacity: 0.8;
|
||||
transform: rotate(2deg);
|
||||
z-index: 1000 !important;
|
||||
}
|
||||
|
||||
.rbc-addons-dnd-drag-preview {
|
||||
background: rgba(255, 255, 255, 0.9) !important;
|
||||
border: 2px dashed #3b82f6 !important;
|
||||
border-radius: 8px !important;
|
||||
padding: 8px 12px !important;
|
||||
font-weight: bold !important;
|
||||
color: #1f2937 !important;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3) !important;
|
||||
}
|
||||
|
||||
/* Improve event hover states */
|
||||
.rbc-event:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2) !important;
|
||||
}
|
||||
|
||||
/* Better spacing between events */
|
||||
.rbc-time-slot {
|
||||
min-height: 24px;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.rbc-toolbar {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.rbc-toolbar button {
|
||||
font-size: 14px;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.rbc-toolbar-label {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
1678
scheduling-remote/src/components/Scheduling.tsx
Normal file
1678
scheduling-remote/src/components/Scheduling.tsx
Normal file
File diff suppressed because it is too large
Load Diff
2
scheduling-remote/src/index.css
Normal file
2
scheduling-remote/src/index.css
Normal file
@@ -0,0 +1,2 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
11
scheduling-remote/src/main.tsx
Normal file
11
scheduling-remote/src/main.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App.tsx'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
|
||||
415
scheduling-remote/src/services/supabase.ts
Normal file
415
scheduling-remote/src/services/supabase.ts
Normal file
@@ -0,0 +1,415 @@
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
|
||||
// Supabase configuration from environment
|
||||
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL
|
||||
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY
|
||||
|
||||
if (!supabaseUrl || !supabaseAnonKey) {
|
||||
throw new Error('Supabase is not configured. Please set VITE_SUPABASE_URL and VITE_SUPABASE_ANON_KEY in your environment (.env)')
|
||||
}
|
||||
|
||||
export const supabase = createClient(supabaseUrl, supabaseAnonKey)
|
||||
|
||||
// Type definitions
|
||||
export type RoleName = 'admin' | 'conductor' | 'analyst' | 'data recorder'
|
||||
export type UserStatus = 'active' | 'disabled'
|
||||
|
||||
export interface User {
|
||||
id: string
|
||||
email: string
|
||||
first_name?: string
|
||||
last_name?: string
|
||||
roles: RoleName[]
|
||||
status: UserStatus
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface ExperimentPhase {
|
||||
id: string
|
||||
name: string
|
||||
description?: string | null
|
||||
has_soaking: boolean
|
||||
has_airdrying: boolean
|
||||
has_cracking: boolean
|
||||
has_shelling: boolean
|
||||
cracking_machine_type_id?: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
created_by: string
|
||||
}
|
||||
|
||||
export interface Experiment {
|
||||
id: string
|
||||
experiment_number: number
|
||||
reps_required: number
|
||||
weight_per_repetition_lbs: number
|
||||
phase_id?: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
created_by: string
|
||||
}
|
||||
|
||||
export interface ExperimentRepetition {
|
||||
id: string
|
||||
experiment_id: string
|
||||
repetition_number: number
|
||||
scheduled_date?: string | null
|
||||
completion_status: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
created_by: string
|
||||
}
|
||||
|
||||
export interface Soaking {
|
||||
id: string
|
||||
experiment_id: string
|
||||
repetition_id?: string | null
|
||||
scheduled_start_time: string
|
||||
actual_start_time?: string | null
|
||||
soaking_duration_minutes: number
|
||||
scheduled_end_time: string
|
||||
actual_end_time?: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
created_by: string
|
||||
}
|
||||
|
||||
export interface Airdrying {
|
||||
id: string
|
||||
experiment_id: string
|
||||
repetition_id?: string | null
|
||||
scheduled_start_time: string
|
||||
actual_start_time?: string | null
|
||||
duration_minutes: number
|
||||
scheduled_end_time: string
|
||||
actual_end_time?: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
created_by: string
|
||||
}
|
||||
|
||||
export interface ConductorAvailability {
|
||||
id: string
|
||||
user_id: string
|
||||
available_from: string
|
||||
available_to: string
|
||||
notes?: string | null
|
||||
status: 'active' | 'cancelled'
|
||||
created_at: string
|
||||
updated_at: string
|
||||
created_by: string
|
||||
}
|
||||
|
||||
export interface CreateAvailabilityRequest {
|
||||
available_from: string
|
||||
available_to: string
|
||||
notes?: string
|
||||
}
|
||||
|
||||
export interface CreateRepetitionRequest {
|
||||
experiment_id: string
|
||||
repetition_number: number
|
||||
scheduled_date?: string | null
|
||||
}
|
||||
|
||||
export interface UpdateRepetitionRequest {
|
||||
scheduled_date?: string | null
|
||||
completion_status?: boolean
|
||||
}
|
||||
|
||||
export interface CreateSoakingRequest {
|
||||
experiment_id: string
|
||||
repetition_id?: string | null
|
||||
scheduled_start_time: string
|
||||
soaking_duration_minutes: number
|
||||
}
|
||||
|
||||
export interface CreateAirdryingRequest {
|
||||
experiment_id: string
|
||||
repetition_id?: string | null
|
||||
scheduled_start_time: string
|
||||
duration_minutes: number
|
||||
}
|
||||
|
||||
export interface CreateCrackingRequest {
|
||||
experiment_id: string
|
||||
repetition_id?: string | null
|
||||
machine_type_id: string
|
||||
scheduled_start_time: string
|
||||
}
|
||||
|
||||
// Service APIs
|
||||
export const userManagement = {
|
||||
async getAllUsers(): Promise<User[]> {
|
||||
const { data: profiles, error: profilesError } = await supabase
|
||||
.from('user_profiles')
|
||||
.select(`
|
||||
id,
|
||||
email,
|
||||
first_name,
|
||||
last_name,
|
||||
status,
|
||||
created_at,
|
||||
updated_at
|
||||
`)
|
||||
|
||||
if (profilesError) throw profilesError
|
||||
|
||||
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
|
||||
},
|
||||
}
|
||||
|
||||
export const experimentPhaseManagement = {
|
||||
async getAllExperimentPhases(): Promise<ExperimentPhase[]> {
|
||||
const { data, error } = await supabase
|
||||
.from('experiment_phases')
|
||||
.select('*')
|
||||
.order('created_at', { ascending: false })
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
},
|
||||
}
|
||||
|
||||
export const experimentManagement = {
|
||||
async getExperimentsByPhaseId(phaseId: string): Promise<Experiment[]> {
|
||||
const { data, error } = await supabase
|
||||
.from('experiments')
|
||||
.select('*')
|
||||
.eq('phase_id', phaseId)
|
||||
.order('created_at', { ascending: false })
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
},
|
||||
}
|
||||
|
||||
export const repetitionManagement = {
|
||||
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
|
||||
},
|
||||
|
||||
async createAllRepetitions(experimentId: string): Promise<ExperimentRepetition[]> {
|
||||
const { data: { user }, error: authError } = await supabase.auth.getUser()
|
||||
if (authError || !user) throw new Error('User not authenticated')
|
||||
|
||||
// Get experiment to find reps_required
|
||||
const { data: experiment, error: expError } = await supabase
|
||||
.from('experiments')
|
||||
.select('reps_required')
|
||||
.eq('id', experimentId)
|
||||
.single()
|
||||
|
||||
if (expError || !experiment) throw new Error('Experiment not found')
|
||||
|
||||
// Get existing repetitions to determine next repetition number
|
||||
const existingReps = await this.getExperimentRepetitions(experimentId)
|
||||
const nextRepNumber = existingReps.length > 0
|
||||
? Math.max(...existingReps.map(r => r.repetition_number)) + 1
|
||||
: 1
|
||||
|
||||
// Create all remaining repetitions
|
||||
const repsToCreate = experiment.reps_required - existingReps.length
|
||||
const newReps: ExperimentRepetition[] = []
|
||||
|
||||
for (let i = 0; i < repsToCreate; i++) {
|
||||
const { data, error } = await supabase
|
||||
.from('experiment_repetitions')
|
||||
.insert({
|
||||
experiment_id: experimentId,
|
||||
repetition_number: nextRepNumber + i,
|
||||
completion_status: false,
|
||||
created_by: user.id
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) throw error
|
||||
if (data) newReps.push(data)
|
||||
}
|
||||
|
||||
return [...existingReps, ...newReps]
|
||||
},
|
||||
|
||||
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
|
||||
},
|
||||
}
|
||||
|
||||
export const phaseManagement = {
|
||||
async createSoaking(request: CreateSoakingRequest): Promise<Soaking> {
|
||||
const { data: { user }, error: authError } = await supabase.auth.getUser()
|
||||
if (authError || !user) throw new Error('User not authenticated')
|
||||
|
||||
const scheduledEndTime = new Date(new Date(request.scheduled_start_time).getTime() + request.soaking_duration_minutes * 60000).toISOString()
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('soaking')
|
||||
.upsert({
|
||||
...request,
|
||||
scheduled_end_time: scheduledEndTime,
|
||||
created_by: user.id
|
||||
}, {
|
||||
onConflict: 'experiment_id,repetition_id'
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
},
|
||||
|
||||
async createAirdrying(request: CreateAirdryingRequest): Promise<Airdrying> {
|
||||
const { data: { user }, error: authError } = await supabase.auth.getUser()
|
||||
if (authError || !user) throw new Error('User not authenticated')
|
||||
|
||||
const scheduledEndTime = new Date(new Date(request.scheduled_start_time).getTime() + request.duration_minutes * 60000).toISOString()
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('airdrying')
|
||||
.upsert({
|
||||
...request,
|
||||
scheduled_end_time: scheduledEndTime,
|
||||
created_by: user.id
|
||||
}, {
|
||||
onConflict: 'experiment_id,repetition_id'
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
},
|
||||
|
||||
async createCracking(request: CreateCrackingRequest): Promise<any> {
|
||||
const { data: { user }, error: authError } = await supabase.auth.getUser()
|
||||
if (authError || !user) throw new Error('User not authenticated')
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('cracking')
|
||||
.upsert({
|
||||
...request,
|
||||
created_by: user.id
|
||||
}, {
|
||||
onConflict: 'experiment_id,repetition_id'
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
},
|
||||
|
||||
async getSoakingByExperimentId(experimentId: string): Promise<Soaking | null> {
|
||||
const { data, error } = await supabase
|
||||
.from('soaking')
|
||||
.select('*')
|
||||
.eq('experiment_id', experimentId)
|
||||
.is('repetition_id', null)
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
if (error.code === 'PGRST116') return null
|
||||
throw error
|
||||
}
|
||||
return data
|
||||
},
|
||||
|
||||
async getAirdryingByExperimentId(experimentId: string): Promise<Airdrying | null> {
|
||||
const { data, error } = await supabase
|
||||
.from('airdrying')
|
||||
.select('*')
|
||||
.eq('experiment_id', experimentId)
|
||||
.is('repetition_id', null)
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
if (error.code === 'PGRST116') return null
|
||||
throw error
|
||||
}
|
||||
return data
|
||||
},
|
||||
}
|
||||
|
||||
export const availabilityManagement = {
|
||||
async getMyAvailability(): Promise<ConductorAvailability[]> {
|
||||
const { data: { user }, error: authError } = await supabase.auth.getUser()
|
||||
if (authError || !user) throw new Error('User not authenticated')
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('conductor_availability')
|
||||
.select('*')
|
||||
.eq('user_id', user.id)
|
||||
.eq('status', 'active')
|
||||
.order('available_from', { ascending: true })
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
},
|
||||
|
||||
async createAvailability(request: CreateAvailabilityRequest): Promise<ConductorAvailability> {
|
||||
const { data: { user }, error: authError } = await supabase.auth.getUser()
|
||||
if (authError || !user) throw new Error('User not authenticated')
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('conductor_availability')
|
||||
.insert({
|
||||
user_id: user.id,
|
||||
available_from: request.available_from,
|
||||
available_to: request.available_to,
|
||||
notes: request.notes,
|
||||
created_by: user.id
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
},
|
||||
|
||||
async deleteAvailability(id: string): Promise<void> {
|
||||
const { error } = await supabase
|
||||
.from('conductor_availability')
|
||||
.update({ status: 'cancelled' })
|
||||
.eq('id', id)
|
||||
|
||||
if (error) throw error
|
||||
},
|
||||
}
|
||||
|
||||
11
scheduling-remote/src/vite-env.d.ts
vendored
Normal file
11
scheduling-remote/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_SUPABASE_URL?: string
|
||||
readonly VITE_SUPABASE_ANON_KEY?: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
|
||||
26
scheduling-remote/tsconfig.json
Normal file
26
scheduling-remote/tsconfig.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src", "src/vite-env.d.ts"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
|
||||
11
scheduling-remote/tsconfig.node.json
Normal file
11
scheduling-remote/tsconfig.node.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
|
||||
32
scheduling-remote/vite.config.ts
Normal file
32
scheduling-remote/vite.config.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import federation from '@originjs/vite-plugin-federation'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
react(),
|
||||
tailwindcss(),
|
||||
federation({
|
||||
name: 'schedulingRemote',
|
||||
filename: 'remoteEntry.js',
|
||||
exposes: {
|
||||
'./App': './src/App.tsx',
|
||||
},
|
||||
shared: {
|
||||
react: { singleton: true, eager: true },
|
||||
'react-dom': { singleton: true, eager: true },
|
||||
},
|
||||
}),
|
||||
],
|
||||
server: {
|
||||
port: 3003,
|
||||
host: '0.0.0.0',
|
||||
allowedHosts: ['exp-dash', 'localhost'],
|
||||
cors: true
|
||||
},
|
||||
build: {
|
||||
target: 'esnext',
|
||||
},
|
||||
})
|
||||
|
||||
25
scripts/add-dark-mode-to-components.sh
Normal file
25
scripts/add-dark-mode-to-components.sh
Normal file
@@ -0,0 +1,25 @@
|
||||
#!/bin/bash
|
||||
# Helper script to add dark mode classes to components
|
||||
# This is a reference - actual changes should be made manually for safety
|
||||
|
||||
echo "Patterns to apply for dark mode:"
|
||||
echo ""
|
||||
echo "1. Backgrounds:"
|
||||
echo " bg-white -> bg-white dark:bg-gray-800"
|
||||
echo " bg-gray-50 -> bg-gray-50 dark:bg-gray-900"
|
||||
echo ""
|
||||
echo "2. Text:"
|
||||
echo " text-gray-900 -> text-gray-900 dark:text-white"
|
||||
echo " text-gray-800 -> text-gray-800 dark:text-white/90"
|
||||
echo " text-gray-700 -> text-gray-700 dark:text-gray-300"
|
||||
echo " text-gray-600 -> text-gray-600 dark:text-gray-400"
|
||||
echo " text-gray-500 -> text-gray-500 dark:text-gray-400"
|
||||
echo ""
|
||||
echo "3. Borders:"
|
||||
echo " border-gray-200 -> border-gray-200 dark:border-gray-700"
|
||||
echo " border-gray-300 -> border-gray-300 dark:border-gray-600"
|
||||
echo ""
|
||||
echo "4. Buttons/Inputs:"
|
||||
echo " bg-blue-100 -> bg-blue-100 dark:bg-blue-900/20"
|
||||
echo " text-blue-800 -> text-blue-800 dark:text-blue-300"
|
||||
|
||||
@@ -51,3 +51,5 @@ echo ""
|
||||
echo "All services have been reset and are running in detached mode."
|
||||
echo "Use 'docker compose logs -f' to view logs or 'docker compose ps' to check status."
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -57,8 +57,10 @@ export default function App() {
|
||||
setRecordings(recordingsData)
|
||||
setLastUpdate(new Date())
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch data')
|
||||
// Don't set error state - let widgets show API is unavailable
|
||||
// Keep existing state so UI can still render
|
||||
console.error('Failed to fetch initial data:', err)
|
||||
setLastUpdate(new Date()) // Update timestamp even on error to show we tried
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -221,12 +223,32 @@ export default function App() {
|
||||
const handlePreviewModal = useCallback((cameraName: string) => {
|
||||
setPreviewCamera(cameraName)
|
||||
setPreviewModalOpen(true)
|
||||
// The modal will start streaming and notify us via onStreamStarted callback
|
||||
}, [])
|
||||
|
||||
const handlePreviewNewWindow = useCallback((cameraName: string) => {
|
||||
// Open camera stream in new window/tab
|
||||
const streamUrl = visionApi.getStreamUrl(cameraName)
|
||||
window.open(streamUrl, '_blank')
|
||||
const handlePreviewNewWindow = useCallback(async (cameraName: string) => {
|
||||
try {
|
||||
// Start streaming before opening new window
|
||||
const result = await visionApi.startStream(cameraName)
|
||||
if (result.success) {
|
||||
// Immediately update camera status to show "Stop Streaming" button
|
||||
setCameras((prev) => ({
|
||||
...prev,
|
||||
[cameraName]: {
|
||||
...prev[cameraName],
|
||||
status: 'streaming',
|
||||
},
|
||||
}))
|
||||
|
||||
// Open camera stream in new window/tab
|
||||
const streamUrl = visionApi.getStreamUrl(cameraName)
|
||||
window.open(streamUrl, '_blank')
|
||||
} else {
|
||||
setNotification({ type: 'error', message: `Failed to start stream: ${result.message}` })
|
||||
}
|
||||
} catch (err) {
|
||||
setNotification({ type: 'error', message: err instanceof Error ? err.message : 'Failed to start stream' })
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleConfigure = useCallback((cameraName: string) => {
|
||||
@@ -259,8 +281,20 @@ export default function App() {
|
||||
const result = await visionApi.stopStream(cameraName)
|
||||
if (result.success) {
|
||||
setNotification({ type: 'success', message: 'Streaming stopped' })
|
||||
// Refresh camera status
|
||||
visionApi.getCameras().then(setCameras).catch(console.error)
|
||||
|
||||
// Immediately update camera status (UI updates instantly)
|
||||
setCameras((prev) => ({
|
||||
...prev,
|
||||
[cameraName]: {
|
||||
...prev[cameraName],
|
||||
status: 'available',
|
||||
},
|
||||
}))
|
||||
|
||||
// Refresh camera status from API as backup
|
||||
setTimeout(() => {
|
||||
visionApi.getCameras().then(setCameras).catch(console.error)
|
||||
}, 500)
|
||||
} else {
|
||||
setNotification({ type: 'error', message: `Failed: ${result.message}` })
|
||||
}
|
||||
@@ -438,6 +472,26 @@ export default function App() {
|
||||
setPreviewModalOpen(false)
|
||||
setPreviewCamera(null)
|
||||
}}
|
||||
onStreamStarted={() => {
|
||||
// Update camera status when streaming starts
|
||||
setCameras((prev) => ({
|
||||
...prev,
|
||||
[previewCamera]: {
|
||||
...prev[previewCamera],
|
||||
status: 'streaming',
|
||||
},
|
||||
}))
|
||||
}}
|
||||
onStreamStopped={() => {
|
||||
// Update camera status when streaming stops
|
||||
setCameras((prev) => ({
|
||||
...prev,
|
||||
[previewCamera]: {
|
||||
...prev[previewCamera],
|
||||
status: 'available',
|
||||
},
|
||||
}))
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -195,11 +195,11 @@ export const CameraCard: React.FC<CameraCardProps> = ({
|
||||
{isStreaming && (
|
||||
<button
|
||||
onClick={() => onStopStreaming(cameraName)}
|
||||
className="px-3 py-2 text-sm font-medium text-white bg-orange-600 rounded-lg hover:bg-orange-700 focus:outline-none focus:ring-2 focus:ring-orange-500 focus:ring-offset-2 transition-colors"
|
||||
className="flex items-center justify-center px-3 py-2 text-sm font-medium text-white bg-orange-600 rounded-lg hover:bg-orange-700 focus:outline-none focus:ring-2 focus:ring-orange-500 focus:ring-offset-2 transition-colors"
|
||||
title="Stop streaming"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
<svg className="w-4 h-4 text-white" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -6,6 +6,8 @@ interface CameraPreviewModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onError?: (error: string) => void
|
||||
onStreamStarted?: () => void
|
||||
onStreamStopped?: () => void
|
||||
}
|
||||
|
||||
export const CameraPreviewModal: React.FC<CameraPreviewModalProps> = ({
|
||||
@@ -13,6 +15,8 @@ export const CameraPreviewModal: React.FC<CameraPreviewModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onError,
|
||||
onStreamStarted,
|
||||
onStreamStopped,
|
||||
}) => {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [streaming, setStreaming] = useState(false)
|
||||
@@ -46,6 +50,9 @@ export const CameraPreviewModal: React.FC<CameraPreviewModalProps> = ({
|
||||
if (imgRef.current) {
|
||||
imgRef.current.src = `${streamUrl}?t=${Date.now()}`
|
||||
}
|
||||
|
||||
// Notify parent that streaming started
|
||||
onStreamStarted?.()
|
||||
} else {
|
||||
throw new Error(result.message)
|
||||
}
|
||||
@@ -68,6 +75,9 @@ export const CameraPreviewModal: React.FC<CameraPreviewModalProps> = ({
|
||||
if (imgRef.current) {
|
||||
imgRef.current.src = ''
|
||||
}
|
||||
|
||||
// Notify parent that streaming stopped
|
||||
onStreamStopped?.()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error stopping stream:', err)
|
||||
@@ -82,12 +92,13 @@ export const CameraPreviewModal: React.FC<CameraPreviewModalProps> = ({
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[999999] flex items-center justify-center overflow-y-auto">
|
||||
<div className="fixed inset-0 z-[999999] flex items-center justify-center overflow-y-auto p-4">
|
||||
<div
|
||||
className="fixed inset-0 h-full w-full bg-gray-900/60 backdrop-blur-sm"
|
||||
className="fixed inset-0 h-full w-full bg-gray-900/70 backdrop-blur-md"
|
||||
onClick={handleClose}
|
||||
style={{ backdropFilter: 'blur(8px)', WebkitBackdropFilter: 'blur(8px)' }}
|
||||
/>
|
||||
<div className="relative w-11/12 max-w-5xl rounded-xl bg-white shadow-2xl dark:bg-gray-800 p-6" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="relative w-full max-w-4xl rounded-xl bg-white shadow-2xl dark:bg-gray-800 p-6" onClick={(e) => e.stopPropagation()}>
|
||||
{/* Close Button */}
|
||||
<button
|
||||
onClick={handleClose}
|
||||
@@ -148,7 +159,7 @@ export const CameraPreviewModal: React.FC<CameraPreviewModalProps> = ({
|
||||
<img
|
||||
ref={imgRef}
|
||||
alt={`Live stream from ${cameraName}`}
|
||||
className="w-full h-auto max-h-[70vh] object-contain"
|
||||
className="w-full h-auto max-h-[60vh] object-contain"
|
||||
onError={() => setError('Failed to load camera stream')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user