feat: add Pagination component for video list navigation

- Implemented a reusable Pagination component with first/last, previous/next, and numbered page buttons.
- Added PageInfo component to display current page and total items.
- Integrated pagination into VideoList component, allowing users to navigate through video pages.
- Updated useVideoList hook to manage current page and total pages state.
- Modified videoApi service to support pagination with offset-based API.
- Enhanced VideoCard styling for better UI consistency.
- Updated Tailwind CSS configuration to include custom colors and shadows for branding.
- Refactored video file settings to use 'h264' codec for better compatibility.
This commit is contained in:
Alireza Vaezi
2025-08-05 13:56:26 -04:00
parent 7bc76d72f9
commit 228efb0f55
38 changed files with 1836 additions and 604 deletions

View File

@@ -1,27 +1,32 @@
# API Changes Summary: Camera Settings and Video Format Updates # API Changes Summary: Camera Settings and Video Format Updates
## Overview ## Overview
This document tracks major API changes including camera settings enhancements and the MP4 video format update. This document tracks major API changes including camera settings enhancements and the MP4 video format update.
## 🎥 Latest Update: MP4 Video Format (v2.1) ## 🎥 Latest Update: MP4 Video Format (v2.1)
**Date**: August 2025 **Date**: August 2025
**Major Changes**: **Major Changes**:
- **Video Format**: Changed from AVI/XVID to MP4/MPEG-4 format
- **Video Format**: Changed from AVI/XVID to MP4/H.264 format
- **File Extensions**: New recordings use `.mp4` instead of `.avi` - **File Extensions**: New recordings use `.mp4` instead of `.avi`
- **File Size**: ~40% reduction in file sizes - **File Size**: ~40% reduction in file sizes
- **Streaming**: Better web browser compatibility - **Streaming**: Better web browser compatibility
**New Configuration Fields**: **New Configuration Fields**:
```json ```json
{ {
"video_format": "mp4", // File format: "mp4" or "avi" "video_format": "mp4", // File format: "mp4" or "avi"
"video_codec": "mp4v", // Video codec: "mp4v", "XVID", "MJPG" "video_codec": "h264", // Video codec: "h264", "mp4v", "XVID", "MJPG"
"video_quality": 95 // Quality: 0-100 (higher = better) "video_quality": 95 // Quality: 0-100 (higher = better)
} }
``` ```
**Frontend Impact**: **Frontend Impact**:
- ✅ Better streaming performance and browser support - ✅ Better streaming performance and browser support
- ✅ Smaller file sizes for faster transfers - ✅ Smaller file sizes for faster transfers
- ✅ Universal HTML5 video player compatibility - ✅ Universal HTML5 video player compatibility
@@ -38,12 +43,14 @@ Enhanced the `POST /cameras/{camera_name}/start-recording` API endpoint to accep
## Changes Made ## Changes Made
### 1. API Models (`usda_vision_system/api/models.py`) ### 1. API Models (`usda_vision_system/api/models.py`)
- **Enhanced `StartRecordingRequest`** to include optional parameters: - **Enhanced `StartRecordingRequest`** to include optional parameters:
- `exposure_ms: Optional[float]` - Exposure time in milliseconds - `exposure_ms: Optional[float]` - Exposure time in milliseconds
- `gain: Optional[float]` - Camera gain value - `gain: Optional[float]` - Camera gain value
- `fps: Optional[float]` - Target frames per second - `fps: Optional[float]` - Target frames per second
### 2. Camera Recorder (`usda_vision_system/camera/recorder.py`) ### 2. Camera Recorder (`usda_vision_system/camera/recorder.py`)
- **Added `update_camera_settings()` method** to dynamically update camera settings: - **Added `update_camera_settings()` method** to dynamically update camera settings:
- Updates exposure time using `mvsdk.CameraSetExposureTime()` - Updates exposure time using `mvsdk.CameraSetExposureTime()`
- Updates gain using `mvsdk.CameraSetAnalogGain()` - Updates gain using `mvsdk.CameraSetAnalogGain()`
@@ -52,20 +59,23 @@ Enhanced the `POST /cameras/{camera_name}/start-recording` API endpoint to accep
- Returns boolean indicating success/failure - Returns boolean indicating success/failure
### 3. Camera Manager (`usda_vision_system/camera/manager.py`) ### 3. Camera Manager (`usda_vision_system/camera/manager.py`)
- **Enhanced `manual_start_recording()` method** to accept new parameters: - **Enhanced `manual_start_recording()` method** to accept new parameters:
- Added optional `exposure_ms`, `gain`, and `fps` parameters - Added optional `exposure_ms`, `gain`, and `fps` parameters
- Calls `update_camera_settings()` if any settings are provided - Calls `update_camera_settings()` if any settings are provided
- **Automatic datetime prefix**: Always prepends timestamp to filename - **Automatic datetime prefix**: Always prepends timestamp to filename
- If custom filename provided: `{timestamp}_{custom_filename}` - If custom filename provided: `{timestamp}_{custom_filename}`
- If no filename provided: `{camera_name}_manual_{timestamp}.avi` - If no filename provided: `{camera_name}_manual_{timestamp}.mp4`
### 4. API Server (`usda_vision_system/api/server.py`) ### 4. API Server (`usda_vision_system/api/server.py`)
- **Updated start-recording endpoint** to: - **Updated start-recording endpoint** to:
- Pass new camera settings to camera manager - Pass new camera settings to camera manager
- Handle filename response with datetime prefix - Handle filename response with datetime prefix
- Maintain backward compatibility with existing requests - Maintain backward compatibility with existing requests
### 5. API Tests (`api-tests.http`) ### 5. API Tests (`api-tests.http`)
- **Added comprehensive test examples**: - **Added comprehensive test examples**:
- Basic recording (existing functionality) - Basic recording (existing functionality)
- Recording with camera settings - Recording with camera settings
@@ -75,8 +85,9 @@ Enhanced the `POST /cameras/{camera_name}/start-recording` API endpoint to accep
## Usage Examples ## Usage Examples
### Basic Recording (unchanged) ### Basic Recording (unchanged)
```http ```http
POST http://localhost:8000/cameras/camera1/start-recording POST http://vision:8000/cameras/camera1/start-recording
Content-Type: application/json Content-Type: application/json
{ {
@@ -84,11 +95,13 @@ Content-Type: application/json
"filename": "test.avi" "filename": "test.avi"
} }
``` ```
**Result**: File saved as `20241223_143022_test.avi` **Result**: File saved as `20241223_143022_test.avi`
### Recording with Camera Settings ### Recording with Camera Settings
```http ```http
POST http://localhost:8000/cameras/camera1/start-recording POST http://vision:8000/cameras/camera1/start-recording
Content-Type: application/json Content-Type: application/json
{ {
@@ -99,13 +112,16 @@ Content-Type: application/json
"fps": 5.0 "fps": 5.0
} }
``` ```
**Result**: **Result**:
- Camera settings updated before recording - Camera settings updated before recording
- File saved as `20241223_143022_high_quality.avi` - File saved as `20241223_143022_high_quality.avi`
### Maximum FPS Recording ### Maximum FPS Recording
```http ```http
POST http://localhost:8000/cameras/camera1/start-recording POST http://vision:8000/cameras/camera1/start-recording
Content-Type: application/json Content-Type: application/json
{ {
@@ -116,14 +132,17 @@ Content-Type: application/json
"fps": 0 "fps": 0
} }
``` ```
**Result**: **Result**:
- Camera captures at maximum possible speed (no delay between frames) - Camera captures at maximum possible speed (no delay between frames)
- Video file saved with 30 FPS metadata for proper playback - Video file saved with 30 FPS metadata for proper playback
- Actual capture rate depends on camera hardware and exposure settings - Actual capture rate depends on camera hardware and exposure settings
### Settings Only (no filename) ### Settings Only (no filename)
```http ```http
POST http://localhost:8000/cameras/camera1/start-recording POST http://vision:8000/cameras/camera1/start-recording
Content-Type: application/json Content-Type: application/json
{ {
@@ -133,34 +152,41 @@ Content-Type: application/json
"fps": 7.0 "fps": 7.0
} }
``` ```
**Result**: **Result**:
- Camera settings updated - Camera settings updated
- File saved as `camera1_manual_20241223_143022.avi` - File saved as `camera1_manual_20241223_143022.avi`
## Key Features ## Key Features
### 1. **Backward Compatibility** ### 1. **Backward Compatibility**
- All existing API calls continue to work unchanged - All existing API calls continue to work unchanged
- New parameters are optional - New parameters are optional
- Default behavior preserved when no settings provided - Default behavior preserved when no settings provided
### 2. **Automatic Datetime Prefix** ### 2. **Automatic Datetime Prefix**
- **ALL filenames now have datetime prefix** regardless of what's sent - **ALL filenames now have datetime prefix** regardless of what's sent
- Format: `YYYYMMDD_HHMMSS_` (Atlanta timezone) - Format: `YYYYMMDD_HHMMSS_` (Atlanta timezone)
- Ensures unique filenames and chronological ordering - Ensures unique filenames and chronological ordering
### 3. **Dynamic Camera Settings** ### 3. **Dynamic Camera Settings**
- Settings can be changed per recording without restarting system - Settings can be changed per recording without restarting system
- Based on proven implementation from `old tests/camera_video_recorder.py` - Based on proven implementation from `old tests/camera_video_recorder.py`
- Proper error handling and logging - Proper error handling and logging
### 4. **Maximum FPS Capture** ### 4. **Maximum FPS Capture**
- **`fps: 0`** = Capture at maximum possible speed (no delay between frames) - **`fps: 0`** = Capture at maximum possible speed (no delay between frames)
- **`fps > 0`** = Capture at specified frame rate with controlled timing - **`fps > 0`** = Capture at specified frame rate with controlled timing
- **`fps` omitted** = Uses camera config default (usually 3.0 fps) - **`fps` omitted** = Uses camera config default (usually 3.0 fps)
- Video files saved with 30 FPS metadata when fps=0 for proper playback - Video files saved with 30 FPS metadata when fps=0 for proper playback
### 5. **Parameter Validation** ### 5. **Parameter Validation**
- Uses Pydantic models for automatic validation - Uses Pydantic models for automatic validation
- Optional parameters with proper type checking - Optional parameters with proper type checking
- Descriptive field documentation - Descriptive field documentation
@@ -168,6 +194,7 @@ Content-Type: application/json
## Testing ## Testing
Run the test script to verify functionality: Run the test script to verify functionality:
```bash ```bash
# Start the system first # Start the system first
python main.py python main.py
@@ -177,6 +204,7 @@ python test_api_changes.py
``` ```
The test script verifies: The test script verifies:
- Basic recording functionality - Basic recording functionality
- Camera settings application - Camera settings application
- Filename datetime prefix handling - Filename datetime prefix handling
@@ -185,22 +213,27 @@ The test script verifies:
## Implementation Notes ## Implementation Notes
### Camera Settings Mapping ### Camera Settings Mapping
- **Exposure**: Converted from milliseconds to microseconds for SDK - **Exposure**: Converted from milliseconds to microseconds for SDK
- **Gain**: Converted to camera units (multiplied by 100) - **Gain**: Converted to camera units (multiplied by 100)
- **FPS**: Stored in camera config, used by recording loop - **FPS**: Stored in camera config, used by recording loop
### Error Handling ### Error Handling
- Settings update failures are logged but don't prevent recording - Settings update failures are logged but don't prevent recording
- Invalid camera names return appropriate HTTP errors - Invalid camera names return appropriate HTTP errors
- Camera initialization failures are handled gracefully - Camera initialization failures are handled gracefully
### Filename Generation ### Filename Generation
- Uses `format_filename_timestamp()` from timezone utilities - Uses `format_filename_timestamp()` from timezone utilities
- Ensures Atlanta timezone consistency - Ensures Atlanta timezone consistency
- Handles both custom and auto-generated filenames - Handles both custom and auto-generated filenames
## Similar to Old Implementation ## Similar to Old Implementation
The camera settings functionality mirrors the proven approach in `old tests/camera_video_recorder.py`: The camera settings functionality mirrors the proven approach in `old tests/camera_video_recorder.py`:
- Same parameter names and ranges - Same parameter names and ranges
- Same SDK function calls - Same SDK function calls
- Same conversion factors - Same conversion factors

View File

@@ -18,10 +18,13 @@ This document provides comprehensive documentation for all API endpoints in the
## 🔧 System Status & Health ## 🔧 System Status & Health
### Get System Status ### Get System Status
```http ```http
GET /system/status GET /system/status
``` ```
**Response**: `SystemStatusResponse` **Response**: `SystemStatusResponse`
```json ```json
{ {
"system_started": true, "system_started": true,
@@ -49,10 +52,13 @@ GET /system/status
``` ```
### Health Check ### Health Check
```http ```http
GET /health GET /health
``` ```
**Response**: Simple health status **Response**: Simple health status
```json ```json
{ {
"status": "healthy", "status": "healthy",
@@ -63,16 +69,21 @@ GET /health
## 📷 Camera Management ## 📷 Camera Management
### Get All Cameras ### Get All Cameras
```http ```http
GET /cameras GET /cameras
``` ```
**Response**: `Dict[str, CameraStatusResponse]` **Response**: `Dict[str, CameraStatusResponse]`
### Get Specific Camera Status ### Get Specific Camera Status
```http ```http
GET /cameras/{camera_name}/status GET /cameras/{camera_name}/status
``` ```
**Response**: `CameraStatusResponse` **Response**: `CameraStatusResponse`
```json ```json
{ {
"name": "camera1", "name": "camera1",
@@ -97,12 +108,13 @@ GET /cameras/{camera_name}/status
## 🎥 Recording Control ## 🎥 Recording Control
### Start Recording ### Start Recording
```http ```http
POST /cameras/{camera_name}/start-recording POST /cameras/{camera_name}/start-recording
Content-Type: application/json Content-Type: application/json
{ {
"filename": "test_recording.avi", "filename": "test_recording.mp4",
"exposure_ms": 2.0, "exposure_ms": 2.0,
"gain": 4.0, "gain": 4.0,
"fps": 5.0 "fps": 5.0
@@ -110,30 +122,36 @@ Content-Type: application/json
``` ```
**Request Model**: `StartRecordingRequest` **Request Model**: `StartRecordingRequest`
- `filename` (optional): Custom filename (datetime prefix will be added automatically) - `filename` (optional): Custom filename (datetime prefix will be added automatically)
- `exposure_ms` (optional): Exposure time in milliseconds - `exposure_ms` (optional): Exposure time in milliseconds
- `gain` (optional): Camera gain value - `gain` (optional): Camera gain value
- `fps` (optional): Target frames per second - `fps` (optional): Target frames per second
**Response**: `StartRecordingResponse` **Response**: `StartRecordingResponse`
```json ```json
{ {
"success": true, "success": true,
"message": "Recording started for camera1", "message": "Recording started for camera1",
"filename": "20240115_103000_test_recording.avi" "filename": "20240115_103000_test_recording.mp4"
} }
``` ```
**Key Features**: **Key Features**:
-**Automatic datetime prefix**: All filenames get `YYYYMMDD_HHMMSS_` prefix -**Automatic datetime prefix**: All filenames get `YYYYMMDD_HHMMSS_` prefix
-**Dynamic camera settings**: Adjust exposure, gain, and FPS per recording -**Dynamic camera settings**: Adjust exposure, gain, and FPS per recording
-**Backward compatibility**: All existing API calls work unchanged -**Backward compatibility**: All existing API calls work unchanged
### Stop Recording ### Stop Recording
```http ```http
POST /cameras/{camera_name}/stop-recording POST /cameras/{camera_name}/stop-recording
``` ```
**Response**: `StopRecordingResponse` **Response**: `StopRecordingResponse`
```json ```json
{ {
"success": true, "success": true,
@@ -145,10 +163,13 @@ POST /cameras/{camera_name}/stop-recording
## 🤖 Auto-Recording Management ## 🤖 Auto-Recording Management
### Enable Auto-Recording for Camera ### Enable Auto-Recording for Camera
```http ```http
POST /cameras/{camera_name}/auto-recording/enable POST /cameras/{camera_name}/auto-recording/enable
``` ```
**Response**: `AutoRecordingConfigResponse` **Response**: `AutoRecordingConfigResponse`
```json ```json
{ {
"success": true, "success": true,
@@ -159,16 +180,21 @@ POST /cameras/{camera_name}/auto-recording/enable
``` ```
### Disable Auto-Recording for Camera ### Disable Auto-Recording for Camera
```http ```http
POST /cameras/{camera_name}/auto-recording/disable POST /cameras/{camera_name}/auto-recording/disable
``` ```
**Response**: `AutoRecordingConfigResponse` **Response**: `AutoRecordingConfigResponse`
### Get Auto-Recording Status ### Get Auto-Recording Status
```http ```http
GET /auto-recording/status GET /auto-recording/status
``` ```
**Response**: `AutoRecordingStatusResponse` **Response**: `AutoRecordingStatusResponse`
```json ```json
{ {
"running": true, "running": true,
@@ -179,6 +205,7 @@ GET /auto-recording/status
``` ```
**Auto-Recording Features**: **Auto-Recording Features**:
- 🤖 **MQTT-triggered recording**: Automatically starts/stops based on machine state - 🤖 **MQTT-triggered recording**: Automatically starts/stops based on machine state
- 🔄 **Retry logic**: Failed recordings are retried with configurable delays - 🔄 **Retry logic**: Failed recordings are retried with configurable delays
- 📊 **Per-camera control**: Enable/disable auto-recording individually - 📊 **Per-camera control**: Enable/disable auto-recording individually
@@ -187,10 +214,13 @@ GET /auto-recording/status
## 🎛️ Camera Configuration ## 🎛️ Camera Configuration
### Get Camera Configuration ### Get Camera Configuration
```http ```http
GET /cameras/{camera_name}/config GET /cameras/{camera_name}/config
``` ```
**Response**: `CameraConfigResponse` **Response**: `CameraConfigResponse`
```json ```json
{ {
"name": "camera1", "name": "camera1",
@@ -225,6 +255,7 @@ GET /cameras/{camera_name}/config
``` ```
### Update Camera Configuration ### Update Camera Configuration
```http ```http
PUT /cameras/{camera_name}/config PUT /cameras/{camera_name}/config
Content-Type: application/json Content-Type: application/json
@@ -238,11 +269,13 @@ Content-Type: application/json
``` ```
### Apply Configuration (Restart Required) ### Apply Configuration (Restart Required)
```http ```http
POST /cameras/{camera_name}/apply-config POST /cameras/{camera_name}/apply-config
``` ```
**Configuration Categories**: **Configuration Categories**:
-**Real-time**: `exposure_ms`, `gain`, `target_fps`, `sharpness`, `contrast`, etc. -**Real-time**: `exposure_ms`, `gain`, `target_fps`, `sharpness`, `contrast`, etc.
- ⚠️ **Restart required**: `noise_filter_enabled`, `denoise_3d_enabled`, `bit_depth`, `video_format`, `video_codec`, `video_quality` - ⚠️ **Restart required**: `noise_filter_enabled`, `denoise_3d_enabled`, `bit_depth`, `video_format`, `video_codec`, `video_quality`
@@ -251,16 +284,21 @@ For detailed configuration options, see [Camera Configuration API Guide](api/CAM
## 📡 MQTT & Machine Status ## 📡 MQTT & Machine Status
### Get All Machines ### Get All Machines
```http ```http
GET /machines GET /machines
``` ```
**Response**: `Dict[str, MachineStatusResponse]` **Response**: `Dict[str, MachineStatusResponse]`
### Get MQTT Status ### Get MQTT Status
```http ```http
GET /mqtt/status GET /mqtt/status
``` ```
**Response**: `MQTTStatusResponse` **Response**: `MQTTStatusResponse`
```json ```json
{ {
"connected": true, "connected": true,
@@ -275,10 +313,13 @@ GET /mqtt/status
``` ```
### Get MQTT Events History ### Get MQTT Events History
```http ```http
GET /mqtt/events?limit=10 GET /mqtt/events?limit=10
``` ```
**Response**: `MQTTEventsHistoryResponse` **Response**: `MQTTEventsHistoryResponse`
```json ```json
{ {
"events": [ "events": [
@@ -299,10 +340,13 @@ GET /mqtt/events?limit=10
## 💾 Storage & File Management ## 💾 Storage & File Management
### Get Storage Statistics ### Get Storage Statistics
```http ```http
GET /storage/stats GET /storage/stats
``` ```
**Response**: `StorageStatsResponse` **Response**: `StorageStatsResponse`
```json ```json
{ {
"base_path": "/storage", "base_path": "/storage",
@@ -328,6 +372,7 @@ GET /storage/stats
``` ```
### Get File List ### Get File List
```http ```http
POST /storage/files POST /storage/files
Content-Type: application/json Content-Type: application/json
@@ -339,7 +384,9 @@ Content-Type: application/json
"limit": 50 "limit": 50
} }
``` ```
**Response**: `FileListResponse` **Response**: `FileListResponse`
```json ```json
{ {
"files": [ "files": [
@@ -356,6 +403,7 @@ Content-Type: application/json
``` ```
### Cleanup Old Files ### Cleanup Old Files
```http ```http
POST /storage/cleanup POST /storage/cleanup
Content-Type: application/json Content-Type: application/json
@@ -364,7 +412,9 @@ Content-Type: application/json
"max_age_days": 30 "max_age_days": 30
} }
``` ```
**Response**: `CleanupResponse` **Response**: `CleanupResponse`
```json ```json
{ {
"files_removed": 25, "files_removed": 25,
@@ -376,42 +426,55 @@ Content-Type: application/json
## 🔄 Camera Recovery & Diagnostics ## 🔄 Camera Recovery & Diagnostics
### Test Camera Connection ### Test Camera Connection
```http ```http
POST /cameras/{camera_name}/test-connection POST /cameras/{camera_name}/test-connection
``` ```
**Response**: `CameraTestResponse` **Response**: `CameraTestResponse`
### Reconnect Camera ### Reconnect Camera
```http ```http
POST /cameras/{camera_name}/reconnect POST /cameras/{camera_name}/reconnect
``` ```
**Response**: `CameraRecoveryResponse` **Response**: `CameraRecoveryResponse`
### Restart Camera Grab Process ### Restart Camera Grab Process
```http ```http
POST /cameras/{camera_name}/restart-grab POST /cameras/{camera_name}/restart-grab
``` ```
**Response**: `CameraRecoveryResponse` **Response**: `CameraRecoveryResponse`
### Reset Camera Timestamp ### Reset Camera Timestamp
```http ```http
POST /cameras/{camera_name}/reset-timestamp POST /cameras/{camera_name}/reset-timestamp
``` ```
**Response**: `CameraRecoveryResponse` **Response**: `CameraRecoveryResponse`
### Full Camera Reset ### Full Camera Reset
```http ```http
POST /cameras/{camera_name}/full-reset POST /cameras/{camera_name}/full-reset
``` ```
**Response**: `CameraRecoveryResponse` **Response**: `CameraRecoveryResponse`
### Reinitialize Camera ### Reinitialize Camera
```http ```http
POST /cameras/{camera_name}/reinitialize POST /cameras/{camera_name}/reinitialize
``` ```
**Response**: `CameraRecoveryResponse` **Response**: `CameraRecoveryResponse`
**Recovery Response Example**: **Recovery Response Example**:
```json ```json
{ {
"success": true, "success": true,
@@ -425,22 +488,27 @@ POST /cameras/{camera_name}/reinitialize
## 📺 Live Streaming ## 📺 Live Streaming
### Get Live MJPEG Stream ### Get Live MJPEG Stream
```http ```http
GET /cameras/{camera_name}/stream GET /cameras/{camera_name}/stream
``` ```
**Response**: MJPEG video stream (multipart/x-mixed-replace) **Response**: MJPEG video stream (multipart/x-mixed-replace)
### Start Camera Stream ### Start Camera Stream
```http ```http
POST /cameras/{camera_name}/start-stream POST /cameras/{camera_name}/start-stream
``` ```
### Stop Camera Stream ### Stop Camera Stream
```http ```http
POST /cameras/{camera_name}/stop-stream POST /cameras/{camera_name}/stop-stream
``` ```
**Streaming Features**: **Streaming Features**:
- 📺 **MJPEG format**: Compatible with web browsers and React apps - 📺 **MJPEG format**: Compatible with web browsers and React apps
- 🔄 **Concurrent operation**: Stream while recording simultaneously - 🔄 **Concurrent operation**: Stream while recording simultaneously
-**Low latency**: Real-time preview for monitoring -**Low latency**: Real-time preview for monitoring
@@ -450,8 +518,9 @@ For detailed streaming integration, see [Streaming Guide](guides/STREAMING_GUIDE
## 🌐 WebSocket Real-time Updates ## 🌐 WebSocket Real-time Updates
### Connect to WebSocket ### Connect to WebSocket
```javascript ```javascript
const ws = new WebSocket('ws://localhost:8000/ws'); const ws = new WebSocket('ws://vision:8000/ws');
ws.onmessage = (event) => { ws.onmessage = (event) => {
const update = JSON.parse(event.data); const update = JSON.parse(event.data);
@@ -460,6 +529,7 @@ ws.onmessage = (event) => {
``` ```
**WebSocket Message Types**: **WebSocket Message Types**:
- `system_status`: System status changes - `system_status`: System status changes
- `camera_status`: Camera status updates - `camera_status`: Camera status updates
- `recording_started`: Recording start events - `recording_started`: Recording start events
@@ -468,6 +538,7 @@ ws.onmessage = (event) => {
- `auto_recording_event`: Auto-recording status changes - `auto_recording_event`: Auto-recording status changes
**Example WebSocket Message**: **Example WebSocket Message**:
```json ```json
{ {
"type": "recording_started", "type": "recording_started",
@@ -483,26 +554,28 @@ ws.onmessage = (event) => {
## 🚀 Quick Start Examples ## 🚀 Quick Start Examples
### Basic System Monitoring ### Basic System Monitoring
```bash ```bash
# Check system health # Check system health
curl http://localhost:8000/health curl http://vision:8000/health
# Get overall system status # Get overall system status
curl http://localhost:8000/system/status curl http://vision:8000/system/status
# Get all camera statuses # Get all camera statuses
curl http://localhost:8000/cameras curl http://vision:8000/cameras
``` ```
### Manual Recording Control ### Manual Recording Control
```bash ```bash
# Start recording with default settings # Start recording with default settings
curl -X POST http://localhost:8000/cameras/camera1/start-recording \ curl -X POST http://vision:8000/cameras/camera1/start-recording \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{"filename": "manual_test.avi"}' -d '{"filename": "manual_test.avi"}'
# Start recording with custom camera settings # Start recording with custom camera settings
curl -X POST http://localhost:8000/cameras/camera1/start-recording \ curl -X POST http://vision:8000/cameras/camera1/start-recording \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{ -d '{
"filename": "high_quality.avi", "filename": "high_quality.avi",
@@ -512,28 +585,30 @@ curl -X POST http://localhost:8000/cameras/camera1/start-recording \
}' }'
# Stop recording # Stop recording
curl -X POST http://localhost:8000/cameras/camera1/stop-recording curl -X POST http://vision:8000/cameras/camera1/stop-recording
``` ```
### Auto-Recording Management ### Auto-Recording Management
```bash ```bash
# Enable auto-recording for camera1 # Enable auto-recording for camera1
curl -X POST http://localhost:8000/cameras/camera1/auto-recording/enable curl -X POST http://vision:8000/cameras/camera1/auto-recording/enable
# Check auto-recording status # Check auto-recording status
curl http://localhost:8000/auto-recording/status curl http://vision:8000/auto-recording/status
# Disable auto-recording for camera1 # Disable auto-recording for camera1
curl -X POST http://localhost:8000/cameras/camera1/auto-recording/disable curl -X POST http://vision:8000/cameras/camera1/auto-recording/disable
``` ```
### Camera Configuration ### Camera Configuration
```bash ```bash
# Get current camera configuration # Get current camera configuration
curl http://localhost:8000/cameras/camera1/config curl http://vision:8000/cameras/camera1/config
# Update camera settings (real-time) # Update camera settings (real-time)
curl -X PUT http://localhost:8000/cameras/camera1/config \ curl -X PUT http://vision:8000/cameras/camera1/config \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{ -d '{
"exposure_ms": 1.5, "exposure_ms": 1.5,
@@ -548,28 +623,33 @@ curl -X PUT http://localhost:8000/cameras/camera1/config \
### ✨ New in Latest Version ### ✨ New in Latest Version
#### 1. Enhanced Recording API #### 1. Enhanced Recording API
- **Dynamic camera settings**: Set exposure, gain, and FPS per recording - **Dynamic camera settings**: Set exposure, gain, and FPS per recording
- **Automatic datetime prefixes**: All filenames get timestamp prefixes - **Automatic datetime prefixes**: All filenames get timestamp prefixes
- **Backward compatibility**: Existing API calls work unchanged - **Backward compatibility**: Existing API calls work unchanged
#### 2. Auto-Recording Feature #### 2. Auto-Recording Feature
- **Per-camera control**: Enable/disable auto-recording individually - **Per-camera control**: Enable/disable auto-recording individually
- **MQTT integration**: Automatic recording based on machine states - **MQTT integration**: Automatic recording based on machine states
- **Retry logic**: Failed recordings are automatically retried - **Retry logic**: Failed recordings are automatically retried
- **Status tracking**: Monitor auto-recording attempts and failures - **Status tracking**: Monitor auto-recording attempts and failures
#### 3. Advanced Camera Configuration #### 3. Advanced Camera Configuration
- **Real-time settings**: Update exposure, gain, image quality without restart - **Real-time settings**: Update exposure, gain, image quality without restart
- **Image enhancement**: Sharpness, contrast, saturation, gamma controls - **Image enhancement**: Sharpness, contrast, saturation, gamma controls
- **Noise reduction**: Configurable noise filtering and 3D denoising - **Noise reduction**: Configurable noise filtering and 3D denoising
- **HDR support**: High Dynamic Range imaging capabilities - **HDR support**: High Dynamic Range imaging capabilities
#### 4. Live Streaming #### 4. Live Streaming
- **MJPEG streaming**: Real-time camera preview - **MJPEG streaming**: Real-time camera preview
- **Concurrent operation**: Stream while recording simultaneously - **Concurrent operation**: Stream while recording simultaneously
- **Web-compatible**: Direct integration with React/HTML video elements - **Web-compatible**: Direct integration with React/HTML video elements
#### 5. Enhanced Monitoring #### 5. Enhanced Monitoring
- **MQTT event history**: Track machine state changes over time - **MQTT event history**: Track machine state changes over time
- **Storage statistics**: Monitor disk usage and file counts - **Storage statistics**: Monitor disk usage and file counts
- **WebSocket updates**: Real-time system status notifications - **WebSocket updates**: Real-time system status notifications
@@ -577,11 +657,13 @@ curl -X PUT http://localhost:8000/cameras/camera1/config \
### 🔄 Migration Notes ### 🔄 Migration Notes
#### From Previous Versions #### From Previous Versions
1. **Recording API**: All existing calls work, but now return filenames with datetime prefixes 1. **Recording API**: All existing calls work, but now return filenames with datetime prefixes
2. **Configuration**: New camera settings are optional and backward compatible 2. **Configuration**: New camera settings are optional and backward compatible
3. **Auto-recording**: New feature, requires enabling in `config.json` and per camera 3. **Auto-recording**: New feature, requires enabling in `config.json` and per camera
#### Configuration Updates #### Configuration Updates
```json ```json
{ {
"cameras": [ "cameras": [
@@ -613,22 +695,28 @@ curl -X PUT http://localhost:8000/cameras/camera1/config \
## 📞 Support & Integration ## 📞 Support & Integration
### API Base URL ### API Base URL
- **Development**: `http://localhost:8000`
- **Development**: `http://vision:8000`
- **Production**: Configure in `config.json` under `system.api_host` and `system.api_port` - **Production**: Configure in `config.json` under `system.api_host` and `system.api_port`
### Error Handling ### Error Handling
All endpoints return standard HTTP status codes: All endpoints return standard HTTP status codes:
- `200`: Success - `200`: Success
- `404`: Resource not found (camera, file, etc.) - `404`: Resource not found (camera, file, etc.)
- `500`: Internal server error - `500`: Internal server error
- `503`: Service unavailable (camera manager, MQTT, etc.) - `503`: Service unavailable (camera manager, MQTT, etc.)
### Rate Limiting ### Rate Limiting
- No rate limiting currently implemented - No rate limiting currently implemented
- WebSocket connections are limited to reasonable concurrent connections - WebSocket connections are limited to reasonable concurrent connections
### CORS Support ### CORS Support
- CORS is enabled for web dashboard integration - CORS is enabled for web dashboard integration
- Configure allowed origins in the API server settings - Configure allowed origins in the API server settings
``` ```
``` ```

View File

@@ -6,30 +6,30 @@ Quick reference for the most commonly used API endpoints. For complete documenta
```bash ```bash
# Health check # Health check
curl http://localhost:8000/health curl http://vision:8000/health
# System overview # System overview
curl http://localhost:8000/system/status curl http://vision:8000/system/status
# All cameras # All cameras
curl http://localhost:8000/cameras curl http://vision:8000/cameras
# All machines # All machines
curl http://localhost:8000/machines curl http://vision:8000/machines
``` ```
## 🎥 Recording Control ## 🎥 Recording Control
### Start Recording (Basic) ### Start Recording (Basic)
```bash ```bash
curl -X POST http://localhost:8000/cameras/camera1/start-recording \ curl -X POST http://vision:8000/cameras/camera1/start-recording \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{"filename": "test.avi"}' -d '{"filename": "test.avi"}'
``` ```
### Start Recording (With Settings) ### Start Recording (With Settings)
```bash ```bash
curl -X POST http://localhost:8000/cameras/camera1/start-recording \ curl -X POST http://vision:8000/cameras/camera1/start-recording \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{ -d '{
"filename": "high_quality.avi", "filename": "high_quality.avi",
@@ -41,30 +41,30 @@ curl -X POST http://localhost:8000/cameras/camera1/start-recording \
### Stop Recording ### Stop Recording
```bash ```bash
curl -X POST http://localhost:8000/cameras/camera1/stop-recording curl -X POST http://vision:8000/cameras/camera1/stop-recording
``` ```
## 🤖 Auto-Recording ## 🤖 Auto-Recording
```bash ```bash
# Enable auto-recording # Enable auto-recording
curl -X POST http://localhost:8000/cameras/camera1/auto-recording/enable curl -X POST http://vision:8000/cameras/camera1/auto-recording/enable
# Disable auto-recording # Disable auto-recording
curl -X POST http://localhost:8000/cameras/camera1/auto-recording/disable curl -X POST http://vision:8000/cameras/camera1/auto-recording/disable
# Check auto-recording status # Check auto-recording status
curl http://localhost:8000/auto-recording/status curl http://vision:8000/auto-recording/status
``` ```
## 🎛️ Camera Configuration ## 🎛️ Camera Configuration
```bash ```bash
# Get camera config # Get camera config
curl http://localhost:8000/cameras/camera1/config curl http://vision:8000/cameras/camera1/config
# Update camera settings # Update camera settings
curl -X PUT http://localhost:8000/cameras/camera1/config \ curl -X PUT http://vision:8000/cameras/camera1/config \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{ -d '{
"exposure_ms": 1.5, "exposure_ms": 1.5,
@@ -77,41 +77,41 @@ curl -X PUT http://localhost:8000/cameras/camera1/config \
```bash ```bash
# Start streaming # Start streaming
curl -X POST http://localhost:8000/cameras/camera1/start-stream curl -X POST http://vision:8000/cameras/camera1/start-stream
# Get MJPEG stream (use in browser/video element) # Get MJPEG stream (use in browser/video element)
# http://localhost:8000/cameras/camera1/stream # http://vision:8000/cameras/camera1/stream
# Stop streaming # Stop streaming
curl -X POST http://localhost:8000/cameras/camera1/stop-stream curl -X POST http://vision:8000/cameras/camera1/stop-stream
``` ```
## 🔄 Camera Recovery ## 🔄 Camera Recovery
```bash ```bash
# Test connection # Test connection
curl -X POST http://localhost:8000/cameras/camera1/test-connection curl -X POST http://vision:8000/cameras/camera1/test-connection
# Reconnect camera # Reconnect camera
curl -X POST http://localhost:8000/cameras/camera1/reconnect curl -X POST http://vision:8000/cameras/camera1/reconnect
# Full reset # Full reset
curl -X POST http://localhost:8000/cameras/camera1/full-reset curl -X POST http://vision:8000/cameras/camera1/full-reset
``` ```
## 💾 Storage Management ## 💾 Storage Management
```bash ```bash
# Storage statistics # Storage statistics
curl http://localhost:8000/storage/stats curl http://vision:8000/storage/stats
# List files # List files
curl -X POST http://localhost:8000/storage/files \ curl -X POST http://vision:8000/storage/files \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{"camera_name": "camera1", "limit": 10}' -d '{"camera_name": "camera1", "limit": 10}'
# Cleanup old files # Cleanup old files
curl -X POST http://localhost:8000/storage/cleanup \ curl -X POST http://vision:8000/storage/cleanup \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{"max_age_days": 30}' -d '{"max_age_days": 30}'
``` ```
@@ -120,17 +120,17 @@ curl -X POST http://localhost:8000/storage/cleanup \
```bash ```bash
# MQTT status # MQTT status
curl http://localhost:8000/mqtt/status curl http://vision:8000/mqtt/status
# Recent MQTT events # Recent MQTT events
curl http://localhost:8000/mqtt/events?limit=10 curl http://vision:8000/mqtt/events?limit=10
``` ```
## 🌐 WebSocket Connection ## 🌐 WebSocket Connection
```javascript ```javascript
// Connect to real-time updates // Connect to real-time updates
const ws = new WebSocket('ws://localhost:8000/ws'); const ws = new WebSocket('ws://vision:8000/ws');
ws.onmessage = (event) => { ws.onmessage = (event) => {
const update = JSON.parse(event.data); const update = JSON.parse(event.data);

View File

@@ -1,20 +1,24 @@
# 🎥 MP4 Video Format Update - Frontend Integration Guide # 🎥 MP4 Video Format Update - Frontend Integration Guide
## Overview ## Overview
The USDA Vision Camera System has been updated to record videos in **MP4 format** instead of AVI format for better streaming compatibility and smaller file sizes. The USDA Vision Camera System has been updated to record videos in **MP4 format** instead of AVI format for better streaming compatibility and smaller file sizes.
## 🔄 What Changed ## 🔄 What Changed
### Video Format ### Video Format
- **Before**: AVI files with XVID codec (`.avi` extension) - **Before**: AVI files with XVID codec (`.avi` extension)
- **After**: MP4 files with MPEG-4 codec (`.mp4` extension) - **After**: MP4 files with H.264 codec (`.mp4` extension)
### File Extensions ### File Extensions
- All new video recordings now use `.mp4` extension - All new video recordings now use `.mp4` extension
- Existing `.avi` files remain accessible and functional - Existing `.avi` files remain accessible and functional
- File size reduction: ~40% smaller than equivalent AVI files - File size reduction: ~40% smaller than equivalent AVI files
### API Response Updates ### API Response Updates
New fields added to camera configuration responses: New fields added to camera configuration responses:
```json ```json
@@ -28,13 +32,17 @@ New fields added to camera configuration responses:
## 🌐 Frontend Impact ## 🌐 Frontend Impact
### 1. Video Player Compatibility ### 1. Video Player Compatibility
**✅ Better Browser Support** **✅ Better Browser Support**
- MP4 format has native support in all modern browsers - MP4 format has native support in all modern browsers
- No need for additional codecs or plugins - No need for additional codecs or plugins
- Better mobile device compatibility (iOS/Android) - Better mobile device compatibility (iOS/Android)
### 2. File Handling Updates ### 2. File Handling Updates
**File Extension Handling** **File Extension Handling**
```javascript ```javascript
// Update file extension checks // Update file extension checks
const isVideoFile = (filename) => { const isVideoFile = (filename) => {
@@ -50,7 +58,9 @@ const getVideoMimeType = (filename) => {
``` ```
### 3. Video Streaming ### 3. Video Streaming
**Improved Streaming Performance** **Improved Streaming Performance**
```javascript ```javascript
// MP4 files can be streamed directly without conversion // MP4 files can be streamed directly without conversion
const videoUrl = `/api/videos/${videoId}/stream`; const videoUrl = `/api/videos/${videoId}/stream`;
@@ -63,7 +73,9 @@ const videoUrl = `/api/videos/${videoId}/stream`;
``` ```
### 4. File Size Display ### 4. File Size Display
**Updated Size Expectations** **Updated Size Expectations**
- MP4 files are ~40% smaller than equivalent AVI files - MP4 files are ~40% smaller than equivalent AVI files
- Update any file size warnings or storage calculations - Update any file size warnings or storage calculations
- Better compression means faster downloads and uploads - Better compression means faster downloads and uploads
@@ -71,9 +83,11 @@ const videoUrl = `/api/videos/${videoId}/stream`;
## 📡 API Changes ## 📡 API Changes
### Camera Configuration Endpoint ### Camera Configuration Endpoint
**GET** `/cameras/{camera_name}/config` **GET** `/cameras/{camera_name}/config`
**New Response Fields:** **New Response Fields:**
```json ```json
{ {
"name": "camera1", "name": "camera1",
@@ -95,7 +109,9 @@ const videoUrl = `/api/videos/${videoId}/stream`;
``` ```
### Video Listing Endpoints ### Video Listing Endpoints
**File Extension Updates** **File Extension Updates**
- Video files in responses will now have `.mp4` extensions - Video files in responses will now have `.mp4` extensions
- Existing `.avi` files will still appear in listings - Existing `.avi` files will still appear in listings
- Filter by both extensions when needed - Filter by both extensions when needed
@@ -103,42 +119,49 @@ const videoUrl = `/api/videos/${videoId}/stream`;
## 🔧 Configuration Options ## 🔧 Configuration Options
### Video Format Settings ### Video Format Settings
```json ```json
{ {
"video_format": "mp4", // Options: "mp4", "avi" "video_format": "mp4", // Options: "mp4", "avi"
"video_codec": "mp4v", // Options: "mp4v", "XVID", "MJPG" "video_codec": "h264", // Options: "h264", "mp4v", "XVID", "MJPG"
"video_quality": 95 // Range: 0-100 (higher = better quality) "video_quality": 95 // Range: 0-100 (higher = better quality)
} }
``` ```
### Recommended Settings ### Recommended Settings
- **Production**: `"mp4"` format, `"mp4v"` codec, `95` quality
- **Storage Optimized**: `"mp4"` format, `"mp4v"` codec, `85` quality - **Production**: `"mp4"` format, `"h264"` codec, `95` quality
- **Storage Optimized**: `"mp4"` format, `"h264"` codec, `85` quality
- **Legacy Mode**: `"avi"` format, `"XVID"` codec, `95` quality - **Legacy Mode**: `"avi"` format, `"XVID"` codec, `95` quality
## 🎯 Frontend Implementation Checklist ## 🎯 Frontend Implementation Checklist
### ✅ Video Player Updates ### ✅ Video Player Updates
- [ ] Verify HTML5 video player works with MP4 files - [ ] Verify HTML5 video player works with MP4 files
- [ ] Update video MIME type handling - [ ] Update video MIME type handling
- [ ] Test streaming performance with new format - [ ] Test streaming performance with new format
### ✅ File Management ### ✅ File Management
- [ ] Update file extension filters to include `.mp4` - [ ] Update file extension filters to include `.mp4`
- [ ] Modify file type detection logic - [ ] Modify file type detection logic
- [ ] Update download/upload handling for MP4 files - [ ] Update download/upload handling for MP4 files
### ✅ UI/UX Updates ### ✅ UI/UX Updates
- [ ] Update file size expectations in UI - [ ] Update file size expectations in UI
- [ ] Modify any format-specific icons or indicators - [ ] Modify any format-specific icons or indicators
- [ ] Update help text or tooltips mentioning video formats - [ ] Update help text or tooltips mentioning video formats
### ✅ Configuration Interface ### ✅ Configuration Interface
- [ ] Add video format settings to camera config UI - [ ] Add video format settings to camera config UI
- [ ] Include video quality slider/selector - [ ] Include video quality slider/selector
- [ ] Add restart warning for video format changes - [ ] Add restart warning for video format changes
### ✅ Testing ### ✅ Testing
- [ ] Test video playback with new MP4 files - [ ] Test video playback with new MP4 files
- [ ] Verify backward compatibility with existing AVI files - [ ] Verify backward compatibility with existing AVI files
- [ ] Test streaming performance and loading times - [ ] Test streaming performance and loading times
@@ -146,11 +169,13 @@ const videoUrl = `/api/videos/${videoId}/stream`;
## 🔄 Backward Compatibility ## 🔄 Backward Compatibility
### Existing AVI Files ### Existing AVI Files
- All existing `.avi` files remain fully functional - All existing `.avi` files remain fully functional
- No conversion or migration required - No conversion or migration required
- Video player should handle both formats - Video player should handle both formats
### API Compatibility ### API Compatibility
- All existing API endpoints continue to work - All existing API endpoints continue to work
- New fields are additive (won't break existing code) - New fields are additive (won't break existing code)
- Default values provided for new configuration fields - Default values provided for new configuration fields
@@ -158,6 +183,7 @@ const videoUrl = `/api/videos/${videoId}/stream`;
## 📊 Performance Benefits ## 📊 Performance Benefits
### File Size Reduction ### File Size Reduction
``` ```
Example 5-minute recording at 1280x1024: Example 5-minute recording at 1280x1024:
- AVI/XVID: ~180 MB - AVI/XVID: ~180 MB
@@ -165,12 +191,14 @@ Example 5-minute recording at 1280x1024:
``` ```
### Streaming Improvements ### Streaming Improvements
- Faster initial load times - Faster initial load times
- Better progressive download support - Better progressive download support
- Reduced bandwidth usage - Reduced bandwidth usage
- Native browser optimization - Native browser optimization
### Storage Efficiency ### Storage Efficiency
- More recordings fit in same storage space - More recordings fit in same storage space
- Faster backup and transfer operations - Faster backup and transfer operations
- Reduced storage costs over time - Reduced storage costs over time
@@ -178,16 +206,19 @@ Example 5-minute recording at 1280x1024:
## 🚨 Important Notes ## 🚨 Important Notes
### Restart Required ### Restart Required
- Video format changes require camera service restart - Video format changes require camera service restart
- Mark video format settings as "restart required" in UI - Mark video format settings as "restart required" in UI
- Provide clear user feedback about restart necessity - Provide clear user feedback about restart necessity
### Browser Compatibility ### Browser Compatibility
- MP4 format supported in all modern browsers - MP4 format supported in all modern browsers
- Better mobile device support than AVI - Better mobile device support than AVI
- No additional plugins or codecs needed - No additional plugins or codecs needed
### Quality Assurance ### Quality Assurance
- Video quality maintained at 95/100 setting - Video quality maintained at 95/100 setting
- No visual degradation compared to AVI - No visual degradation compared to AVI
- High bitrate ensures professional quality - High bitrate ensures professional quality

View File

@@ -97,11 +97,11 @@ python test_system.py
### Dashboard Integration ### Dashboard Integration
```javascript ```javascript
// React component example // React component example
const systemStatus = await fetch('http://localhost:8000/system/status'); const systemStatus = await fetch('http://vision:8000/system/status');
const cameras = await fetch('http://localhost:8000/cameras'); const cameras = await fetch('http://vision:8000/cameras');
// WebSocket for real-time updates // WebSocket for real-time updates
const ws = new WebSocket('ws://localhost:8000/ws'); const ws = new WebSocket('ws://vision:8000/ws');
ws.onmessage = (event) => { ws.onmessage = (event) => {
const update = JSON.parse(event.data); const update = JSON.parse(event.data);
// Handle real-time system updates // Handle real-time system updates
@@ -111,13 +111,13 @@ ws.onmessage = (event) => {
### Manual Control ### Manual Control
```bash ```bash
# Start recording manually # Start recording manually
curl -X POST http://localhost:8000/cameras/camera1/start-recording curl -X POST http://vision:8000/cameras/camera1/start-recording
# Stop recording manually # Stop recording manually
curl -X POST http://localhost:8000/cameras/camera1/stop-recording curl -X POST http://vision:8000/cameras/camera1/stop-recording
# Get system status # Get system status
curl http://localhost:8000/system/status curl http://vision:8000/system/status
``` ```
## 📊 System Capabilities ## 📊 System Capabilities
@@ -151,7 +151,7 @@ curl http://localhost:8000/system/status
### Troubleshooting ### Troubleshooting
- **Test Suite**: `python test_system.py` - **Test Suite**: `python test_system.py`
- **Time Check**: `python check_time.py` - **Time Check**: `python check_time.py`
- **API Health**: `curl http://localhost:8000/health` - **API Health**: `curl http://vision:8000/health`
- **Debug Mode**: `python main.py --log-level DEBUG` - **Debug Mode**: `python main.py --log-level DEBUG`
## 🎯 Production Readiness ## 🎯 Production Readiness

View File

@@ -204,10 +204,10 @@ sudo systemctl restart usda-vision-camera
### Check Status ### Check Status
```bash ```bash
# Check video module status # Check video module status
curl http://localhost:8000/system/video-module curl http://vision:8000/system/video-module
# Check available videos # Check available videos
curl http://localhost:8000/videos/ curl http://vision:8000/videos/
``` ```
### Logs ### Logs

View File

@@ -185,7 +185,7 @@ POST /cameras/{camera_name}/apply-config
### Example 1: Adjust Exposure and Gain ### Example 1: Adjust Exposure and Gain
```bash ```bash
curl -X PUT http://localhost:8000/cameras/camera1/config \ curl -X PUT http://vision:8000/cameras/camera1/config \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{ -d '{
"exposure_ms": 1.5, "exposure_ms": 1.5,
@@ -195,7 +195,7 @@ curl -X PUT http://localhost:8000/cameras/camera1/config \
### Example 2: Improve Image Quality ### Example 2: Improve Image Quality
```bash ```bash
curl -X PUT http://localhost:8000/cameras/camera1/config \ curl -X PUT http://vision:8000/cameras/camera1/config \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{ -d '{
"sharpness": 150, "sharpness": 150,
@@ -206,7 +206,7 @@ curl -X PUT http://localhost:8000/cameras/camera1/config \
### Example 3: Configure for Indoor Lighting ### Example 3: Configure for Indoor Lighting
```bash ```bash
curl -X PUT http://localhost:8000/cameras/camera1/config \ curl -X PUT http://vision:8000/cameras/camera1/config \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{ -d '{
"anti_flicker_enabled": true, "anti_flicker_enabled": true,
@@ -218,7 +218,7 @@ curl -X PUT http://localhost:8000/cameras/camera1/config \
### Example 4: Enable HDR Mode ### Example 4: Enable HDR Mode
```bash ```bash
curl -X PUT http://localhost:8000/cameras/camera1/config \ curl -X PUT http://vision:8000/cameras/camera1/config \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{ -d '{
"hdr_enabled": true, "hdr_enabled": true,
@@ -232,7 +232,7 @@ curl -X PUT http://localhost:8000/cameras/camera1/config \
```jsx ```jsx
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
const CameraConfig = ({ cameraName, apiBaseUrl = 'http://localhost:8000' }) => { const CameraConfig = ({ cameraName, apiBaseUrl = 'http://vision:8000' }) => {
const [config, setConfig] = useState(null); const [config, setConfig] = useState(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(null); const [error, setError] = useState(null);

View File

@@ -56,27 +56,27 @@ When a camera has issues, follow this order:
1. **Test Connection** - Diagnose the problem 1. **Test Connection** - Diagnose the problem
```http ```http
POST http://localhost:8000/cameras/camera1/test-connection POST http://vision:8000/cameras/camera1/test-connection
``` ```
2. **Try Reconnect** - Most common fix 2. **Try Reconnect** - Most common fix
```http ```http
POST http://localhost:8000/cameras/camera1/reconnect POST http://vision:8000/cameras/camera1/reconnect
``` ```
3. **Restart Grab** - If reconnect doesn't work 3. **Restart Grab** - If reconnect doesn't work
```http ```http
POST http://localhost:8000/cameras/camera1/restart-grab POST http://vision:8000/cameras/camera1/restart-grab
``` ```
4. **Full Reset** - For persistent issues 4. **Full Reset** - For persistent issues
```http ```http
POST http://localhost:8000/cameras/camera1/full-reset POST http://vision:8000/cameras/camera1/full-reset
``` ```
5. **Reinitialize** - For cameras that never worked 5. **Reinitialize** - For cameras that never worked
```http ```http
POST http://localhost:8000/cameras/camera1/reinitialize POST http://vision:8000/cameras/camera1/reinitialize
``` ```
## Response Format ## Response Format

View File

@@ -38,7 +38,7 @@ When you run the system, you'll see:
### MQTT Status ### MQTT Status
```http ```http
GET http://localhost:8000/mqtt/status GET http://vision:8000/mqtt/status
``` ```
**Response:** **Response:**
@@ -60,7 +60,7 @@ GET http://localhost:8000/mqtt/status
### Machine Status ### Machine Status
```http ```http
GET http://localhost:8000/machines GET http://vision:8000/machines
``` ```
**Response:** **Response:**
@@ -85,7 +85,7 @@ GET http://localhost:8000/machines
### System Status ### System Status
```http ```http
GET http://localhost:8000/system/status GET http://vision:8000/system/status
``` ```
**Response:** **Response:**
@@ -125,13 +125,13 @@ Tests all the API endpoints and shows expected responses.
### 4. **Query APIs Directly** ### 4. **Query APIs Directly**
```bash ```bash
# Check MQTT status # Check MQTT status
curl http://localhost:8000/mqtt/status curl http://vision:8000/mqtt/status
# Check machine states # Check machine states
curl http://localhost:8000/machines curl http://vision:8000/machines
# Check overall system status # Check overall system status
curl http://localhost:8000/system/status curl http://vision:8000/system/status
``` ```
## 🔧 Configuration ## 🔧 Configuration

View File

@@ -40,13 +40,13 @@ Open `camera_preview.html` in your browser and click "Start Stream" for any came
### 3. API Usage ### 3. API Usage
```bash ```bash
# Start streaming for camera1 # Start streaming for camera1
curl -X POST http://localhost:8000/cameras/camera1/start-stream curl -X POST http://vision:8000/cameras/camera1/start-stream
# View live stream (open in browser) # View live stream (open in browser)
http://localhost:8000/cameras/camera1/stream http://vision:8000/cameras/camera1/stream
# Stop streaming # Stop streaming
curl -X POST http://localhost:8000/cameras/camera1/stop-stream curl -X POST http://vision:8000/cameras/camera1/stop-stream
``` ```
## 📡 API Endpoints ## 📡 API Endpoints
@@ -150,10 +150,10 @@ The system supports these concurrent operations:
### Example: Concurrent Usage ### Example: Concurrent Usage
```bash ```bash
# Start streaming # Start streaming
curl -X POST http://localhost:8000/cameras/camera1/start-stream curl -X POST http://vision:8000/cameras/camera1/start-stream
# Start recording (while streaming continues) # Start recording (while streaming continues)
curl -X POST http://localhost:8000/cameras/camera1/start-recording \ curl -X POST http://vision:8000/cameras/camera1/start-recording \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{"filename": "test_recording.avi"}' -d '{"filename": "test_recording.avi"}'
@@ -232,8 +232,8 @@ For issues with streaming functionality:
1. Check the system logs: `usda_vision_system.log` 1. Check the system logs: `usda_vision_system.log`
2. Run the test script: `python test_streaming.py` 2. Run the test script: `python test_streaming.py`
3. Verify API health: `http://localhost:8000/health` 3. Verify API health: `http://vision:8000/health`
4. Check camera status: `http://localhost:8000/cameras` 4. Check camera status: `http://vision:8000/cameras`
--- ---

View File

@@ -73,10 +73,10 @@ Edit `config.json` to customize:
- System parameters - System parameters
### API Access ### API Access
- System status: `http://localhost:8000/system/status` - System status: `http://vision:8000/system/status`
- Camera status: `http://localhost:8000/cameras` - Camera status: `http://vision:8000/cameras`
- Manual recording: `POST http://localhost:8000/cameras/camera1/start-recording` - Manual recording: `POST http://vision:8000/cameras/camera1/start-recording`
- Real-time updates: WebSocket at `ws://localhost:8000/ws` - Real-time updates: WebSocket at `ws://vision:8000/ws`
## 📊 Test Results ## 📊 Test Results
@@ -146,18 +146,18 @@ The system provides everything needed for your React dashboard:
```javascript ```javascript
// Example API usage // Example API usage
const systemStatus = await fetch('http://localhost:8000/system/status'); const systemStatus = await fetch('http://vision:8000/system/status');
const cameras = await fetch('http://localhost:8000/cameras'); const cameras = await fetch('http://vision:8000/cameras');
// WebSocket for real-time updates // WebSocket for real-time updates
const ws = new WebSocket('ws://localhost:8000/ws'); const ws = new WebSocket('ws://vision:8000/ws');
ws.onmessage = (event) => { ws.onmessage = (event) => {
const update = JSON.parse(event.data); const update = JSON.parse(event.data);
// Handle real-time system updates // Handle real-time system updates
}; };
// Manual recording control // Manual recording control
await fetch('http://localhost:8000/cameras/camera1/start-recording', { await fetch('http://vision:8000/cameras/camera1/start-recording', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ camera_name: 'camera1' }) body: JSON.stringify({ camera_name: 'camera1' })

View File

@@ -192,13 +192,13 @@ Comprehensive error tracking with:
```bash ```bash
# Check system status # Check system status
curl http://localhost:8000/system/status curl http://vision:8000/system/status
# Check camera status # Check camera status
curl http://localhost:8000/cameras curl http://vision:8000/cameras
# Manual recording start # Manual recording start
curl -X POST http://localhost:8000/cameras/camera1/start-recording \ curl -X POST http://vision:8000/cameras/camera1/start-recording \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{"camera_name": "camera1"}' -d '{"camera_name": "camera1"}'
``` ```
@@ -246,4 +246,4 @@ This project is developed for USDA research purposes.
For issues and questions: For issues and questions:
1. Check the logs in `usda_vision_system.log` 1. Check the logs in `usda_vision_system.log`
2. Review the troubleshooting section 2. Review the troubleshooting section
3. Check API status at `http://localhost:8000/health` 3. Check API status at `http://vision:8000/health`

View File

@@ -76,7 +76,7 @@ timedatectl status
### API Endpoints ### API Endpoints
```bash ```bash
# System status includes time info # System status includes time info
curl http://localhost:8000/system/status curl http://vision:8000/system/status
# Example response includes: # Example response includes:
{ {

View File

@@ -171,7 +171,7 @@ POST http://vision:8000/cameras/camera1/start-recording
Content-Type: application/json Content-Type: application/json
{ {
"filename": "test_recording.avi", "filename": "test_recording.mp4",
"exposure_ms": 1.5, "exposure_ms": 1.5,
"gain": 3.0, "gain": 3.0,
"fps": 0 "fps": 0
@@ -187,7 +187,7 @@ Content-Type: application/json
# { # {
# "success": true, # "success": true,
# "message": "Recording started for camera1", # "message": "Recording started for camera1",
# "filename": "20250728_120000_test_recording.avi" # "filename": "20250728_120000_test_recording.mp4"
# } # }
### ###
@@ -197,7 +197,7 @@ POST http://vision:8000/cameras/camera1/start-recording
Content-Type: application/json Content-Type: application/json
{ {
"filename": "simple_test.avi" "filename": "simple_test.mp4"
} }
### ###

View File

@@ -52,7 +52,7 @@ export function AutoRecordingTest() {
if (state === 'on') { if (state === 'on') {
// Simulate starting recording on the correct camera // Simulate starting recording on the correct camera
const result = await visionApi.startRecording(cameraName, { const result = await visionApi.startRecording(cameraName, {
filename: `test_auto_${machine}_${Date.now()}.avi` filename: `test_auto_${machine}_${Date.now()}.mp4`
}) })
event.result = result.success ? `✅ Recording started on ${cameraName}: ${result.filename}` : `❌ Failed: ${result.message}` event.result = result.success ? `✅ Recording started on ${cameraName}: ${result.filename}` : `❌ Failed: ${result.message}`
} else { } else {

View File

@@ -166,22 +166,39 @@ export function CameraConfigModal({ cameraName, isOpen, onClose, onSuccess, onEr
if (!isOpen) return null if (!isOpen) return null
return ( return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"> <div className="fixed inset-0 flex items-center justify-center overflow-y-auto modal z-999999">
<div className="bg-white rounded-lg shadow-xl max-w-4xl w-full mx-4 max-h-[90vh] overflow-hidden"> <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 */} {/* Header */}
<div className="px-6 py-4 border-b border-gray-200"> <div className="px-6 py-4 border-b border-gray-200 dark:border-gray-800">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h3 className="text-lg font-medium text-gray-900"> <h3 className="text-lg font-medium text-gray-900 dark:text-white/90">
Camera Configuration - {cameraName} Camera Configuration - {cameraName}
</h3> </h3>
<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> </div>

View File

@@ -100,22 +100,39 @@ export function CameraPreviewModal({ cameraName, isOpen, onClose, onError }: Cam
if (!isOpen) return null if (!isOpen) return null
return ( return (
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50"> <div className="fixed inset-0 flex items-center justify-center overflow-y-auto modal z-999999">
<div className="relative top-20 mx-auto p-5 border w-11/12 max-w-4xl shadow-lg rounded-md bg-white"> <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"> <div className="mt-3">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-medium text-gray-900"> <h3 className="text-lg font-medium text-gray-900 dark:text-white/90">
Camera Preview: {cameraName} Camera Preview: {cameraName}
</h3> </h3>
<button
onClick={handleClose}
className="text-gray-400 hover:text-gray-600 focus:outline-none"
>
<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>
{/* Content */} {/* Content */}

View File

@@ -106,19 +106,36 @@ export function CreateUserModal({ roles, onClose, onUserCreated }: CreateUserMod
} }
return ( return (
<div className="fixed inset-0 bg-black bg-opacity-25 overflow-y-auto h-full w-full z-50 flex items-center justify-center p-4"> <div className="fixed inset-0 flex items-center justify-center overflow-y-auto modal z-999999">
<div className="relative bg-white rounded-xl shadow-2xl w-full max-w-md mx-auto"> <div
{/* Header */} className="fixed inset-0 h-full w-full bg-gray-400/50 backdrop-blur-[32px]"
<div className="flex items-center justify-between p-6 border-b border-gray-200"> onClick={onClose}
<h3 className="text-xl font-semibold text-gray-900">Create New User</h3> />
<button <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()}>
onClick={onClose} {/* Close Button */}
className="text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg p-2 transition-colors" <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"
> >
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <path
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /> fillRule="evenodd"
</svg> clipRule="evenodd"
</button> 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>
<div className="p-6"> <div className="p-6">
@@ -135,7 +152,7 @@ export function CreateUserModal({ roles, onClose, onUserCreated }: CreateUserMod
id="email" id="email"
value={formData.email} value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })} onChange={(e) => setFormData({ ...formData, email: e.target.value })}
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors text-sm placeholder-gray-400" 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" placeholder="user@example.com"
required required
/> />
@@ -238,11 +255,11 @@ export function CreateUserModal({ roles, onClose, onUserCreated }: CreateUserMod
</div> </div>
{/* Actions */} {/* Actions */}
<div className="flex justify-end space-x-3 p-6 border-t border-gray-200 bg-gray-50 rounded-b-xl"> <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 <button
type="button" type="button"
onClick={onClose} onClick={onClose}
className="px-6 py-2.5 border border-gray-300 rounded-lg text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors" 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 Cancel
</button> </button>
@@ -250,7 +267,7 @@ export function CreateUserModal({ roles, onClose, onUserCreated }: CreateUserMod
type="submit" type="submit"
form="create-user-form" form="create-user-form"
disabled={loading} disabled={loading}
className="px-6 py-2.5 border border-transparent rounded-lg shadow-sm 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" 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 ? ( {loading ? (
<div className="flex items-center"> <div className="flex items-center">

View File

@@ -8,15 +8,15 @@ export function DashboardHome({ user }: DashboardHomeProps) {
const getRoleBadgeColor = (role: string) => { const getRoleBadgeColor = (role: string) => {
switch (role) { switch (role) {
case 'admin': case 'admin':
return 'bg-red-100 text-red-800' return 'bg-error-50 text-error-600 dark:bg-error-500/15 dark:text-error-500'
case 'conductor': case 'conductor':
return 'bg-blue-100 text-blue-800' return 'bg-brand-50 text-brand-500 dark:bg-brand-500/15 dark:text-brand-400'
case 'analyst': case 'analyst':
return 'bg-green-100 text-green-800' return 'bg-success-50 text-success-600 dark:bg-success-500/15 dark:text-success-500'
case 'data recorder': case 'data recorder':
return 'bg-purple-100 text-purple-800' return 'bg-theme-purple-500/10 text-theme-purple-500'
default: default:
return 'bg-gray-100 text-gray-800' return 'bg-gray-100 text-gray-700 dark:bg-white/5 dark:text-white/80'
} }
} }
@@ -36,126 +36,144 @@ export function DashboardHome({ user }: DashboardHomeProps) {
} }
return ( return (
<div className="p-6"> <div className="grid grid-cols-12 gap-4 md:gap-6">
<div className="mb-8"> {/* Welcome Section */}
<h1 className="text-3xl font-bold text-gray-900">Dashboard</h1> <div className="col-span-12 mb-6">
<p className="mt-2 text-gray-600">Welcome to the Pecan Experiments Dashboard</p> <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> </div>
{/* User Information Card */} {/* User Information Card */}
<div className="bg-white shadow overflow-hidden sm:rounded-lg mb-8"> <div className="col-span-12 xl:col-span-8">
<div className="px-4 py-5 sm:px-6"> <div className="rounded-2xl border border-gray-200 bg-white p-5 dark:border-gray-800 dark:bg-white/[0.03] md:p-6">
<h3 className="text-lg leading-6 font-medium text-gray-900"> <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 User Information
</h3> </h3>
<p className="mt-1 max-w-2xl text-sm text-gray-500"> <p className="text-sm text-gray-500 dark:text-gray-400 mb-6">
Your account details and role permissions. Your account details and role permissions.
</p> </p>
</div>
<div className="border-t border-gray-200"> <div className="space-y-4">
<dl> <div className="flex items-center justify-between py-3 border-b border-gray-200 dark:border-gray-800">
<div className="bg-gray-50 px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6"> <span className="text-sm font-medium text-gray-500 dark:text-gray-400">Email</span>
<dt className="text-sm font-medium text-gray-500">Email</dt> <span className="text-sm text-gray-800 dark:text-white/90">{user.email}</span>
<dd className="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">
{user.email}
</dd>
</div> </div>
<div className="bg-white px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
<dt className="text-sm font-medium text-gray-500">Roles</dt> <div className="flex items-center justify-between py-3 border-b border-gray-200 dark:border-gray-800">
<dd className="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2"> <span className="text-sm font-medium text-gray-500 dark:text-gray-400">Roles</span>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-1">
{user.roles.map((role) => ( {user.roles.map((role) => (
<span <span
key={role} key={role}
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getRoleBadgeColor(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)} {role.charAt(0).toUpperCase() + role.slice(1)}
</span> </span>
))} ))}
</div> </div>
</dd>
</div> </div>
<div className="bg-gray-50 px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
<dt className="text-sm font-medium text-gray-500">Status</dt> <div className="flex items-center justify-between py-3 border-b border-gray-200 dark:border-gray-800">
<dd className="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2"> <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 ${ <span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${user.status === 'active'
user.status === 'active' ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800' ? '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)} {user.status.charAt(0).toUpperCase() + user.status.slice(1)}
</span> </span>
</dd>
</div> </div>
<div className="bg-white px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
<dt className="text-sm font-medium text-gray-500">User ID</dt> <div className="flex items-center justify-between py-3 border-b border-gray-200 dark:border-gray-800">
<dd className="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2 font-mono"> <span className="text-sm font-medium text-gray-500 dark:text-gray-400">User ID</span>
{user.id} <span className="text-sm text-gray-800 dark:text-white/90 font-mono">{user.id}</span>
</dd>
</div> </div>
<div className="bg-gray-50 px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
<dt className="text-sm font-medium text-gray-500">Member since</dt> <div className="flex items-center justify-between py-3">
<dd className="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2"> <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', { {new Date(user.created_at).toLocaleDateString('en-US', {
year: 'numeric', year: 'numeric',
month: 'long', month: 'long',
day: 'numeric' day: 'numeric'
})} })}
</dd> </span>
</div> </div>
</dl> </div>
</div> </div>
</div> </div>
{/* Role Permissions */} {/* Role Permissions */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> <div className="col-span-12 xl:col-span-4">
{user.roles.map((role) => ( <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 key={role} className="bg-white overflow-hidden shadow rounded-lg"> <div className="flex items-center justify-center w-12 h-12 bg-gray-100 rounded-xl dark:bg-gray-800 mb-5">
<div className="p-5"> <svg className="text-gray-800 size-6 dark:text-white/90" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<div className="flex items-center"> <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" />
<div className="flex-shrink-0"> </svg>
<span className={`inline-flex items-center px-3 py-1 rounded-full text-sm font-medium ${getRoleBadgeColor(role)}`}> </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)} {role.charAt(0).toUpperCase() + role.slice(1)}
</span> </span>
</div> </div>
</div>
<div className="mt-4">
<h3 className="text-lg font-medium text-gray-900 mb-3">Permissions</h3>
<ul className="space-y-2"> <ul className="space-y-2">
{getPermissionsByRole(role).map((permission, index) => ( {getPermissionsByRole(role).map((permission, index) => (
<li key={index} className="flex items-center text-sm text-gray-600"> <li key={index} className="flex items-center text-sm text-gray-600 dark:text-gray-400">
<span className="text-green-500 mr-2"></span> <span className="text-success-500 mr-2"></span>
{permission} {permission}
</li> </li>
))} ))}
</ul> </ul>
</div> </div>
</div> ))}
</div> </div>
))} </div>
</div> </div>
{/* Quick Actions */} {/* Quick Actions */}
{user.roles.includes('admin') && ( {user.roles.includes('admin') && (
<div className="mt-8 bg-white shadow rounded-lg"> <div className="col-span-12">
<div className="px-4 py-5 sm:px-6"> <div className="rounded-2xl border border-gray-200 bg-white p-5 dark:border-gray-800 dark:bg-white/[0.03] md:p-6">
<h3 className="text-lg leading-6 font-medium text-gray-900"> <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 Quick Actions
</h3> </h3>
<p className="mt-1 max-w-2xl text-sm text-gray-500"> <p className="text-sm text-gray-500 dark:text-gray-400 mb-6">
Administrative shortcuts and tools. Administrative shortcuts and tools.
</p> </p>
</div>
<div className="border-t border-gray-200 px-4 py-5 sm:px-6">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<button 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"> <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 👥 Manage Users
</button> </button>
<button className="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"> <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 🧪 View Experiments
</button> </button>
<button className="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"> <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 📊 Analytics
</button> </button>
<button className="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"> <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 Settings
</button> </button>
</div> </div>

View File

@@ -18,6 +18,9 @@ export function DashboardLayout({ onLogout }: DashboardLayoutProps) {
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [currentView, setCurrentView] = useState('dashboard') const [currentView, setCurrentView] = useState('dashboard')
const [isExpanded, setIsExpanded] = useState(true)
const [isMobileOpen, setIsMobileOpen] = useState(false)
const [isHovered, setIsHovered] = useState(false)
useEffect(() => { useEffect(() => {
fetchUserProfile() fetchUserProfile()
@@ -48,6 +51,22 @@ export function DashboardLayout({ onLogout }: DashboardLayoutProps) {
window.dispatchEvent(new PopStateEvent('popstate')) window.dispatchEvent(new PopStateEvent('popstate'))
} }
const toggleSidebar = () => {
setIsExpanded(!isExpanded)
}
const toggleMobileSidebar = () => {
setIsMobileOpen(!isMobileOpen)
}
const handleToggleSidebar = () => {
if (window.innerWidth >= 1024) {
toggleSidebar()
} else {
toggleMobileSidebar()
}
}
const renderCurrentView = () => { const renderCurrentView = () => {
if (!user) return null if (!user) return null
@@ -96,8 +115,8 @@ export function DashboardLayout({ onLogout }: DashboardLayoutProps) {
return ( 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">
<div className="text-center"> <div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600 mx-auto"></div> <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">Loading dashboard...</p> <p className="mt-4 text-gray-600 dark:text-gray-400">Loading dashboard...</p>
</div> </div>
</div> </div>
) )
@@ -107,12 +126,12 @@ export function DashboardLayout({ onLogout }: DashboardLayoutProps) {
return ( 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">
<div className="max-w-md w-full"> <div className="max-w-md w-full">
<div className="rounded-md bg-red-50 p-4"> <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-red-700">{error}</div> <div className="text-sm text-error-700 dark:text-error-500">{error}</div>
</div> </div>
<button <button
onClick={handleLogout} onClick={handleLogout}
className="mt-4 w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-gray-600 hover:bg-gray-700" 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 Back to Login
</button> </button>
@@ -125,10 +144,10 @@ export function DashboardLayout({ onLogout }: DashboardLayoutProps) {
return ( 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">
<div className="text-center"> <div className="text-center">
<div className="text-gray-600">No user data available</div> <div className="text-gray-600 dark:text-gray-400">No user data available</div>
<button <button
onClick={handleLogout} onClick={handleLogout}
className="mt-4 px-4 py-2 bg-gray-600 text-white rounded-md hover:bg-gray-700" 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 Back to Login
</button> </button>
@@ -138,17 +157,39 @@ export function DashboardLayout({ onLogout }: DashboardLayoutProps) {
} }
return ( return (
<div className="min-h-screen bg-gray-100 flex"> <div className="min-h-screen xl:flex">
<Sidebar <div>
user={user} <Sidebar
currentView={currentView} user={user}
onViewChange={setCurrentView} currentView={currentView}
/> onViewChange={setCurrentView}
<div className="flex-1 flex flex-col"> isExpanded={isExpanded}
<TopNavbar user={user} onLogout={handleLogout} currentView={currentView} /> isMobileOpen={isMobileOpen}
<main className="flex-1 overflow-auto"> 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()} {renderCurrentView()}
</main> </div>
</div> </div>
</div> </div>
) )

View File

@@ -60,21 +60,38 @@ export function ExperimentModal({ experiment, onClose, onExperimentSaved }: Expe
} }
return ( return (
<div className="fixed inset-0 bg-black bg-opacity-25 overflow-y-auto h-full w-full z-50 flex items-center justify-center p-4"> <div className="fixed inset-0 flex items-center justify-center overflow-y-auto modal z-999999">
<div className="relative bg-white rounded-xl shadow-2xl w-full max-w-4xl mx-auto max-h-[90vh] overflow-y-auto"> <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 */} {/* Header */}
<div className="sticky top-0 bg-white flex items-center justify-between p-6 border-b border-gray-200 rounded-t-xl"> <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"> <h3 className="text-xl font-semibold text-gray-900 dark:text-white/90">
{isEditing ? `Edit Experiment #${experiment.experiment_number}` : 'Create New Experiment'} {isEditing ? `Edit Experiment #${experiment.experiment_number}` : 'Create New Experiment'}
</h3> </h3>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg p-2 transition-colors"
>
<svg className="w-5 h-5" 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"> <div className="p-6">

View File

@@ -92,21 +92,38 @@ export function RepetitionScheduleModal({ experiment, repetition, onClose, onSch
} }
return ( return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50"> <div className="fixed inset-0 flex items-center justify-center overflow-y-auto modal z-999999">
<div className="bg-white rounded-lg shadow-xl max-w-md w-full max-h-[90vh] overflow-y-auto"> <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 */} {/* Header */}
<div className="flex items-center justify-between p-6 border-b"> <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"> <h3 className="text-lg font-semibold text-gray-900 dark:text-white/90">
Schedule Repetition Schedule Repetition
</h3> </h3>
<button
onClick={handleCancel}
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>
<div className="p-6"> <div className="p-6">

View File

@@ -92,21 +92,38 @@ export function ScheduleModal({ experiment, onClose, onScheduleUpdated }: Schedu
} }
return ( return (
<div className="fixed inset-0 bg-black bg-opacity-25 overflow-y-auto h-full w-full z-50 flex items-center justify-center p-4"> <div className="fixed inset-0 flex items-center justify-center overflow-y-auto modal z-999999">
<div className="relative bg-white rounded-xl shadow-2xl w-full max-w-md mx-auto"> <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 */} {/* Header */}
<div className="flex items-center justify-between p-6 border-b border-gray-200"> <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"> <h3 className="text-xl font-semibold text-gray-900 dark:text-white/90">
{isScheduled ? 'Update Schedule' : 'Schedule Experiment'} {isScheduled ? 'Update Schedule' : 'Schedule Experiment'}
</h3> </h3>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg p-2 transition-colors"
>
<svg className="w-5 h-5" 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"> <div className="p-6">
@@ -138,31 +155,45 @@ export function ScheduleModal({ experiment, onClose, onScheduleUpdated }: Schedu
{/* Schedule Form */} {/* Schedule Form */}
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<div> <div>
<label htmlFor="date" className="block text-sm font-medium text-gray-700 mb-2"> <label htmlFor="date" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Date * Date *
</label> </label>
<input <div className="relative max-w-xs">
type="date" <input
id="date" type="date"
value={dateTime.date} id="date"
onChange={(e) => setDateTime({ ...dateTime, date: e.target.value })} value={dateTime.date}
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" onChange={(e) => setDateTime({ ...dateTime, date: e.target.value })}
required 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>
<div> <div>
<label htmlFor="time" className="block text-sm font-medium text-gray-700 mb-2"> <label htmlFor="time" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Time * Time *
</label> </label>
<input <div className="relative max-w-xs">
type="time" <input
id="time" type="time"
value={dateTime.time} id="time"
onChange={(e) => setDateTime({ ...dateTime, time: e.target.value })} value={dateTime.time}
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" onChange={(e) => setDateTime({ ...dateTime, time: e.target.value })}
required 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> </div>
{/* Action Buttons */} {/* Action Buttons */}
@@ -173,7 +204,7 @@ export function ScheduleModal({ experiment, onClose, onScheduleUpdated }: Schedu
type="button" type="button"
onClick={handleRemoveSchedule} onClick={handleRemoveSchedule}
disabled={loading} 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" 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 Remove Schedule
</button> </button>
@@ -185,14 +216,14 @@ export function ScheduleModal({ experiment, onClose, onScheduleUpdated }: Schedu
type="button" type="button"
onClick={handleCancel} onClick={handleCancel}
disabled={loading} disabled={loading}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors disabled:opacity-50" 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 Cancel
</button> </button>
<button <button
type="submit" type="submit"
disabled={loading} disabled={loading}
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors disabled:opacity-50" 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')} {loading ? 'Saving...' : (isScheduled ? 'Update Schedule' : 'Schedule Experiment')}
</button> </button>

View File

@@ -1,10 +1,14 @@
import { useState } from 'react' import { useState, useCallback, useEffect, useRef } from 'react'
import type { User } from '../lib/supabase' import type { User } from '../lib/supabase'
interface SidebarProps { interface SidebarProps {
user: User user: User
currentView: string currentView: string
onViewChange: (view: string) => void onViewChange: (view: string) => void
isExpanded?: boolean
isMobileOpen?: boolean
isHovered?: boolean
setIsHovered?: (hovered: boolean) => void
} }
interface MenuItem { interface MenuItem {
@@ -12,17 +16,28 @@ interface MenuItem {
name: string name: string
icon: React.ReactElement icon: React.ReactElement
requiredRoles?: string[] requiredRoles?: string[]
subItems?: { name: string; id: string; requiredRoles?: string[] }[]
} }
export function Sidebar({ user, currentView, onViewChange }: SidebarProps) { export function Sidebar({
const [isCollapsed, setIsCollapsed] = useState(false) 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[] = [ const menuItems: MenuItem[] = [
{ {
id: 'dashboard', id: 'dashboard',
name: 'Dashboard', name: 'Dashboard',
icon: ( icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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="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" /> <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> </svg>
@@ -32,7 +47,7 @@ export function Sidebar({ user, currentView, onViewChange }: SidebarProps) {
id: 'user-management', id: 'user-management',
name: 'User Management', name: 'User Management',
icon: ( icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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" /> <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> </svg>
), ),
@@ -42,7 +57,7 @@ export function Sidebar({ user, currentView, onViewChange }: SidebarProps) {
id: 'experiments', id: 'experiments',
name: 'Experiments', name: 'Experiments',
icon: ( icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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" /> <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> </svg>
), ),
@@ -52,7 +67,7 @@ export function Sidebar({ user, currentView, onViewChange }: SidebarProps) {
id: 'video-library', id: 'video-library',
name: 'Video Library', name: 'Video Library',
icon: ( icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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" /> <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> </svg>
), ),
@@ -61,7 +76,7 @@ export function Sidebar({ user, currentView, onViewChange }: SidebarProps) {
id: 'analytics', id: 'analytics',
name: 'Analytics', name: 'Analytics',
icon: ( icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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" /> <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> </svg>
), ),
@@ -71,7 +86,7 @@ export function Sidebar({ user, currentView, onViewChange }: SidebarProps) {
id: 'data-entry', id: 'data-entry',
name: 'Data Entry', name: 'Data Entry',
icon: ( icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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" /> <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> </svg>
), ),
@@ -81,80 +96,216 @@ export function Sidebar({ user, currentView, onViewChange }: SidebarProps) {
id: 'vision-system', id: 'vision-system',
name: 'Vision System', name: 'Vision System',
icon: ( icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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" /> <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> </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 => { const hasAccess = (item: MenuItem): boolean => {
if (!item.requiredRoles) return true if (!item.requiredRoles) return true
return item.requiredRoles.some(role => user.roles.includes(role as any)) 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 ( return (
<div className={`bg-slate-800 transition-all duration-300 ${isCollapsed ? 'w-16' : 'w-64'} min-h-screen flex flex-col shadow-xl relative z-20`}> <aside
{/* Header */} 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
<div className="p-4 border-b border-slate-700"> ${isExpanded || isMobileOpen
<div className="flex items-center justify-between"> ? "w-[290px]"
{!isCollapsed && ( : isHovered
<div> ? "w-[290px]"
<h1 className="text-xl font-bold text-white">Pecan Experiments</h1> : "w-[90px]"
<p className="text-sm text-slate-400">Admin Dashboard</p> }
${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>
)} )}
<button
onClick={() => setIsCollapsed(!isCollapsed)}
className="p-2 rounded-lg hover:bg-slate-700 transition-colors text-slate-400 hover:text-white"
title={isCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
{isCollapsed ? (
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
) : (
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
)}
</svg>
</button>
</div> </div>
</div> </div>
{/* Navigation Menu */} <div className="flex flex-col overflow-y-auto duration-300 ease-linear no-scrollbar">
<nav className="flex-1 p-4"> <nav className="mb-6">
<ul className="space-y-2"> <div className="flex flex-col gap-4">
{menuItems.map((item) => { <div>
if (!hasAccess(item)) return null <h2
className={`mb-4 text-xs uppercase flex leading-[20px] text-gray-400 ${!isExpanded && !isHovered
return ( ? "lg:justify-center"
<li key={item.id}> : "justify-start"
<button }`}
onClick={() => onViewChange(item.id)} >
className={`w-full flex items-center px-3 py-3 rounded-lg transition-all duration-200 group ${currentView === item.id {isExpanded || isHovered || isMobileOpen ? (
? 'bg-blue-600 text-white shadow-lg' "Menu"
: 'text-slate-300 hover:bg-slate-700 hover:text-white' ) : (
}`} <svg className="size-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
title={isCollapsed ? item.name : undefined} <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>
<span className={`transition-colors ${currentView === item.id ? 'text-white' : 'text-slate-400 group-hover:text-white'}`}> )}
{item.icon} </h2>
</span> {renderMenuItems(menuItems)}
{!isCollapsed && ( </div>
<span className="ml-3 text-sm font-medium">{item.name}</span> </div>
)} </nav>
{!isCollapsed && currentView === item.id && ( </div>
<div className="ml-auto"> </aside>
<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>
</div>
)}
</button>
</li>
)
})}
</ul>
</nav>
</div>
) )
} }

View File

@@ -5,9 +5,17 @@ interface TopNavbarProps {
user: User user: User
onLogout: () => void onLogout: () => void
currentView?: string currentView?: string
onToggleSidebar?: () => void
isSidebarOpen?: boolean
} }
export function TopNavbar({ user, onLogout, currentView = 'dashboard' }: TopNavbarProps) { export function TopNavbar({
user,
onLogout,
currentView = 'dashboard',
onToggleSidebar,
isSidebarOpen = false
}: TopNavbarProps) {
const [isUserMenuOpen, setIsUserMenuOpen] = useState(false) const [isUserMenuOpen, setIsUserMenuOpen] = useState(false)
const getPageTitle = (view: string) => { const getPageTitle = (view: string) => {
@@ -24,6 +32,8 @@ export function TopNavbar({ user, onLogout, currentView = 'dashboard' }: TopNavb
return 'Data Entry' return 'Data Entry'
case 'vision-system': case 'vision-system':
return 'Vision System' return 'Vision System'
case 'video-library':
return 'Video Library'
default: default:
return 'Dashboard' return 'Dashboard'
} }
@@ -32,110 +42,215 @@ export function TopNavbar({ user, onLogout, currentView = 'dashboard' }: TopNavb
const getRoleBadgeColor = (role: string) => { const getRoleBadgeColor = (role: string) => {
switch (role) { switch (role) {
case 'admin': case 'admin':
return 'bg-red-100 text-red-800' return 'bg-error-50 text-error-600 dark:bg-error-500/15 dark:text-error-500'
case 'conductor': case 'conductor':
return 'bg-blue-100 text-blue-800' return 'bg-brand-50 text-brand-500 dark:bg-brand-500/15 dark:text-brand-400'
case 'analyst': case 'analyst':
return 'bg-green-100 text-green-800' return 'bg-success-50 text-success-600 dark:bg-success-500/15 dark:text-success-500'
case 'data recorder': case 'data recorder':
return 'bg-purple-100 text-purple-800' return 'bg-theme-purple-500/10 text-theme-purple-500'
default: default:
return 'bg-gray-100 text-gray-800' return 'bg-gray-100 text-gray-700 dark:bg-white/5 dark:text-white/80'
} }
} }
return ( return (
<header className="bg-white shadow-sm border-b border-gray-200 sticky top-0 z-30"> <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 items-center justify-between h-16 px-6"> <div className="flex flex-col items-center justify-between grow lg:flex-row lg:px-6">
{/* Left side - could add breadcrumbs or page title here */} <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">
<div className="flex items-center"> <button
<h1 className="text-lg font-semibold text-gray-900">{getPageTitle(currentView)}</h1> 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>
{/* Right side - User menu */} <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">
<div className="flex items-center space-x-4"> {/* User Area */}
{/* User info and avatar */}
<div className="relative"> <div className="relative">
<button <button
onClick={() => setIsUserMenuOpen(!isUserMenuOpen)} onClick={() => setIsUserMenuOpen(!isUserMenuOpen)}
className="flex items-center space-x-3 p-2 rounded-lg hover:bg-gray-50 transition-colors" className="flex items-center text-gray-700 dropdown-toggle dark:text-gray-400"
> >
{/* User avatar */} <span className="mr-3 overflow-hidden rounded-full h-11 w-11">
<div className="w-8 h-8 bg-indigo-600 rounded-full flex items-center justify-center text-white text-sm font-medium"> <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()} {user.email.charAt(0).toUpperCase()}
</div>
{/* User info */}
<div className="hidden md:block text-left">
<div className="text-sm font-medium text-gray-900 truncate max-w-32">
{user.email}
</div> </div>
<div className="text-xs text-gray-500"> </span>
{user.roles.length > 0 ? user.roles[0].charAt(0).toUpperCase() + user.roles[0].slice(1) : 'User'}
</div>
</div>
{/* Dropdown arrow */} <span className="block mr-1 font-medium text-theme-sm">{user.email.split('@')[0]}</span>
<svg <svg
className={`w-4 h-4 text-gray-400 transition-transform ${isUserMenuOpen ? 'rotate-180' : ''}`} 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" fill="none"
stroke="currentColor" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
> >
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /> <path
d="M4.3125 8.65625L9 13.3437L13.6875 8.65625"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg> </svg>
</button> </button>
{/* Dropdown menu */} {/* Dropdown menu */}
{isUserMenuOpen && ( {isUserMenuOpen && (
<div className="absolute right-0 mt-2 w-80 bg-white rounded-lg shadow-lg border border-gray-200 z-50 animate-in fade-in slide-in-from-top-2 duration-200"> <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 className="p-4 border-b border-gray-100"> <div>
<div className="flex items-center space-x-3"> <span className="block font-medium text-gray-700 text-theme-sm dark:text-gray-400">
<div className="w-12 h-12 bg-indigo-600 rounded-full flex items-center justify-center text-white text-lg font-medium"> {user.email.split('@')[0]}
{user.email.charAt(0).toUpperCase()} </span>
</div> <span className="mt-0.5 block text-theme-xs text-gray-500 dark:text-gray-400">
<div className="flex-1 min-w-0"> {user.email}
<div className="text-sm font-medium text-gray-900 truncate"> </span>
{user.email}
</div>
<div className="text-xs text-gray-500 mt-1">
Status: <span className={user.status === 'active' ? 'text-green-600' : 'text-red-600'}>
{user.status}
</span>
</div>
</div>
</div>
{/* User roles */}
<div className="mt-3">
<div className="text-xs text-gray-500 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 py-0.5 rounded-full text-xs font-medium ${getRoleBadgeColor(role)}`}
>
{role.charAt(0).toUpperCase() + role.slice(1)}
</span>
))}
</div>
</div>
</div> </div>
<div className="p-2"> <ul className="flex flex-col gap-1 pt-4 pb-3 border-b border-gray-200 dark:border-gray-800">
<button <li>
onClick={() => { <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">
setIsUserMenuOpen(false) <svg
onLogout() className="fill-gray-500 dark:fill-gray-400"
}} width="24"
className="w-full flex items-center px-3 py-2 text-sm text-gray-700 hover:bg-red-50 hover:text-red-700 rounded-md transition-colors" 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"
> >
<svg className="w-4 h-4 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <path
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" /> fillRule="evenodd"
</svg> clipRule="evenodd"
Sign Out 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"
</button> fill=""
</div> />
</svg>
Sign out
</button>
</div> </div>
)} )}
</div> </div>

View File

@@ -618,7 +618,7 @@ export function VisionSystem() {
const handleStartRecording = async (cameraName: string) => { const handleStartRecording = async (cameraName: string) => {
try { try {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-') const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
const filename = `manual_${cameraName}_${timestamp}.avi` const filename = `manual_${cameraName}_${timestamp}.mp4`
const result = await visionApi.startRecording(cameraName, { filename }) const result = await visionApi.startRecording(cameraName, { filename })

View File

@@ -50,123 +50,117 @@ export const VideoStreamingPage: React.FC = () => {
}; };
return ( return (
<div className="min-h-screen bg-gray-50"> <div className="space-y-6">
{/* Header */} {/* Header */}
<div className="bg-white shadow"> <div>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> <h1 className="text-3xl font-bold text-gray-900">Video Library</h1>
<div className="py-6"> <p className="mt-2 text-gray-600">
<h1 className="text-3xl font-bold text-gray-900">Video Library</h1> Browse and view recorded videos from your camera system
<p className="mt-2 text-gray-600"> </p>
Browse and view recorded videos from your camera system
</p>
</div>
</div>
</div> </div>
{/* Filters and Controls */} {/* Filters and Controls */}
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6"> <div className="bg-white rounded-xl border border-gray-200 p-6 shadow-theme-sm">
<div className="bg-white rounded-lg shadow p-6 mb-6"> <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4"> {/* Camera Filter */}
{/* Camera Filter */} <div>
<div> <label className="block text-sm font-medium text-gray-700 mb-2">
<label className="block text-sm font-medium text-gray-700 mb-2"> Filter by Camera
Filter by Camera </label>
</label> <select
value={filters.cameraName || 'all'}
onChange={(e) => handleCameraFilterChange(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
>
<option value="all">All Cameras</option>
{availableCameras.map(camera => (
<option key={camera} value={camera}>
{camera}
</option>
))}
</select>
</div>
{/* Sort Options */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Sort by
</label>
<div className="flex space-x-2">
<select <select
value={filters.cameraName || 'all'} value={sortOptions.field}
onChange={(e) => handleCameraFilterChange(e.target.value)} onChange={(e) => handleSortChange(e.target.value as VideoListSortOptions['field'], sortOptions.direction)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500" className="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
> >
<option value="all">All Cameras</option> <option value="created_at">Date Created</option>
{availableCameras.map(camera => ( <option value="file_size_bytes">File Size</option>
<option key={camera} value={camera}> <option value="camera_name">Camera Name</option>
{camera} <option value="filename">Filename</option>
</option>
))}
</select> </select>
</div> <button
onClick={() => handleSortChange(sortOptions.field, sortOptions.direction === 'asc' ? 'desc' : 'asc')}
{/* Sort Options */} className="px-3 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
<div> title={`Sort ${sortOptions.direction === 'asc' ? 'Descending' : 'Ascending'}`}
<label className="block text-sm font-medium text-gray-700 mb-2"> >
Sort by {sortOptions.direction === 'asc' ? (
</label> <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<div className="flex space-x-2"> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 4h13M3 8h9m-9 4h6m4 0l4-4m0 0l4 4m-4-4v12" />
<select </svg>
value={sortOptions.field} ) : (
onChange={(e) => handleSortChange(e.target.value as VideoListSortOptions['field'], sortOptions.direction)} <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
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-blue-500" <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 4h13M3 8h9m-9 4h9m5-4v12m0 0l-4-4m4 4l4-4" />
> </svg>
<option value="created_at">Date Created</option> )}
<option value="file_size_bytes">File Size</option> </button>
<option value="camera_name">Camera Name</option>
<option value="filename">Filename</option>
</select>
<button
onClick={() => handleSortChange(sortOptions.field, sortOptions.direction === 'asc' ? 'desc' : 'asc')}
className="px-3 py-2 border border-gray-300 rounded-md hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
title={`Sort ${sortOptions.direction === 'asc' ? 'Descending' : 'Ascending'}`}
>
{sortOptions.direction === 'asc' ? (
<svg className="w-5 h-5" 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-5 h-5" 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">
Date Range
</label>
<div className="flex space-x-2">
<input
type="date"
value={filters.dateRange?.start || ''}
onChange={(e) => handleDateRangeChange(e.target.value, filters.dateRange?.end || '')}
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-blue-500"
/>
<input
type="date"
value={filters.dateRange?.end || ''}
onChange={(e) => handleDateRangeChange(filters.dateRange?.start || '', 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-blue-500"
/>
</div>
</div> </div>
</div> </div>
{/* Clear Filters */} {/* Date Range Filter */}
{(filters.cameraName || filters.dateRange) && ( <div>
<div className="mt-4 pt-4 border-t"> <label className="block text-sm font-medium text-gray-700 mb-2">
<button Date Range
onClick={() => setFilters({})} </label>
className="inline-flex items-center px-3 py-1.5 border border-gray-300 text-sm font-medium rounded text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500" <div className="flex space-x-2">
> <input
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"> type="date"
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /> value={filters.dateRange?.start || ''}
</svg> onChange={(e) => handleDateRangeChange(e.target.value, filters.dateRange?.end || '')}
Clear Filters 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-blue-500"
</button> />
<input
type="date"
value={filters.dateRange?.end || ''}
onChange={(e) => handleDateRangeChange(filters.dateRange?.start || '', 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-blue-500"
/>
</div> </div>
)} </div>
</div> </div>
{/* Video List */} {/* Clear Filters */}
<VideoList {(filters.cameraName || filters.dateRange) && (
filters={filters} <div className="mt-4 pt-4 border-t border-gray-100">
sortOptions={sortOptions} <button
onVideoSelect={handleVideoSelect} onClick={() => setFilters({})}
limit={24} 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"
/> >
<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="M6 18L18 6M6 6l12 12" />
</svg>
Clear Filters
</button>
</div>
)}
</div> </div>
{/* Video List */}
<VideoList
filters={filters}
sortOptions={sortOptions}
onVideoSelect={handleVideoSelect}
limit={24}
/>
{/* Video Modal */} {/* Video Modal */}
<VideoModal <VideoModal
video={selectedVideo} video={selectedVideo}

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 rounded-lg border";
// Active page button classes
const activeButtonClasses = "bg-brand-500 text-white border-brand-500 hover:bg-brand-600 shadow-theme-xs";
// Inactive page button classes
const inactiveButtonClasses = "bg-white text-gray-700 border-gray-300 hover:bg-gray-50";
// 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

@@ -33,8 +33,8 @@ export const VideoCard: React.FC<VideoCardProps> = ({
}; };
const cardClasses = [ const cardClasses = [
'bg-white rounded-lg shadow-md overflow-hidden transition-shadow hover:shadow-lg', 'bg-white rounded-xl border border-gray-200 overflow-hidden transition-all hover:shadow-theme-md',
onClick ? 'cursor-pointer' : '', onClick ? 'cursor-pointer hover:border-gray-300' : '',
className, className,
].filter(Boolean).join(' '); ].filter(Boolean).join(' ');
@@ -117,7 +117,7 @@ export const VideoCard: React.FC<VideoCardProps> = ({
{/* Metadata (if available and requested) */} {/* Metadata (if available and requested) */}
{showMetadata && 'metadata' in video && video.metadata && ( {showMetadata && 'metadata' in video && video.metadata && (
<div className="border-t pt-3 mt-3"> <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 className="grid grid-cols-2 gap-4 text-sm text-gray-600">
<div> <div>
<span className="font-medium">Duration:</span> {Math.round(video.metadata.duration_seconds)}s <span className="font-medium">Duration:</span> {Math.round(video.metadata.duration_seconds)}s
@@ -136,7 +136,7 @@ export const VideoCard: React.FC<VideoCardProps> = ({
)} )}
{/* Actions */} {/* Actions */}
<div className="flex justify-between items-center mt-4 pt-3 border-t"> <div className="flex justify-between items-center mt-4 pt-3 border-t border-gray-100">
<div className="text-xs text-gray-500"> <div className="text-xs text-gray-500">
{formatVideoDate(video.created_at)} {formatVideoDate(video.created_at)}
</div> </div>
@@ -147,7 +147,7 @@ export const VideoCard: React.FC<VideoCardProps> = ({
e.stopPropagation(); e.stopPropagation();
handleClick(); handleClick();
}} }}
className="inline-flex items-center px-3 py-1.5 border border-transparent text-xs font-medium rounded text-blue-700 bg-blue-100 hover:bg-blue-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500" 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"> <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" /> <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" />

View File

@@ -8,6 +8,7 @@ import React, { useState, useEffect } from 'react';
import { type VideoListProps, type VideoListFilters, type VideoListSortOptions } from '../types'; import { type VideoListProps, type VideoListFilters, type VideoListSortOptions } from '../types';
import { useVideoList } from '../hooks/useVideoList'; import { useVideoList } from '../hooks/useVideoList';
import { VideoCard } from './VideoCard'; import { VideoCard } from './VideoCard';
import { Pagination, PageInfo } from './Pagination';
export const VideoList: React.FC<VideoListProps> = ({ export const VideoList: React.FC<VideoListProps> = ({
filters, filters,
@@ -24,11 +25,16 @@ export const VideoList: React.FC<VideoListProps> = ({
const { const {
videos, videos,
totalCount, totalCount,
currentPage,
totalPages,
loading, loading,
error, error,
refetch, refetch,
loadMore, loadMore,
hasMore, hasMore,
goToPage,
nextPage,
previousPage,
updateFilters, updateFilters,
updateSort, updateSort,
} = useVideoList({ } = useVideoList({
@@ -38,6 +44,7 @@ export const VideoList: React.FC<VideoListProps> = ({
end_date: localFilters.dateRange?.end, end_date: localFilters.dateRange?.end,
limit, limit,
include_metadata: true, include_metadata: true,
page: 1, // Start with page 1
}, },
autoFetch: true, autoFetch: true,
}); });
@@ -130,17 +137,22 @@ export const VideoList: React.FC<VideoListProps> = ({
{/* Results Summary */} {/* Results Summary */}
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
<div className="text-sm text-gray-600"> <div className="text-sm text-gray-600">
Showing {videos.length} of {totalCount} videos {totalPages > 0 ? (
<>Showing page {currentPage} of {totalPages} ({totalCount} total videos)</>
) : (
<>Showing {videos.length} of {totalCount} videos</>
)}
</div> </div>
<button <button
onClick={refetch} onClick={refetch}
className="inline-flex items-center px-3 py-1.5 border border-gray-300 text-sm font-medium rounded text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500" 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"
> >
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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" /> <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> </svg>
Refresh {loading === 'loading' ? 'Refreshing...' : 'Refresh'}
</button> </button>
</div> </div>
@@ -156,37 +168,37 @@ export const VideoList: React.FC<VideoListProps> = ({
))} ))}
</div> </div>
{/* Load More Button */} {/* Pagination */}
{hasMore && ( {totalPages > 1 && (
<div className="flex justify-center mt-8"> <div className="mt-8 space-y-4">
<button {/* Page Info */}
onClick={handleLoadMore} <PageInfo
disabled={loading === 'loading'} currentPage={currentPage}
className="inline-flex items-center px-6 py-3 border border-transparent text-base 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 disabled:opacity-50 disabled:cursor-not-allowed" totalPages={totalPages}
> totalItems={totalCount}
{loading === 'loading' ? ( itemsPerPage={limit}
<> className="text-center"
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div> />
Loading...
</> {/* Pagination Controls */}
) : ( <Pagination
<> currentPage={currentPage}
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"> totalPages={totalPages}
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" /> onPageChange={goToPage}
</svg> showFirstLast={true}
Load More Videos showPrevNext={true}
</> maxVisiblePages={5}
)} className="justify-center"
</button> />
</div> </div>
)} )}
{/* Loading Indicator for Additional Videos */} {/* Loading Indicator */}
{loading === 'loading' && videos.length > 0 && ( {loading === 'loading' && (
<div className="flex justify-center mt-4"> <div className="flex justify-center mt-8">
<div className="text-sm text-gray-600 flex items-center"> <div className="text-sm text-gray-600 flex items-center">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-500 mr-2"></div> <div className="animate-spin rounded-full h-4 w-4 border-b-2 border-brand-500 mr-2"></div>
Loading more videos... Loading videos...
</div> </div>
</div> </div>
)} )}

View File

@@ -10,6 +10,7 @@ export { VideoThumbnail } from './VideoThumbnail';
export { VideoCard } from './VideoCard'; export { VideoCard } from './VideoCard';
export { VideoList } from './VideoList'; export { VideoList } from './VideoList';
export { VideoModal } from './VideoModal'; export { VideoModal } from './VideoModal';
export { Pagination, PageInfo } from './Pagination';
// Re-export component prop types for convenience // Re-export component prop types for convenience
export type { export type {
@@ -17,4 +18,5 @@ export type {
VideoThumbnailProps, VideoThumbnailProps,
VideoCardProps, VideoCardProps,
VideoListProps, VideoListProps,
PaginationProps,
} from '../types'; } from '../types';

View File

@@ -19,11 +19,16 @@ import {
export interface UseVideoListReturn { export interface UseVideoListReturn {
videos: VideoFile[]; videos: VideoFile[];
totalCount: number; totalCount: number;
currentPage: number;
totalPages: number;
loading: LoadingState; loading: LoadingState;
error: VideoError | null; error: VideoError | null;
refetch: () => Promise<void>; refetch: () => Promise<void>;
loadMore: () => Promise<void>; loadMore: () => Promise<void>;
hasMore: boolean; hasMore: boolean;
goToPage: (page: number) => Promise<void>;
nextPage: () => Promise<void>;
previousPage: () => Promise<void>;
updateFilters: (filters: VideoListFilters) => void; updateFilters: (filters: VideoListFilters) => void;
updateSort: (sortOptions: VideoListSortOptions) => void; updateSort: (sortOptions: VideoListSortOptions) => void;
clearCache: () => void; clearCache: () => void;
@@ -47,6 +52,8 @@ export function useVideoList(options: UseVideoListOptions = {}) {
// State // State
const [videos, setVideos] = useState<VideoFile[]>([]); const [videos, setVideos] = useState<VideoFile[]>([]);
const [totalCount, setTotalCount] = useState(0); const [totalCount, setTotalCount] = useState(0);
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(0);
const [loading, setLoading] = useState<LoadingState>('idle'); const [loading, setLoading] = useState<LoadingState>('idle');
const [error, setError] = useState<VideoError | null>(null); const [error, setError] = useState<VideoError | null>(null);
const [hasMore, setHasMore] = useState(true); const [hasMore, setHasMore] = useState(true);
@@ -85,7 +92,17 @@ export function useVideoList(options: UseVideoListOptions = {}) {
// Update state // Update state
setVideos(append ? prev => [...prev, ...response.videos] : response.videos); setVideos(append ? prev => [...prev, ...response.videos] : response.videos);
setTotalCount(response.total_count); setTotalCount(response.total_count);
setHasMore(response.videos.length === (params.limit || 50));
// 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'); setLoading('success');
} catch (err) { } catch (err) {
@@ -105,14 +122,19 @@ export function useVideoList(options: UseVideoListOptions = {}) {
}, [initialParams]); }, [initialParams]);
/** /**
* Refetch videos with initial parameters * Refetch videos with current page
*/ */
const refetch = useCallback(async (): Promise<void> => { const refetch = useCallback(async (): Promise<void> => {
await fetchVideos(initialParams, false); const currentParams = {
}, [fetchVideos, initialParams]); ...initialParams,
page: currentPage,
limit: initialParams.limit || 20,
};
await fetchVideos(currentParams, false);
}, [fetchVideos, initialParams, currentPage]);
/** /**
* Load more videos (pagination) * Load more videos (pagination) - for backward compatibility
*/ */
const loadMore = useCallback(async (): Promise<void> => { const loadMore = useCallback(async (): Promise<void> => {
if (!hasMore || loading === 'loading') { if (!hasMore || loading === 'loading') {
@@ -124,6 +146,36 @@ export function useVideoList(options: UseVideoListOptions = {}) {
await fetchVideos(params, true); await fetchVideos(params, true);
}, [hasMore, loading, videos.length, initialParams, fetchVideos]); }, [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 = { ...initialParams, page, limit: initialParams.limit || 20 };
await fetchVideos(params, false);
}, [initialParams, 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 * Update filters and refetch
*/ */
@@ -133,6 +185,8 @@ export function useVideoList(options: UseVideoListOptions = {}) {
camera_name: filters.cameraName, camera_name: filters.cameraName,
start_date: filters.dateRange?.start, start_date: filters.dateRange?.start,
end_date: filters.dateRange?.end, end_date: filters.dateRange?.end,
page: 1, // Reset to first page when filters change
limit: initialParams.limit || 20,
}; };
fetchVideos(newParams, false); fetchVideos(newParams, false);
@@ -146,12 +200,22 @@ export function useVideoList(options: UseVideoListOptions = {}) {
setVideos(prev => sortVideos(prev, sortOptions.field, sortOptions.direction)); 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 * Reset to initial state
*/ */
const reset = useCallback((): void => { const reset = useCallback((): void => {
setVideos([]); setVideos([]);
setTotalCount(0); setTotalCount(0);
setCurrentPage(1);
setTotalPages(0);
setLoading('idle'); setLoading('idle');
setError(null); setError(null);
setHasMore(true); setHasMore(true);
@@ -174,14 +238,21 @@ export function useVideoList(options: UseVideoListOptions = {}) {
return { return {
videos, videos,
totalCount, totalCount,
currentPage,
totalPages,
loading, loading,
error, error,
refetch, refetch,
loadMore, loadMore,
hasMore, hasMore,
// Pagination methods
goToPage,
nextPage,
previousPage,
// Additional utility methods // Additional utility methods
updateFilters, updateFilters,
updateSort, updateSort,
clearCache,
reset, reset,
}; };
} }

View File

@@ -90,7 +90,16 @@ export class VideoApiService {
*/ */
async getVideos(params: VideoListParams = {}): Promise<VideoListResponse> { async getVideos(params: VideoListParams = {}): Promise<VideoListResponse> {
try { try {
const queryString = buildQueryString(params); // 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 url = `${this.baseUrl}/videos/${queryString ? `?${queryString}` : ''}`;
const response = await fetch(url, { const response = await fetch(url, {
@@ -100,7 +109,21 @@ export class VideoApiService {
}, },
}); });
return await handleApiResponse<VideoListResponse>(response); const result = await handleApiResponse<VideoListResponse>(response);
// Add pagination metadata if page was requested
if (params.page && params.limit) {
const totalPages = Math.ceil(result.total_count / params.limit);
return {
...result,
page: params.page,
total_pages: totalPages,
has_next: params.page < totalPages,
has_previous: params.page > 1,
};
}
return result;
} catch (error) { } catch (error) {
if (error instanceof VideoApiError) { if (error instanceof VideoApiError) {
throw error; throw error;

View File

@@ -35,6 +35,10 @@ export interface VideoWithMetadata extends VideoFile {
export interface VideoListResponse { export interface VideoListResponse {
videos: VideoFile[]; videos: VideoFile[];
total_count: number; total_count: number;
page?: number;
total_pages?: number;
has_next?: boolean;
has_previous?: boolean;
} }
// API response for video info // API response for video info
@@ -66,6 +70,8 @@ export interface VideoListParams {
end_date?: string; end_date?: string;
limit?: number; limit?: number;
include_metadata?: boolean; include_metadata?: boolean;
page?: number;
offset?: number;
} }
// Thumbnail request parameters // Thumbnail request parameters
@@ -122,6 +128,17 @@ export interface VideoListProps {
className?: string; 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 { export interface VideoThumbnailProps {
fileId: string; fileId: string;
timestamp?: number; timestamp?: number;

View File

@@ -1,15 +1,290 @@
@import url("https://fonts.googleapis.com/css2?family=Outfit:wght@100..900&display=swap") layer(base);
@import "tailwindcss"; @import "tailwindcss";
/* Reset some default styles that conflict with Tailwind */ @custom-variant dark (&:is(.dark *));
body {
margin: 0; @theme {
min-height: 100vh; --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;
} }
/* Custom styles that don't conflict with Tailwind */ /*
:root { The default border color has changed to `currentColor` in Tailwind CSS v4,
font-synthesis: none; so we've added these compatibility styles to make sure everything still
text-rendering: optimizeLegibility; looks the same as it did with Tailwind CSS v3.
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; 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

@@ -226,7 +226,7 @@ export class AutoRecordingManager {
private async startAutoRecording(cameraName: string, machineName: string): Promise<void> { private async startAutoRecording(cameraName: string, machineName: string): Promise<void> {
try { try {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-') const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
const filename = `auto_${machineName}_${timestamp}.avi` const filename = `auto_${machineName}_${timestamp}.mp4`
const result = await visionApi.startRecording(cameraName, { filename }) const result = await visionApi.startRecording(cameraName, { filename })

View File

@@ -136,12 +136,12 @@ export function getRecommendedVideoSettings(useCase: 'production' | 'storage-opt
const settings = { const settings = {
production: { production: {
video_format: 'mp4', video_format: 'mp4',
video_codec: 'mp4v', video_codec: 'h264',
video_quality: 95, video_quality: 95,
}, },
'storage-optimized': { 'storage-optimized': {
video_format: 'mp4', video_format: 'mp4',
video_codec: 'mp4v', video_codec: 'h264',
video_quality: 85, video_quality: 85,
}, },
legacy: { legacy: {

View File

@@ -5,7 +5,92 @@ export default {
"./src/**/*.{js,ts,jsx,tsx}", "./src/**/*.{js,ts,jsx,tsx}",
], ],
theme: { theme: {
extend: {}, extend: {
colors: {
brand: {
25: '#f2f7ff',
50: '#ecf3ff',
100: '#dde9ff',
200: '#c2d6ff',
300: '#9cb9ff',
400: '#7592ff',
500: '#465fff',
600: '#3641f5',
700: '#2a31d8',
800: '#252dae',
900: '#262e89',
950: '#161950',
},
gray: {
25: '#fcfcfd',
50: '#f9fafb',
100: '#f2f4f7',
200: '#e4e7ec',
300: '#d0d5dd',
400: '#98a2b3',
500: '#667085',
600: '#475467',
700: '#344054',
800: '#1d2939',
900: '#101828',
950: '#0c111d',
},
success: {
25: '#f6fef9',
50: '#ecfdf3',
100: '#d1fadf',
200: '#a6f4c5',
300: '#6ce9a6',
400: '#32d583',
500: '#12b76a',
600: '#039855',
700: '#027a48',
800: '#05603a',
900: '#054f31',
950: '#053321',
},
error: {
25: '#fffbfa',
50: '#fef3f2',
100: '#fee4e2',
200: '#fecdca',
300: '#fda29b',
400: '#f97066',
500: '#f04438',
600: '#d92d20',
700: '#b42318',
800: '#912018',
900: '#7a271a',
950: '#55160c',
},
warning: {
25: '#fffcf5',
50: '#fffaeb',
100: '#fef0c7',
200: '#fedf89',
300: '#fec84b',
400: '#fdb022',
500: '#f79009',
600: '#dc6803',
700: '#b54708',
800: '#93370d',
900: '#7a2e0e',
950: '#4e1d09',
},
},
boxShadow: {
'theme-xs': '0px 1px 2px 0px rgba(16, 24, 40, 0.05)',
'theme-sm': '0px 1px 3px 0px rgba(16, 24, 40, 0.1), 0px 1px 2px 0px rgba(16, 24, 40, 0.06)',
'theme-md': '0px 4px 8px -2px rgba(16, 24, 40, 0.1), 0px 2px 4px -2px rgba(16, 24, 40, 0.06)',
'theme-lg': '0px 12px 16px -4px rgba(16, 24, 40, 0.08), 0px 4px 6px -2px rgba(16, 24, 40, 0.03)',
'theme-xl': '0px 20px 24px -4px rgba(16, 24, 40, 0.08), 0px 8px 8px -4px rgba(16, 24, 40, 0.03)',
},
fontSize: {
'theme-xs': ['12px', '18px'],
'theme-sm': ['14px', '20px'],
'theme-xl': ['20px', '30px'],
},
},
}, },
plugins: [], plugins: [],
} }