feat: add Pagination component for video list navigation

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

View File

@@ -1,27 +1,32 @@
# API Changes Summary: Camera Settings and Video Format Updates
## 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,160 @@
/**
* Pagination Component
*
* A reusable pagination component that matches the dashboard template's styling patterns.
* Provides page navigation with first/last, previous/next, and numbered page buttons.
*/
import React from 'react';
import { type PaginationProps } from '../types';
export const Pagination: React.FC<PaginationProps> = ({
currentPage,
totalPages,
onPageChange,
showFirstLast = true,
showPrevNext = true,
maxVisiblePages = 5,
className = '',
}) => {
// Don't render if there's only one page or no pages
if (totalPages <= 1) {
return null;
}
// Calculate visible page numbers
const getVisiblePages = (): number[] => {
const pages: number[] = [];
const halfVisible = Math.floor(maxVisiblePages / 2);
let startPage = Math.max(1, currentPage - halfVisible);
let endPage = Math.min(totalPages, currentPage + halfVisible);
// Adjust if we're near the beginning or end
if (endPage - startPage + 1 < maxVisiblePages) {
if (startPage === 1) {
endPage = Math.min(totalPages, startPage + maxVisiblePages - 1);
} else {
startPage = Math.max(1, endPage - maxVisiblePages + 1);
}
}
for (let i = startPage; i <= endPage; i++) {
pages.push(i);
}
return pages;
};
const visiblePages = getVisiblePages();
const isFirstPage = currentPage === 1;
const isLastPage = currentPage === totalPages;
// Button base classes matching dashboard template
const baseButtonClasses = "inline-flex items-center justify-center px-3 py-2 text-sm font-medium transition rounded-lg border";
// Active page button classes
const activeButtonClasses = "bg-brand-500 text-white border-brand-500 hover:bg-brand-600 shadow-theme-xs";
// Inactive page button classes
const inactiveButtonClasses = "bg-white text-gray-700 border-gray-300 hover:bg-gray-50";
// Disabled button classes
const disabledButtonClasses = "bg-gray-100 text-gray-400 border-gray-200 cursor-not-allowed opacity-50";
const handlePageClick = (page: number) => {
if (page !== currentPage && page >= 1 && page <= totalPages) {
onPageChange(page);
}
};
return (
<div className={`flex items-center justify-center space-x-1 ${className}`}>
{/* First Page Button */}
{showFirstLast && !isFirstPage && (
<button
onClick={() => handlePageClick(1)}
className={`${baseButtonClasses} ${inactiveButtonClasses}`}
aria-label="Go to first page"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 19l-7-7 7-7m8 14l-7-7 7-7" />
</svg>
</button>
)}
{/* Previous Page Button */}
{showPrevNext && (
<button
onClick={() => handlePageClick(currentPage - 1)}
disabled={isFirstPage}
className={`${baseButtonClasses} ${isFirstPage ? disabledButtonClasses : inactiveButtonClasses}`}
aria-label="Go to previous page"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
</button>
)}
{/* Page Number Buttons */}
{visiblePages.map((page) => (
<button
key={page}
onClick={() => handlePageClick(page)}
className={`${baseButtonClasses} ${page === currentPage ? activeButtonClasses : inactiveButtonClasses
} min-w-[40px]`}
aria-label={`Go to page ${page}`}
aria-current={page === currentPage ? 'page' : undefined}
>
{page}
</button>
))}
{/* Next Page Button */}
{showPrevNext && (
<button
onClick={() => handlePageClick(currentPage + 1)}
disabled={isLastPage}
className={`${baseButtonClasses} ${isLastPage ? disabledButtonClasses : inactiveButtonClasses}`}
aria-label="Go to next page"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
)}
{/* Last Page Button */}
{showFirstLast && !isLastPage && (
<button
onClick={() => handlePageClick(totalPages)}
className={`${baseButtonClasses} ${inactiveButtonClasses}`}
aria-label="Go to last page"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 5l7 7-7 7M5 5l7 7-7 7" />
</svg>
</button>
)}
</div>
);
};
// Page info component to show current page and total
export const PageInfo: React.FC<{
currentPage: number;
totalPages: number;
totalItems: number;
itemsPerPage: number;
className?: string;
}> = ({ currentPage, totalPages, totalItems, itemsPerPage, className = '' }) => {
const startItem = (currentPage - 1) * itemsPerPage + 1;
const endItem = Math.min(currentPage * itemsPerPage, totalItems);
return (
<div className={`text-sm text-gray-600 ${className}`}>
Showing {startItem} to {endItem} of {totalItems} results (Page {currentPage} of {totalPages})
</div>
);
};

View File

@@ -33,8 +33,8 @@ export const VideoCard: React.FC<VideoCardProps> = ({
};
const cardClasses = [
'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" />

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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: [],
}