commit 0d71830cfb769c1df48c661283ee55a58132efb4 Author: Alihan Date: Sun Oct 12 02:22:12 2025 +0300 Initial commit: Drone Footage Manager with Video Compression - React frontend with video/image browser - Python FastAPI backend with video compression - Docker containerized setup - Video compression with FFmpeg (two-pass encoding) - Real-time job monitoring with SSE - Global active jobs monitor - Clickable header to reset navigation - Toast notifications for user feedback diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..ed4a461 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,13 @@ +{ + "permissions": { + "allow": [ + "Bash(docker compose:*)", + "mcp__playwright__browser_console_messages", + "mcp__playwright__browser_click", + "mcp__playwright__browser_evaluate", + "mcp__playwright__browser_navigate" + ], + "deny": [], + "ask": [] + } +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b591a1e --- /dev/null +++ b/.gitignore @@ -0,0 +1,54 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +env/ +venv/ +ENV/ +.venv + +# Node +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* +dist/ +build/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Environment +.env +.env.local +.env.development.local +.env.test.local +.env.production.local + +# Docker logs +*.log + +# Local sensitive data - drone footage +data/ +footage/ +videos/ +media/ + +# Database files +*.db +*.sqlite +*.sqlite3 + +# Temporary files +*.tmp +*.temp diff --git a/README.md b/README.md new file mode 100644 index 0000000..32c9428 --- /dev/null +++ b/README.md @@ -0,0 +1,190 @@ +# Drone Footage Manager + +A web application for browsing and viewing drone footage organized by location and date. + +## Features + +- 📁 **Hierarchical Navigation**: Browse footage by location → date → files +- 🎥 **Video Streaming**: Built-in HTML5 video player with seeking support +- 🖼️ **Image Viewer**: View drone photos directly in the browser +- 🐳 **Dockerized**: Fully containerized application with docker-compose +- 🔒 **Read-only Access**: Footage directory mounted as read-only for safety + +## Architecture + +- **Backend**: Python FastAPI server for file browsing and media streaming +- **Frontend**: React application with Tailwind CSS +- **Deployment**: Docker containers with nginx for frontend static files + +## Quick Start + +### Prerequisites + +- Docker +- Docker Compose + +### Running the Application + +1. **Start the containers:** + ```bash + docker-compose up -d + ``` + +2. **Access the application:** + - **From the host machine**: `http://localhost` or `http://127.0.0.1` + - **From local network**: `http://` (e.g., `http://192.168.1.100`) + - The backend API is available at port 8000 + + To find your server's IP address: + ```bash + # Linux/Mac + hostname -I | awk '{print $1}' + # or + ip addr show | grep "inet " | grep -v 127.0.0.1 + ``` + +3. **Stop the containers:** + ```bash + docker-compose down + ``` + +### Rebuilding After Changes + +If you make changes to the code, rebuild the containers: + +```bash +docker-compose up -d --build +``` + +## Project Structure + +``` +drone-footage-manager/ +├── backend/ +│ ├── main.py # FastAPI application +│ ├── requirements.txt # Python dependencies +│ ├── Dockerfile # Backend container definition +│ └── .dockerignore +├── frontend/ +│ ├── src/ +│ │ ├── App.jsx # Main React component +│ │ ├── main.jsx # React entry point +│ │ └── index.css # Tailwind styles +│ ├── index.html +│ ├── package.json # Node dependencies +│ ├── vite.config.js # Vite configuration +│ ├── tailwind.config.js # Tailwind configuration +│ ├── nginx.conf # Nginx configuration +│ ├── Dockerfile # Frontend container definition +│ └── .dockerignore +├── docker-compose.yml # Docker Compose configuration +└── README.md +``` + +## Configuration + +### Volume Mount + +The footage directory is mounted in `docker-compose.yml`: + +```yaml +volumes: + - /home/uad/nextcloud/footages:/footages:ro +``` + +To use a different directory, edit the `docker-compose.yml` file and update the path before the colon. + +### Ports + +- **Frontend**: Port 80 (accessible from local network) +- **Backend**: Port 8000 (accessible from local network) + +The application binds to `0.0.0.0`, making it accessible from: +- The host machine (localhost) +- Other devices on the local network (using server's IP) + +To change ports, edit the `ports` section in `docker-compose.yml`. + +### Network Access + +By default, the application is accessible from any device on your local network. If you cannot access it from other devices, check your firewall: + +```bash +# Allow ports 80 and 8000 through firewall (Ubuntu/Debian) +sudo ufw allow 80/tcp +sudo ufw allow 8000/tcp +``` + +## API Endpoints + +- `GET /api/locations` - List all location folders +- `GET /api/locations/{location}/dates` - List dates for a location +- `GET /api/files/{location}/{date}` - List files with metadata +- `GET /api/stream/{location}/{date}/{filename}` - Stream video files +- `GET /api/image/{location}/{date}/{filename}` - Serve image files + +## Development + +### Backend Development + +```bash +cd backend +python -m venv venv +source venv/bin/activate # On Windows: venv\Scripts\activate +pip install -r requirements.txt +uvicorn main:app --reload +``` + +### Frontend Development + +```bash +cd frontend +npm install +npm run dev +``` + +## Supported File Types + +**Videos**: `.mp4`, `.MP4`, `.mov`, `.MOV`, `.avi`, `.AVI` + +**Images**: `.jpg`, `.JPG`, `.jpeg`, `.JPEG`, `.png`, `.PNG` + +## Logs + +View container logs: + +```bash +# All services +docker-compose logs -f + +# Backend only +docker-compose logs -f backend + +# Frontend only +docker-compose logs -f frontend +``` + +## Troubleshooting + +### Videos not playing + +- Ensure the video codec is supported by your browser (H.264 recommended) +- Check browser console for errors +- Verify file permissions on the footage directory + +### Cannot access the application + +- Verify containers are running: `docker-compose ps` +- Check logs: `docker-compose logs` +- Ensure ports 80 and 8000 are not in use by other applications +- Check firewall settings: `sudo ufw status` +- Verify you're using the correct IP address: `hostname -I` + +### Changes not reflected + +- Rebuild containers: `docker-compose up -d --build` +- Clear browser cache + +## License + +MIT diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..c2641d7 --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,18 @@ +__pycache__ +*.pyc +*.pyo +*.pyd +.Python +env/ +venv/ +ENV/ +.venv +*.egg-info/ +dist/ +build/ +.pytest_cache/ +.coverage +htmlcov/ +.git +.gitignore +*.md diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..adcf4b4 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,21 @@ +FROM python:3.11-slim + +# Install ffmpeg +RUN apt-get update && \ + apt-get install -y ffmpeg && \ + rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +# Copy requirements and install dependencies +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copy application code +COPY . . + +# Expose port +EXPOSE 8000 + +# Run the application +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/backend/compression.py b/backend/compression.py new file mode 100644 index 0000000..58e7a11 --- /dev/null +++ b/backend/compression.py @@ -0,0 +1,287 @@ +import asyncio +import subprocess +import uuid +import os +import re +from pathlib import Path +from datetime import datetime +from typing import Dict, Optional + + +class CompressionJob: + def __init__(self, file_path: str, reduce_percentage: int): + self.job_id = str(uuid.uuid4()) + self.file_path = file_path + self.reduce_percentage = reduce_percentage + self.status = "pending" # pending, processing, validating, completed, failed, cancelled + self.progress = 0.0 + self.eta_seconds = None + self.current_pass = 0 # 0=not started, 1=first pass, 2=second pass + self.created_at = datetime.now() + self.started_at = None + self.completed_at = None + self.error = None + self.output_file = None + self.current_size_mb = None + self.target_size_mb = None + self.video_bitrate = None + self.duration_seconds = None + + +class CompressionManager: + def __init__(self): + self.jobs: Dict[str, CompressionJob] = {} + self.active_jobs: Dict[str, asyncio.Task] = {} + + async def get_video_info(self, file_path: str) -> Dict: + """Extract video duration and file size using ffprobe""" + # Get file size in MB + file_size_mb = os.path.getsize(file_path) / (1024 * 1024) + + # Get duration using ffprobe + cmd = [ + 'ffprobe', '-i', file_path, + '-show_entries', 'format=duration', + '-v', 'quiet', + '-of', 'csv=p=0' + ] + result = await asyncio.create_subprocess_exec( + *cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE + ) + stdout, _ = await result.communicate() + duration = int(float(stdout.decode().strip())) + + return { + 'size_mb': file_size_mb, + 'duration_seconds': duration + } + + def calculate_bitrates(self, current_size_mb: float, + target_size_mb: float, + duration_seconds: int, + audio_bitrate: int = 128) -> int: + """Calculate video bitrate based on target size""" + # Total bitrate in kbps + total_bitrate = (target_size_mb * 8192) / duration_seconds + # Video bitrate = total - audio + video_bitrate = int(total_bitrate - audio_bitrate) + return max(video_bitrate, 100) # Minimum 100kbps + + async def compress_video(self, job: CompressionJob): + """Main compression function - two-pass encoding""" + try: + job.status = "processing" + job.started_at = datetime.now() + + # Get video information + info = await self.get_video_info(job.file_path) + job.current_size_mb = info['size_mb'] + job.duration_seconds = info['duration_seconds'] + + # Calculate target size and bitrate + job.target_size_mb = job.current_size_mb * (1 - job.reduce_percentage / 100) + job.video_bitrate = self.calculate_bitrates( + job.current_size_mb, + job.target_size_mb, + job.duration_seconds + ) + + print(f"Job {job.job_id}:") + print(f" Current Size: {job.current_size_mb:.2f} MB") + print(f" Target Size: {job.target_size_mb:.2f} MB") + print(f" Duration: {job.duration_seconds}s") + print(f" Video Bitrate: {job.video_bitrate} kbps") + + # Generate output filename + file_path = Path(job.file_path) + temp_file = file_path.parent / f"temp_{file_path.name}" + output_file = file_path.parent / f"{file_path.stem}_compressed_{job.reduce_percentage}{file_path.suffix}" + + # PASS 1: Analysis + job.current_pass = 1 + await self.run_ffmpeg_pass1(job, temp_file) + + if job.status == "cancelled": + self.cleanup_temp_files(job) + return + + # PASS 2: Encoding + job.current_pass = 2 + await self.run_ffmpeg_pass2(job, temp_file) + + if job.status == "cancelled": + self.cleanup_temp_files(job) + return + + # VALIDATION + job.status = "validating" + job.progress = 95.0 + if await self.validate_video(temp_file): + # Move temp file to final output + os.rename(temp_file, output_file) + job.output_file = str(output_file) + job.status = "completed" + job.progress = 100.0 + job.completed_at = datetime.now() + self.cleanup_temp_files(job) + print(f"Job {job.job_id} completed successfully") + else: + job.status = "failed" + job.error = "Validation failed: Compressed video is corrupted" + self.cleanup_temp_files(job) + print(f"Job {job.job_id} failed validation") + + except asyncio.CancelledError: + job.status = "cancelled" + self.cleanup_temp_files(job) + print(f"Job {job.job_id} cancelled") + except Exception as e: + job.status = "failed" + job.error = str(e) + self.cleanup_temp_files(job) + print(f"Job {job.job_id} failed: {e}") + + async def run_ffmpeg_pass1(self, job: CompressionJob, output_file: Path): + """First pass: Analysis""" + cmd = [ + 'ffmpeg', '-y', + '-i', job.file_path, + '-c:v', 'libx264', + '-b:v', f'{job.video_bitrate}k', + '-pass', '1', + '-an', # No audio in first pass + '-f', 'null', + '/dev/null', + '-progress', 'pipe:1' + ] + + await self.run_ffmpeg_with_progress(job, cmd, pass_num=1) + + async def run_ffmpeg_pass2(self, job: CompressionJob, output_file: Path): + """Second pass: Encoding""" + cmd = [ + 'ffmpeg', + '-i', job.file_path, + '-c:v', 'libx264', + '-b:v', f'{job.video_bitrate}k', + '-pass', '2', + '-c:a', 'aac', + '-b:a', '128k', + '-movflags', '+faststart', # Important for web streaming + str(output_file), + '-progress', 'pipe:1' + ] + + await self.run_ffmpeg_with_progress(job, cmd, pass_num=2) + + async def run_ffmpeg_with_progress(self, job: CompressionJob, cmd: list, pass_num: int): + """Run ffmpeg and track progress""" + process = await asyncio.create_subprocess_exec( + *cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE + ) + + # Progress tracking + time_pattern = re.compile(r'out_time_ms=(\d+)') + fps_pattern = re.compile(r'fps=([\d.]+)') + + async for line in process.stdout: + if job.status == "cancelled": + process.kill() + raise asyncio.CancelledError() + + line_str = line.decode('utf-8', errors='ignore') + + # Extract time + time_match = time_pattern.search(line_str) + if time_match: + current_time_ms = int(time_match.group(1)) + current_time_sec = current_time_ms / 1_000_000 + + # Calculate progress for this pass (0-50% or 50-100%) + pass_progress = (current_time_sec / job.duration_seconds) * 50 + if pass_num == 1: + job.progress = min(pass_progress, 50) + else: + job.progress = min(50 + pass_progress, 95) + + # Extract FPS for ETA calculation + fps_match = fps_pattern.search(line_str) + if fps_match: + fps = float(fps_match.group(1)) + if fps > 0: + remaining_sec = (job.duration_seconds - current_time_sec) + if pass_num == 1: + remaining_sec = remaining_sec + job.duration_seconds # Account for both passes + job.eta_seconds = int(remaining_sec) + + await process.wait() + + if process.returncode != 0: + stderr = await process.stderr.read() + raise Exception(f"FFmpeg pass {pass_num} failed: {stderr.decode()}") + + async def validate_video(self, file_path: Path) -> bool: + """Validate compressed video is not corrupted""" + cmd = [ + 'ffmpeg', + '-v', 'error', + '-i', str(file_path), + '-f', 'null', + '-' + ] + + process = await asyncio.create_subprocess_exec( + *cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE + ) + + _, stderr = await process.communicate() + + # If there are errors in stderr, validation failed + return len(stderr) == 0 + + def cleanup_temp_files(self, job: CompressionJob): + """Clean up temporary files""" + file_path = Path(job.file_path) + temp_file = file_path.parent / f"temp_{file_path.name}" + + # Remove temp file + try: + if temp_file.exists(): + temp_file.unlink() + except Exception as e: + print(f"Failed to remove temp file: {e}") + + # Remove ffmpeg pass log files + try: + log_file = Path("ffmpeg2pass-0.log") + if log_file.exists(): + log_file.unlink() + except Exception as e: + print(f"Failed to remove log file: {e}") + + async def start_compression(self, file_path: str, reduce_percentage: int) -> str: + """Start a new compression job""" + job = CompressionJob(file_path, reduce_percentage) + self.jobs[job.job_id] = job + + # Start compression in background + task = asyncio.create_task(self.compress_video(job)) + self.active_jobs[job.job_id] = task + + # Clean up task when done + task.add_done_callback(lambda t: self.active_jobs.pop(job.job_id, None)) + + return job.job_id + + async def cancel_job(self, job_id: str): + """Cancel a running compression job""" + if job_id in self.jobs: + self.jobs[job_id].status = "cancelled" + if job_id in self.active_jobs: + self.active_jobs[job_id].cancel() diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..fbf0a35 --- /dev/null +++ b/backend/main.py @@ -0,0 +1,313 @@ +from fastapi import FastAPI, HTTPException, Request +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import FileResponse, StreamingResponse, Response +from pathlib import Path +from typing import List, Dict +from pydantic import BaseModel +import os +from datetime import datetime +import aiofiles +import asyncio +import json +from sse_starlette.sse import EventSourceResponse +from compression import CompressionManager + +app = FastAPI(title="Drone Footage Manager API") + +# Initialize compression manager +compression_manager = CompressionManager() + +# CORS middleware for frontend communication +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], # In production, specify your frontend domain + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# Base path for footages +FOOTAGES_PATH = Path("/footages") + +# Supported video and image extensions +VIDEO_EXTENSIONS = {".mp4", ".MP4", ".mov", ".MOV", ".avi", ".AVI"} +IMAGE_EXTENSIONS = {".jpg", ".JPG", ".jpeg", ".JPEG", ".png", ".PNG"} + + +def is_media_file(filename: str) -> bool: + """Check if file is a video or image""" + ext = Path(filename).suffix + return ext in VIDEO_EXTENSIONS or ext in IMAGE_EXTENSIONS + + +def get_file_info(file_path: Path) -> Dict: + """Get file metadata""" + stat = file_path.stat() + return { + "name": file_path.name, + "size": stat.st_size, + "modified": datetime.fromtimestamp(stat.st_mtime).isoformat(), + "is_video": file_path.suffix in VIDEO_EXTENSIONS, + "is_image": file_path.suffix in IMAGE_EXTENSIONS, + } + + +@app.get("/") +async def root(): + return {"message": "Drone Footage Manager API", "status": "running"} + + +@app.get("/api/locations") +async def get_locations() -> List[Dict]: + """Get list of all location folders with metadata""" + if not FOOTAGES_PATH.exists(): + raise HTTPException(status_code=500, detail="Footages directory not found") + + locations = [] + for item in FOOTAGES_PATH.iterdir(): + if item.is_dir(): + stat = item.stat() + locations.append({ + "name": item.name, + "modified": datetime.fromtimestamp(stat.st_mtime).isoformat() + }) + + return locations + + +@app.get("/api/locations/{location}/dates") +async def get_dates(location: str) -> List[Dict]: + """Get list of date folders for a location with metadata""" + location_path = FOOTAGES_PATH / location + + if not location_path.exists() or not location_path.is_dir(): + raise HTTPException(status_code=404, detail="Location not found") + + dates = [] + for item in location_path.iterdir(): + if item.is_dir(): + stat = item.stat() + dates.append({ + "name": item.name, + "modified": datetime.fromtimestamp(stat.st_mtime).isoformat() + }) + + return dates + + +@app.get("/api/files/{location}/{date}") +async def get_files(location: str, date: str) -> List[Dict]: + """Get list of files for a location and date""" + files_path = FOOTAGES_PATH / location / date + + if not files_path.exists() or not files_path.is_dir(): + raise HTTPException(status_code=404, detail="Path not found") + + files = [] + for item in sorted(files_path.iterdir()): + if item.is_file() and is_media_file(item.name): + files.append(get_file_info(item)) + + return files + + +@app.get("/api/stream/{location}/{date}/{filename}") +async def stream_video(location: str, date: str, filename: str, request: Request): + """Stream video file with HTTP range request support for fast seeking""" + file_path = FOOTAGES_PATH / location / date / filename + + if not file_path.exists() or not file_path.is_file(): + raise HTTPException(status_code=404, detail="File not found") + + # Check if it's a video file + if file_path.suffix not in VIDEO_EXTENSIONS: + raise HTTPException(status_code=400, detail="Not a video file") + + # Get file size + file_size = file_path.stat().st_size + + # Parse range header + range_header = request.headers.get("range") + + if range_header: + # Parse range header (e.g., "bytes=0-1023") + range_match = range_header.replace("bytes=", "").split("-") + start = int(range_match[0]) if range_match[0] else 0 + end = int(range_match[1]) if range_match[1] else file_size - 1 + end = min(end, file_size - 1) + + # Calculate content length + content_length = end - start + 1 + + # Create streaming response + async def iterfile(): + async with aiofiles.open(file_path, mode='rb') as f: + await f.seek(start) + remaining = content_length + chunk_size = 1024 * 1024 # 1MB chunks + + while remaining > 0: + chunk = await f.read(min(chunk_size, remaining)) + if not chunk: + break + remaining -= len(chunk) + yield chunk + + headers = { + "Content-Range": f"bytes {start}-{end}/{file_size}", + "Accept-Ranges": "bytes", + "Content-Length": str(content_length), + "Content-Type": "video/mp4", + } + + return StreamingResponse( + iterfile(), + status_code=206, + headers=headers, + media_type="video/mp4" + ) + + # No range header - return full file + return FileResponse( + file_path, + media_type="video/mp4", + headers={"Accept-Ranges": "bytes"} + ) + + +@app.get("/api/image/{location}/{date}/{filename}") +async def get_image(location: str, date: str, filename: str): + """Serve image file""" + file_path = FOOTAGES_PATH / location / date / filename + + if not file_path.exists() or not file_path.is_file(): + raise HTTPException(status_code=404, detail="File not found") + + # Check if it's an image file + if file_path.suffix not in IMAGE_EXTENSIONS: + raise HTTPException(status_code=400, detail="Not an image file") + + # Determine media type + media_type = "image/jpeg" if file_path.suffix.lower() in {".jpg", ".jpeg"} else "image/png" + + return FileResponse(file_path, media_type=media_type) + + +# ========== COMPRESSION API ENDPOINTS ========== + +class CompressionRequest(BaseModel): + location: str + date: str + filename: str + reduce_percentage: int + + +@app.post("/api/compress/start") +async def start_compression(request: CompressionRequest): + """Start a compression job""" + if not 1 <= request.reduce_percentage <= 90: + raise HTTPException(status_code=400, detail="Percentage must be between 1-90") + + file_path = FOOTAGES_PATH / request.location / request.date / request.filename + + if not file_path.exists(): + raise HTTPException(status_code=404, detail="File not found") + + if file_path.suffix not in VIDEO_EXTENSIONS: + raise HTTPException(status_code=400, detail="File is not a video") + + job_id = await compression_manager.start_compression(str(file_path), request.reduce_percentage) + + return {"job_id": job_id, "status": "started"} + + +@app.get("/api/compress/jobs") +async def get_all_jobs(): + """Get all compression jobs""" + jobs = [] + for job in compression_manager.jobs.values(): + jobs.append({ + "job_id": job.job_id, + "file_path": job.file_path, + "file_name": Path(job.file_path).name, + "reduce_percentage": job.reduce_percentage, + "status": job.status, + "progress": round(job.progress, 1), + "eta_seconds": job.eta_seconds, + "current_pass": job.current_pass, + "current_size_mb": round(job.current_size_mb, 2) if job.current_size_mb else None, + "target_size_mb": round(job.target_size_mb, 2) if job.target_size_mb else None, + "video_bitrate": job.video_bitrate, + "created_at": job.created_at.isoformat() if job.created_at else None, + "output_file": Path(job.output_file).name if job.output_file else None, + "error": job.error + }) + return jobs + + +@app.get("/api/compress/jobs/{job_id}") +async def get_job_status(job_id: str): + """Get status of specific compression job""" + if job_id not in compression_manager.jobs: + raise HTTPException(status_code=404, detail="Job not found") + + job = compression_manager.jobs[job_id] + return { + "job_id": job.job_id, + "status": job.status, + "progress": round(job.progress, 1), + "eta_seconds": job.eta_seconds, + "current_pass": job.current_pass, + "output_file": Path(job.output_file).name if job.output_file else None, + "error": job.error + } + + +@app.delete("/api/compress/jobs/{job_id}") +async def cancel_job(job_id: str): + """Cancel a compression job""" + if job_id not in compression_manager.jobs: + raise HTTPException(status_code=404, detail="Job not found") + + await compression_manager.cancel_job(job_id) + return {"status": "cancelled"} + + +@app.get("/api/compress/events") +async def compression_events(request: Request): + """Server-Sent Events endpoint for real-time progress updates""" + async def event_generator(): + try: + while True: + # Check if client is still connected + if await request.is_disconnected(): + break + + # Send status of all active jobs + active_jobs = [] + for job in compression_manager.jobs.values(): + if job.status in ["pending", "processing", "validating"]: + active_jobs.append({ + "job_id": job.job_id, + "status": job.status, + "progress": round(job.progress, 1), + "eta_seconds": job.eta_seconds, + "current_pass": job.current_pass + }) + + if active_jobs: + yield { + "event": "progress", + "data": json.dumps(active_jobs) + } + + await asyncio.sleep(0.5) # Update every 500ms + except asyncio.CancelledError: + pass + + return EventSourceResponse(event_generator()) + + +if __name__ == "__main__": + import uvicorn + uvicorn.run(app, host="0.0.0.0", port=8000) diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..3bb44dc --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,5 @@ +fastapi==0.109.0 +uvicorn[standard]==0.27.0 +python-multipart==0.0.6 +aiofiles==23.2.1 +sse-starlette==1.6.5 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..f8c6c8a --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,32 @@ +version: '3.8' + +services: + backend: + build: + context: ./backend + dockerfile: Dockerfile + container_name: drone-footage-backend + volumes: + - /home/uad/nextcloud/footages:/footages:ro + ports: + - "8000:8000" + restart: unless-stopped + networks: + - drone-footage-network + + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + container_name: drone-footage-frontend + ports: + - "9999:80" + depends_on: + - backend + restart: unless-stopped + networks: + - drone-footage-network + +networks: + drone-footage-network: + driver: bridge diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..6930945 --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,14 @@ +node_modules +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.DS_Store +.env.local +.env.development.local +.env.test.local +.env.production.local +dist +build +.git +.gitignore +*.md diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..30f5811 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,30 @@ +# Build stage +FROM node:20-alpine as builder + +WORKDIR /app + +# Copy package files +COPY package.json package-lock.json* ./ + +# Install dependencies +RUN npm install + +# Copy source code +COPY . . + +# Build the application +RUN npm run build + +# Production stage +FROM nginx:alpine + +# Copy built assets from builder stage +COPY --from=builder /app/dist /usr/share/nginx/html + +# Copy nginx configuration +COPY nginx.conf /etc/nginx/conf.d/default.conf + +# Expose port +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..02f1b29 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + Drone Footage Manager + + +
+ + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..edfb5bd --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,29 @@ +server { + listen 80; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + # Serve static files + location / { + try_files $uri $uri/ /index.html; + } + + # Proxy API requests to backend + location /api/ { + proxy_pass http://backend:8000/api/; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_cache_bypass $http_upgrade; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # Important for video streaming + proxy_buffering off; + proxy_request_buffering off; + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..f5f8361 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,24 @@ +{ + "name": "drone-footage-manager-frontend", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "devDependencies": { + "@types/react": "^18.2.43", + "@types/react-dom": "^18.2.17", + "@vitejs/plugin-react": "^4.2.1", + "autoprefixer": "^10.4.16", + "postcss": "^8.4.32", + "tailwindcss": "^3.4.0", + "vite": "^5.0.8" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/frontend/src/ActiveJobsMonitor.jsx b/frontend/src/ActiveJobsMonitor.jsx new file mode 100644 index 0000000..6cb1d20 --- /dev/null +++ b/frontend/src/ActiveJobsMonitor.jsx @@ -0,0 +1,152 @@ +import { useState, useEffect, useRef } from 'react' + +function ActiveJobsMonitor() { + const [jobs, setJobs] = useState([]) + const eventSourceRef = useRef(null) + + const API_URL = import.meta.env.VITE_API_URL || '/api' + + useEffect(() => { + // Fetch initial jobs + fetchJobs() + + // Connect to SSE for real-time updates + connectSSE() + + // Refresh jobs every 5 seconds as backup + const interval = setInterval(fetchJobs, 5000) + + return () => { + if (eventSourceRef.current) { + eventSourceRef.current.close() + } + clearInterval(interval) + } + }, []) + + const connectSSE = () => { + const eventSource = new EventSource(`${API_URL}/compress/events`) + + eventSource.addEventListener('progress', (event) => { + const updates = JSON.parse(event.data) + setJobs(prevJobs => { + const updatedJobs = [...prevJobs] + updates.forEach(update => { + const index = updatedJobs.findIndex(j => j.job_id === update.job_id) + if (index !== -1) { + updatedJobs[index] = { ...updatedJobs[index], ...update } + } + }) + return updatedJobs + }) + }) + + eventSource.onerror = () => { + console.error('SSE connection error') + eventSource.close() + setTimeout(connectSSE, 5000) // Reconnect after 5s + } + + eventSourceRef.current = eventSource + } + + const fetchJobs = async () => { + try { + const response = await fetch(`${API_URL}/compress/jobs`) + const data = await response.json() + setJobs(data) + } catch (error) { + console.error('Failed to fetch jobs:', error) + } + } + + const cancelJob = async (jobId) => { + try { + await fetch(`${API_URL}/compress/jobs/${jobId}`, { method: 'DELETE' }) + await fetchJobs() + } catch (error) { + console.error('Failed to cancel job:', error) + } + } + + const formatETA = (seconds) => { + if (!seconds) return '--' + const mins = Math.floor(seconds / 60) + const secs = seconds % 60 + return `${mins}m ${secs}s` + } + + const formatFileSize = (mb) => { + if (!mb) return '--' + return `${mb.toFixed(2)} MB` + } + + const activeJobs = jobs.filter(j => ['pending', 'processing', 'validating'].includes(j.status)) + + // Don't show the component if there are no active jobs + if (activeJobs.length === 0) { + return null + } + + return ( +
+
+

+ + Active Compression Jobs ({activeJobs.length}) +

+
+ +
+ {activeJobs.map(job => ( +
+
+
+

{job.file_name}

+

+ {job.current_size_mb && `${formatFileSize(job.current_size_mb)} → ${formatFileSize(job.target_size_mb)}`} + {` (${job.reduce_percentage}% reduction)`} +

+
+ + {job.status === 'validating' ? '✓ Validating...' : + job.status === 'pending' ? '⏳ Pending' : + `🎬 Pass ${job.current_pass}/2`} + + {job.video_bitrate && Bitrate: {job.video_bitrate}k} +
+
+ +
+ + {/* Progress Bar */} +
+
+ {job.progress > 10 && ( + {job.progress.toFixed(1)}% + )} +
+
+ +
+ {job.progress.toFixed(1)}% complete + + {job.status === 'validating' ? '✨ Almost done...' : `⏱ ETA: ${formatETA(job.eta_seconds)}`} + +
+
+ ))} +
+
+ ) +} + +export default ActiveJobsMonitor diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx new file mode 100644 index 0000000..6e61a0e --- /dev/null +++ b/frontend/src/App.jsx @@ -0,0 +1,489 @@ +import { useState, useEffect, useRef } from 'react' +import ActiveJobsMonitor from './ActiveJobsMonitor' + +function App() { + const [locations, setLocations] = useState([]) + const [selectedLocation, setSelectedLocation] = useState(null) + const [dates, setDates] = useState([]) + const [selectedDate, setSelectedDate] = useState(null) + const [files, setFiles] = useState([]) + const [selectedFile, setSelectedFile] = useState(null) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [percentage, setPercentage] = useState(30) + const [compressing, setCompressing] = useState(false) + const [toast, setToast] = useState(null) + + // Sorting states + const [locationSort, setLocationSort] = useState('modified') // 'name' or 'modified' + const [dateSort, setDateSort] = useState('modified') // 'name' or 'modified' + const [fileSort, setFileSort] = useState('modified') // 'name' or 'modified' + + // Video ref for play/pause control + const videoRef = useRef(null) + + const API_URL = import.meta.env.VITE_API_URL || '/api' + + // Fetch locations on mount + useEffect(() => { + fetchLocations() + }, []) + + // Fetch dates when location changes + useEffect(() => { + if (selectedLocation) { + fetchDates(selectedLocation) + } + }, [selectedLocation]) + + // Fetch files when date changes + useEffect(() => { + if (selectedLocation && selectedDate) { + fetchFiles(selectedLocation, selectedDate) + } + }, [selectedLocation, selectedDate]) + + // Keyboard event listener for spacebar play/pause + useEffect(() => { + const handleKeyPress = (e) => { + // Only handle spacebar if we're not focused on an interactive element + // and the video player is not focused + if (e.code === 'Space' && !e.repeat) { + const target = e.target + const tagName = target.tagName + + // Ignore if focused on input elements, buttons, or the video element itself + if (tagName === 'INPUT' || + tagName === 'TEXTAREA' || + tagName === 'BUTTON' || + tagName === 'VIDEO') { + return + } + + // Only handle if we have a video loaded + if (videoRef.current && selectedFile?.is_video) { + e.preventDefault() // Prevent page scroll + e.stopPropagation() // Prevent event bubbling + + if (videoRef.current.paused) { + videoRef.current.play() + } else { + videoRef.current.pause() + } + } + } + } + + window.addEventListener('keydown', handleKeyPress) + return () => window.removeEventListener('keydown', handleKeyPress) + }, [selectedFile]) + + const fetchLocations = async () => { + setLoading(true) + setError(null) + try { + const response = await fetch(`${API_URL}/locations`) + if (!response.ok) throw new Error('Failed to fetch locations') + const data = await response.json() + setLocations(data) + } catch (err) { + setError(err.message) + } finally { + setLoading(false) + } + } + + const fetchDates = async (location) => { + setLoading(true) + setError(null) + setSelectedDate(null) + setFiles([]) + setSelectedFile(null) + try { + const response = await fetch(`${API_URL}/locations/${encodeURIComponent(location)}/dates`) + if (!response.ok) throw new Error('Failed to fetch dates') + const data = await response.json() + setDates(data) + } catch (err) { + setError(err.message) + } finally { + setLoading(false) + } + } + + const fetchFiles = async (location, date) => { + setLoading(true) + setError(null) + setSelectedFile(null) + try { + const response = await fetch(`${API_URL}/files/${encodeURIComponent(location)}/${encodeURIComponent(date)}`) + if (!response.ok) throw new Error('Failed to fetch files') + const data = await response.json() + setFiles(data) + } catch (err) { + setError(err.message) + } finally { + setLoading(false) + } + } + + const handleLocationClick = (location) => { + setSelectedLocation(location) + } + + const handleDateClick = (date) => { + setSelectedDate(date) + } + + const handleFileClick = (file) => { + setSelectedFile(file) + } + + // Sorting functions + const sortItems = (items, sortBy) => { + if (!items || items.length === 0) return items + + const sorted = [...items] + if (sortBy === 'name') { + sorted.sort((a, b) => { + const nameA = (a.name || a).toLowerCase() + const nameB = (b.name || b).toLowerCase() + return nameA.localeCompare(nameB) + }) + } else if (sortBy === 'modified') { + sorted.sort((a, b) => { + const dateA = new Date(a.modified || 0) + const dateB = new Date(b.modified || 0) + return dateB - dateA // Most recent first + }) + } + return sorted + } + + const getSortedLocations = () => sortItems(locations, locationSort) + const getSortedDates = () => sortItems(dates, dateSort) + const getSortedFiles = () => sortItems(files, fileSort) + + const formatFileSize = (bytes) => { + if (bytes === 0) return '0 Bytes' + const k = 1024 + const sizes = ['Bytes', 'KB', 'MB', 'GB'] + const i = Math.floor(Math.log(bytes) / Math.log(k)) + return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i] + } + + const getMediaUrl = (file) => { + const endpoint = file.is_video ? 'stream' : 'image' + return `${API_URL}/${endpoint}/${encodeURIComponent(selectedLocation)}/${encodeURIComponent(selectedDate)}/${encodeURIComponent(file.name)}` + } + + const handleResetToMain = () => { + setSelectedLocation(null) + setSelectedDate(null) + setSelectedFile(null) + setDates([]) + setFiles([]) + } + + const showToast = (message, type = 'success') => { + setToast({ message, type }) + setTimeout(() => setToast(null), 3000) + } + + const startCompression = async () => { + if (!selectedFile || !selectedFile.is_video) return + + setCompressing(true) + try { + const response = await fetch(`${API_URL}/compress/start`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + location: selectedLocation, + date: selectedDate, + filename: selectedFile.name, + reduce_percentage: percentage + }) + }) + + if (response.ok) { + showToast('Compression job started!', 'success') + } else { + const error = await response.json() + showToast(`Failed: ${error.detail}`, 'error') + } + } catch (error) { + showToast(`Error: ${error.message}`, 'error') + } finally { + setCompressing(false) + } + } + + return ( +
+ {/* Header */} +
+
+

+ Drone Footage Manager +

+
+
+ + {/* Main Content */} +
+ {error && ( +
+ {error} +
+ )} + + {/* Active Jobs Monitor - Always visible when there are active jobs */} + + + {/* Media Viewer */} + {selectedFile && ( +
+

{selectedFile.name}

+
+ {selectedFile.is_video ? ( + + ) : ( + {selectedFile.name} + )} +
+
+

Size: {formatFileSize(selectedFile.size)}

+

Modified: {new Date(selectedFile.modified).toLocaleString()}

+
+
+ )} + + {/* Compression Controls - Only show for videos */} + {selectedFile && selectedFile.is_video && ( +
+

Video Compression

+
+ + setPercentage(Number(e.target.value))} + className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer" + style={{ + background: `linear-gradient(to right, #3b82f6 0%, #3b82f6 ${(percentage - 10) / 0.8}%, #e5e7eb ${(percentage - 10) / 0.8}%, #e5e7eb 100%)` + }} + /> +
+ 10% + 50% + 90% +
+ +
+

File: {selectedFile.name}

+

Size: {formatFileSize(selectedFile.size)}

+
+ + +
+
+ )} + +
+ {/* Locations Panel */} +
+
+

Locations

+
+ + +
+
+ {loading && !locations.length ? ( +

Loading...

+ ) : ( +
    + {getSortedLocations().map((location) => { + const locationName = location.name || location + return ( +
  • + +
  • + ) + })} +
+ )} +
+ + {/* Dates Panel */} +
+
+

Dates

+
+ + +
+
+ {!selectedLocation ? ( +

Select a location

+ ) : loading && !dates.length ? ( +

Loading...

+ ) : ( +
    + {getSortedDates().map((date) => { + const dateName = date.name || date + return ( +
  • + +
  • + ) + })} +
+ )} +
+ + {/* Files Panel */} +
+
+

Files

+
+ + +
+
+ {!selectedDate ? ( +

Select a date

+ ) : loading && !files.length ? ( +

Loading...

+ ) : files.length === 0 ? ( +

No media files found

+ ) : ( +
    + {getSortedFiles().map((file, index) => ( +
  • + +
  • + ))} +
+ )} +
+
+
+ + {/* Toast Notification */} + {toast && ( +
+
+

{toast.message}

+
+
+ )} +
+ ) +} + +export default App diff --git a/frontend/src/CompressionPanel.jsx b/frontend/src/CompressionPanel.jsx new file mode 100644 index 0000000..0822907 --- /dev/null +++ b/frontend/src/CompressionPanel.jsx @@ -0,0 +1,278 @@ +import { useState, useEffect, useRef } from 'react' + +function CompressionPanel({ selectedFile, location, date }) { + const [percentage, setPercentage] = useState(30) + const [jobs, setJobs] = useState([]) + const [loading, setLoading] = useState(false) + const eventSourceRef = useRef(null) + + const API_URL = import.meta.env.VITE_API_URL || '/api' + + useEffect(() => { + // Fetch initial jobs + fetchJobs() + + // Connect to SSE for real-time updates + connectSSE() + + return () => { + if (eventSourceRef.current) { + eventSourceRef.current.close() + } + } + }, []) + + const connectSSE = () => { + const eventSource = new EventSource(`${API_URL}/compress/events`) + + eventSource.addEventListener('progress', (event) => { + const updates = JSON.parse(event.data) + setJobs(prevJobs => { + const updatedJobs = [...prevJobs] + updates.forEach(update => { + const index = updatedJobs.findIndex(j => j.job_id === update.job_id) + if (index !== -1) { + updatedJobs[index] = { ...updatedJobs[index], ...update } + } + }) + return updatedJobs + }) + }) + + eventSource.onerror = () => { + console.error('SSE connection error') + eventSource.close() + setTimeout(connectSSE, 5000) // Reconnect after 5s + } + + eventSourceRef.current = eventSource + } + + const fetchJobs = async () => { + try { + const response = await fetch(`${API_URL}/compress/jobs`) + const data = await response.json() + setJobs(data) + } catch (error) { + console.error('Failed to fetch jobs:', error) + } + } + + const startCompression = async () => { + if (!selectedFile || !selectedFile.is_video) return + + setLoading(true) + try { + const response = await fetch(`${API_URL}/compress/start`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + location, + date, + filename: selectedFile.name, + reduce_percentage: percentage + }) + }) + + if (response.ok) { + await fetchJobs() + } else { + const error = await response.json() + alert(`Failed to start compression: ${error.detail}`) + } + } catch (error) { + alert(`Error: ${error.message}`) + } finally { + setLoading(false) + } + } + + const cancelJob = async (jobId) => { + try { + await fetch(`${API_URL}/compress/jobs/${jobId}`, { method: 'DELETE' }) + await fetchJobs() + } catch (error) { + console.error('Failed to cancel job:', error) + } + } + + const formatETA = (seconds) => { + if (!seconds) return '--' + const mins = Math.floor(seconds / 60) + const secs = seconds % 60 + return `${mins}m ${secs}s` + } + + const formatFileSize = (mb) => { + if (!mb) return '--' + return `${mb.toFixed(2)} MB` + } + + const activeJobs = jobs.filter(j => ['pending', 'processing', 'validating'].includes(j.status)) + const completedJobs = jobs.filter(j => j.status === 'completed').slice(0, 5) + const failedJobs = jobs.filter(j => j.status === 'failed').slice(0, 3) + + return ( +
+

Video Compression

+ + {/* Compression Controls */} +
+ + setPercentage(Number(e.target.value))} + className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer" + style={{ + background: `linear-gradient(to right, #3b82f6 0%, #3b82f6 ${(percentage - 10) / 0.8}%, #e5e7eb ${(percentage - 10) / 0.8}%, #e5e7eb 100%)` + }} + /> +
+ 10% + 50% + 90% +
+ + {selectedFile && selectedFile.is_video && ( +
+

File: {selectedFile.name}

+

Size: {formatFileSize(selectedFile.size / (1024 * 1024))}

+
+ )} + + + + {!selectedFile?.is_video && ( +

Select a video file to enable compression

+ )} +
+ + {/* Active Jobs */} + {activeJobs.length > 0 && ( +
+

Active Jobs

+
+ {activeJobs.map(job => ( +
+
+
+

{job.file_name}

+

+ {job.current_size_mb && `${formatFileSize(job.current_size_mb)} → ${formatFileSize(job.target_size_mb)}`} + {` (${job.reduce_percentage}% reduction)`} +

+

+ {job.status === 'validating' ? 'Validating video...' : `Pass ${job.current_pass}/2`} + {job.video_bitrate && ` | Bitrate: ${job.video_bitrate}k`} +

+
+ +
+ + {/* Progress Bar */} +
+
+ {job.progress > 15 && ( + {job.progress}% + )} +
+
+ +
+ {job.progress.toFixed(1)}% complete + + {job.status === 'validating' ? 'Almost done...' : `ETA: ${formatETA(job.eta_seconds)}`} + +
+
+ ))} +
+
+ )} + + {/* Completed Jobs */} + {completedJobs.length > 0 && ( +
+

Completed

+
+ {completedJobs.map(job => ( +
+
+
+

{job.file_name}

+

+ {formatFileSize(job.current_size_mb)} → {formatFileSize(job.target_size_mb)} + {` (saved ${formatFileSize(job.current_size_mb - job.target_size_mb)})`} +

+ {job.output_file && ( +

Output: {job.output_file}

+ )} +
+
+ + + Download + +
+
+
+ ))} +
+
+ )} + + {/* Failed Jobs */} + {failedJobs.length > 0 && ( +
+

Failed

+
+ {failedJobs.map(job => ( +
+

{job.file_name}

+

{job.error}

+
+ ))} +
+
+ )} + + {/* Empty State */} + {activeJobs.length === 0 && completedJobs.length === 0 && failedJobs.length === 0 && ( +

No compression jobs yet

+ )} +
+ ) +} + +export default CompressionPanel diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..634141e --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,12 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +body { + margin: 0; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', + 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', + sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx new file mode 100644 index 0000000..5cc5991 --- /dev/null +++ b/frontend/src/main.jsx @@ -0,0 +1,10 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import App from './App' +import './index.css' + +ReactDOM.createRoot(document.getElementById('root')).render( + + + , +) diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js new file mode 100644 index 0000000..dca8ba0 --- /dev/null +++ b/frontend/tailwind.config.js @@ -0,0 +1,11 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: [ + "./index.html", + "./src/**/*.{js,ts,jsx,tsx}", + ], + theme: { + extend: {}, + }, + plugins: [], +} diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..42e2117 --- /dev/null +++ b/frontend/vite.config.js @@ -0,0 +1,16 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [react()], + server: { + host: '0.0.0.0', + port: 9999, + proxy: { + '/api': { + target: 'http://backend:8000', + changeOrigin: true, + } + } + } +})