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:
@@ -1,27 +1,32 @@
|
||||
# API Changes Summary: Camera Settings and Video Format Updates
|
||||
|
||||
## Overview
|
||||
|
||||
This document tracks major API changes including camera settings enhancements and the MP4 video format update.
|
||||
|
||||
## 🎥 Latest Update: MP4 Video Format (v2.1)
|
||||
|
||||
**Date**: August 2025
|
||||
|
||||
**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 Size**: ~40% reduction in file sizes
|
||||
- **Streaming**: Better web browser compatibility
|
||||
|
||||
**New Configuration Fields**:
|
||||
|
||||
```json
|
||||
{
|
||||
"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)
|
||||
}
|
||||
```
|
||||
|
||||
**Frontend Impact**:
|
||||
|
||||
- ✅ Better streaming performance and browser support
|
||||
- ✅ Smaller file sizes for faster transfers
|
||||
- ✅ Universal HTML5 video player compatibility
|
||||
@@ -38,12 +43,14 @@ Enhanced the `POST /cameras/{camera_name}/start-recording` API endpoint to accep
|
||||
## Changes Made
|
||||
|
||||
### 1. API Models (`usda_vision_system/api/models.py`)
|
||||
|
||||
- **Enhanced `StartRecordingRequest`** to include optional parameters:
|
||||
- `exposure_ms: Optional[float]` - Exposure time in milliseconds
|
||||
- `gain: Optional[float]` - Camera gain value
|
||||
- `fps: Optional[float]` - Target frames per second
|
||||
|
||||
### 2. Camera Recorder (`usda_vision_system/camera/recorder.py`)
|
||||
|
||||
- **Added `update_camera_settings()` method** to dynamically update camera settings:
|
||||
- Updates exposure time using `mvsdk.CameraSetExposureTime()`
|
||||
- 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
|
||||
|
||||
### 3. Camera Manager (`usda_vision_system/camera/manager.py`)
|
||||
|
||||
- **Enhanced `manual_start_recording()` method** to accept new parameters:
|
||||
- Added optional `exposure_ms`, `gain`, and `fps` parameters
|
||||
- Calls `update_camera_settings()` if any settings are provided
|
||||
- **Automatic datetime prefix**: Always prepends timestamp to 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`)
|
||||
|
||||
- **Updated start-recording endpoint** to:
|
||||
- Pass new camera settings to camera manager
|
||||
- Handle filename response with datetime prefix
|
||||
- Maintain backward compatibility with existing requests
|
||||
|
||||
### 5. API Tests (`api-tests.http`)
|
||||
|
||||
- **Added comprehensive test examples**:
|
||||
- Basic recording (existing functionality)
|
||||
- Recording with camera settings
|
||||
@@ -75,8 +85,9 @@ Enhanced the `POST /cameras/{camera_name}/start-recording` API endpoint to accep
|
||||
## Usage Examples
|
||||
|
||||
### Basic Recording (unchanged)
|
||||
|
||||
```http
|
||||
POST http://localhost:8000/cameras/camera1/start-recording
|
||||
POST http://vision:8000/cameras/camera1/start-recording
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
@@ -84,11 +95,13 @@ Content-Type: application/json
|
||||
"filename": "test.avi"
|
||||
}
|
||||
```
|
||||
|
||||
**Result**: File saved as `20241223_143022_test.avi`
|
||||
|
||||
### Recording with Camera Settings
|
||||
|
||||
```http
|
||||
POST http://localhost:8000/cameras/camera1/start-recording
|
||||
POST http://vision:8000/cameras/camera1/start-recording
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
@@ -99,13 +112,16 @@ Content-Type: application/json
|
||||
"fps": 5.0
|
||||
}
|
||||
```
|
||||
|
||||
**Result**:
|
||||
|
||||
- Camera settings updated before recording
|
||||
- File saved as `20241223_143022_high_quality.avi`
|
||||
|
||||
### Maximum FPS Recording
|
||||
|
||||
```http
|
||||
POST http://localhost:8000/cameras/camera1/start-recording
|
||||
POST http://vision:8000/cameras/camera1/start-recording
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
@@ -116,14 +132,17 @@ Content-Type: application/json
|
||||
"fps": 0
|
||||
}
|
||||
```
|
||||
|
||||
**Result**:
|
||||
|
||||
- Camera captures at maximum possible speed (no delay between frames)
|
||||
- Video file saved with 30 FPS metadata for proper playback
|
||||
- Actual capture rate depends on camera hardware and exposure settings
|
||||
|
||||
### Settings Only (no filename)
|
||||
|
||||
```http
|
||||
POST http://localhost:8000/cameras/camera1/start-recording
|
||||
POST http://vision:8000/cameras/camera1/start-recording
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
@@ -133,34 +152,41 @@ Content-Type: application/json
|
||||
"fps": 7.0
|
||||
}
|
||||
```
|
||||
**Result**:
|
||||
|
||||
**Result**:
|
||||
|
||||
- Camera settings updated
|
||||
- File saved as `camera1_manual_20241223_143022.avi`
|
||||
|
||||
## Key Features
|
||||
|
||||
### 1. **Backward Compatibility**
|
||||
|
||||
- All existing API calls continue to work unchanged
|
||||
- New parameters are optional
|
||||
- Default behavior preserved when no settings provided
|
||||
|
||||
### 2. **Automatic Datetime Prefix**
|
||||
|
||||
- **ALL filenames now have datetime prefix** regardless of what's sent
|
||||
- Format: `YYYYMMDD_HHMMSS_` (Atlanta timezone)
|
||||
- Ensures unique filenames and chronological ordering
|
||||
|
||||
### 3. **Dynamic Camera Settings**
|
||||
|
||||
- Settings can be changed per recording without restarting system
|
||||
- Based on proven implementation from `old tests/camera_video_recorder.py`
|
||||
- Proper error handling and logging
|
||||
|
||||
### 4. **Maximum FPS Capture**
|
||||
|
||||
- **`fps: 0`** = Capture at maximum possible speed (no delay between frames)
|
||||
- **`fps > 0`** = Capture at specified frame rate with controlled timing
|
||||
- **`fps` omitted** = Uses camera config default (usually 3.0 fps)
|
||||
- Video files saved with 30 FPS metadata when fps=0 for proper playback
|
||||
|
||||
### 5. **Parameter Validation**
|
||||
|
||||
- Uses Pydantic models for automatic validation
|
||||
- Optional parameters with proper type checking
|
||||
- Descriptive field documentation
|
||||
@@ -168,6 +194,7 @@ Content-Type: application/json
|
||||
## Testing
|
||||
|
||||
Run the test script to verify functionality:
|
||||
|
||||
```bash
|
||||
# Start the system first
|
||||
python main.py
|
||||
@@ -177,6 +204,7 @@ python test_api_changes.py
|
||||
```
|
||||
|
||||
The test script verifies:
|
||||
|
||||
- Basic recording functionality
|
||||
- Camera settings application
|
||||
- Filename datetime prefix handling
|
||||
@@ -185,22 +213,27 @@ The test script verifies:
|
||||
## Implementation Notes
|
||||
|
||||
### Camera Settings Mapping
|
||||
|
||||
- **Exposure**: Converted from milliseconds to microseconds for SDK
|
||||
- **Gain**: Converted to camera units (multiplied by 100)
|
||||
- **FPS**: Stored in camera config, used by recording loop
|
||||
|
||||
### Error Handling
|
||||
|
||||
- Settings update failures are logged but don't prevent recording
|
||||
- Invalid camera names return appropriate HTTP errors
|
||||
- Camera initialization failures are handled gracefully
|
||||
|
||||
### Filename Generation
|
||||
|
||||
- Uses `format_filename_timestamp()` from timezone utilities
|
||||
- Ensures Atlanta timezone consistency
|
||||
- Handles both custom and auto-generated filenames
|
||||
|
||||
## Similar to Old Implementation
|
||||
|
||||
The camera settings functionality mirrors the proven approach in `old tests/camera_video_recorder.py`:
|
||||
|
||||
- Same parameter names and ranges
|
||||
- Same SDK function calls
|
||||
- Same conversion factors
|
||||
|
||||
@@ -18,10 +18,13 @@ This document provides comprehensive documentation for all API endpoints in the
|
||||
## 🔧 System Status & Health
|
||||
|
||||
### Get System Status
|
||||
|
||||
```http
|
||||
GET /system/status
|
||||
```
|
||||
|
||||
**Response**: `SystemStatusResponse`
|
||||
|
||||
```json
|
||||
{
|
||||
"system_started": true,
|
||||
@@ -49,10 +52,13 @@ GET /system/status
|
||||
```
|
||||
|
||||
### Health Check
|
||||
|
||||
```http
|
||||
GET /health
|
||||
```
|
||||
|
||||
**Response**: Simple health status
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "healthy",
|
||||
@@ -63,16 +69,21 @@ GET /health
|
||||
## 📷 Camera Management
|
||||
|
||||
### Get All Cameras
|
||||
|
||||
```http
|
||||
GET /cameras
|
||||
```
|
||||
|
||||
**Response**: `Dict[str, CameraStatusResponse]`
|
||||
|
||||
### Get Specific Camera Status
|
||||
|
||||
```http
|
||||
GET /cameras/{camera_name}/status
|
||||
```
|
||||
|
||||
**Response**: `CameraStatusResponse`
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "camera1",
|
||||
@@ -97,12 +108,13 @@ GET /cameras/{camera_name}/status
|
||||
## 🎥 Recording Control
|
||||
|
||||
### Start Recording
|
||||
|
||||
```http
|
||||
POST /cameras/{camera_name}/start-recording
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"filename": "test_recording.avi",
|
||||
"filename": "test_recording.mp4",
|
||||
"exposure_ms": 2.0,
|
||||
"gain": 4.0,
|
||||
"fps": 5.0
|
||||
@@ -110,30 +122,36 @@ Content-Type: application/json
|
||||
```
|
||||
|
||||
**Request Model**: `StartRecordingRequest`
|
||||
|
||||
- `filename` (optional): Custom filename (datetime prefix will be added automatically)
|
||||
- `exposure_ms` (optional): Exposure time in milliseconds
|
||||
- `gain` (optional): Camera gain value
|
||||
- `fps` (optional): Target frames per second
|
||||
|
||||
**Response**: `StartRecordingResponse`
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "Recording started for camera1",
|
||||
"filename": "20240115_103000_test_recording.avi"
|
||||
"filename": "20240115_103000_test_recording.mp4"
|
||||
}
|
||||
```
|
||||
|
||||
**Key Features**:
|
||||
|
||||
- ✅ **Automatic datetime prefix**: All filenames get `YYYYMMDD_HHMMSS_` prefix
|
||||
- ✅ **Dynamic camera settings**: Adjust exposure, gain, and FPS per recording
|
||||
- ✅ **Backward compatibility**: All existing API calls work unchanged
|
||||
|
||||
### Stop Recording
|
||||
|
||||
```http
|
||||
POST /cameras/{camera_name}/stop-recording
|
||||
```
|
||||
|
||||
**Response**: `StopRecordingResponse`
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
@@ -145,10 +163,13 @@ POST /cameras/{camera_name}/stop-recording
|
||||
## 🤖 Auto-Recording Management
|
||||
|
||||
### Enable Auto-Recording for Camera
|
||||
|
||||
```http
|
||||
POST /cameras/{camera_name}/auto-recording/enable
|
||||
```
|
||||
|
||||
**Response**: `AutoRecordingConfigResponse`
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
@@ -159,16 +180,21 @@ POST /cameras/{camera_name}/auto-recording/enable
|
||||
```
|
||||
|
||||
### Disable Auto-Recording for Camera
|
||||
|
||||
```http
|
||||
POST /cameras/{camera_name}/auto-recording/disable
|
||||
```
|
||||
|
||||
**Response**: `AutoRecordingConfigResponse`
|
||||
|
||||
### Get Auto-Recording Status
|
||||
|
||||
```http
|
||||
GET /auto-recording/status
|
||||
```
|
||||
|
||||
**Response**: `AutoRecordingStatusResponse`
|
||||
|
||||
```json
|
||||
{
|
||||
"running": true,
|
||||
@@ -179,6 +205,7 @@ GET /auto-recording/status
|
||||
```
|
||||
|
||||
**Auto-Recording Features**:
|
||||
|
||||
- 🤖 **MQTT-triggered recording**: Automatically starts/stops based on machine state
|
||||
- 🔄 **Retry logic**: Failed recordings are retried with configurable delays
|
||||
- 📊 **Per-camera control**: Enable/disable auto-recording individually
|
||||
@@ -187,10 +214,13 @@ GET /auto-recording/status
|
||||
## 🎛️ Camera Configuration
|
||||
|
||||
### Get Camera Configuration
|
||||
|
||||
```http
|
||||
GET /cameras/{camera_name}/config
|
||||
```
|
||||
|
||||
**Response**: `CameraConfigResponse`
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "camera1",
|
||||
@@ -225,6 +255,7 @@ GET /cameras/{camera_name}/config
|
||||
```
|
||||
|
||||
### Update Camera Configuration
|
||||
|
||||
```http
|
||||
PUT /cameras/{camera_name}/config
|
||||
Content-Type: application/json
|
||||
@@ -238,11 +269,13 @@ Content-Type: application/json
|
||||
```
|
||||
|
||||
### Apply Configuration (Restart Required)
|
||||
|
||||
```http
|
||||
POST /cameras/{camera_name}/apply-config
|
||||
```
|
||||
|
||||
**Configuration Categories**:
|
||||
|
||||
- ✅ **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`
|
||||
|
||||
@@ -251,16 +284,21 @@ For detailed configuration options, see [Camera Configuration API Guide](api/CAM
|
||||
## 📡 MQTT & Machine Status
|
||||
|
||||
### Get All Machines
|
||||
|
||||
```http
|
||||
GET /machines
|
||||
```
|
||||
|
||||
**Response**: `Dict[str, MachineStatusResponse]`
|
||||
|
||||
### Get MQTT Status
|
||||
|
||||
```http
|
||||
GET /mqtt/status
|
||||
```
|
||||
|
||||
**Response**: `MQTTStatusResponse`
|
||||
|
||||
```json
|
||||
{
|
||||
"connected": true,
|
||||
@@ -275,10 +313,13 @@ GET /mqtt/status
|
||||
```
|
||||
|
||||
### Get MQTT Events History
|
||||
|
||||
```http
|
||||
GET /mqtt/events?limit=10
|
||||
```
|
||||
|
||||
**Response**: `MQTTEventsHistoryResponse`
|
||||
|
||||
```json
|
||||
{
|
||||
"events": [
|
||||
@@ -299,10 +340,13 @@ GET /mqtt/events?limit=10
|
||||
## 💾 Storage & File Management
|
||||
|
||||
### Get Storage Statistics
|
||||
|
||||
```http
|
||||
GET /storage/stats
|
||||
```
|
||||
|
||||
**Response**: `StorageStatsResponse`
|
||||
|
||||
```json
|
||||
{
|
||||
"base_path": "/storage",
|
||||
@@ -328,6 +372,7 @@ GET /storage/stats
|
||||
```
|
||||
|
||||
### Get File List
|
||||
|
||||
```http
|
||||
POST /storage/files
|
||||
Content-Type: application/json
|
||||
@@ -339,7 +384,9 @@ Content-Type: application/json
|
||||
"limit": 50
|
||||
}
|
||||
```
|
||||
|
||||
**Response**: `FileListResponse`
|
||||
|
||||
```json
|
||||
{
|
||||
"files": [
|
||||
@@ -356,6 +403,7 @@ Content-Type: application/json
|
||||
```
|
||||
|
||||
### Cleanup Old Files
|
||||
|
||||
```http
|
||||
POST /storage/cleanup
|
||||
Content-Type: application/json
|
||||
@@ -364,7 +412,9 @@ Content-Type: application/json
|
||||
"max_age_days": 30
|
||||
}
|
||||
```
|
||||
|
||||
**Response**: `CleanupResponse`
|
||||
|
||||
```json
|
||||
{
|
||||
"files_removed": 25,
|
||||
@@ -376,42 +426,55 @@ Content-Type: application/json
|
||||
## 🔄 Camera Recovery & Diagnostics
|
||||
|
||||
### Test Camera Connection
|
||||
|
||||
```http
|
||||
POST /cameras/{camera_name}/test-connection
|
||||
```
|
||||
|
||||
**Response**: `CameraTestResponse`
|
||||
|
||||
### Reconnect Camera
|
||||
|
||||
```http
|
||||
POST /cameras/{camera_name}/reconnect
|
||||
```
|
||||
|
||||
**Response**: `CameraRecoveryResponse`
|
||||
|
||||
### Restart Camera Grab Process
|
||||
|
||||
```http
|
||||
POST /cameras/{camera_name}/restart-grab
|
||||
```
|
||||
|
||||
**Response**: `CameraRecoveryResponse`
|
||||
|
||||
### Reset Camera Timestamp
|
||||
|
||||
```http
|
||||
POST /cameras/{camera_name}/reset-timestamp
|
||||
```
|
||||
|
||||
**Response**: `CameraRecoveryResponse`
|
||||
|
||||
### Full Camera Reset
|
||||
|
||||
```http
|
||||
POST /cameras/{camera_name}/full-reset
|
||||
```
|
||||
|
||||
**Response**: `CameraRecoveryResponse`
|
||||
|
||||
### Reinitialize Camera
|
||||
|
||||
```http
|
||||
POST /cameras/{camera_name}/reinitialize
|
||||
```
|
||||
|
||||
**Response**: `CameraRecoveryResponse`
|
||||
|
||||
**Recovery Response Example**:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
@@ -425,22 +488,27 @@ POST /cameras/{camera_name}/reinitialize
|
||||
## 📺 Live Streaming
|
||||
|
||||
### Get Live MJPEG Stream
|
||||
|
||||
```http
|
||||
GET /cameras/{camera_name}/stream
|
||||
```
|
||||
|
||||
**Response**: MJPEG video stream (multipart/x-mixed-replace)
|
||||
|
||||
### Start Camera Stream
|
||||
|
||||
```http
|
||||
POST /cameras/{camera_name}/start-stream
|
||||
```
|
||||
|
||||
### Stop Camera Stream
|
||||
|
||||
```http
|
||||
POST /cameras/{camera_name}/stop-stream
|
||||
```
|
||||
|
||||
**Streaming Features**:
|
||||
|
||||
- 📺 **MJPEG format**: Compatible with web browsers and React apps
|
||||
- 🔄 **Concurrent operation**: Stream while recording simultaneously
|
||||
- ⚡ **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
|
||||
|
||||
### Connect to WebSocket
|
||||
|
||||
```javascript
|
||||
const ws = new WebSocket('ws://localhost:8000/ws');
|
||||
const ws = new WebSocket('ws://vision:8000/ws');
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const update = JSON.parse(event.data);
|
||||
@@ -460,6 +529,7 @@ ws.onmessage = (event) => {
|
||||
```
|
||||
|
||||
**WebSocket Message Types**:
|
||||
|
||||
- `system_status`: System status changes
|
||||
- `camera_status`: Camera status updates
|
||||
- `recording_started`: Recording start events
|
||||
@@ -468,6 +538,7 @@ ws.onmessage = (event) => {
|
||||
- `auto_recording_event`: Auto-recording status changes
|
||||
|
||||
**Example WebSocket Message**:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "recording_started",
|
||||
@@ -483,26 +554,28 @@ ws.onmessage = (event) => {
|
||||
## 🚀 Quick Start Examples
|
||||
|
||||
### Basic System Monitoring
|
||||
|
||||
```bash
|
||||
# Check system health
|
||||
curl http://localhost:8000/health
|
||||
curl http://vision:8000/health
|
||||
|
||||
# Get overall system status
|
||||
curl http://localhost:8000/system/status
|
||||
curl http://vision:8000/system/status
|
||||
|
||||
# Get all camera statuses
|
||||
curl http://localhost:8000/cameras
|
||||
curl http://vision:8000/cameras
|
||||
```
|
||||
|
||||
### Manual Recording Control
|
||||
|
||||
```bash
|
||||
# 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" \
|
||||
-d '{"filename": "manual_test.avi"}'
|
||||
|
||||
# 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" \
|
||||
-d '{
|
||||
"filename": "high_quality.avi",
|
||||
@@ -512,28 +585,30 @@ curl -X POST http://localhost:8000/cameras/camera1/start-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
|
||||
|
||||
```bash
|
||||
# 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
|
||||
curl http://localhost:8000/auto-recording/status
|
||||
curl http://vision:8000/auto-recording/status
|
||||
|
||||
# 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
|
||||
|
||||
```bash
|
||||
# Get current camera configuration
|
||||
curl http://localhost:8000/cameras/camera1/config
|
||||
curl http://vision:8000/cameras/camera1/config
|
||||
|
||||
# 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" \
|
||||
-d '{
|
||||
"exposure_ms": 1.5,
|
||||
@@ -548,28 +623,33 @@ curl -X PUT http://localhost:8000/cameras/camera1/config \
|
||||
### ✨ New in Latest Version
|
||||
|
||||
#### 1. Enhanced Recording API
|
||||
|
||||
- **Dynamic camera settings**: Set exposure, gain, and FPS per recording
|
||||
- **Automatic datetime prefixes**: All filenames get timestamp prefixes
|
||||
- **Backward compatibility**: Existing API calls work unchanged
|
||||
|
||||
#### 2. Auto-Recording Feature
|
||||
|
||||
- **Per-camera control**: Enable/disable auto-recording individually
|
||||
- **MQTT integration**: Automatic recording based on machine states
|
||||
- **Retry logic**: Failed recordings are automatically retried
|
||||
- **Status tracking**: Monitor auto-recording attempts and failures
|
||||
|
||||
#### 3. Advanced Camera Configuration
|
||||
|
||||
- **Real-time settings**: Update exposure, gain, image quality without restart
|
||||
- **Image enhancement**: Sharpness, contrast, saturation, gamma controls
|
||||
- **Noise reduction**: Configurable noise filtering and 3D denoising
|
||||
- **HDR support**: High Dynamic Range imaging capabilities
|
||||
|
||||
#### 4. Live Streaming
|
||||
|
||||
- **MJPEG streaming**: Real-time camera preview
|
||||
- **Concurrent operation**: Stream while recording simultaneously
|
||||
- **Web-compatible**: Direct integration with React/HTML video elements
|
||||
|
||||
#### 5. Enhanced Monitoring
|
||||
|
||||
- **MQTT event history**: Track machine state changes over time
|
||||
- **Storage statistics**: Monitor disk usage and file counts
|
||||
- **WebSocket updates**: Real-time system status notifications
|
||||
@@ -577,11 +657,13 @@ curl -X PUT http://localhost:8000/cameras/camera1/config \
|
||||
### 🔄 Migration Notes
|
||||
|
||||
#### From Previous Versions
|
||||
|
||||
1. **Recording API**: All existing calls work, but now return filenames with datetime prefixes
|
||||
2. **Configuration**: New camera settings are optional and backward compatible
|
||||
3. **Auto-recording**: New feature, requires enabling in `config.json` and per camera
|
||||
|
||||
#### Configuration Updates
|
||||
|
||||
```json
|
||||
{
|
||||
"cameras": [
|
||||
@@ -613,22 +695,28 @@ curl -X PUT http://localhost:8000/cameras/camera1/config \
|
||||
## 📞 Support & Integration
|
||||
|
||||
### API Base URL
|
||||
- **Development**: `http://localhost:8000`
|
||||
|
||||
- **Development**: `http://vision:8000`
|
||||
- **Production**: Configure in `config.json` under `system.api_host` and `system.api_port`
|
||||
|
||||
### Error Handling
|
||||
|
||||
All endpoints return standard HTTP status codes:
|
||||
|
||||
- `200`: Success
|
||||
- `404`: Resource not found (camera, file, etc.)
|
||||
- `500`: Internal server error
|
||||
- `503`: Service unavailable (camera manager, MQTT, etc.)
|
||||
|
||||
### Rate Limiting
|
||||
|
||||
- No rate limiting currently implemented
|
||||
- WebSocket connections are limited to reasonable concurrent connections
|
||||
|
||||
### CORS Support
|
||||
|
||||
- CORS is enabled for web dashboard integration
|
||||
- Configure allowed origins in the API server settings
|
||||
|
||||
```
|
||||
```
|
||||
|
||||
@@ -6,30 +6,30 @@ Quick reference for the most commonly used API endpoints. For complete documenta
|
||||
|
||||
```bash
|
||||
# Health check
|
||||
curl http://localhost:8000/health
|
||||
curl http://vision:8000/health
|
||||
|
||||
# System overview
|
||||
curl http://localhost:8000/system/status
|
||||
curl http://vision:8000/system/status
|
||||
|
||||
# All cameras
|
||||
curl http://localhost:8000/cameras
|
||||
curl http://vision:8000/cameras
|
||||
|
||||
# All machines
|
||||
curl http://localhost:8000/machines
|
||||
curl http://vision:8000/machines
|
||||
```
|
||||
|
||||
## 🎥 Recording Control
|
||||
|
||||
### Start Recording (Basic)
|
||||
```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" \
|
||||
-d '{"filename": "test.avi"}'
|
||||
```
|
||||
|
||||
### Start Recording (With Settings)
|
||||
```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" \
|
||||
-d '{
|
||||
"filename": "high_quality.avi",
|
||||
@@ -41,30 +41,30 @@ curl -X POST http://localhost:8000/cameras/camera1/start-recording \
|
||||
|
||||
### Stop Recording
|
||||
```bash
|
||||
curl -X POST http://localhost:8000/cameras/camera1/stop-recording
|
||||
curl -X POST http://vision:8000/cameras/camera1/stop-recording
|
||||
```
|
||||
|
||||
## 🤖 Auto-Recording
|
||||
|
||||
```bash
|
||||
# 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
|
||||
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
|
||||
curl http://localhost:8000/auto-recording/status
|
||||
curl http://vision:8000/auto-recording/status
|
||||
```
|
||||
|
||||
## 🎛️ Camera Configuration
|
||||
|
||||
```bash
|
||||
# Get camera config
|
||||
curl http://localhost:8000/cameras/camera1/config
|
||||
curl http://vision:8000/cameras/camera1/config
|
||||
|
||||
# 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" \
|
||||
-d '{
|
||||
"exposure_ms": 1.5,
|
||||
@@ -77,41 +77,41 @@ curl -X PUT http://localhost:8000/cameras/camera1/config \
|
||||
|
||||
```bash
|
||||
# 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)
|
||||
# http://localhost:8000/cameras/camera1/stream
|
||||
# http://vision:8000/cameras/camera1/stream
|
||||
|
||||
# Stop streaming
|
||||
curl -X POST http://localhost:8000/cameras/camera1/stop-stream
|
||||
curl -X POST http://vision:8000/cameras/camera1/stop-stream
|
||||
```
|
||||
|
||||
## 🔄 Camera Recovery
|
||||
|
||||
```bash
|
||||
# Test connection
|
||||
curl -X POST http://localhost:8000/cameras/camera1/test-connection
|
||||
curl -X POST http://vision:8000/cameras/camera1/test-connection
|
||||
|
||||
# Reconnect camera
|
||||
curl -X POST http://localhost:8000/cameras/camera1/reconnect
|
||||
curl -X POST http://vision:8000/cameras/camera1/reconnect
|
||||
|
||||
# Full reset
|
||||
curl -X POST http://localhost:8000/cameras/camera1/full-reset
|
||||
curl -X POST http://vision:8000/cameras/camera1/full-reset
|
||||
```
|
||||
|
||||
## 💾 Storage Management
|
||||
|
||||
```bash
|
||||
# Storage statistics
|
||||
curl http://localhost:8000/storage/stats
|
||||
curl http://vision:8000/storage/stats
|
||||
|
||||
# List files
|
||||
curl -X POST http://localhost:8000/storage/files \
|
||||
curl -X POST http://vision:8000/storage/files \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"camera_name": "camera1", "limit": 10}'
|
||||
|
||||
# Cleanup old files
|
||||
curl -X POST http://localhost:8000/storage/cleanup \
|
||||
curl -X POST http://vision:8000/storage/cleanup \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"max_age_days": 30}'
|
||||
```
|
||||
@@ -120,17 +120,17 @@ curl -X POST http://localhost:8000/storage/cleanup \
|
||||
|
||||
```bash
|
||||
# MQTT status
|
||||
curl http://localhost:8000/mqtt/status
|
||||
curl http://vision:8000/mqtt/status
|
||||
|
||||
# Recent MQTT events
|
||||
curl http://localhost:8000/mqtt/events?limit=10
|
||||
curl http://vision:8000/mqtt/events?limit=10
|
||||
```
|
||||
|
||||
## 🌐 WebSocket Connection
|
||||
|
||||
```javascript
|
||||
// Connect to real-time updates
|
||||
const ws = new WebSocket('ws://localhost:8000/ws');
|
||||
const ws = new WebSocket('ws://vision:8000/ws');
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const update = JSON.parse(event.data);
|
||||
|
||||
@@ -1,20 +1,24 @@
|
||||
# 🎥 MP4 Video Format Update - Frontend Integration Guide
|
||||
|
||||
## 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.
|
||||
|
||||
## 🔄 What Changed
|
||||
|
||||
### Video Format
|
||||
|
||||
- **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
|
||||
|
||||
- All new video recordings now use `.mp4` extension
|
||||
- Existing `.avi` files remain accessible and functional
|
||||
- File size reduction: ~40% smaller than equivalent AVI files
|
||||
|
||||
### API Response Updates
|
||||
|
||||
New fields added to camera configuration responses:
|
||||
|
||||
```json
|
||||
@@ -28,13 +32,17 @@ New fields added to camera configuration responses:
|
||||
## 🌐 Frontend Impact
|
||||
|
||||
### 1. Video Player Compatibility
|
||||
|
||||
**✅ Better Browser Support**
|
||||
|
||||
- MP4 format has native support in all modern browsers
|
||||
- No need for additional codecs or plugins
|
||||
- Better mobile device compatibility (iOS/Android)
|
||||
|
||||
### 2. File Handling Updates
|
||||
|
||||
**File Extension Handling**
|
||||
|
||||
```javascript
|
||||
// Update file extension checks
|
||||
const isVideoFile = (filename) => {
|
||||
@@ -50,7 +58,9 @@ const getVideoMimeType = (filename) => {
|
||||
```
|
||||
|
||||
### 3. Video Streaming
|
||||
|
||||
**Improved Streaming Performance**
|
||||
|
||||
```javascript
|
||||
// MP4 files can be streamed directly without conversion
|
||||
const videoUrl = `/api/videos/${videoId}/stream`;
|
||||
@@ -63,7 +73,9 @@ const videoUrl = `/api/videos/${videoId}/stream`;
|
||||
```
|
||||
|
||||
### 4. File Size Display
|
||||
|
||||
**Updated Size Expectations**
|
||||
|
||||
- MP4 files are ~40% smaller than equivalent AVI files
|
||||
- Update any file size warnings or storage calculations
|
||||
- Better compression means faster downloads and uploads
|
||||
@@ -71,9 +83,11 @@ const videoUrl = `/api/videos/${videoId}/stream`;
|
||||
## 📡 API Changes
|
||||
|
||||
### Camera Configuration Endpoint
|
||||
|
||||
**GET** `/cameras/{camera_name}/config`
|
||||
|
||||
**New Response Fields:**
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "camera1",
|
||||
@@ -95,7 +109,9 @@ const videoUrl = `/api/videos/${videoId}/stream`;
|
||||
```
|
||||
|
||||
### Video Listing Endpoints
|
||||
|
||||
**File Extension Updates**
|
||||
|
||||
- Video files in responses will now have `.mp4` extensions
|
||||
- Existing `.avi` files will still appear in listings
|
||||
- Filter by both extensions when needed
|
||||
@@ -103,42 +119,49 @@ const videoUrl = `/api/videos/${videoId}/stream`;
|
||||
## 🔧 Configuration Options
|
||||
|
||||
### Video Format Settings
|
||||
|
||||
```json
|
||||
{
|
||||
"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)
|
||||
}
|
||||
```
|
||||
|
||||
### 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
|
||||
|
||||
## 🎯 Frontend Implementation Checklist
|
||||
|
||||
### ✅ Video Player Updates
|
||||
|
||||
- [ ] Verify HTML5 video player works with MP4 files
|
||||
- [ ] Update video MIME type handling
|
||||
- [ ] Test streaming performance with new format
|
||||
|
||||
### ✅ File Management
|
||||
|
||||
- [ ] Update file extension filters to include `.mp4`
|
||||
- [ ] Modify file type detection logic
|
||||
- [ ] Update download/upload handling for MP4 files
|
||||
|
||||
### ✅ UI/UX Updates
|
||||
|
||||
- [ ] Update file size expectations in UI
|
||||
- [ ] Modify any format-specific icons or indicators
|
||||
- [ ] Update help text or tooltips mentioning video formats
|
||||
|
||||
### ✅ Configuration Interface
|
||||
|
||||
- [ ] Add video format settings to camera config UI
|
||||
- [ ] Include video quality slider/selector
|
||||
- [ ] Add restart warning for video format changes
|
||||
|
||||
### ✅ Testing
|
||||
|
||||
- [ ] Test video playback with new MP4 files
|
||||
- [ ] Verify backward compatibility with existing AVI files
|
||||
- [ ] Test streaming performance and loading times
|
||||
@@ -146,11 +169,13 @@ const videoUrl = `/api/videos/${videoId}/stream`;
|
||||
## 🔄 Backward Compatibility
|
||||
|
||||
### Existing AVI Files
|
||||
|
||||
- All existing `.avi` files remain fully functional
|
||||
- No conversion or migration required
|
||||
- Video player should handle both formats
|
||||
|
||||
### API Compatibility
|
||||
|
||||
- All existing API endpoints continue to work
|
||||
- New fields are additive (won't break existing code)
|
||||
- Default values provided for new configuration fields
|
||||
@@ -158,6 +183,7 @@ const videoUrl = `/api/videos/${videoId}/stream`;
|
||||
## 📊 Performance Benefits
|
||||
|
||||
### File Size Reduction
|
||||
|
||||
```
|
||||
Example 5-minute recording at 1280x1024:
|
||||
- AVI/XVID: ~180 MB
|
||||
@@ -165,12 +191,14 @@ Example 5-minute recording at 1280x1024:
|
||||
```
|
||||
|
||||
### Streaming Improvements
|
||||
|
||||
- Faster initial load times
|
||||
- Better progressive download support
|
||||
- Reduced bandwidth usage
|
||||
- Native browser optimization
|
||||
|
||||
### Storage Efficiency
|
||||
|
||||
- More recordings fit in same storage space
|
||||
- Faster backup and transfer operations
|
||||
- Reduced storage costs over time
|
||||
@@ -178,16 +206,19 @@ Example 5-minute recording at 1280x1024:
|
||||
## 🚨 Important Notes
|
||||
|
||||
### Restart Required
|
||||
|
||||
- Video format changes require camera service restart
|
||||
- Mark video format settings as "restart required" in UI
|
||||
- Provide clear user feedback about restart necessity
|
||||
|
||||
### Browser Compatibility
|
||||
|
||||
- MP4 format supported in all modern browsers
|
||||
- Better mobile device support than AVI
|
||||
- No additional plugins or codecs needed
|
||||
|
||||
### Quality Assurance
|
||||
|
||||
- Video quality maintained at 95/100 setting
|
||||
- No visual degradation compared to AVI
|
||||
- High bitrate ensures professional quality
|
||||
|
||||
@@ -97,11 +97,11 @@ python test_system.py
|
||||
### Dashboard Integration
|
||||
```javascript
|
||||
// React component example
|
||||
const systemStatus = await fetch('http://localhost:8000/system/status');
|
||||
const cameras = await fetch('http://localhost:8000/cameras');
|
||||
const systemStatus = await fetch('http://vision:8000/system/status');
|
||||
const cameras = await fetch('http://vision:8000/cameras');
|
||||
|
||||
// WebSocket for real-time updates
|
||||
const ws = new WebSocket('ws://localhost:8000/ws');
|
||||
const ws = new WebSocket('ws://vision:8000/ws');
|
||||
ws.onmessage = (event) => {
|
||||
const update = JSON.parse(event.data);
|
||||
// Handle real-time system updates
|
||||
@@ -111,13 +111,13 @@ ws.onmessage = (event) => {
|
||||
### Manual Control
|
||||
```bash
|
||||
# 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
|
||||
curl -X POST http://localhost:8000/cameras/camera1/stop-recording
|
||||
curl -X POST http://vision:8000/cameras/camera1/stop-recording
|
||||
|
||||
# Get system status
|
||||
curl http://localhost:8000/system/status
|
||||
curl http://vision:8000/system/status
|
||||
```
|
||||
|
||||
## 📊 System Capabilities
|
||||
@@ -151,7 +151,7 @@ curl http://localhost:8000/system/status
|
||||
### Troubleshooting
|
||||
- **Test Suite**: `python test_system.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`
|
||||
|
||||
## 🎯 Production Readiness
|
||||
|
||||
@@ -204,10 +204,10 @@ sudo systemctl restart usda-vision-camera
|
||||
### Check Status
|
||||
```bash
|
||||
# Check video module status
|
||||
curl http://localhost:8000/system/video-module
|
||||
curl http://vision:8000/system/video-module
|
||||
|
||||
# Check available videos
|
||||
curl http://localhost:8000/videos/
|
||||
curl http://vision:8000/videos/
|
||||
```
|
||||
|
||||
### Logs
|
||||
|
||||
@@ -185,7 +185,7 @@ POST /cameras/{camera_name}/apply-config
|
||||
|
||||
### Example 1: Adjust Exposure and Gain
|
||||
```bash
|
||||
curl -X PUT http://localhost:8000/cameras/camera1/config \
|
||||
curl -X PUT http://vision:8000/cameras/camera1/config \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"exposure_ms": 1.5,
|
||||
@@ -195,7 +195,7 @@ curl -X PUT http://localhost:8000/cameras/camera1/config \
|
||||
|
||||
### Example 2: Improve Image Quality
|
||||
```bash
|
||||
curl -X PUT http://localhost:8000/cameras/camera1/config \
|
||||
curl -X PUT http://vision:8000/cameras/camera1/config \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"sharpness": 150,
|
||||
@@ -206,7 +206,7 @@ curl -X PUT http://localhost:8000/cameras/camera1/config \
|
||||
|
||||
### Example 3: Configure for Indoor Lighting
|
||||
```bash
|
||||
curl -X PUT http://localhost:8000/cameras/camera1/config \
|
||||
curl -X PUT http://vision:8000/cameras/camera1/config \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"anti_flicker_enabled": true,
|
||||
@@ -218,7 +218,7 @@ curl -X PUT http://localhost:8000/cameras/camera1/config \
|
||||
|
||||
### Example 4: Enable HDR Mode
|
||||
```bash
|
||||
curl -X PUT http://localhost:8000/cameras/camera1/config \
|
||||
curl -X PUT http://vision:8000/cameras/camera1/config \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"hdr_enabled": true,
|
||||
@@ -232,7 +232,7 @@ curl -X PUT http://localhost:8000/cameras/camera1/config \
|
||||
```jsx
|
||||
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 [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
@@ -56,27 +56,27 @@ When a camera has issues, follow this order:
|
||||
|
||||
1. **Test Connection** - Diagnose the problem
|
||||
```http
|
||||
POST http://localhost:8000/cameras/camera1/test-connection
|
||||
POST http://vision:8000/cameras/camera1/test-connection
|
||||
```
|
||||
|
||||
2. **Try Reconnect** - Most common fix
|
||||
```http
|
||||
POST http://localhost:8000/cameras/camera1/reconnect
|
||||
POST http://vision:8000/cameras/camera1/reconnect
|
||||
```
|
||||
|
||||
3. **Restart Grab** - If reconnect doesn't work
|
||||
```http
|
||||
POST http://localhost:8000/cameras/camera1/restart-grab
|
||||
POST http://vision:8000/cameras/camera1/restart-grab
|
||||
```
|
||||
|
||||
4. **Full Reset** - For persistent issues
|
||||
```http
|
||||
POST http://localhost:8000/cameras/camera1/full-reset
|
||||
POST http://vision:8000/cameras/camera1/full-reset
|
||||
```
|
||||
|
||||
5. **Reinitialize** - For cameras that never worked
|
||||
```http
|
||||
POST http://localhost:8000/cameras/camera1/reinitialize
|
||||
POST http://vision:8000/cameras/camera1/reinitialize
|
||||
```
|
||||
|
||||
## Response Format
|
||||
|
||||
@@ -38,7 +38,7 @@ When you run the system, you'll see:
|
||||
|
||||
### MQTT Status
|
||||
```http
|
||||
GET http://localhost:8000/mqtt/status
|
||||
GET http://vision:8000/mqtt/status
|
||||
```
|
||||
|
||||
**Response:**
|
||||
@@ -60,7 +60,7 @@ GET http://localhost:8000/mqtt/status
|
||||
|
||||
### Machine Status
|
||||
```http
|
||||
GET http://localhost:8000/machines
|
||||
GET http://vision:8000/machines
|
||||
```
|
||||
|
||||
**Response:**
|
||||
@@ -85,7 +85,7 @@ GET http://localhost:8000/machines
|
||||
|
||||
### System Status
|
||||
```http
|
||||
GET http://localhost:8000/system/status
|
||||
GET http://vision:8000/system/status
|
||||
```
|
||||
|
||||
**Response:**
|
||||
@@ -125,13 +125,13 @@ Tests all the API endpoints and shows expected responses.
|
||||
### 4. **Query APIs Directly**
|
||||
```bash
|
||||
# Check MQTT status
|
||||
curl http://localhost:8000/mqtt/status
|
||||
curl http://vision:8000/mqtt/status
|
||||
|
||||
# Check machine states
|
||||
curl http://localhost:8000/machines
|
||||
curl http://vision:8000/machines
|
||||
|
||||
# Check overall system status
|
||||
curl http://localhost:8000/system/status
|
||||
curl http://vision:8000/system/status
|
||||
```
|
||||
|
||||
## 🔧 Configuration
|
||||
|
||||
@@ -40,13 +40,13 @@ Open `camera_preview.html` in your browser and click "Start Stream" for any came
|
||||
### 3. API Usage
|
||||
```bash
|
||||
# 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)
|
||||
http://localhost:8000/cameras/camera1/stream
|
||||
http://vision:8000/cameras/camera1/stream
|
||||
|
||||
# Stop streaming
|
||||
curl -X POST http://localhost:8000/cameras/camera1/stop-stream
|
||||
curl -X POST http://vision:8000/cameras/camera1/stop-stream
|
||||
```
|
||||
|
||||
## 📡 API Endpoints
|
||||
@@ -150,10 +150,10 @@ The system supports these concurrent operations:
|
||||
### Example: Concurrent Usage
|
||||
```bash
|
||||
# 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)
|
||||
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" \
|
||||
-d '{"filename": "test_recording.avi"}'
|
||||
|
||||
@@ -232,8 +232,8 @@ For issues with streaming functionality:
|
||||
|
||||
1. Check the system logs: `usda_vision_system.log`
|
||||
2. Run the test script: `python test_streaming.py`
|
||||
3. Verify API health: `http://localhost:8000/health`
|
||||
4. Check camera status: `http://localhost:8000/cameras`
|
||||
3. Verify API health: `http://vision:8000/health`
|
||||
4. Check camera status: `http://vision:8000/cameras`
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -73,10 +73,10 @@ Edit `config.json` to customize:
|
||||
- System parameters
|
||||
|
||||
### API Access
|
||||
- System status: `http://localhost:8000/system/status`
|
||||
- Camera status: `http://localhost:8000/cameras`
|
||||
- Manual recording: `POST http://localhost:8000/cameras/camera1/start-recording`
|
||||
- Real-time updates: WebSocket at `ws://localhost:8000/ws`
|
||||
- System status: `http://vision:8000/system/status`
|
||||
- Camera status: `http://vision:8000/cameras`
|
||||
- Manual recording: `POST http://vision:8000/cameras/camera1/start-recording`
|
||||
- Real-time updates: WebSocket at `ws://vision:8000/ws`
|
||||
|
||||
## 📊 Test Results
|
||||
|
||||
@@ -146,18 +146,18 @@ The system provides everything needed for your React dashboard:
|
||||
|
||||
```javascript
|
||||
// Example API usage
|
||||
const systemStatus = await fetch('http://localhost:8000/system/status');
|
||||
const cameras = await fetch('http://localhost:8000/cameras');
|
||||
const systemStatus = await fetch('http://vision:8000/system/status');
|
||||
const cameras = await fetch('http://vision:8000/cameras');
|
||||
|
||||
// WebSocket for real-time updates
|
||||
const ws = new WebSocket('ws://localhost:8000/ws');
|
||||
const ws = new WebSocket('ws://vision:8000/ws');
|
||||
ws.onmessage = (event) => {
|
||||
const update = JSON.parse(event.data);
|
||||
// Handle real-time system updates
|
||||
};
|
||||
|
||||
// Manual recording control
|
||||
await fetch('http://localhost:8000/cameras/camera1/start-recording', {
|
||||
await fetch('http://vision:8000/cameras/camera1/start-recording', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ camera_name: 'camera1' })
|
||||
|
||||
@@ -192,13 +192,13 @@ Comprehensive error tracking with:
|
||||
|
||||
```bash
|
||||
# Check system status
|
||||
curl http://localhost:8000/system/status
|
||||
curl http://vision:8000/system/status
|
||||
|
||||
# Check camera status
|
||||
curl http://localhost:8000/cameras
|
||||
curl http://vision:8000/cameras
|
||||
|
||||
# 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" \
|
||||
-d '{"camera_name": "camera1"}'
|
||||
```
|
||||
@@ -246,4 +246,4 @@ This project is developed for USDA research purposes.
|
||||
For issues and questions:
|
||||
1. Check the logs in `usda_vision_system.log`
|
||||
2. Review the troubleshooting section
|
||||
3. Check API status at `http://localhost:8000/health`
|
||||
3. Check API status at `http://vision:8000/health`
|
||||
|
||||
@@ -76,7 +76,7 @@ timedatectl status
|
||||
### API Endpoints
|
||||
```bash
|
||||
# System status includes time info
|
||||
curl http://localhost:8000/system/status
|
||||
curl http://vision:8000/system/status
|
||||
|
||||
# Example response includes:
|
||||
{
|
||||
|
||||
@@ -171,7 +171,7 @@ POST http://vision:8000/cameras/camera1/start-recording
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"filename": "test_recording.avi",
|
||||
"filename": "test_recording.mp4",
|
||||
"exposure_ms": 1.5,
|
||||
"gain": 3.0,
|
||||
"fps": 0
|
||||
@@ -187,7 +187,7 @@ Content-Type: application/json
|
||||
# {
|
||||
# "success": true,
|
||||
# "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
|
||||
|
||||
{
|
||||
"filename": "simple_test.avi"
|
||||
"filename": "simple_test.mp4"
|
||||
}
|
||||
|
||||
###
|
||||
|
||||
@@ -52,7 +52,7 @@ export function AutoRecordingTest() {
|
||||
if (state === 'on') {
|
||||
// Simulate starting recording on the correct camera
|
||||
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}`
|
||||
} else {
|
||||
|
||||
@@ -166,22 +166,39 @@ export function CameraConfigModal({ cameraName, isOpen, onClose, onSuccess, onEr
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg shadow-xl max-w-4xl w-full mx-4 max-h-[90vh] overflow-hidden">
|
||||
<div className="fixed inset-0 flex items-center justify-center overflow-y-auto modal z-999999">
|
||||
<div
|
||||
className="fixed inset-0 h-full w-full bg-gray-400/50 backdrop-blur-[32px]"
|
||||
onClick={onClose}
|
||||
/>
|
||||
<div className="relative w-full rounded-2xl bg-white shadow-theme-xl dark:bg-gray-900 max-w-4xl mx-4 max-h-[90vh] overflow-hidden" onClick={(e) => e.stopPropagation()}>
|
||||
{/* Close Button */}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute right-3 top-3 z-999 flex h-9.5 w-9.5 items-center justify-center rounded-full bg-gray-100 text-gray-400 transition-colors hover:bg-gray-200 hover:text-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white sm:right-6 sm:top-6 sm:h-11 sm:w-11"
|
||||
>
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M6.04289 16.5413C5.65237 16.9318 5.65237 17.565 6.04289 17.9555C6.43342 18.346 7.06658 18.346 7.45711 17.9555L11.9987 13.4139L16.5408 17.956C16.9313 18.3466 17.5645 18.3466 17.955 17.956C18.3455 17.5655 18.3455 16.9323 17.955 16.5418L13.4129 11.9997L17.955 7.4576C18.3455 7.06707 18.3455 6.43391 17.955 6.04338C17.5645 5.65286 16.9313 5.65286 16.5408 6.04338L11.9987 10.5855L7.45711 6.0439C7.06658 5.65338 6.43342 5.65338 6.04289 6.0439C5.65237 6.43442 5.65237 7.06759 6.04289 7.45811L10.5845 11.9997L6.04289 16.5413Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Header */}
|
||||
<div className="px-6 py-4 border-b border-gray-200">
|
||||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-800">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-medium text-gray-900">
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white/90">
|
||||
Camera Configuration - {cameraName}
|
||||
</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>
|
||||
|
||||
|
||||
@@ -44,12 +44,12 @@ export function CameraPreviewModal({ cameraName, isOpen, onClose, onError }: Cam
|
||||
setError(null)
|
||||
|
||||
const result = await visionApi.startStream(cameraName)
|
||||
|
||||
|
||||
if (result.success) {
|
||||
setStreaming(true)
|
||||
const streamUrl = visionApi.getStreamUrl(cameraName)
|
||||
streamUrlRef.current = streamUrl
|
||||
|
||||
|
||||
// Add timestamp to prevent caching
|
||||
if (imgRef.current) {
|
||||
imgRef.current.src = `${streamUrl}?t=${Date.now()}`
|
||||
@@ -72,7 +72,7 @@ export function CameraPreviewModal({ cameraName, isOpen, onClose, onError }: Cam
|
||||
await visionApi.stopStream(cameraName)
|
||||
setStreaming(false)
|
||||
streamUrlRef.current = null
|
||||
|
||||
|
||||
// Clear the image source
|
||||
if (imgRef.current) {
|
||||
imgRef.current.src = ''
|
||||
@@ -100,22 +100,39 @@ export function CameraPreviewModal({ cameraName, isOpen, onClose, onError }: Cam
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
|
||||
<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 flex items-center justify-center overflow-y-auto modal z-999999">
|
||||
<div
|
||||
className="fixed inset-0 h-full w-full bg-gray-400/50 backdrop-blur-[32px]"
|
||||
onClick={handleClose}
|
||||
/>
|
||||
<div className="relative w-11/12 max-w-4xl rounded-2xl bg-white shadow-theme-xl dark:bg-gray-900 p-5" onClick={(e) => e.stopPropagation()}>
|
||||
{/* Close Button */}
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="absolute right-3 top-3 z-999 flex h-9.5 w-9.5 items-center justify-center rounded-full bg-gray-100 text-gray-400 transition-colors hover:bg-gray-200 hover:text-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white sm:right-6 sm:top-6 sm:h-11 sm:w-11"
|
||||
>
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M6.04289 16.5413C5.65237 16.9318 5.65237 17.565 6.04289 17.9555C6.43342 18.346 7.06658 18.346 7.45711 17.9555L11.9987 13.4139L16.5408 17.956C16.9313 18.3466 17.5645 18.3466 17.955 17.956C18.3455 17.5655 18.3455 16.9323 17.955 16.5418L13.4129 11.9997L17.955 7.4576C18.3455 7.06707 18.3455 6.43391 17.955 6.04338C17.5645 5.65286 16.9313 5.65286 16.5408 6.04338L11.9987 10.5855L7.45711 6.0439C7.06658 5.65338 6.43342 5.65338 6.04289 6.0439C5.65237 6.43442 5.65237 7.06759 6.04289 7.45811L10.5845 11.9997L6.04289 16.5413Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div className="mt-3">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-medium text-gray-900">
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white/90">
|
||||
Camera Preview: {cameraName}
|
||||
</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>
|
||||
|
||||
{/* Content */}
|
||||
|
||||
@@ -106,19 +106,36 @@ export function CreateUserModal({ roles, onClose, onUserCreated }: CreateUserMod
|
||||
}
|
||||
|
||||
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="relative bg-white rounded-xl shadow-2xl w-full max-w-md mx-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-gray-200">
|
||||
<h3 className="text-xl font-semibold text-gray-900">Create New User</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg p-2 transition-colors"
|
||||
<div className="fixed inset-0 flex items-center justify-center overflow-y-auto modal z-999999">
|
||||
<div
|
||||
className="fixed inset-0 h-full w-full bg-gray-400/50 backdrop-blur-[32px]"
|
||||
onClick={onClose}
|
||||
/>
|
||||
<div className="relative w-full rounded-2xl bg-white shadow-theme-xl dark:bg-gray-900 max-w-md mx-auto p-4" onClick={(e) => e.stopPropagation()}>
|
||||
{/* Close Button */}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute right-3 top-3 z-999 flex h-9.5 w-9.5 items-center justify-center rounded-full bg-gray-100 text-gray-400 transition-colors hover:bg-gray-200 hover:text-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white sm:right-6 sm:top-6 sm:h-11 sm:w-11"
|
||||
>
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<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>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M6.04289 16.5413C5.65237 16.9318 5.65237 17.565 6.04289 17.9555C6.43342 18.346 7.06658 18.346 7.45711 17.9555L11.9987 13.4139L16.5408 17.956C16.9313 18.3466 17.5645 18.3466 17.955 17.956C18.3455 17.5655 18.3455 16.9323 17.955 16.5418L13.4129 11.9997L17.955 7.4576C18.3455 7.06707 18.3455 6.43391 17.955 6.04338C17.5645 5.65286 16.9313 5.65286 16.5408 6.04338L11.9987 10.5855L7.45711 6.0439C7.06658 5.65338 6.43342 5.65338 6.04289 6.0439C5.65237 6.43442 5.65237 7.06759 6.04289 7.45811L10.5845 11.9997L6.04289 16.5413Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-800">
|
||||
<h3 className="text-xl font-semibold text-gray-900 dark:text-white/90">Create New User</h3>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
@@ -135,7 +152,7 @@ export function CreateUserModal({ roles, onClose, onUserCreated }: CreateUserMod
|
||||
id="email"
|
||||
value={formData.email}
|
||||
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"
|
||||
required
|
||||
/>
|
||||
@@ -238,11 +255,11 @@ export function CreateUserModal({ roles, onClose, onUserCreated }: CreateUserMod
|
||||
</div>
|
||||
|
||||
{/* 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
|
||||
type="button"
|
||||
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
|
||||
</button>
|
||||
@@ -250,7 +267,7 @@ export function CreateUserModal({ roles, onClose, onUserCreated }: CreateUserMod
|
||||
type="submit"
|
||||
form="create-user-form"
|
||||
disabled={loading}
|
||||
className="px-6 py-2.5 border border-transparent rounded-lg shadow-sm text-sm font-medium text-white bg-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 ? (
|
||||
<div className="flex items-center">
|
||||
|
||||
@@ -8,15 +8,15 @@ export function DashboardHome({ user }: DashboardHomeProps) {
|
||||
const getRoleBadgeColor = (role: string) => {
|
||||
switch (role) {
|
||||
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':
|
||||
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':
|
||||
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':
|
||||
return 'bg-purple-100 text-purple-800'
|
||||
return 'bg-theme-purple-500/10 text-theme-purple-500'
|
||||
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 (
|
||||
<div className="p-6">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900">Dashboard</h1>
|
||||
<p className="mt-2 text-gray-600">Welcome to the Pecan Experiments Dashboard</p>
|
||||
<div className="grid grid-cols-12 gap-4 md:gap-6">
|
||||
{/* Welcome Section */}
|
||||
<div className="col-span-12 mb-6">
|
||||
<h1 className="text-title-md font-bold text-gray-800 dark:text-white/90">Dashboard</h1>
|
||||
<p className="mt-2 text-gray-500 dark:text-gray-400">Welcome to the Pecan Experiments Dashboard</p>
|
||||
</div>
|
||||
|
||||
{/* User Information Card */}
|
||||
<div className="bg-white shadow overflow-hidden sm:rounded-lg mb-8">
|
||||
<div className="px-4 py-5 sm:px-6">
|
||||
<h3 className="text-lg leading-6 font-medium text-gray-900">
|
||||
<div className="col-span-12 xl:col-span-8">
|
||||
<div className="rounded-2xl border border-gray-200 bg-white p-5 dark:border-gray-800 dark:bg-white/[0.03] md:p-6">
|
||||
<div className="flex items-center justify-center w-12 h-12 bg-gray-100 rounded-xl dark:bg-gray-800 mb-5">
|
||||
<svg className="text-gray-800 size-6 dark:text-white/90" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<h3 className="text-lg font-bold text-gray-800 dark:text-white/90 mb-2">
|
||||
User Information
|
||||
</h3>
|
||||
<p className="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.
|
||||
</p>
|
||||
</div>
|
||||
<div className="border-t border-gray-200">
|
||||
<dl>
|
||||
<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">Email</dt>
|
||||
<dd className="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">
|
||||
{user.email}
|
||||
</dd>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between py-3 border-b border-gray-200 dark:border-gray-800">
|
||||
<span className="text-sm font-medium text-gray-500 dark:text-gray-400">Email</span>
|
||||
<span className="text-sm text-gray-800 dark:text-white/90">{user.email}</span>
|
||||
</div>
|
||||
<div className="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>
|
||||
<dd className="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{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>
|
||||
</dd>
|
||||
|
||||
<div className="flex items-center justify-between py-3 border-b border-gray-200 dark:border-gray-800">
|
||||
<span className="text-sm font-medium text-gray-500 dark:text-gray-400">Roles</span>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{user.roles.map((role) => (
|
||||
<span
|
||||
key={role}
|
||||
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getRoleBadgeColor(role)}`}
|
||||
>
|
||||
{role.charAt(0).toUpperCase() + role.slice(1)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="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>
|
||||
<dd className="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||
user.status === 'active' ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
|
||||
|
||||
<div className="flex items-center justify-between py-3 border-b border-gray-200 dark:border-gray-800">
|
||||
<span className="text-sm font-medium text-gray-500 dark:text-gray-400">Status</span>
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${user.status === 'active'
|
||||
? 'bg-success-50 text-success-600 dark:bg-success-500/15 dark:text-success-500'
|
||||
: 'bg-error-50 text-error-600 dark:bg-error-500/15 dark:text-error-500'
|
||||
}`}>
|
||||
{user.status.charAt(0).toUpperCase() + user.status.slice(1)}
|
||||
</span>
|
||||
</dd>
|
||||
{user.status.charAt(0).toUpperCase() + user.status.slice(1)}
|
||||
</span>
|
||||
</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>
|
||||
<dd className="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2 font-mono">
|
||||
{user.id}
|
||||
</dd>
|
||||
|
||||
<div className="flex items-center justify-between py-3 border-b border-gray-200 dark:border-gray-800">
|
||||
<span className="text-sm font-medium text-gray-500 dark:text-gray-400">User ID</span>
|
||||
<span className="text-sm text-gray-800 dark:text-white/90 font-mono">{user.id}</span>
|
||||
</div>
|
||||
<div className="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>
|
||||
<dd className="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">
|
||||
|
||||
<div className="flex items-center justify-between py-3">
|
||||
<span className="text-sm font-medium text-gray-500 dark:text-gray-400">Member since</span>
|
||||
<span className="text-sm text-gray-800 dark:text-white/90">
|
||||
{new Date(user.created_at).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
})}
|
||||
</dd>
|
||||
</span>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Role Permissions */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{user.roles.map((role) => (
|
||||
<div key={role} className="bg-white overflow-hidden shadow rounded-lg">
|
||||
<div className="p-5">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0">
|
||||
<span className={`inline-flex items-center px-3 py-1 rounded-full text-sm font-medium ${getRoleBadgeColor(role)}`}>
|
||||
<div className="col-span-12 xl:col-span-4">
|
||||
<div className="rounded-2xl border border-gray-200 bg-white p-5 dark:border-gray-800 dark:bg-white/[0.03] md:p-6">
|
||||
<div className="flex items-center justify-center w-12 h-12 bg-gray-100 rounded-xl dark:bg-gray-800 mb-5">
|
||||
<svg className="text-gray-800 size-6 dark:text-white/90" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<h3 className="text-lg font-bold text-gray-800 dark:text-white/90 mb-2">
|
||||
Role Permissions
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-6">
|
||||
Your access levels and capabilities.
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
{user.roles.map((role) => (
|
||||
<div key={role} className="border border-gray-200 dark:border-gray-800 rounded-lg p-4">
|
||||
<div className="flex items-center mb-3">
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getRoleBadgeColor(role)}`}>
|
||||
{role.charAt(0).toUpperCase() + role.slice(1)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-3">Permissions</h3>
|
||||
<ul className="space-y-2">
|
||||
{getPermissionsByRole(role).map((permission, index) => (
|
||||
<li key={index} className="flex items-center text-sm text-gray-600">
|
||||
<span className="text-green-500 mr-2">✓</span>
|
||||
<li key={index} className="flex items-center text-sm text-gray-600 dark:text-gray-400">
|
||||
<span className="text-success-500 mr-2">✓</span>
|
||||
{permission}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
{user.roles.includes('admin') && (
|
||||
<div className="mt-8 bg-white shadow rounded-lg">
|
||||
<div className="px-4 py-5 sm:px-6">
|
||||
<h3 className="text-lg leading-6 font-medium text-gray-900">
|
||||
<div className="col-span-12">
|
||||
<div className="rounded-2xl border border-gray-200 bg-white p-5 dark:border-gray-800 dark:bg-white/[0.03] md:p-6">
|
||||
<div className="flex items-center justify-center w-12 h-12 bg-gray-100 rounded-xl dark:bg-gray-800 mb-5">
|
||||
<svg className="text-gray-800 size-6 dark:text-white/90" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<h3 className="text-lg font-bold text-gray-800 dark:text-white/90 mb-2">
|
||||
Quick Actions
|
||||
</h3>
|
||||
<p className="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.
|
||||
</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">
|
||||
<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
|
||||
</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
|
||||
</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
|
||||
</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
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -18,6 +18,9 @@ export function DashboardLayout({ onLogout }: DashboardLayoutProps) {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [currentView, setCurrentView] = useState('dashboard')
|
||||
const [isExpanded, setIsExpanded] = useState(true)
|
||||
const [isMobileOpen, setIsMobileOpen] = useState(false)
|
||||
const [isHovered, setIsHovered] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
fetchUserProfile()
|
||||
@@ -48,6 +51,22 @@ export function DashboardLayout({ onLogout }: DashboardLayoutProps) {
|
||||
window.dispatchEvent(new PopStateEvent('popstate'))
|
||||
}
|
||||
|
||||
const toggleSidebar = () => {
|
||||
setIsExpanded(!isExpanded)
|
||||
}
|
||||
|
||||
const toggleMobileSidebar = () => {
|
||||
setIsMobileOpen(!isMobileOpen)
|
||||
}
|
||||
|
||||
const handleToggleSidebar = () => {
|
||||
if (window.innerWidth >= 1024) {
|
||||
toggleSidebar()
|
||||
} else {
|
||||
toggleMobileSidebar()
|
||||
}
|
||||
}
|
||||
|
||||
const renderCurrentView = () => {
|
||||
if (!user) return null
|
||||
|
||||
@@ -96,8 +115,8 @@ export function DashboardLayout({ onLogout }: DashboardLayoutProps) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600 mx-auto"></div>
|
||||
<p className="mt-4 text-gray-600">Loading dashboard...</p>
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-brand-500 mx-auto"></div>
|
||||
<p className="mt-4 text-gray-600 dark:text-gray-400">Loading dashboard...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -107,12 +126,12 @@ export function DashboardLayout({ onLogout }: DashboardLayoutProps) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="max-w-md w-full">
|
||||
<div className="rounded-md bg-red-50 p-4">
|
||||
<div className="text-sm text-red-700">{error}</div>
|
||||
<div className="rounded-2xl bg-error-50 border border-error-200 p-4 dark:bg-error-500/15 dark:border-error-500/20">
|
||||
<div className="text-sm text-error-700 dark:text-error-500">{error}</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="mt-4 w-full flex justify-center py-2 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
|
||||
</button>
|
||||
@@ -125,10 +144,10 @@ export function DashboardLayout({ onLogout }: DashboardLayoutProps) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<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
|
||||
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
|
||||
</button>
|
||||
@@ -138,17 +157,39 @@ export function DashboardLayout({ onLogout }: DashboardLayoutProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100 flex">
|
||||
<Sidebar
|
||||
user={user}
|
||||
currentView={currentView}
|
||||
onViewChange={setCurrentView}
|
||||
/>
|
||||
<div className="flex-1 flex flex-col">
|
||||
<TopNavbar user={user} onLogout={handleLogout} currentView={currentView} />
|
||||
<main className="flex-1 overflow-auto">
|
||||
<div className="min-h-screen xl:flex">
|
||||
<div>
|
||||
<Sidebar
|
||||
user={user}
|
||||
currentView={currentView}
|
||||
onViewChange={setCurrentView}
|
||||
isExpanded={isExpanded}
|
||||
isMobileOpen={isMobileOpen}
|
||||
isHovered={isHovered}
|
||||
setIsHovered={setIsHovered}
|
||||
/>
|
||||
{/* Backdrop for mobile */}
|
||||
{isMobileOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-40 bg-gray-900/50 lg:hidden"
|
||||
onClick={() => setIsMobileOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={`flex-1 transition-all duration-300 ease-in-out ${isExpanded || isHovered ? "lg:ml-[290px]" : "lg:ml-[90px]"
|
||||
} ${isMobileOpen ? "ml-0" : ""}`}
|
||||
>
|
||||
<TopNavbar
|
||||
user={user}
|
||||
onLogout={handleLogout}
|
||||
currentView={currentView}
|
||||
onToggleSidebar={handleToggleSidebar}
|
||||
isSidebarOpen={isMobileOpen}
|
||||
/>
|
||||
<div className="p-4 mx-auto max-w-(--breakpoint-2xl) md:p-6">
|
||||
{renderCurrentView()}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -60,21 +60,38 @@ export function ExperimentModal({ experiment, onClose, onExperimentSaved }: Expe
|
||||
}
|
||||
|
||||
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="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 flex items-center justify-center overflow-y-auto modal z-999999">
|
||||
<div
|
||||
className="fixed inset-0 h-full w-full bg-gray-400/50 backdrop-blur-[32px]"
|
||||
onClick={onClose}
|
||||
/>
|
||||
<div className="relative w-full rounded-2xl bg-white shadow-theme-xl dark:bg-gray-900 max-w-4xl mx-auto max-h-[90vh] overflow-y-auto p-4" onClick={(e) => e.stopPropagation()}>
|
||||
{/* Close Button */}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute right-3 top-3 z-999 flex h-9.5 w-9.5 items-center justify-center rounded-full bg-gray-100 text-gray-400 transition-colors hover:bg-gray-200 hover:text-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white sm:right-6 sm:top-6 sm:h-11 sm:w-11"
|
||||
>
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M6.04289 16.5413C5.65237 16.9318 5.65237 17.565 6.04289 17.9555C6.43342 18.346 7.06658 18.346 7.45711 17.9555L11.9987 13.4139L16.5408 17.956C16.9313 18.3466 17.5645 18.3466 17.955 17.956C18.3455 17.5655 18.3455 16.9323 17.955 16.5418L13.4129 11.9997L17.955 7.4576C18.3455 7.06707 18.3455 6.43391 17.955 6.04338C17.5645 5.65286 16.9313 5.65286 16.5408 6.04338L11.9987 10.5855L7.45711 6.0439C7.06658 5.65338 6.43342 5.65338 6.04289 6.0439C5.65237 6.43442 5.65237 7.06759 6.04289 7.45811L10.5845 11.9997L6.04289 16.5413Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Header */}
|
||||
<div className="sticky top-0 bg-white flex items-center justify-between p-6 border-b border-gray-200 rounded-t-xl">
|
||||
<h3 className="text-xl font-semibold text-gray-900">
|
||||
<div className="sticky top-0 bg-white dark:bg-gray-900 flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-800 rounded-t-2xl">
|
||||
<h3 className="text-xl font-semibold text-gray-900 dark:text-white/90">
|
||||
{isEditing ? `Edit Experiment #${experiment.experiment_number}` : 'Create New Experiment'}
|
||||
</h3>
|
||||
<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 className="p-6">
|
||||
|
||||
@@ -12,7 +12,7 @@ interface RepetitionScheduleModalProps {
|
||||
export function RepetitionScheduleModal({ experiment, repetition, onClose, onScheduleUpdated }: RepetitionScheduleModalProps) {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
|
||||
// Initialize with existing scheduled date or current date/time
|
||||
const getInitialDateTime = () => {
|
||||
if (repetition.scheduled_date) {
|
||||
@@ -22,7 +22,7 @@ export function RepetitionScheduleModal({ experiment, repetition, onClose, onSch
|
||||
time: date.toTimeString().slice(0, 5)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const now = new Date()
|
||||
// Set to next hour by default
|
||||
now.setHours(now.getHours() + 1, 0, 0, 0)
|
||||
@@ -92,21 +92,38 @@ export function RepetitionScheduleModal({ experiment, repetition, onClose, onSch
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
|
||||
<div className="bg-white rounded-lg shadow-xl max-w-md w-full max-h-[90vh] overflow-y-auto">
|
||||
<div className="fixed inset-0 flex items-center justify-center overflow-y-auto modal z-999999">
|
||||
<div
|
||||
className="fixed inset-0 h-full w-full bg-gray-400/50 backdrop-blur-[32px]"
|
||||
onClick={onClose}
|
||||
/>
|
||||
<div className="relative w-full rounded-2xl bg-white shadow-theme-xl dark:bg-gray-900 max-w-md mx-auto max-h-[90vh] overflow-y-auto p-4" onClick={(e) => e.stopPropagation()}>
|
||||
{/* Close Button */}
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
className="absolute right-3 top-3 z-999 flex h-9.5 w-9.5 items-center justify-center rounded-full bg-gray-100 text-gray-400 transition-colors hover:bg-gray-200 hover:text-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white sm:right-6 sm:top-6 sm:h-11 sm:w-11"
|
||||
>
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M6.04289 16.5413C5.65237 16.9318 5.65237 17.565 6.04289 17.9555C6.43342 18.346 7.06658 18.346 7.45711 17.9555L11.9987 13.4139L16.5408 17.956C16.9313 18.3466 17.5645 18.3466 17.955 17.956C18.3455 17.5655 18.3455 16.9323 17.955 16.5418L13.4129 11.9997L17.955 7.4576C18.3455 7.06707 18.3455 6.43391 17.955 6.04338C17.5645 5.65286 16.9313 5.65286 16.5408 6.04338L11.9987 10.5855L7.45711 6.0439C7.06658 5.65338 6.43342 5.65338 6.04289 6.0439C5.65237 6.43442 5.65237 7.06759 6.04289 7.45811L10.5845 11.9997L6.04289 16.5413Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b">
|
||||
<h3 className="text-lg font-semibold text-gray-900">
|
||||
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-800">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white/90">
|
||||
Schedule Repetition
|
||||
</h3>
|
||||
<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 className="p-6">
|
||||
|
||||
@@ -11,7 +11,7 @@ interface ScheduleModalProps {
|
||||
export function ScheduleModal({ experiment, onClose, onScheduleUpdated }: ScheduleModalProps) {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
|
||||
// Initialize with existing scheduled date or current date/time
|
||||
const getInitialDateTime = () => {
|
||||
if (experiment.scheduled_date) {
|
||||
@@ -21,7 +21,7 @@ export function ScheduleModal({ experiment, onClose, onScheduleUpdated }: Schedu
|
||||
time: date.toTimeString().slice(0, 5)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const now = new Date()
|
||||
// Set to next hour by default
|
||||
now.setHours(now.getHours() + 1, 0, 0, 0)
|
||||
@@ -92,21 +92,38 @@ export function ScheduleModal({ experiment, onClose, onScheduleUpdated }: Schedu
|
||||
}
|
||||
|
||||
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="relative bg-white rounded-xl shadow-2xl w-full max-w-md mx-auto">
|
||||
<div className="fixed inset-0 flex items-center justify-center overflow-y-auto modal z-999999">
|
||||
<div
|
||||
className="fixed inset-0 h-full w-full bg-gray-400/50 backdrop-blur-[32px]"
|
||||
onClick={onClose}
|
||||
/>
|
||||
<div className="relative w-full rounded-2xl bg-white shadow-theme-xl dark:bg-gray-900 max-w-md mx-auto p-4" onClick={(e) => e.stopPropagation()}>
|
||||
{/* Close Button */}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute right-3 top-3 z-999 flex h-9.5 w-9.5 items-center justify-center rounded-full bg-gray-100 text-gray-400 transition-colors hover:bg-gray-200 hover:text-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white sm:right-6 sm:top-6 sm:h-11 sm:w-11"
|
||||
>
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M6.04289 16.5413C5.65237 16.9318 5.65237 17.565 6.04289 17.9555C6.43342 18.346 7.06658 18.346 7.45711 17.9555L11.9987 13.4139L16.5408 17.956C16.9313 18.3466 17.5645 18.3466 17.955 17.956C18.3455 17.5655 18.3455 16.9323 17.955 16.5418L13.4129 11.9997L17.955 7.4576C18.3455 7.06707 18.3455 6.43391 17.955 6.04338C17.5645 5.65286 16.9313 5.65286 16.5408 6.04338L11.9987 10.5855L7.45711 6.0439C7.06658 5.65338 6.43342 5.65338 6.04289 6.0439C5.65237 6.43442 5.65237 7.06759 6.04289 7.45811L10.5845 11.9997L6.04289 16.5413Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-gray-200">
|
||||
<h3 className="text-xl font-semibold text-gray-900">
|
||||
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-800">
|
||||
<h3 className="text-xl font-semibold text-gray-900 dark:text-white/90">
|
||||
{isScheduled ? 'Update Schedule' : 'Schedule Experiment'}
|
||||
</h3>
|
||||
<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 className="p-6">
|
||||
@@ -138,31 +155,45 @@ export function ScheduleModal({ experiment, onClose, onScheduleUpdated }: Schedu
|
||||
{/* Schedule Form */}
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<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 *
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
id="date"
|
||||
value={dateTime.date}
|
||||
onChange={(e) => setDateTime({ ...dateTime, date: e.target.value })}
|
||||
className="max-w-xs px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors text-sm"
|
||||
required
|
||||
/>
|
||||
<div className="relative max-w-xs">
|
||||
<input
|
||||
type="date"
|
||||
id="date"
|
||||
value={dateTime.date}
|
||||
onChange={(e) => setDateTime({ ...dateTime, date: e.target.value })}
|
||||
className="h-11 w-full rounded-lg border appearance-none px-4 py-2.5 text-sm shadow-theme-xs placeholder:text-gray-400 focus:outline-hidden focus:ring-3 dark:bg-gray-900 dark:text-white/90 dark:placeholder:text-white/30 bg-transparent text-gray-800 border-gray-300 focus:border-brand-300 focus:ring-brand-500/20 dark:border-gray-700 dark:focus:border-brand-800"
|
||||
required
|
||||
/>
|
||||
<span className="absolute text-gray-500 -translate-y-1/2 pointer-events-none right-3 top-1/2 dark:text-gray-400">
|
||||
<svg className="size-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="time" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
<label htmlFor="time" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Time *
|
||||
</label>
|
||||
<input
|
||||
type="time"
|
||||
id="time"
|
||||
value={dateTime.time}
|
||||
onChange={(e) => setDateTime({ ...dateTime, time: e.target.value })}
|
||||
className="max-w-xs px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors text-sm"
|
||||
required
|
||||
/>
|
||||
<div className="relative max-w-xs">
|
||||
<input
|
||||
type="time"
|
||||
id="time"
|
||||
value={dateTime.time}
|
||||
onChange={(e) => setDateTime({ ...dateTime, time: e.target.value })}
|
||||
className="h-11 w-full rounded-lg border appearance-none px-4 py-2.5 text-sm shadow-theme-xs placeholder:text-gray-400 focus:outline-hidden focus:ring-3 dark:bg-gray-900 dark:text-white/90 dark:placeholder:text-white/30 bg-transparent text-gray-800 border-gray-300 focus:border-brand-300 focus:ring-brand-500/20 dark:border-gray-700 dark:focus:border-brand-800"
|
||||
required
|
||||
/>
|
||||
<span className="absolute text-gray-500 -translate-y-1/2 pointer-events-none right-3 top-1/2 dark:text-gray-400">
|
||||
<svg className="size-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
@@ -173,26 +204,26 @@ export function ScheduleModal({ experiment, onClose, onScheduleUpdated }: Schedu
|
||||
type="button"
|
||||
onClick={handleRemoveSchedule}
|
||||
disabled={loading}
|
||||
className="px-4 py-2 text-sm font-medium text-red-600 hover:text-red-700 hover:bg-red-50 rounded-lg transition-colors disabled:opacity-50"
|
||||
className="px-4 py-2 text-sm font-medium text-error-600 hover:text-error-700 hover:bg-error-50 dark:text-error-500 dark:hover:bg-error-500/15 rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
Remove Schedule
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex space-x-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancel}
|
||||
disabled={loading}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 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
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
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')}
|
||||
</button>
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { useState } from 'react'
|
||||
import { useState, useCallback, useEffect, useRef } from 'react'
|
||||
import type { User } from '../lib/supabase'
|
||||
|
||||
interface SidebarProps {
|
||||
user: User
|
||||
currentView: string
|
||||
onViewChange: (view: string) => void
|
||||
isExpanded?: boolean
|
||||
isMobileOpen?: boolean
|
||||
isHovered?: boolean
|
||||
setIsHovered?: (hovered: boolean) => void
|
||||
}
|
||||
|
||||
interface MenuItem {
|
||||
@@ -12,17 +16,28 @@ interface MenuItem {
|
||||
name: string
|
||||
icon: React.ReactElement
|
||||
requiredRoles?: string[]
|
||||
subItems?: { name: string; id: string; requiredRoles?: string[] }[]
|
||||
}
|
||||
|
||||
export function Sidebar({ user, currentView, onViewChange }: SidebarProps) {
|
||||
const [isCollapsed, setIsCollapsed] = useState(false)
|
||||
export function Sidebar({
|
||||
user,
|
||||
currentView,
|
||||
onViewChange,
|
||||
isExpanded = true,
|
||||
isMobileOpen = false,
|
||||
isHovered = false,
|
||||
setIsHovered
|
||||
}: SidebarProps) {
|
||||
const [openSubmenu, setOpenSubmenu] = useState<number | null>(null)
|
||||
const [subMenuHeight, setSubMenuHeight] = useState<Record<string, number>>({})
|
||||
const subMenuRefs = useRef<Record<string, HTMLDivElement | null>>({})
|
||||
|
||||
const menuItems: MenuItem[] = [
|
||||
{
|
||||
id: 'dashboard',
|
||||
name: 'Dashboard',
|
||||
icon: (
|
||||
<svg className="w-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="M8 5a2 2 0 012-2h4a2 2 0 012 2v6a2 2 0 01-2 2H10a2 2 0 01-2-2V5z" />
|
||||
</svg>
|
||||
@@ -32,7 +47,7 @@ export function Sidebar({ user, currentView, onViewChange }: SidebarProps) {
|
||||
id: 'user-management',
|
||||
name: 'User Management',
|
||||
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" />
|
||||
</svg>
|
||||
),
|
||||
@@ -42,7 +57,7 @@ export function Sidebar({ user, currentView, onViewChange }: SidebarProps) {
|
||||
id: 'experiments',
|
||||
name: 'Experiments',
|
||||
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" />
|
||||
</svg>
|
||||
),
|
||||
@@ -52,7 +67,7 @@ export function Sidebar({ user, currentView, onViewChange }: SidebarProps) {
|
||||
id: 'video-library',
|
||||
name: 'Video Library',
|
||||
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" />
|
||||
</svg>
|
||||
),
|
||||
@@ -61,7 +76,7 @@ export function Sidebar({ user, currentView, onViewChange }: SidebarProps) {
|
||||
id: 'analytics',
|
||||
name: 'Analytics',
|
||||
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" />
|
||||
</svg>
|
||||
),
|
||||
@@ -71,7 +86,7 @@ export function Sidebar({ user, currentView, onViewChange }: SidebarProps) {
|
||||
id: 'data-entry',
|
||||
name: 'Data Entry',
|
||||
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" />
|
||||
</svg>
|
||||
),
|
||||
@@ -81,80 +96,216 @@ export function Sidebar({ user, currentView, onViewChange }: SidebarProps) {
|
||||
id: 'vision-system',
|
||||
name: 'Vision System',
|
||||
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" />
|
||||
</svg>
|
||||
),
|
||||
}
|
||||
]
|
||||
|
||||
// const isActive = (path: string) => location.pathname === path;
|
||||
const isActive = useCallback(
|
||||
(id: string) => currentView === id,
|
||||
[currentView]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
// Auto-open submenu if current view is in a submenu
|
||||
menuItems.forEach((nav, index) => {
|
||||
if (nav.subItems) {
|
||||
nav.subItems.forEach((subItem) => {
|
||||
if (isActive(subItem.id)) {
|
||||
setOpenSubmenu(index)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}, [currentView, isActive, menuItems])
|
||||
|
||||
useEffect(() => {
|
||||
if (openSubmenu !== null) {
|
||||
const key = `submenu-${openSubmenu}`
|
||||
if (subMenuRefs.current[key]) {
|
||||
setSubMenuHeight((prevHeights) => ({
|
||||
...prevHeights,
|
||||
[key]: subMenuRefs.current[key]?.scrollHeight || 0,
|
||||
}))
|
||||
}
|
||||
}
|
||||
}, [openSubmenu])
|
||||
|
||||
const handleSubmenuToggle = (index: number) => {
|
||||
setOpenSubmenu((prevOpenSubmenu) => {
|
||||
if (prevOpenSubmenu === index) {
|
||||
return null
|
||||
}
|
||||
return index
|
||||
})
|
||||
}
|
||||
|
||||
const hasAccess = (item: MenuItem): boolean => {
|
||||
if (!item.requiredRoles) return true
|
||||
return item.requiredRoles.some(role => user.roles.includes(role as any))
|
||||
}
|
||||
|
||||
const renderMenuItems = (items: MenuItem[]) => (
|
||||
<ul className="flex flex-col gap-4">
|
||||
{items.map((nav, index) => {
|
||||
if (!hasAccess(nav)) return null
|
||||
|
||||
return (
|
||||
<li key={nav.id}>
|
||||
{nav.subItems ? (
|
||||
<button
|
||||
onClick={() => handleSubmenuToggle(index)}
|
||||
className={`menu-item group ${openSubmenu === index
|
||||
? "menu-item-active"
|
||||
: "menu-item-inactive"
|
||||
} cursor-pointer ${!isExpanded && !isHovered
|
||||
? "lg:justify-center"
|
||||
: "lg:justify-start"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`menu-item-icon-size ${openSubmenu === index
|
||||
? "menu-item-icon-active"
|
||||
: "menu-item-icon-inactive"
|
||||
}`}
|
||||
>
|
||||
{nav.icon}
|
||||
</span>
|
||||
{(isExpanded || isHovered || isMobileOpen) && (
|
||||
<span className="menu-item-text">{nav.name}</span>
|
||||
)}
|
||||
{(isExpanded || isHovered || isMobileOpen) && (
|
||||
<svg
|
||||
className={`ml-auto w-5 h-5 transition-transform duration-200 ${openSubmenu === index
|
||||
? "rotate-180 text-brand-500"
|
||||
: ""
|
||||
}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => onViewChange(nav.id)}
|
||||
className={`menu-item group ${isActive(nav.id) ? "menu-item-active" : "menu-item-inactive"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`menu-item-icon-size ${isActive(nav.id)
|
||||
? "menu-item-icon-active"
|
||||
: "menu-item-icon-inactive"
|
||||
}`}
|
||||
>
|
||||
{nav.icon}
|
||||
</span>
|
||||
{(isExpanded || isHovered || isMobileOpen) && (
|
||||
<span className="menu-item-text">{nav.name}</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{nav.subItems && (isExpanded || isHovered || isMobileOpen) && (
|
||||
<div
|
||||
ref={(el) => {
|
||||
subMenuRefs.current[`submenu-${index}`] = el
|
||||
}}
|
||||
className="overflow-hidden transition-all duration-300"
|
||||
style={{
|
||||
height:
|
||||
openSubmenu === index
|
||||
? `${subMenuHeight[`submenu-${index}`]}px`
|
||||
: "0px",
|
||||
}}
|
||||
>
|
||||
<ul className="mt-2 space-y-1 ml-9">
|
||||
{nav.subItems.map((subItem) => {
|
||||
if (subItem.requiredRoles && !subItem.requiredRoles.some(role => user.roles.includes(role as any))) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<li key={subItem.id}>
|
||||
<button
|
||||
onClick={() => onViewChange(subItem.id)}
|
||||
className={`menu-dropdown-item ${isActive(subItem.id)
|
||||
? "menu-dropdown-item-active"
|
||||
: "menu-dropdown-item-inactive"
|
||||
}`}
|
||||
>
|
||||
{subItem.name}
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)
|
||||
|
||||
return (
|
||||
<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`}>
|
||||
{/* Header */}
|
||||
<div className="p-4 border-b border-slate-700">
|
||||
<div className="flex items-center justify-between">
|
||||
{!isCollapsed && (
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-white">Pecan Experiments</h1>
|
||||
<p className="text-sm text-slate-400">Admin Dashboard</p>
|
||||
<aside
|
||||
className={`fixed mt-16 flex flex-col lg:mt-0 top-0 px-5 left-0 bg-white dark:bg-gray-900 dark:border-gray-800 text-gray-900 h-screen transition-all duration-300 ease-in-out z-50 border-r border-gray-200
|
||||
${isExpanded || isMobileOpen
|
||||
? "w-[290px]"
|
||||
: isHovered
|
||||
? "w-[290px]"
|
||||
: "w-[90px]"
|
||||
}
|
||||
${isMobileOpen ? "translate-x-0" : "-translate-x-full"}
|
||||
lg:translate-x-0`}
|
||||
onMouseEnter={() => !isExpanded && setIsHovered && setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered && setIsHovered(false)}
|
||||
>
|
||||
<div
|
||||
className={`py-8 flex ${!isExpanded && !isHovered ? "lg:justify-center" : "justify-start"
|
||||
}`}
|
||||
>
|
||||
<div>
|
||||
{isExpanded || isHovered || isMobileOpen ? (
|
||||
<>
|
||||
<h1 className="text-xl font-bold text-gray-800 dark:text-white/90">Pecan Experiments</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Research Dashboard</p>
|
||||
</>
|
||||
) : (
|
||||
<div className="w-8 h-8 bg-brand-500 rounded-lg flex items-center justify-center text-white font-bold text-lg">
|
||||
P
|
||||
</div>
|
||||
)}
|
||||
<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>
|
||||
|
||||
{/* Navigation Menu */}
|
||||
<nav className="flex-1 p-4">
|
||||
<ul className="space-y-2">
|
||||
{menuItems.map((item) => {
|
||||
if (!hasAccess(item)) return null
|
||||
|
||||
return (
|
||||
<li key={item.id}>
|
||||
<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
|
||||
? 'bg-blue-600 text-white shadow-lg'
|
||||
: 'text-slate-300 hover:bg-slate-700 hover:text-white'
|
||||
}`}
|
||||
title={isCollapsed ? item.name : undefined}
|
||||
>
|
||||
<span className={`transition-colors ${currentView === item.id ? 'text-white' : 'text-slate-400 group-hover:text-white'}`}>
|
||||
{item.icon}
|
||||
</span>
|
||||
{!isCollapsed && (
|
||||
<span className="ml-3 text-sm font-medium">{item.name}</span>
|
||||
)}
|
||||
{!isCollapsed && currentView === item.id && (
|
||||
<div className="ml-auto">
|
||||
<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>
|
||||
<div className="flex flex-col overflow-y-auto duration-300 ease-linear no-scrollbar">
|
||||
<nav className="mb-6">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<h2
|
||||
className={`mb-4 text-xs uppercase flex leading-[20px] text-gray-400 ${!isExpanded && !isHovered
|
||||
? "lg:justify-center"
|
||||
: "justify-start"
|
||||
}`}
|
||||
>
|
||||
{isExpanded || isHovered || isMobileOpen ? (
|
||||
"Menu"
|
||||
) : (
|
||||
<svg className="size-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 12h.01M12 12h.01M19 12h.01M6 12a1 1 0 11-2 0 1 1 0 012 0zm7 0a1 1 0 11-2 0 1 1 0 012 0zm7 0a1 1 0 11-2 0 1 1 0 012 0z" />
|
||||
</svg>
|
||||
)}
|
||||
</h2>
|
||||
{renderMenuItems(menuItems)}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,9 +5,17 @@ interface TopNavbarProps {
|
||||
user: User
|
||||
onLogout: () => void
|
||||
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 getPageTitle = (view: string) => {
|
||||
@@ -24,6 +32,8 @@ export function TopNavbar({ user, onLogout, currentView = 'dashboard' }: TopNavb
|
||||
return 'Data Entry'
|
||||
case 'vision-system':
|
||||
return 'Vision System'
|
||||
case 'video-library':
|
||||
return 'Video Library'
|
||||
default:
|
||||
return 'Dashboard'
|
||||
}
|
||||
@@ -32,110 +42,215 @@ export function TopNavbar({ user, onLogout, currentView = 'dashboard' }: TopNavb
|
||||
const getRoleBadgeColor = (role: string) => {
|
||||
switch (role) {
|
||||
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':
|
||||
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':
|
||||
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':
|
||||
return 'bg-purple-100 text-purple-800'
|
||||
return 'bg-theme-purple-500/10 text-theme-purple-500'
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800'
|
||||
return 'bg-gray-100 text-gray-700 dark:bg-white/5 dark:text-white/80'
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<header className="bg-white shadow-sm border-b border-gray-200 sticky top-0 z-30">
|
||||
<div className="flex items-center justify-between h-16 px-6">
|
||||
{/* Left side - could add breadcrumbs or page title here */}
|
||||
<div className="flex items-center">
|
||||
<h1 className="text-lg font-semibold text-gray-900">{getPageTitle(currentView)}</h1>
|
||||
<header className="sticky top-0 flex w-full bg-white border-gray-200 z-99999 dark:border-gray-800 dark:bg-gray-900 lg:border-b">
|
||||
<div className="flex flex-col items-center justify-between grow lg:flex-row lg:px-6">
|
||||
<div className="flex items-center justify-between w-full gap-2 px-3 py-3 border-b border-gray-200 dark:border-gray-800 sm:gap-4 lg:justify-normal lg:border-b-0 lg:px-0 lg:py-4">
|
||||
<button
|
||||
className="items-center justify-center w-10 h-10 text-gray-500 border-gray-200 rounded-lg z-99999 dark:border-gray-800 lg:flex dark:text-gray-400 lg:h-11 lg:w-11 lg:border"
|
||||
onClick={onToggleSidebar}
|
||||
aria-label="Toggle Sidebar"
|
||||
>
|
||||
{isSidebarOpen ? (
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M6.21967 7.28131C5.92678 6.98841 5.92678 6.51354 6.21967 6.22065C6.51256 5.92775 6.98744 5.92775 7.28033 6.22065L11.999 10.9393L16.7176 6.22078C17.0105 5.92789 17.4854 5.92788 17.7782 6.22078C18.0711 6.51367 18.0711 6.98855 17.7782 7.28144L13.0597 12L17.7782 16.7186C18.0711 17.0115 18.0711 17.4863 17.7782 17.7792C17.4854 18.0721 17.0105 18.0721 16.7176 17.7792L11.999 13.0607L7.28033 17.7794C6.98744 18.0722 6.51256 18.0722 6.21967 17.7794C5.92678 17.4865 5.92678 17.0116 6.21967 16.7187L10.9384 12L6.21967 7.28131Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
width="16"
|
||||
height="12"
|
||||
viewBox="0 0 16 12"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M0.583252 1C0.583252 0.585788 0.919038 0.25 1.33325 0.25H14.6666C15.0808 0.25 15.4166 0.585786 15.4166 1C15.4166 1.41421 15.0808 1.75 14.6666 1.75L1.33325 1.75C0.919038 1.75 0.583252 1.41422 0.583252 1ZM0.583252 11C0.583252 10.5858 0.919038 10.25 1.33325 10.25L14.6666 10.25C15.0808 10.25 15.4166 10.5858 15.4166 11C15.4166 11.4142 15.0808 11.75 14.6666 11.75L1.33325 11.75C0.919038 11.75 0.583252 11.4142 0.583252 11ZM1.33325 5.25C0.919038 5.25 0.583252 5.58579 0.583252 6C0.583252 6.41421 0.919038 6.75 1.33325 6.75L7.99992 6.75C8.41413 6.75 8.74992 6.41421 8.74992 6C8.74992 5.58579 8.41413 5.25 7.99992 5.25L1.33325 5.25Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Page title */}
|
||||
<div className="flex items-center lg:hidden">
|
||||
<h1 className="text-lg font-medium text-gray-800 dark:text-white/90">{getPageTitle(currentView)}</h1>
|
||||
</div>
|
||||
|
||||
{/* Search bar - hidden on mobile, shown on desktop */}
|
||||
<div className="hidden lg:block">
|
||||
<form>
|
||||
<div className="relative">
|
||||
<span className="absolute -translate-y-1/2 pointer-events-none left-4 top-1/2">
|
||||
<svg
|
||||
className="fill-gray-500 dark:fill-gray-400"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M3.04175 9.37363C3.04175 5.87693 5.87711 3.04199 9.37508 3.04199C12.8731 3.04199 15.7084 5.87693 15.7084 9.37363C15.7084 12.8703 12.8731 15.7053 9.37508 15.7053C5.87711 15.7053 3.04175 12.8703 3.04175 9.37363ZM9.37508 1.54199C5.04902 1.54199 1.54175 5.04817 1.54175 9.37363C1.54175 13.6991 5.04902 17.2053 9.37508 17.2053C11.2674 17.2053 13.003 16.5344 14.357 15.4176L17.177 18.238C17.4699 18.5309 17.9448 18.5309 18.2377 18.238C18.5306 17.9451 18.5306 17.4703 18.2377 17.1774L15.418 14.3573C16.5365 13.0033 17.2084 11.2669 17.2084 9.37363C17.2084 5.04817 13.7011 1.54199 9.37508 1.54199Z"
|
||||
fill=""
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search or type command..."
|
||||
className="dark:bg-dark-900 h-11 w-full rounded-lg border border-gray-200 bg-transparent py-2.5 pl-12 pr-14 text-sm text-gray-800 shadow-theme-xs placeholder:text-gray-400 focus:border-brand-300 focus:outline-hidden focus:ring-3 focus:ring-brand-500/10 dark:border-gray-800 dark:bg-gray-900 dark:bg-white/[0.03] dark:text-white/90 dark:placeholder:text-white/30 dark:focus:border-brand-800 xl:w-[430px]"
|
||||
/>
|
||||
<button className="absolute right-2.5 top-1/2 inline-flex -translate-y-1/2 items-center gap-0.5 rounded-lg border border-gray-200 bg-gray-50 px-[7px] py-[4.5px] text-xs -tracking-[0.2px] text-gray-500 dark:border-gray-800 dark:bg-white/[0.03] dark:text-gray-400">
|
||||
<span> ⌘ </span>
|
||||
<span> K </span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right side - User menu */}
|
||||
<div className="flex items-center space-x-4">
|
||||
{/* User info and avatar */}
|
||||
<div className="flex items-center justify-between w-full gap-4 px-5 py-4 lg:flex shadow-theme-md lg:justify-end lg:px-0 lg:shadow-none">
|
||||
{/* User Area */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setIsUserMenuOpen(!isUserMenuOpen)}
|
||||
className="flex items-center 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 */}
|
||||
<div className="w-8 h-8 bg-indigo-600 rounded-full flex items-center justify-center text-white text-sm font-medium">
|
||||
{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}
|
||||
<span className="mr-3 overflow-hidden rounded-full h-11 w-11">
|
||||
<div className="w-11 h-11 bg-brand-500 rounded-full flex items-center justify-center text-white text-sm font-medium">
|
||||
{user.email.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{user.roles.length > 0 ? user.roles[0].charAt(0).toUpperCase() + user.roles[0].slice(1) : 'User'}
|
||||
</div>
|
||||
</div>
|
||||
</span>
|
||||
|
||||
{/* Dropdown arrow */}
|
||||
<span className="block mr-1 font-medium text-theme-sm">{user.email.split('@')[0]}</span>
|
||||
<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"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<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>
|
||||
</button>
|
||||
|
||||
{/* Dropdown menu */}
|
||||
{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="p-4 border-b border-gray-100">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-12 h-12 bg-indigo-600 rounded-full flex items-center justify-center text-white text-lg font-medium">
|
||||
{user.email.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-gray-900 truncate">
|
||||
{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 className="absolute right-0 mt-[17px] flex w-[260px] flex-col rounded-2xl border border-gray-200 bg-white p-3 shadow-theme-lg dark:border-gray-800 dark:bg-gray-dark">
|
||||
<div>
|
||||
<span className="block font-medium text-gray-700 text-theme-sm dark:text-gray-400">
|
||||
{user.email.split('@')[0]}
|
||||
</span>
|
||||
<span className="mt-0.5 block text-theme-xs text-gray-500 dark:text-gray-400">
|
||||
{user.email}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="p-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsUserMenuOpen(false)
|
||||
onLogout()
|
||||
}}
|
||||
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"
|
||||
<ul className="flex flex-col gap-1 pt-4 pb-3 border-b border-gray-200 dark:border-gray-800">
|
||||
<li>
|
||||
<div className="flex items-center gap-3 px-3 py-2 font-medium text-gray-700 rounded-lg text-theme-sm dark:text-gray-400">
|
||||
<svg
|
||||
className="fill-gray-500 dark:fill-gray-400"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M12 3.5C7.30558 3.5 3.5 7.30558 3.5 12C3.5 14.1526 4.3002 16.1184 5.61936 17.616C6.17279 15.3096 8.24852 13.5955 10.7246 13.5955H13.2746C15.7509 13.5955 17.8268 15.31 18.38 17.6167C19.6996 16.119 20.5 14.153 20.5 12C20.5 7.30558 16.6944 3.5 12 3.5ZM17.0246 18.8566V18.8455C17.0246 16.7744 15.3457 15.0955 13.2746 15.0955H10.7246C8.65354 15.0955 6.97461 16.7744 6.97461 18.8455V18.856C8.38223 19.8895 10.1198 20.5 12 20.5C13.8798 20.5 15.6171 19.8898 17.0246 18.8566ZM2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12ZM11.9991 7.25C10.8847 7.25 9.98126 8.15342 9.98126 9.26784C9.98126 10.3823 10.8847 11.2857 11.9991 11.2857C13.1135 11.2857 14.0169 10.3823 14.0169 9.26784C14.0169 8.15342 13.1135 7.25 11.9991 7.25ZM8.48126 9.26784C8.48126 7.32499 10.0563 5.75 11.9991 5.75C13.9419 5.75 15.5169 7.32499 15.5169 9.26784C15.5169 11.2107 13.9419 12.7857 11.9991 12.7857C10.0563 12.7857 8.48126 11.2107 8.48126 9.26784Z"
|
||||
fill=""
|
||||
/>
|
||||
</svg>
|
||||
Profile
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<div className="flex items-center gap-3 px-3 py-2 font-medium text-gray-700 rounded-lg text-theme-sm dark:text-gray-400">
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">Status:</span>
|
||||
<span className={user.status === 'active' ? 'text-success-600 dark:text-success-500' : 'text-error-600 dark:text-error-500'}>
|
||||
{user.status}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<div className="px-3 py-2">
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mb-2">Roles:</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{user.roles.map((role) => (
|
||||
<span
|
||||
key={role}
|
||||
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getRoleBadgeColor(role)}`}
|
||||
>
|
||||
{role.charAt(0).toUpperCase() + role.slice(1)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsUserMenuOpen(false)
|
||||
onLogout()
|
||||
}}
|
||||
className="flex items-center gap-3 px-3 py-2 mt-3 font-medium text-gray-700 rounded-lg group text-theme-sm hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-gray-300"
|
||||
>
|
||||
<svg
|
||||
className="fill-gray-500 group-hover:fill-gray-700 dark:group-hover:fill-gray-300"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<svg className="w-4 h-4 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<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" />
|
||||
</svg>
|
||||
Sign Out
|
||||
</button>
|
||||
</div>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M15.1007 19.247C14.6865 19.247 14.3507 18.9112 14.3507 18.497L14.3507 14.245H12.8507V18.497C12.8507 19.7396 13.8581 20.747 15.1007 20.747H18.5007C19.7434 20.747 20.7507 19.7396 20.7507 18.497L20.7507 5.49609C20.7507 4.25345 19.7433 3.24609 18.5007 3.24609H15.1007C13.8581 3.24609 12.8507 4.25345 12.8507 5.49609V9.74501L14.3507 9.74501V5.49609C14.3507 5.08188 14.6865 4.74609 15.1007 4.74609L18.5007 4.74609C18.9149 4.74609 19.2507 5.08188 19.2507 5.49609L19.2507 18.497C19.2507 18.9112 18.9149 19.247 18.5007 19.247H15.1007ZM3.25073 11.9984C3.25073 12.2144 3.34204 12.4091 3.48817 12.546L8.09483 17.1556C8.38763 17.4485 8.86251 17.4487 9.15549 17.1559C9.44848 16.8631 9.44863 16.3882 9.15583 16.0952L5.81116 12.7484L16.0007 12.7484C16.4149 12.7484 16.7507 12.4127 16.7507 11.9984C16.7507 11.5842 16.4149 11.2484 16.0007 11.2484L5.81528 11.2484L9.15585 7.90554C9.44864 7.61255 9.44847 7.13767 9.15547 6.84488C8.86248 6.55209 8.3876 6.55226 8.09481 6.84525L3.52309 11.4202C3.35673 11.5577 3.25073 11.7657 3.25073 11.9984Z"
|
||||
fill=""
|
||||
/>
|
||||
</svg>
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -618,7 +618,7 @@ export function VisionSystem() {
|
||||
const handleStartRecording = async (cameraName: string) => {
|
||||
try {
|
||||
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 })
|
||||
|
||||
|
||||
@@ -50,123 +50,117 @@ export const VideoStreamingPage: React.FC = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="bg-white shadow">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="py-6">
|
||||
<h1 className="text-3xl font-bold text-gray-900">Video Library</h1>
|
||||
<p className="mt-2 text-gray-600">
|
||||
Browse and view recorded videos from your camera system
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Video Library</h1>
|
||||
<p className="mt-2 text-gray-600">
|
||||
Browse and view recorded videos from your camera system
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 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-lg shadow p-6 mb-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{/* Camera Filter */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Filter by Camera
|
||||
</label>
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6 shadow-theme-sm">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{/* Camera Filter */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Filter by Camera
|
||||
</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
|
||||
value={filters.cameraName || 'all'}
|
||||
onChange={(e) => handleCameraFilterChange(e.target.value)}
|
||||
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"
|
||||
value={sortOptions.field}
|
||||
onChange={(e) => handleSortChange(e.target.value as VideoListSortOptions['field'], sortOptions.direction)}
|
||||
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>
|
||||
{availableCameras.map(camera => (
|
||||
<option key={camera} value={camera}>
|
||||
{camera}
|
||||
</option>
|
||||
))}
|
||||
<option value="created_at">Date Created</option>
|
||||
<option value="file_size_bytes">File Size</option>
|
||||
<option value="camera_name">Camera Name</option>
|
||||
<option value="filename">Filename</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 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
|
||||
value={sortOptions.field}
|
||||
onChange={(e) => handleSortChange(e.target.value as VideoListSortOptions['field'], sortOptions.direction)}
|
||||
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"
|
||||
>
|
||||
<option value="created_at">Date Created</option>
|
||||
<option value="file_size_bytes">File Size</option>
|
||||
<option value="camera_name">Camera Name</option>
|
||||
<option value="filename">Filename</option>
|
||||
</select>
|
||||
<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>
|
||||
<button
|
||||
onClick={() => handleSortChange(sortOptions.field, sortOptions.direction === 'asc' ? 'desc' : 'asc')}
|
||||
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"
|
||||
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>
|
||||
|
||||
{/* Clear Filters */}
|
||||
{(filters.cameraName || filters.dateRange) && (
|
||||
<div className="mt-4 pt-4 border-t">
|
||||
<button
|
||||
onClick={() => setFilters({})}
|
||||
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"
|
||||
>
|
||||
<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>
|
||||
{/* 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>
|
||||
|
||||
{/* Video List */}
|
||||
<VideoList
|
||||
filters={filters}
|
||||
sortOptions={sortOptions}
|
||||
onVideoSelect={handleVideoSelect}
|
||||
limit={24}
|
||||
/>
|
||||
{/* Clear Filters */}
|
||||
{(filters.cameraName || filters.dateRange) && (
|
||||
<div className="mt-4 pt-4 border-t border-gray-100">
|
||||
<button
|
||||
onClick={() => setFilters({})}
|
||||
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>
|
||||
|
||||
{/* Video List */}
|
||||
<VideoList
|
||||
filters={filters}
|
||||
sortOptions={sortOptions}
|
||||
onVideoSelect={handleVideoSelect}
|
||||
limit={24}
|
||||
/>
|
||||
|
||||
{/* Video Modal */}
|
||||
<VideoModal
|
||||
video={selectedVideo}
|
||||
|
||||
160
src/features/video-streaming/components/Pagination.tsx
Normal file
160
src/features/video-streaming/components/Pagination.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -33,8 +33,8 @@ export const VideoCard: React.FC<VideoCardProps> = ({
|
||||
};
|
||||
|
||||
const cardClasses = [
|
||||
'bg-white rounded-lg shadow-md overflow-hidden transition-shadow hover:shadow-lg',
|
||||
onClick ? 'cursor-pointer' : '',
|
||||
'bg-white rounded-xl border border-gray-200 overflow-hidden transition-all hover:shadow-theme-md',
|
||||
onClick ? 'cursor-pointer hover:border-gray-300' : '',
|
||||
className,
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
@@ -117,7 +117,7 @@ export const VideoCard: React.FC<VideoCardProps> = ({
|
||||
|
||||
{/* Metadata (if available and requested) */}
|
||||
{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>
|
||||
<span className="font-medium">Duration:</span> {Math.round(video.metadata.duration_seconds)}s
|
||||
@@ -136,7 +136,7 @@ export const VideoCard: React.FC<VideoCardProps> = ({
|
||||
)}
|
||||
|
||||
{/* 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">
|
||||
{formatVideoDate(video.created_at)}
|
||||
</div>
|
||||
@@ -147,7 +147,7 @@ export const VideoCard: React.FC<VideoCardProps> = ({
|
||||
e.stopPropagation();
|
||||
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">
|
||||
<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" />
|
||||
|
||||
@@ -8,6 +8,7 @@ import React, { useState, useEffect } from 'react';
|
||||
import { type VideoListProps, type VideoListFilters, type VideoListSortOptions } from '../types';
|
||||
import { useVideoList } from '../hooks/useVideoList';
|
||||
import { VideoCard } from './VideoCard';
|
||||
import { Pagination, PageInfo } from './Pagination';
|
||||
|
||||
export const VideoList: React.FC<VideoListProps> = ({
|
||||
filters,
|
||||
@@ -24,11 +25,16 @@ export const VideoList: React.FC<VideoListProps> = ({
|
||||
const {
|
||||
videos,
|
||||
totalCount,
|
||||
currentPage,
|
||||
totalPages,
|
||||
loading,
|
||||
error,
|
||||
refetch,
|
||||
loadMore,
|
||||
hasMore,
|
||||
goToPage,
|
||||
nextPage,
|
||||
previousPage,
|
||||
updateFilters,
|
||||
updateSort,
|
||||
} = useVideoList({
|
||||
@@ -38,6 +44,7 @@ export const VideoList: React.FC<VideoListProps> = ({
|
||||
end_date: localFilters.dateRange?.end,
|
||||
limit,
|
||||
include_metadata: true,
|
||||
page: 1, // Start with page 1
|
||||
},
|
||||
autoFetch: true,
|
||||
});
|
||||
@@ -130,17 +137,22 @@ export const VideoList: React.FC<VideoListProps> = ({
|
||||
{/* Results Summary */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<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>
|
||||
|
||||
<button
|
||||
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">
|
||||
<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>
|
||||
Refresh
|
||||
{loading === 'loading' ? 'Refreshing...' : 'Refresh'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -156,37 +168,37 @@ export const VideoList: React.FC<VideoListProps> = ({
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Load More Button */}
|
||||
{hasMore && (
|
||||
<div className="flex justify-center mt-8">
|
||||
<button
|
||||
onClick={handleLoadMore}
|
||||
disabled={loading === 'loading'}
|
||||
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"
|
||||
>
|
||||
{loading === 'loading' ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
||||
Loading...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
</svg>
|
||||
Load More Videos
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="mt-8 space-y-4">
|
||||
{/* Page Info */}
|
||||
<PageInfo
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
totalItems={totalCount}
|
||||
itemsPerPage={limit}
|
||||
className="text-center"
|
||||
/>
|
||||
|
||||
{/* Pagination Controls */}
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={goToPage}
|
||||
showFirstLast={true}
|
||||
showPrevNext={true}
|
||||
maxVisiblePages={5}
|
||||
className="justify-center"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading Indicator for Additional Videos */}
|
||||
{loading === 'loading' && videos.length > 0 && (
|
||||
<div className="flex justify-center mt-4">
|
||||
{/* Loading Indicator */}
|
||||
{loading === 'loading' && (
|
||||
<div className="flex justify-center mt-8">
|
||||
<div className="text-sm text-gray-600 flex items-center">
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-500 mr-2"></div>
|
||||
Loading more videos...
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-brand-500 mr-2"></div>
|
||||
Loading videos...
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -10,6 +10,7 @@ export { VideoThumbnail } from './VideoThumbnail';
|
||||
export { VideoCard } from './VideoCard';
|
||||
export { VideoList } from './VideoList';
|
||||
export { VideoModal } from './VideoModal';
|
||||
export { Pagination, PageInfo } from './Pagination';
|
||||
|
||||
// Re-export component prop types for convenience
|
||||
export type {
|
||||
@@ -17,4 +18,5 @@ export type {
|
||||
VideoThumbnailProps,
|
||||
VideoCardProps,
|
||||
VideoListProps,
|
||||
PaginationProps,
|
||||
} from '../types';
|
||||
|
||||
@@ -19,11 +19,16 @@ import {
|
||||
export interface UseVideoListReturn {
|
||||
videos: VideoFile[];
|
||||
totalCount: number;
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
loading: LoadingState;
|
||||
error: VideoError | null;
|
||||
refetch: () => Promise<void>;
|
||||
loadMore: () => Promise<void>;
|
||||
hasMore: boolean;
|
||||
goToPage: (page: number) => Promise<void>;
|
||||
nextPage: () => Promise<void>;
|
||||
previousPage: () => Promise<void>;
|
||||
updateFilters: (filters: VideoListFilters) => void;
|
||||
updateSort: (sortOptions: VideoListSortOptions) => void;
|
||||
clearCache: () => void;
|
||||
@@ -47,6 +52,8 @@ export function useVideoList(options: UseVideoListOptions = {}) {
|
||||
// State
|
||||
const [videos, setVideos] = useState<VideoFile[]>([]);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(0);
|
||||
const [loading, setLoading] = useState<LoadingState>('idle');
|
||||
const [error, setError] = useState<VideoError | null>(null);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
@@ -85,7 +92,17 @@ export function useVideoList(options: UseVideoListOptions = {}) {
|
||||
// Update state
|
||||
setVideos(append ? prev => [...prev, ...response.videos] : response.videos);
|
||||
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');
|
||||
|
||||
} catch (err) {
|
||||
@@ -105,14 +122,19 @@ export function useVideoList(options: UseVideoListOptions = {}) {
|
||||
}, [initialParams]);
|
||||
|
||||
/**
|
||||
* Refetch videos with initial parameters
|
||||
* Refetch videos with current page
|
||||
*/
|
||||
const refetch = useCallback(async (): Promise<void> => {
|
||||
await fetchVideos(initialParams, false);
|
||||
}, [fetchVideos, initialParams]);
|
||||
const currentParams = {
|
||||
...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> => {
|
||||
if (!hasMore || loading === 'loading') {
|
||||
@@ -124,6 +146,36 @@ export function useVideoList(options: UseVideoListOptions = {}) {
|
||||
await fetchVideos(params, true);
|
||||
}, [hasMore, loading, videos.length, initialParams, fetchVideos]);
|
||||
|
||||
/**
|
||||
* Go to specific page
|
||||
*/
|
||||
const goToPage = useCallback(async (page: number): Promise<void> => {
|
||||
if (page < 1 || (totalPages > 0 && page > totalPages) || loading === 'loading') {
|
||||
return;
|
||||
}
|
||||
|
||||
const params = { ...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
|
||||
*/
|
||||
@@ -133,6 +185,8 @@ export function useVideoList(options: UseVideoListOptions = {}) {
|
||||
camera_name: filters.cameraName,
|
||||
start_date: filters.dateRange?.start,
|
||||
end_date: filters.dateRange?.end,
|
||||
page: 1, // Reset to first page when filters change
|
||||
limit: initialParams.limit || 20,
|
||||
};
|
||||
|
||||
fetchVideos(newParams, false);
|
||||
@@ -146,12 +200,22 @@ export function useVideoList(options: UseVideoListOptions = {}) {
|
||||
setVideos(prev => sortVideos(prev, sortOptions.field, sortOptions.direction));
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Clear cache (placeholder for future caching implementation)
|
||||
*/
|
||||
const clearCache = useCallback((): void => {
|
||||
// TODO: Implement cache clearing when caching is added
|
||||
console.log('Cache cleared');
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Reset to initial state
|
||||
*/
|
||||
const reset = useCallback((): void => {
|
||||
setVideos([]);
|
||||
setTotalCount(0);
|
||||
setCurrentPage(1);
|
||||
setTotalPages(0);
|
||||
setLoading('idle');
|
||||
setError(null);
|
||||
setHasMore(true);
|
||||
@@ -174,14 +238,21 @@ export function useVideoList(options: UseVideoListOptions = {}) {
|
||||
return {
|
||||
videos,
|
||||
totalCount,
|
||||
currentPage,
|
||||
totalPages,
|
||||
loading,
|
||||
error,
|
||||
refetch,
|
||||
loadMore,
|
||||
hasMore,
|
||||
// Pagination methods
|
||||
goToPage,
|
||||
nextPage,
|
||||
previousPage,
|
||||
// Additional utility methods
|
||||
updateFilters,
|
||||
updateSort,
|
||||
clearCache,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -90,9 +90,18 @@ export class VideoApiService {
|
||||
*/
|
||||
async getVideos(params: VideoListParams = {}): Promise<VideoListResponse> {
|
||||
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 response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
@@ -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) {
|
||||
if (error instanceof VideoApiError) {
|
||||
throw error;
|
||||
|
||||
@@ -35,6 +35,10 @@ export interface VideoWithMetadata extends VideoFile {
|
||||
export interface VideoListResponse {
|
||||
videos: VideoFile[];
|
||||
total_count: number;
|
||||
page?: number;
|
||||
total_pages?: number;
|
||||
has_next?: boolean;
|
||||
has_previous?: boolean;
|
||||
}
|
||||
|
||||
// API response for video info
|
||||
@@ -66,6 +70,8 @@ export interface VideoListParams {
|
||||
end_date?: string;
|
||||
limit?: number;
|
||||
include_metadata?: boolean;
|
||||
page?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
// Thumbnail request parameters
|
||||
@@ -122,6 +128,17 @@ export interface VideoListProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// Pagination component props
|
||||
export interface PaginationProps {
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
onPageChange: (page: number) => void;
|
||||
showFirstLast?: boolean;
|
||||
showPrevNext?: boolean;
|
||||
maxVisiblePages?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export interface VideoThumbnailProps {
|
||||
fileId: string;
|
||||
timestamp?: number;
|
||||
|
||||
295
src/index.css
295
src/index.css
@@ -1,15 +1,290 @@
|
||||
@import url("https://fonts.googleapis.com/css2?family=Outfit:wght@100..900&display=swap") layer(base);
|
||||
|
||||
@import "tailwindcss";
|
||||
|
||||
/* Reset some default styles that conflict with Tailwind */
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme {
|
||||
--font-*: initial;
|
||||
--font-outfit: Outfit, sans-serif;
|
||||
|
||||
--breakpoint-*: initial;
|
||||
--breakpoint-2xsm: 375px;
|
||||
--breakpoint-xsm: 425px;
|
||||
--breakpoint-3xl: 2000px;
|
||||
--breakpoint-sm: 640px;
|
||||
--breakpoint-md: 768px;
|
||||
--breakpoint-lg: 1024px;
|
||||
--breakpoint-xl: 1280px;
|
||||
--breakpoint-2xl: 1536px;
|
||||
|
||||
--text-title-2xl: 72px;
|
||||
--text-title-2xl--line-height: 90px;
|
||||
--text-title-xl: 60px;
|
||||
--text-title-xl--line-height: 72px;
|
||||
--text-title-lg: 48px;
|
||||
--text-title-lg--line-height: 60px;
|
||||
--text-title-md: 36px;
|
||||
--text-title-md--line-height: 44px;
|
||||
--text-title-sm: 30px;
|
||||
--text-title-sm--line-height: 38px;
|
||||
--text-theme-xl: 20px;
|
||||
--text-theme-xl--line-height: 30px;
|
||||
--text-theme-sm: 14px;
|
||||
--text-theme-sm--line-height: 20px;
|
||||
--text-theme-xs: 12px;
|
||||
--text-theme-xs--line-height: 18px;
|
||||
|
||||
--color-current: currentColor;
|
||||
--color-transparent: transparent;
|
||||
--color-white: #ffffff;
|
||||
--color-black: #101828;
|
||||
|
||||
--color-brand-25: #f2f7ff;
|
||||
--color-brand-50: #ecf3ff;
|
||||
--color-brand-100: #dde9ff;
|
||||
--color-brand-200: #c2d6ff;
|
||||
--color-brand-300: #9cb9ff;
|
||||
--color-brand-400: #7592ff;
|
||||
--color-brand-500: #465fff;
|
||||
--color-brand-600: #3641f5;
|
||||
--color-brand-700: #2a31d8;
|
||||
--color-brand-800: #252dae;
|
||||
--color-brand-900: #262e89;
|
||||
--color-brand-950: #161950;
|
||||
|
||||
--color-blue-light-25: #f5fbff;
|
||||
--color-blue-light-50: #f0f9ff;
|
||||
--color-blue-light-100: #e0f2fe;
|
||||
--color-blue-light-200: #b9e6fe;
|
||||
--color-blue-light-300: #7cd4fd;
|
||||
--color-blue-light-400: #36bffa;
|
||||
--color-blue-light-500: #0ba5ec;
|
||||
--color-blue-light-600: #0086c9;
|
||||
--color-blue-light-700: #026aa2;
|
||||
--color-blue-light-800: #065986;
|
||||
--color-blue-light-900: #0b4a6f;
|
||||
--color-blue-light-950: #062c41;
|
||||
|
||||
--color-gray-25: #fcfcfd;
|
||||
--color-gray-50: #f9fafb;
|
||||
--color-gray-100: #f2f4f7;
|
||||
--color-gray-200: #e4e7ec;
|
||||
--color-gray-300: #d0d5dd;
|
||||
--color-gray-400: #98a2b3;
|
||||
--color-gray-500: #667085;
|
||||
--color-gray-600: #475467;
|
||||
--color-gray-700: #344054;
|
||||
--color-gray-800: #1d2939;
|
||||
--color-gray-900: #101828;
|
||||
--color-gray-950: #0c111d;
|
||||
--color-gray-dark: #1a2231;
|
||||
|
||||
--color-orange-25: #fffaf5;
|
||||
--color-orange-50: #fff6ed;
|
||||
--color-orange-100: #ffead5;
|
||||
--color-orange-200: #fddcab;
|
||||
--color-orange-300: #feb273;
|
||||
--color-orange-400: #fd853a;
|
||||
--color-orange-500: #fb6514;
|
||||
--color-orange-600: #ec4a0a;
|
||||
--color-orange-700: #c4320a;
|
||||
--color-orange-800: #9c2a10;
|
||||
--color-orange-900: #7e2410;
|
||||
--color-orange-950: #511c10;
|
||||
|
||||
--color-success-25: #f6fef9;
|
||||
--color-success-50: #ecfdf3;
|
||||
--color-success-100: #d1fadf;
|
||||
--color-success-200: #a6f4c5;
|
||||
--color-success-300: #6ce9a6;
|
||||
--color-success-400: #32d583;
|
||||
--color-success-500: #12b76a;
|
||||
--color-success-600: #039855;
|
||||
--color-success-700: #027a48;
|
||||
--color-success-800: #05603a;
|
||||
--color-success-900: #054f31;
|
||||
--color-success-950: #053321;
|
||||
|
||||
--color-error-25: #fffbfa;
|
||||
--color-error-50: #fef3f2;
|
||||
--color-error-100: #fee4e2;
|
||||
--color-error-200: #fecdca;
|
||||
--color-error-300: #fda29b;
|
||||
--color-error-400: #f97066;
|
||||
--color-error-500: #f04438;
|
||||
--color-error-600: #d92d20;
|
||||
--color-error-700: #b42318;
|
||||
--color-error-800: #912018;
|
||||
--color-error-900: #7a271a;
|
||||
--color-error-950: #55160c;
|
||||
|
||||
--color-warning-25: #fffcf5;
|
||||
--color-warning-50: #fffaeb;
|
||||
--color-warning-100: #fef0c7;
|
||||
--color-warning-200: #fedf89;
|
||||
--color-warning-300: #fec84b;
|
||||
--color-warning-400: #fdb022;
|
||||
--color-warning-500: #f79009;
|
||||
--color-warning-600: #dc6803;
|
||||
--color-warning-700: #b54708;
|
||||
--color-warning-800: #93370d;
|
||||
--color-warning-900: #7a2e0e;
|
||||
--color-warning-950: #4e1d09;
|
||||
|
||||
--color-theme-pink-500: #ee46bc;
|
||||
|
||||
--color-theme-purple-500: #7a5af8;
|
||||
|
||||
--shadow-theme-md: 0px 4px 8px -2px rgba(16, 24, 40, 0.1),
|
||||
0px 2px 4px -2px rgba(16, 24, 40, 0.06);
|
||||
--shadow-theme-lg: 0px 12px 16px -4px rgba(16, 24, 40, 0.08),
|
||||
0px 4px 6px -2px rgba(16, 24, 40, 0.03);
|
||||
--shadow-theme-sm: 0px 1px 3px 0px rgba(16, 24, 40, 0.1),
|
||||
0px 1px 2px 0px rgba(16, 24, 40, 0.06);
|
||||
--shadow-theme-xs: 0px 1px 2px 0px rgba(16, 24, 40, 0.05);
|
||||
--shadow-theme-xl: 0px 20px 24px -4px rgba(16, 24, 40, 0.08),
|
||||
0px 8px 8px -4px rgba(16, 24, 40, 0.03);
|
||||
--shadow-datepicker: -5px 0 0 #262d3c, 5px 0 0 #262d3c;
|
||||
--shadow-focus-ring: 0px 0px 0px 4px rgba(70, 95, 255, 0.12);
|
||||
--shadow-slider-navigation: 0px 1px 2px 0px rgba(16, 24, 40, 0.1),
|
||||
0px 1px 3px 0px rgba(16, 24, 40, 0.1);
|
||||
--shadow-tooltip: 0px 4px 6px -2px rgba(16, 24, 40, 0.05),
|
||||
-8px 0px 20px 8px rgba(16, 24, 40, 0.05);
|
||||
|
||||
--drop-shadow-4xl: 0 35px 35px rgba(0, 0, 0, 0.25),
|
||||
0 45px 65px rgba(0, 0, 0, 0.15);
|
||||
|
||||
--z-index-1: 1;
|
||||
--z-index-9: 9;
|
||||
--z-index-99: 99;
|
||||
--z-index-999: 999;
|
||||
--z-index-9999: 9999;
|
||||
--z-index-99999: 99999;
|
||||
--z-index-999999: 999999;
|
||||
}
|
||||
|
||||
/* Custom styles that don't conflict with Tailwind */
|
||||
:root {
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
/*
|
||||
The default border color has changed to `currentColor` in Tailwind CSS v4,
|
||||
so we've added these compatibility styles to make sure everything still
|
||||
looks the same as it did with Tailwind CSS v3.
|
||||
|
||||
If we ever want to remove these styles, we need to add an explicit border
|
||||
color utility to any element that depends on these defaults.
|
||||
*/
|
||||
@layer base {
|
||||
|
||||
*,
|
||||
::after,
|
||||
::before,
|
||||
::backdrop,
|
||||
::file-selector-button {
|
||||
border-color: var(--color-gray-200, currentColor);
|
||||
}
|
||||
|
||||
button:not(:disabled),
|
||||
[role="button"]:not(:disabled) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply relative font-normal font-outfit z-1 bg-gray-50;
|
||||
}
|
||||
}
|
||||
|
||||
@utility menu-item {
|
||||
@apply relative flex items-center w-full gap-3 px-3 py-2 font-medium rounded-lg text-theme-sm;
|
||||
}
|
||||
|
||||
@utility menu-item-active {
|
||||
@apply bg-brand-50 text-brand-500 dark:bg-brand-500/[0.12] dark:text-brand-400;
|
||||
}
|
||||
|
||||
@utility menu-item-inactive {
|
||||
@apply text-gray-700 hover:bg-gray-100 group-hover:text-gray-700 dark:text-gray-300 dark:hover:bg-white/5 dark:hover:text-gray-300;
|
||||
}
|
||||
|
||||
@utility menu-item-icon {
|
||||
@apply text-gray-500 group-hover:text-gray-700 dark:text-gray-400;
|
||||
}
|
||||
|
||||
@utility menu-item-icon-active {
|
||||
@apply text-brand-500 dark:text-brand-400;
|
||||
}
|
||||
|
||||
@utility menu-item-icon-size {
|
||||
& svg {
|
||||
@apply !size-6;
|
||||
}
|
||||
}
|
||||
|
||||
@utility menu-item-icon-inactive {
|
||||
@apply text-gray-500 group-hover:text-gray-700 dark:text-gray-400 dark:group-hover:text-gray-300;
|
||||
}
|
||||
|
||||
@utility menu-item-arrow {
|
||||
@apply relative;
|
||||
}
|
||||
|
||||
@utility menu-item-arrow-active {
|
||||
@apply rotate-180 text-brand-500 dark:text-brand-400;
|
||||
}
|
||||
|
||||
@utility menu-item-arrow-inactive {
|
||||
@apply text-gray-500 group-hover:text-gray-700 dark:text-gray-400 dark:group-hover:text-gray-300;
|
||||
}
|
||||
|
||||
@utility menu-dropdown-item {
|
||||
@apply relative flex items-center gap-3 rounded-lg px-3 py-2.5 text-theme-sm font-medium;
|
||||
}
|
||||
|
||||
@utility menu-dropdown-item-active {
|
||||
@apply bg-brand-50 text-brand-500 dark:bg-brand-500/[0.12] dark:text-brand-400;
|
||||
}
|
||||
|
||||
@utility menu-dropdown-item-inactive {
|
||||
@apply text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-white/5;
|
||||
}
|
||||
|
||||
@utility menu-dropdown-badge {
|
||||
@apply block rounded-full px-2.5 py-0.5 text-xs font-medium uppercase text-brand-500 dark:text-brand-400;
|
||||
}
|
||||
|
||||
@utility menu-dropdown-badge-active {
|
||||
@apply bg-brand-100 dark:bg-brand-500/20;
|
||||
}
|
||||
|
||||
@utility menu-dropdown-badge-inactive {
|
||||
@apply bg-brand-50 group-hover:bg-brand-100 dark:bg-brand-500/15 dark:group-hover:bg-brand-500/20;
|
||||
}
|
||||
|
||||
@utility no-scrollbar {
|
||||
|
||||
/* Chrome, Safari and Opera */
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
-ms-overflow-style: none;
|
||||
/* IE and Edge */
|
||||
scrollbar-width: none;
|
||||
/* Firefox */
|
||||
}
|
||||
|
||||
@utility custom-scrollbar {
|
||||
&::-webkit-scrollbar {
|
||||
@apply size-1.5;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
@apply rounded-full;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
@apply bg-gray-200 rounded-full dark:bg-gray-700;
|
||||
}
|
||||
}
|
||||
|
||||
.dark .custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background-color: #344054;
|
||||
}
|
||||
@@ -226,8 +226,8 @@ export class AutoRecordingManager {
|
||||
private async startAutoRecording(cameraName: string, machineName: string): Promise<void> {
|
||||
try {
|
||||
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 })
|
||||
|
||||
if (result.success) {
|
||||
|
||||
@@ -136,12 +136,12 @@ export function getRecommendedVideoSettings(useCase: 'production' | 'storage-opt
|
||||
const settings = {
|
||||
production: {
|
||||
video_format: 'mp4',
|
||||
video_codec: 'mp4v',
|
||||
video_codec: 'h264',
|
||||
video_quality: 95,
|
||||
},
|
||||
'storage-optimized': {
|
||||
video_format: 'mp4',
|
||||
video_codec: 'mp4v',
|
||||
video_codec: 'h264',
|
||||
video_quality: 85,
|
||||
},
|
||||
legacy: {
|
||||
|
||||
@@ -5,7 +5,92 @@ export default {
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
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: [],
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user