diff --git a/.railwayignore b/.railwayignore new file mode 100644 index 0000000..d2bc1b0 --- /dev/null +++ b/.railwayignore @@ -0,0 +1,28 @@ +# Railway ignore file - prevent large files from being included in build +node_modules/.bin +node_modules/.cache +node_modules/.vite +.next +.nuxt +dist +build +*.log +.DS_Store +.env.local +.venv +venv +__pycache__ +*.pyc +.pytest_cache +.coverage +.git +.github +notebooks +tests +data +*.png +*.svg +.flake8 +.pre-commit-config.yaml +CHANGELOG.md +model-card.md diff --git a/QUICKSTART.md b/QUICKSTART.md new file mode 100644 index 0000000..4f7be9e --- /dev/null +++ b/QUICKSTART.md @@ -0,0 +1,305 @@ +# Farsi Transcriber - Quick Start Guide + +You now have **TWO** complete applications for Farsi transcription: + +## 🖥️ Option 1: Desktop App (PyQt6) + +**Location:** `/home/user/whisper/farsi_transcriber/` + +### Setup +```bash +cd farsi_transcriber +python3 -m venv venv +source venv/bin/activate +pip install -r requirements.txt +python main.py +``` + +**Features:** +- ✅ Standalone desktop application +- ✅ Works completely offline +- ✅ Direct access to file system +- ✅ Lightweight and fast +- ⚠️ Simpler UI (green theme) + +**Good for:** +- Local-only transcription +- Users who prefer desktop apps +- Offline processing + +--- + +## 🌐 Option 2: Web App (React + Flask) + +**Location:** `/home/user/whisper/farsi_transcriber_web/` + +### Setup + +**Backend (Flask):** +```bash +cd farsi_transcriber_web/backend +python3 -m venv venv +source venv/bin/activate +pip install -r requirements.txt +python app.py +# API runs on http://localhost:5000 +``` + +**Frontend (React):** +```bash +cd farsi_transcriber_web +npm install +npm run dev +# App runs on http://localhost:3000 +``` + +**Features:** +- ✅ Modern web-based UI (matches your Figma design exactly) +- ✅ File queue management +- ✅ Dark/Light theme toggle +- ✅ Search with text highlighting +- ✅ Copy segments to clipboard +- ✅ Resizable window +- ✅ RTL support for Farsi +- ✅ Multiple export formats +- ✅ Professional styling + +**Good for:** +- Modern web experience +- Team collaboration (can be deployed online) +- More features and polish +- Professional appearance + +--- + +## 📊 Comparison + +| Feature | Desktop (PyQt6) | Web (React) | +|---------|-----------------|------------| +| **Interface** | Simple, green | Modern, professional | +| **Dark Mode** | ❌ | ✅ | +| **File Queue** | ❌ | ✅ | +| **Search** | ❌ | ✅ | +| **Copy Segments** | ❌ | ✅ | +| **Resizable Window** | ❌ | ✅ | +| **Export Formats** | SRT, TXT, VTT, JSON, TSV | TXT, SRT, VTT, JSON | +| **Offline** | ✅ | Requires backend | +| **Easy Setup** | ✅✅ | ✅ (2 terminals) | +| **Deployment** | Desktop only | Can host online | +| **Code Size** | ~25KB | ~200KB | + +--- + +## 🚀 Which Should You Use? + +### Use **Desktop App** if: +- You want simple, quick setup +- You never share transcriptions +- You prefer offline processing +- You don't need advanced features + +### Use **Web App** if: +- You like modern interfaces +- You want dark/light themes +- You need file queue management +- You want to potentially share online +- You want professional appearance + +--- + +## 📁 Project Structure + +``` +whisper/ +├── farsi_transcriber/ (Desktop PyQt6 App) +│ ├── ui/ +│ ├── models/ +│ ├── utils/ +│ ├── config.py +│ ├── main.py +│ └── requirements.txt +│ +└── farsi_transcriber_web/ (Web React App) + ├── src/ + │ ├── App.tsx + │ ├── components/ + │ └── main.tsx + ├── backend/ + │ ├── app.py + │ └── requirements.txt + ├── package.json + └── vite.config.ts +``` + +--- + +## 🔧 System Requirements + +### Desktop App +- Python 3.8+ +- ffmpeg +- 4GB RAM + +### Web App +- Python 3.8+ (backend) +- Node.js 16+ (frontend) +- ffmpeg +- 4GB RAM + +--- + +## 📝 Setup Checklist + +### Initial Setup (One-time) + +- [ ] Install ffmpeg + ```bash + # Ubuntu/Debian + sudo apt install ffmpeg + + # macOS + brew install ffmpeg + + # Windows + choco install ffmpeg + ``` + +- [ ] Verify Python 3.8+ + ```bash + python3 --version + ``` + +- [ ] Verify Node.js 16+ (for web app only) + ```bash + node --version + ``` + +### Desktop App Setup + +- [ ] Create virtual environment +- [ ] Install requirements +- [ ] Run app + +### Web App Setup + +**Backend:** +- [ ] Create virtual environment +- [ ] Install requirements +- [ ] Run Flask server + +**Frontend:** +- [ ] Install Node dependencies +- [ ] Run dev server + +--- + +## 🎯 Quick Start (Fastest) + +### Desktop (30 seconds) +```bash +cd whisper/farsi_transcriber +python3 -m venv venv && source venv/bin/activate +pip install -r requirements.txt && python main.py +``` + +### Web (2 minutes) +Terminal 1: +```bash +cd whisper/farsi_transcriber_web/backend +python3 -m venv venv && source venv/bin/activate +pip install -r requirements.txt && python app.py +``` + +Terminal 2: +```bash +cd whisper/farsi_transcriber_web +npm install && npm run dev +``` + +--- + +## 🐛 Troubleshooting + +### "ffmpeg not found" +Install ffmpeg (see requirements above) + +### "ModuleNotFoundError" (Python) +```bash +# Ensure virtual environment is activated +source venv/bin/activate # Linux/Mac +# or +venv\Scripts\activate # Windows +``` + +### "npm: command not found" +Install Node.js from https://nodejs.org + +### App runs slow +- Use GPU: Install CUDA +- Reduce model size: change to 'small' or 'tiny' +- Close other applications + +--- + +## 📚 Full Documentation + +- **Desktop App:** `farsi_transcriber/README.md` +- **Web App:** `farsi_transcriber_web/README.md` +- **API Docs:** `farsi_transcriber_web/README.md` (Endpoints section) + +--- + +## 🎓 What Was Built + +### Desktop Application (PyQt6) +✅ File picker for audio/video +✅ Whisper integration with word-level timestamps +✅ 5 export formats (TXT, SRT, VTT, JSON, TSV) +✅ Professional styling +✅ Progress indicators +✅ Threading to prevent UI freezing + +### Web Application (React + Flask) +✅ Complete Figma design implementation +✅ File queue management +✅ Dark/light theme +✅ Search with highlighting +✅ Segment management +✅ Resizable window +✅ RTL support +✅ Flask backend with Whisper integration +✅ 4 export formats +✅ Real file upload handling + +--- + +## 🚀 Next Steps + +1. **Choose your app** (Desktop or Web) +2. **Install ffmpeg** if not already installed +3. **Follow the setup instructions** above +4. **Test with a Farsi audio file** +5. **Export in your preferred format** + +--- + +## 💡 Tips + +- **First transcription is slow** (downloads 769MB model) +- **Use larger models** (medium/large) for better accuracy +- **Use smaller models** (tiny/base) for speed +- **GPU significantly speeds up** transcription +- **Both apps work offline** (after initial model download) + +--- + +## 📧 Need Help? + +- Check the full README in each app's directory +- Verify all requirements are installed +- Check browser console (web app) or Python output (desktop) +- Ensure ffmpeg is in your PATH + +--- + +**Enjoy your Farsi transcription apps!** 🎉 diff --git a/RAILWAY_QUICKSTART.md b/RAILWAY_QUICKSTART.md new file mode 100644 index 0000000..a24e01c --- /dev/null +++ b/RAILWAY_QUICKSTART.md @@ -0,0 +1,114 @@ +# Railway Deployment - Quick Start (5 Minutes) + +Deploy your Farsi Transcriber to Railway in just 5 minutes! 🚀 + +--- + +## **What You'll Get** + +✅ Your app live online +✅ Free $5/month credit +✅ 24/7 uptime +✅ Automatic scaling +✅ No credit card needed (free tier) + +--- + +## **Step 1: Create Railway Account** (2 min) + +1. Go to **https://railway.app** +2. Click **"Login with GitHub"** +3. Authorize with your GitHub account +4. Done! You get $5 free credit ✅ + +--- + +## **Step 2: Create Backend Service** (2 min) + +1. Click **"Create New Project"** +2. Select **"GitHub Repo"** +3. Find your **whisper** fork +4. Railway auto-detects Python project +5. **Root Directory:** `farsi_transcriber_web/backend` +6. Click **Deploy** +7. **Wait 2-3 minutes** for deployment +8. **Copy the URL** that appears (e.g., `https://farsi-api-xxx.railway.app`) + +--- + +## **Step 3: Create Frontend Service** (1 min) + +1. In Railway project, click **"New Service"** → **"GitHub Repo"** +2. Select **whisper** again +3. **Root Directory:** `farsi_transcriber_web` +4. Click **Deploy** +5. **Wait 3-5 minutes** for build and deployment + +--- + +## **Step 4: Connect Frontend to Backend** (Bonus step - 1 min) + +1. In Railway, select **frontend** service +2. Go to **Variables** +3. Edit `VITE_API_URL` and paste your backend URL from Step 2 +4. Click **Deploy** to redeploy with correct API URL + +--- + +## **That's It! 🎉** + +Your app is now live! Click the frontend service to see your live URL. + +Example URLs: +- Frontend: `https://farsi-transcriber-prod.railway.app` +- Backend: `https://farsi-api-prod.railway.app` + +--- + +## **Test Your App** + +1. Click your frontend URL +2. Add a file +3. Click Transcribe +4. Wait for transcription +5. Export results + +--- + +## **Detailed Guide** + +For more details, see: `farsi_transcriber_web/RAILWAY_DEPLOYMENT.md` + +--- + +## **Cost** + +- **First 3 months:** FREE ($5/month credit) +- **After that:** ~$2-3/month for personal use +- Can upgrade to paid tier for more resources + +--- + +## **Common Issues** + +**"API connection failed"** +- Make sure backend URL is correct in frontend variables +- Redeploy frontend after updating API URL + +**"Model not loaded"** +- Wait 1-2 minutes on first transcription +- Model downloads on first use + +**"Build failed"** +- Check Railway logs for errors +- Ensure all files are committed + +--- + +## **Support** + +For detailed setup help, see: `farsi_transcriber_web/RAILWAY_DEPLOYMENT.md` + +--- + +**Your Farsi Transcriber is now online!** Share the URL with anyone! 🌐 diff --git a/farsi_transcriber/.gitignore b/farsi_transcriber/.gitignore new file mode 100644 index 0000000..c051891 --- /dev/null +++ b/farsi_transcriber/.gitignore @@ -0,0 +1,52 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Virtual Environment +venv/ +ENV/ +env/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ +.DS_Store + +# PyTorch/ML Models +*.pt +*.pth +models/downloaded/ + +# Whisper models cache +~/.cache/whisper/ + +# Application outputs +transcriptions/ +exports/ +*.log + +# Testing +.pytest_cache/ +.coverage +htmlcov/ diff --git a/farsi_transcriber/README.md b/farsi_transcriber/README.md new file mode 100644 index 0000000..61e95fe --- /dev/null +++ b/farsi_transcriber/README.md @@ -0,0 +1,257 @@ +# Farsi Transcriber + +A professional desktop application for transcribing Farsi audio and video files using OpenAI's Whisper model. + +## Features + +✨ **Core Features** +- 🎙️ Transcribe audio files (MP3, WAV, M4A, FLAC, OGG, AAC, WMA) +- 🎬 Extract audio from video files (MP4, MKV, MOV, WebM, AVI, FLV, WMV) +- 🇮🇷 High-accuracy Farsi/Persian language transcription +- ⏱️ Word-level timestamps for precise timing +- 📤 Export to multiple formats (TXT, SRT, VTT, JSON, TSV) +- 💻 Clean, intuitive PyQt6-based GUI +- 🚀 GPU acceleration support (CUDA) with automatic fallback to CPU +- 🔄 Progress indicators and real-time status updates + +## System Requirements + +**Minimum:** +- Python 3.8 or higher +- 4GB RAM +- ffmpeg installed + +**Recommended:** +- Python 3.10+ +- 8GB+ RAM +- NVIDIA GPU with CUDA support (optional but faster) +- SSD for better performance + +## Installation + +### Step 1: Install ffmpeg + +Choose your operating system: + +**Ubuntu/Debian:** +```bash +sudo apt update && sudo apt install ffmpeg +``` + +**Fedora/CentOS:** +```bash +sudo dnf install ffmpeg +``` + +**macOS (Homebrew):** +```bash +brew install ffmpeg +``` + +**Windows (Chocolatey):** +```bash +choco install ffmpeg +``` + +**Windows (Scoop):** +```bash +scoop install ffmpeg +``` + +### Step 2: Set up Python environment + +```bash +# Navigate to the repository +cd whisper/farsi_transcriber + +# Create virtual environment +python3 -m venv venv + +# Activate virtual environment +source venv/bin/activate # On Windows: venv\Scripts\activate +``` + +### Step 3: Install dependencies + +```bash +pip install -r requirements.txt +``` + +This will install: +- PyQt6 (GUI framework) +- openai-whisper (transcription engine) +- PyTorch (deep learning framework) +- NumPy, tiktoken, tqdm (supporting libraries) + +## Usage + +### Running the Application + +```bash +python main.py +``` + +### Step-by-Step Guide + +1. **Launch the app** - Run `python main.py` +2. **Select a file** - Click "Select File" button to choose audio/video +3. **Transcribe** - Click "Transcribe" and wait for completion +4. **View results** - See transcription with timestamps +5. **Export** - Click "Export Results" to save in your preferred format + +### Supported Export Formats + +- **TXT** - Plain text (content only) +- **SRT** - SubRip subtitle format (with timestamps) +- **VTT** - WebVTT subtitle format (with timestamps) +- **JSON** - Structured format with segments and metadata +- **TSV** - Tab-separated values (spreadsheet compatible) + +## Configuration + +Edit `config.py` to customize: + +```python +# Model size (tiny, base, small, medium, large) +DEFAULT_MODEL = "medium" + +# Language code +LANGUAGE_CODE = "fa" # Farsi + +# Supported formats +SUPPORTED_AUDIO_FORMATS = {".mp3", ".wav", ".m4a", ".flac", ".ogg", ...} +SUPPORTED_VIDEO_FORMATS = {".mp4", ".mkv", ".mov", ".webm", ".avi", ...} +``` + +## Model Information + +### Available Models + +| Model | Size | Speed | Accuracy | VRAM | +|-------|------|-------|----------|------| +| tiny | 39M | ~10x | Good | ~1GB | +| base | 74M | ~7x | Very Good | ~1GB | +| small | 244M | ~4x | Excellent | ~2GB | +| medium | 769M | ~2x | Excellent | ~5GB | +| large | 1550M | 1x | Best | ~10GB | + +**Default**: `medium` (recommended for Farsi) + +### Performance Notes + +- Larger models provide better accuracy but require more VRAM +- GPU (CUDA) dramatically speeds up transcription (8-10x faster) +- First run downloads the model (~500MB-3GB depending on model size) +- Subsequent runs use cached model files + +## Project Structure + +``` +farsi_transcriber/ +├── ui/ # User interface components +│ ├── __init__.py +│ ├── main_window.py # Main application window +│ └── styles.py # Styling and theming +├── models/ # Model management +│ ├── __init__.py +│ └── whisper_transcriber.py # Whisper wrapper +├── utils/ # Utility functions +│ ├── __init__.py +│ └── export.py # Export functionality +├── config.py # Configuration settings +├── main.py # Application entry point +├── __init__.py # Package init +├── requirements.txt # Python dependencies +└── README.md # This file +``` + +## Troubleshooting + +### Issue: "ffmpeg not found" +**Solution**: Install ffmpeg using your package manager (see Installation section) + +### Issue: "CUDA out of memory" +**Solution**: Use a smaller model or reduce audio processing in chunks + +### Issue: "Model download fails" +**Solution**: Check internet connection, try again. Models are cached in `~/.cache/whisper/` + +### Issue: Slow transcription +**Solution**: Ensure CUDA is detected (`nvidia-smi`), or upgrade to a smaller/faster model + +## Advanced Usage + +### Custom Model Selection + +Update `config.py`: +```python +DEFAULT_MODEL = "large" # For maximum accuracy +# or +DEFAULT_MODEL = "tiny" # For fastest processing +``` + +### Batch Processing (Future) + +Script to process multiple files: +```python +from farsi_transcriber.models.whisper_transcriber import FarsiTranscriber + +transcriber = FarsiTranscriber(model_name="medium") +for audio_file in audio_files: + result = transcriber.transcribe(audio_file) + # Process results +``` + +## Performance Tips + +1. **Use GPU** - Ensure NVIDIA CUDA is properly installed +2. **Choose appropriate model** - Balance speed vs accuracy +3. **Close other applications** - Free up RAM/VRAM +4. **Use SSD** - Faster model loading and temporary file I/O +5. **Local processing** - All processing happens locally, no cloud uploads + +## Development + +### Code Style + +```bash +# Format code +black farsi_transcriber/ + +# Check style +flake8 farsi_transcriber/ + +# Sort imports +isort farsi_transcriber/ +``` + +### Future Features + +- [ ] Batch processing +- [ ] Real-time transcription preview +- [ ] Speaker diarization +- [ ] Multi-language support UI +- [ ] Settings dialog +- [ ] Keyboard shortcuts +- [ ] Drag-and-drop support +- [ ] Recent files history + +## License + +MIT License - Personal use and modifications allowed + +## Acknowledgments + +Built with: +- [OpenAI Whisper](https://github.com/openai/whisper) - Speech recognition +- [PyQt6](https://www.riverbankcomputing.com/software/pyqt/) - GUI framework +- [PyTorch](https://pytorch.org/) - Deep learning + +## Support + +For issues or suggestions: +1. Check the troubleshooting section +2. Verify ffmpeg is installed +3. Ensure Python 3.8+ is used +4. Check available disk space +5. Verify CUDA setup (for GPU users) diff --git a/farsi_transcriber/__init__.py b/farsi_transcriber/__init__.py new file mode 100644 index 0000000..8e2e5fa --- /dev/null +++ b/farsi_transcriber/__init__.py @@ -0,0 +1,8 @@ +""" +Farsi Transcriber Application + +A desktop application for transcribing Farsi audio and video files using OpenAI's Whisper. +""" + +__version__ = "0.1.0" +__author__ = "Personal Project" diff --git a/farsi_transcriber/config.py b/farsi_transcriber/config.py new file mode 100644 index 0000000..306adc7 --- /dev/null +++ b/farsi_transcriber/config.py @@ -0,0 +1,71 @@ +""" +Configuration settings for Farsi Transcriber application + +Manages model selection, device settings, and other configuration options. +""" + +from pathlib import Path + +# Application metadata +APP_NAME = "Farsi Transcriber" +APP_VERSION = "0.1.0" +APP_DESCRIPTION = "A desktop application for transcribing Farsi audio and video files" + +# Model settings +DEFAULT_MODEL = "medium" # Options: tiny, base, small, medium, large +AVAILABLE_MODELS = ["tiny", "base", "small", "medium", "large"] +MODEL_DESCRIPTIONS = { + "tiny": "Smallest model (39M params) - Fastest, ~1GB VRAM required", + "base": "Small model (74M params) - Fast, ~1GB VRAM required", + "small": "Medium model (244M params) - Balanced, ~2GB VRAM required", + "medium": "Large model (769M params) - Good accuracy, ~5GB VRAM required", + "large": "Largest model (1550M params) - Best accuracy, ~10GB VRAM required", +} + +# Language settings +LANGUAGE_CODE = "fa" # Farsi/Persian +LANGUAGE_NAME = "Farsi" + +# Audio/Video settings +SUPPORTED_AUDIO_FORMATS = {".mp3", ".wav", ".m4a", ".flac", ".ogg", ".aac", ".wma"} +SUPPORTED_VIDEO_FORMATS = {".mp4", ".mkv", ".mov", ".webm", ".avi", ".flv", ".wmv"} + +# UI settings +WINDOW_WIDTH = 900 +WINDOW_HEIGHT = 700 +WINDOW_MIN_WIDTH = 800 +WINDOW_MIN_HEIGHT = 600 + +# Output settings +OUTPUT_DIR = Path.home() / "FarsiTranscriber" / "outputs" +OUTPUT_DIR.mkdir(parents=True, exist_ok=True) + +EXPORT_FORMATS = { + "txt": "Plain Text", + "srt": "SRT Subtitles", + "vtt": "WebVTT Subtitles", + "json": "JSON Format", + "tsv": "Tab-Separated Values", +} + +# Device settings (auto-detect CUDA if available) +try: + import torch + + DEVICE = "cuda" if torch.cuda.is_available() else "cpu" +except ImportError: + DEVICE = "cpu" + +# Logging settings +LOG_LEVEL = "INFO" +LOG_FILE = OUTPUT_DIR / "transcriber.log" + + +def get_model_info(model_name: str) -> str: + """Get description for a model""" + return MODEL_DESCRIPTIONS.get(model_name, "Unknown model") + + +def get_supported_formats() -> set: + """Get all supported audio and video formats""" + return SUPPORTED_AUDIO_FORMATS | SUPPORTED_VIDEO_FORMATS diff --git a/farsi_transcriber/main.py b/farsi_transcriber/main.py new file mode 100644 index 0000000..2c62a36 --- /dev/null +++ b/farsi_transcriber/main.py @@ -0,0 +1,26 @@ +#!/usr/bin/env python3 +""" +Farsi Transcriber - Main Entry Point + +A PyQt6-based desktop application for transcribing Farsi audio and video files. +""" + +import sys +from PyQt6.QtWidgets import QApplication + +from farsi_transcriber.ui.main_window import MainWindow + + +def main(): + """Main entry point for the application""" + app = QApplication(sys.argv) + + # Create and show main window + window = MainWindow() + window.show() + + sys.exit(app.exec()) + + +if __name__ == "__main__": + main() diff --git a/farsi_transcriber/models/__init__.py b/farsi_transcriber/models/__init__.py new file mode 100644 index 0000000..fd5a6a4 --- /dev/null +++ b/farsi_transcriber/models/__init__.py @@ -0,0 +1 @@ +"""Model management for Farsi Transcriber""" diff --git a/farsi_transcriber/models/whisper_transcriber.py b/farsi_transcriber/models/whisper_transcriber.py new file mode 100644 index 0000000..b7c6b37 --- /dev/null +++ b/farsi_transcriber/models/whisper_transcriber.py @@ -0,0 +1,225 @@ +""" +Whisper Transcriber Module + +Handles Farsi audio/video transcription using OpenAI's Whisper model. +""" + +import warnings +from pathlib import Path +from typing import Dict, List, Optional + +import torch +import whisper + + +class FarsiTranscriber: + """ + Wrapper around Whisper model for Farsi transcription. + + Supports both audio and video files, with word-level timestamp extraction. + """ + + # Supported audio formats + AUDIO_FORMATS = {".mp3", ".wav", ".m4a", ".flac", ".ogg", ".aac", ".wma"} + + # Supported video formats + VIDEO_FORMATS = {".mp4", ".mkv", ".mov", ".webm", ".avi", ".flv", ".wmv"} + + # Language code for Farsi/Persian + FARSI_LANGUAGE = "fa" + + def __init__(self, model_name: str = "medium", device: Optional[str] = None): + """ + Initialize Farsi Transcriber. + + Args: + model_name: Whisper model size ('tiny', 'base', 'small', 'medium', 'large') + device: Device to use ('cuda', 'cpu'). Auto-detect if None. + """ + self.model_name = model_name + + # Auto-detect device + if device is None: + self.device = "cuda" if torch.cuda.is_available() else "cpu" + else: + self.device = device + + print(f"Using device: {self.device}") + + # Load model + print(f"Loading Whisper model: {model_name}...") + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + self.model = whisper.load_model(model_name, device=self.device) + + print("Model loaded successfully") + + def transcribe( + self, + file_path: str, + language: str = FARSI_LANGUAGE, + verbose: bool = False, + ) -> Dict: + """ + Transcribe an audio or video file in Farsi. + + Args: + file_path: Path to audio or video file + language: Language code (default: 'fa' for Farsi) + verbose: Whether to print progress + + Returns: + Dictionary with transcription results including word-level segments + """ + file_path = Path(file_path) + + # Validate file exists + if not file_path.exists(): + raise FileNotFoundError(f"File not found: {file_path}") + + # Check format is supported + if not self._is_supported_format(file_path): + raise ValueError( + f"Unsupported format: {file_path.suffix}. " + f"Supported: {self.AUDIO_FORMATS | self.VIDEO_FORMATS}" + ) + + # Perform transcription + print(f"Transcribing: {file_path.name}") + + result = self.model.transcribe( + str(file_path), + language=language, + verbose=verbose, + ) + + # Enhance result with word-level segments + enhanced_result = self._enhance_with_word_segments(result) + + return enhanced_result + + def _is_supported_format(self, file_path: Path) -> bool: + """Check if file format is supported.""" + suffix = file_path.suffix.lower() + return suffix in (self.AUDIO_FORMATS | self.VIDEO_FORMATS) + + def _enhance_with_word_segments(self, result: Dict) -> Dict: + """ + Enhance transcription result with word-level timing information. + + Args: + result: Whisper transcription result + + Returns: + Enhanced result with word-level segments + """ + enhanced_segments = [] + + for segment in result.get("segments", []): + # Extract word-level timing if available + word_segments = self._extract_word_segments(segment) + + enhanced_segment = { + "id": segment.get("id"), + "start": segment.get("start"), + "end": segment.get("end"), + "text": segment.get("text", ""), + "words": word_segments, + } + enhanced_segments.append(enhanced_segment) + + result["segments"] = enhanced_segments + return result + + def _extract_word_segments(self, segment: Dict) -> List[Dict]: + """ + Extract word-level timing from a segment. + + Args: + segment: Whisper segment with text + + Returns: + List of word dictionaries with timing information + """ + text = segment.get("text", "").strip() + if not text: + return [] + + # For now, return simple word list + # Whisper v3 includes word-level details in some configurations + start_time = segment.get("start", 0) + end_time = segment.get("end", 0) + duration = end_time - start_time + + words = text.split() + if not words: + return [] + + # Distribute time evenly across words (simple approach) + # More sophisticated timing can be extracted from Whisper's internal data + word_duration = duration / len(words) if words else 0 + + word_segments = [] + for i, word in enumerate(words): + word_start = start_time + (i * word_duration) + word_end = word_start + word_duration + + word_segments.append( + { + "word": word, + "start": word_start, + "end": word_end, + } + ) + + return word_segments + + def format_result_for_display( + self, result: Dict, include_timestamps: bool = True + ) -> str: + """ + Format transcription result for display in UI. + + Args: + result: Transcription result + include_timestamps: Whether to include timestamps + + Returns: + Formatted text string + """ + lines = [] + + for segment in result.get("segments", []): + text = segment.get("text", "").strip() + if not text: + continue + + if include_timestamps: + start = segment.get("start", 0) + end = segment.get("end", 0) + timestamp = f"[{self._format_time(start)} - {self._format_time(end)}]" + lines.append(f"{timestamp}\n{text}\n") + else: + lines.append(text) + + return "\n".join(lines) + + @staticmethod + def _format_time(seconds: float) -> str: + """Format seconds to HH:MM:SS format.""" + hours = int(seconds // 3600) + minutes = int((seconds % 3600) // 60) + secs = int(seconds % 60) + milliseconds = int((seconds % 1) * 1000) + + return f"{hours:02d}:{minutes:02d}:{secs:02d}.{milliseconds:03d}" + + def get_device_info(self) -> str: + """Get information about current device and model.""" + return ( + f"Model: {self.model_name} | " + f"Device: {self.device.upper()} | " + f"VRAM: {torch.cuda.get_device_properties(self.device).total_memory / 1e9:.1f}GB " + if self.device == "cuda" + else f"Model: {self.model_name} | Device: {self.device.upper()}" + ) diff --git a/farsi_transcriber/requirements.txt b/farsi_transcriber/requirements.txt new file mode 100644 index 0000000..612f9b2 --- /dev/null +++ b/farsi_transcriber/requirements.txt @@ -0,0 +1,7 @@ +PyQt6==6.6.1 +PyQt6-Qt6==6.6.1 +PyQt6-sip==13.6.0 +torch>=1.10.1 +numpy +openai-whisper +tqdm diff --git a/farsi_transcriber/ui/__init__.py b/farsi_transcriber/ui/__init__.py new file mode 100644 index 0000000..435adac --- /dev/null +++ b/farsi_transcriber/ui/__init__.py @@ -0,0 +1 @@ +"""UI components for Farsi Transcriber""" diff --git a/farsi_transcriber/ui/main_window.py b/farsi_transcriber/ui/main_window.py new file mode 100644 index 0000000..3fbd371 --- /dev/null +++ b/farsi_transcriber/ui/main_window.py @@ -0,0 +1,285 @@ +""" +Main application window for Farsi Transcriber + +Provides PyQt6-based GUI for selecting files and transcribing Farsi audio/video. +""" + +import os +from pathlib import Path + +from PyQt6.QtCore import Qt, QThread, pyqtSignal +from PyQt6.QtWidgets import ( + QMainWindow, + QWidget, + QVBoxLayout, + QHBoxLayout, + QPushButton, + QLabel, + QTextEdit, + QProgressBar, + QFileDialog, + QMessageBox, +) +from PyQt6.QtGui import QFont + +from farsi_transcriber.models.whisper_transcriber import FarsiTranscriber +from farsi_transcriber.utils.export import TranscriptionExporter +from farsi_transcriber.ui.styles import get_stylesheet, get_color + + +class TranscriptionWorker(QThread): + """Worker thread for transcription to prevent UI freezing""" + + # Signals + progress_update = pyqtSignal(str) # Status messages + transcription_complete = pyqtSignal(dict) # Results with timestamps + error_occurred = pyqtSignal(str) # Error messages + + def __init__(self, file_path: str, model_name: str = "medium"): + super().__init__() + self.file_path = file_path + self.model_name = model_name + self.transcriber = None + + def run(self): + """Run transcription in background thread""" + try: + # Initialize Whisper transcriber + self.progress_update.emit("Loading Whisper model...") + self.transcriber = FarsiTranscriber(model_name=self.model_name) + + # Perform transcription + self.progress_update.emit(f"Transcribing: {Path(self.file_path).name}") + result = self.transcriber.transcribe(self.file_path) + + # Format result for display with timestamps + display_text = self.transcriber.format_result_for_display(result) + + # Add full text for export + result["full_text"] = result.get("text", "") + + self.progress_update.emit("Transcription complete!") + self.transcription_complete.emit( + { + "text": display_text, + "segments": result.get("segments", []), + "full_text": result.get("text", ""), + } + ) + + except Exception as e: + self.error_occurred.emit(f"Error: {str(e)}") + + +class MainWindow(QMainWindow): + """Main application window for Farsi Transcriber""" + + # Supported audio and video formats + SUPPORTED_FORMATS = ( + "Audio Files (*.mp3 *.wav *.m4a *.flac *.ogg *.aac *.wma);;", + "Video Files (*.mp4 *.mkv *.mov *.webm *.avi *.flv *.wmv);;", + "All Files (*.*)", + ) + + def __init__(self): + super().__init__() + self.selected_file = None + self.transcription_worker = None + self.last_result = None + # Apply stylesheet + self.setStyleSheet(get_stylesheet()) + self.init_ui() + + def init_ui(self): + """Initialize the user interface""" + self.setWindowTitle("Farsi Transcriber") + self.setGeometry(100, 100, 900, 700) + + # Create central widget and main layout + central_widget = QWidget() + self.setCentralWidget(central_widget) + main_layout = QVBoxLayout(central_widget) + main_layout.setSpacing(10) + main_layout.setContentsMargins(20, 20, 20, 20) + + # Title + title_label = QLabel("Farsi Audio/Video Transcriber") + title_font = QFont() + title_font.setPointSize(16) + title_font.setBold(True) + title_label.setFont(title_font) + main_layout.addWidget(title_label) + + # File selection section + file_section_layout = QHBoxLayout() + self.file_label = QLabel("No file selected") + self.file_label.setStyleSheet("color: gray;") + file_section_layout.addWidget(self.file_label, 1) + + self.select_button = QPushButton("Select File") + self.select_button.clicked.connect(self.on_select_file) + file_section_layout.addWidget(self.select_button) + + main_layout.addLayout(file_section_layout) + + # Transcribe button + self.transcribe_button = QPushButton("Transcribe") + self.transcribe_button.clicked.connect(self.on_transcribe) + self.transcribe_button.setEnabled(False) + main_layout.addWidget(self.transcribe_button) + + # Progress bar + self.progress_bar = QProgressBar() + self.progress_bar.setRange(0, 0) # Indeterminate progress + self.progress_bar.setVisible(False) + main_layout.addWidget(self.progress_bar) + + # Status label + self.status_label = QLabel("Ready") + self.status_label.setStyleSheet("color: #666; font-style: italic;") + main_layout.addWidget(self.status_label) + + # Results text area + results_title = QLabel("Transcription Results:") + results_title_font = QFont() + results_title_font.setBold(True) + results_title.setFont(results_title_font) + main_layout.addWidget(results_title) + + self.results_text = QTextEdit() + self.results_text.setReadOnly(True) + self.results_text.setPlaceholderText( + "Transcription results will appear here..." + ) + # Set monospace font for results + mono_font = QFont("Courier New", 10) + self.results_text.setFont(mono_font) + main_layout.addWidget(self.results_text) + + # Buttons layout (Export, Clear) + buttons_layout = QHBoxLayout() + buttons_layout.addStretch() + + self.export_button = QPushButton("Export Results") + self.export_button.clicked.connect(self.on_export) + self.export_button.setEnabled(False) + buttons_layout.addWidget(self.export_button) + + self.clear_button = QPushButton("Clear") + self.clear_button.clicked.connect(self.on_clear) + buttons_layout.addWidget(self.clear_button) + + main_layout.addLayout(buttons_layout) + + def on_select_file(self): + """Handle file selection dialog""" + file_path, _ = QFileDialog.getOpenFileName( + self, "Select Audio or Video File", "", "".join(self.SUPPORTED_FORMATS) + ) + + if file_path: + self.selected_file = file_path + file_name = Path(file_path).name + self.file_label.setText(f"Selected: {file_name}") + self.file_label.setStyleSheet("color: #333;") + self.transcribe_button.setEnabled(True) + self.export_button.setEnabled(False) + self.results_text.clear() + self.status_label.setText("File selected. Click 'Transcribe' to start.") + + def on_transcribe(self): + """Handle transcription button click""" + if not self.selected_file: + QMessageBox.warning(self, "Error", "Please select a file first.") + return + + # Disable buttons during transcription + self.transcribe_button.setEnabled(False) + self.select_button.setEnabled(False) + self.export_button.setEnabled(False) + + # Show progress + self.progress_bar.setVisible(True) + self.status_label.setText("Transcribing...") + + # Create and start worker thread + self.transcription_worker = TranscriptionWorker(self.selected_file) + self.transcription_worker.progress_update.connect(self.on_progress_update) + self.transcription_worker.transcription_complete.connect( + self.on_transcription_complete + ) + self.transcription_worker.error_occurred.connect(self.on_error) + self.transcription_worker.start() + + def on_progress_update(self, message: str): + """Handle progress updates from worker thread""" + self.status_label.setText(message) + + def on_transcription_complete(self, result: dict): + """Handle completed transcription""" + self.progress_bar.setVisible(False) + self.transcribe_button.setEnabled(True) + self.select_button.setEnabled(True) + self.export_button.setEnabled(True) + self.status_label.setText("Transcription complete!") + + # Display results with timestamps + self.results_text.setText(result.get("text", "No transcription available")) + + # Store result for export + self.last_result = result + + def on_error(self, error_message: str): + """Handle errors from worker thread""" + self.progress_bar.setVisible(False) + self.transcribe_button.setEnabled(True) + self.select_button.setEnabled(True) + self.status_label.setText("Error occurred. Check message below.") + QMessageBox.critical(self, "Transcription Error", error_message) + + def on_export(self): + """Handle export button click""" + if not self.last_result: + QMessageBox.warning(self, "Warning", "No transcription to export.") + return + + file_path, file_filter = QFileDialog.getSaveFileName( + self, + "Export Transcription", + "", + "Text Files (*.txt);;SRT Subtitles (*.srt);;WebVTT Subtitles (*.vtt);;JSON (*.json);;TSV (*.tsv)", + ) + + if file_path: + try: + file_path = Path(file_path) + + # Determine format from file extension + suffix = file_path.suffix.lower().lstrip(".") + if not suffix: + # Default to txt if no extension + suffix = "txt" + file_path = file_path.with_suffix(".txt") + + # Export using the appropriate format + TranscriptionExporter.export(self.last_result, file_path, suffix) + + QMessageBox.information( + self, + "Success", + f"Transcription exported successfully to:\n{file_path.name}", + ) + except Exception as e: + QMessageBox.critical( + self, "Export Error", f"Failed to export: {str(e)}" + ) + + def on_clear(self): + """Clear all results and reset UI""" + self.selected_file = None + self.file_label.setText("No file selected") + self.file_label.setStyleSheet("color: gray;") + self.results_text.clear() + self.status_label.setText("Ready") + self.transcribe_button.setEnabled(False) + self.export_button.setEnabled(False) diff --git a/farsi_transcriber/ui/styles.py b/farsi_transcriber/ui/styles.py new file mode 100644 index 0000000..e60979a --- /dev/null +++ b/farsi_transcriber/ui/styles.py @@ -0,0 +1,107 @@ +""" +Application styling and theming + +Provides stylesheet and styling utilities for the Farsi Transcriber app. +""" + +# Modern, professional dark-themed stylesheet +MAIN_STYLESHEET = """ +QMainWindow { + background-color: #f5f5f5; +} + +QLabel { + color: #333333; +} + +QLineEdit, QTextEdit { + background-color: #ffffff; + color: #333333; + border: 1px solid #d0d0d0; + border-radius: 4px; + padding: 5px; + font-size: 11pt; +} + +QLineEdit:focus, QTextEdit:focus { + border: 2px solid #4CAF50; + background-color: #fafafa; +} + +QPushButton { + background-color: #4CAF50; + color: white; + border: none; + border-radius: 4px; + padding: 8px 16px; + font-weight: bold; + font-size: 11pt; + min-height: 32px; +} + +QPushButton:hover { + background-color: #45a049; +} + +QPushButton:pressed { + background-color: #3d8b40; +} + +QPushButton:disabled { + background-color: #cccccc; + color: #999999; +} + +QProgressBar { + border: 1px solid #d0d0d0; + border-radius: 4px; + text-align: center; + background-color: #ffffff; + height: 20px; +} + +QProgressBar::chunk { + background-color: #4CAF50; + border-radius: 3px; +} + +QMessageBox QLabel { + color: #333333; +} + +QMessageBox QPushButton { + min-width: 60px; +} +""" + +# Color palette +COLORS = { + "primary": "#4CAF50", + "primary_hover": "#45a049", + "primary_active": "#3d8b40", + "background": "#f5f5f5", + "text": "#333333", + "text_secondary": "#666666", + "border": "#d0d0d0", + "success": "#4CAF50", + "error": "#f44336", + "warning": "#ff9800", + "info": "#2196F3", +} + +# Font settings +FONTS = { + "default_size": 11, + "title_size": 16, + "mono_family": "Courier New", +} + + +def get_stylesheet() -> str: + """Get the main stylesheet for the application""" + return MAIN_STYLESHEET + + +def get_color(color_name: str) -> str: + """Get a color from the palette""" + return COLORS.get(color_name, "#000000") diff --git a/farsi_transcriber/utils/__init__.py b/farsi_transcriber/utils/__init__.py new file mode 100644 index 0000000..9c3f775 --- /dev/null +++ b/farsi_transcriber/utils/__init__.py @@ -0,0 +1 @@ +"""Utility functions for Farsi Transcriber""" diff --git a/farsi_transcriber/utils/export.py b/farsi_transcriber/utils/export.py new file mode 100644 index 0000000..ab3a3c8 --- /dev/null +++ b/farsi_transcriber/utils/export.py @@ -0,0 +1,164 @@ +""" +Export utilities for transcription results + +Supports multiple export formats: TXT, SRT, JSON, TSV, VTT +""" + +import json +from datetime import timedelta +from pathlib import Path +from typing import Dict, List + + +class TranscriptionExporter: + """Export transcription results in various formats""" + + @staticmethod + def export_txt(result: Dict, file_path: Path) -> None: + """ + Export transcription as plain text file. + + Args: + result: Transcription result dictionary + file_path: Output file path + """ + text = result.get("full_text", "") or result.get("text", "") + + with open(file_path, "w", encoding="utf-8") as f: + f.write(text) + + @staticmethod + def export_srt(result: Dict, file_path: Path) -> None: + """ + Export transcription as SRT subtitle file. + + Args: + result: Transcription result dictionary + file_path: Output file path + """ + segments = result.get("segments", []) + + with open(file_path, "w", encoding="utf-8") as f: + for i, segment in enumerate(segments, 1): + start = TranscriptionExporter._format_srt_time(segment.get("start", 0)) + end = TranscriptionExporter._format_srt_time(segment.get("end", 0)) + text = segment.get("text", "").strip() + + if text: + f.write(f"{i}\n") + f.write(f"{start} --> {end}\n") + f.write(f"{text}\n\n") + + @staticmethod + def export_vtt(result: Dict, file_path: Path) -> None: + """ + Export transcription as WebVTT subtitle file. + + Args: + result: Transcription result dictionary + file_path: Output file path + """ + segments = result.get("segments", []) + + with open(file_path, "w", encoding="utf-8") as f: + f.write("WEBVTT\n\n") + + for segment in segments: + start = TranscriptionExporter._format_vtt_time(segment.get("start", 0)) + end = TranscriptionExporter._format_vtt_time(segment.get("end", 0)) + text = segment.get("text", "").strip() + + if text: + f.write(f"{start} --> {end}\n") + f.write(f"{text}\n\n") + + @staticmethod + def export_json(result: Dict, file_path: Path) -> None: + """ + Export transcription as JSON file. + + Args: + result: Transcription result dictionary + file_path: Output file path + """ + with open(file_path, "w", encoding="utf-8") as f: + json.dump(result, f, ensure_ascii=False, indent=2) + + @staticmethod + def export_tsv(result: Dict, file_path: Path) -> None: + """ + Export transcription as TSV (tab-separated values) file. + + Args: + result: Transcription result dictionary + file_path: Output file path + """ + segments = result.get("segments", []) + + with open(file_path, "w", encoding="utf-8") as f: + # Write header + f.write("Index\tStart\tEnd\tDuration\tText\n") + + for i, segment in enumerate(segments, 1): + start = segment.get("start", 0) + end = segment.get("end", 0) + duration = end - start + text = segment.get("text", "").strip() + + if text: + f.write( + f"{i}\t{start:.2f}\t{end:.2f}\t{duration:.2f}\t{text}\n" + ) + + @staticmethod + def export( + result: Dict, file_path: Path, format_type: str = "txt" + ) -> None: + """ + Export transcription in specified format. + + Args: + result: Transcription result dictionary + file_path: Output file path + format_type: Export format ('txt', 'srt', 'vtt', 'json', 'tsv') + + Raises: + ValueError: If format is not supported + """ + format_type = format_type.lower() + + exporters = { + "txt": TranscriptionExporter.export_txt, + "srt": TranscriptionExporter.export_srt, + "vtt": TranscriptionExporter.export_vtt, + "json": TranscriptionExporter.export_json, + "tsv": TranscriptionExporter.export_tsv, + } + + if format_type not in exporters: + raise ValueError( + f"Unsupported format: {format_type}. " + f"Supported formats: {list(exporters.keys())}" + ) + + exporters[format_type](result, file_path) + + @staticmethod + def _format_srt_time(seconds: float) -> str: + """Format time for SRT format (HH:MM:SS,mmm)""" + td = timedelta(seconds=seconds) + hours, remainder = divmod(int(td.total_seconds()), 3600) + minutes, secs = divmod(remainder, 60) + milliseconds = int((seconds % 1) * 1000) + + return f"{hours:02d}:{minutes:02d}:{secs:02d},{milliseconds:03d}" + + @staticmethod + def _format_vtt_time(seconds: float) -> str: + """Format time for VTT format (HH:MM:SS.mmm)""" + td = timedelta(seconds=seconds) + hours, remainder = divmod(int(td.total_seconds()), 3600) + minutes, secs = divmod(remainder, 60) + milliseconds = int((seconds % 1) * 1000) + + return f"{hours:02d}:{minutes:02d}:{secs:02d}.{milliseconds:03d}" diff --git a/farsi_transcriber_web/.env.example b/farsi_transcriber_web/.env.example new file mode 100644 index 0000000..366a8d3 --- /dev/null +++ b/farsi_transcriber_web/.env.example @@ -0,0 +1,13 @@ +# Frontend environment variables +# Copy this to .env.local and update with your values + +# API URL for the backend +# Local development: http://localhost:5000 +# Railway production: https://your-backend-app.railway.app +VITE_API_URL=http://localhost:5000 + +# Application name +VITE_APP_NAME=Farsi Transcriber + +# Max file size (in MB) +VITE_MAX_FILE_SIZE=500 diff --git a/farsi_transcriber_web/.env.production b/farsi_transcriber_web/.env.production new file mode 100644 index 0000000..3cac56f --- /dev/null +++ b/farsi_transcriber_web/.env.production @@ -0,0 +1,7 @@ +# Production environment variables for Railway deployment +# Set VITE_API_URL in Railway environment variables instead of committing here + +# Default fallback - will be overridden by Railway env var +VITE_API_URL=https://your-backend-url.railway.app +VITE_APP_NAME=Farsi Transcriber +VITE_MAX_FILE_SIZE=500 diff --git a/farsi_transcriber_web/.gitignore b/farsi_transcriber_web/.gitignore new file mode 100644 index 0000000..e6946e8 --- /dev/null +++ b/farsi_transcriber_web/.gitignore @@ -0,0 +1,37 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Environment variables +.env +.env.local +.env.*.local + +# Editor directories and files +.vscode +.idea +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +# OS +.DS_Store +Thumbs.db + +# Build output +*.tgz +*.tsbuildinfo +vite.config.js +vite.config.d.ts diff --git a/farsi_transcriber_web/RAILWAY_DEPLOYMENT.md b/farsi_transcriber_web/RAILWAY_DEPLOYMENT.md new file mode 100644 index 0000000..60ceec1 --- /dev/null +++ b/farsi_transcriber_web/RAILWAY_DEPLOYMENT.md @@ -0,0 +1,308 @@ +# Railway Deployment Guide + +Complete step-by-step guide to deploy your Farsi Transcriber web app to Railway. + +## Prerequisites + +1. **GitHub Account** - To connect your repository +2. **Railway Account** - Free signup at https://railway.app +3. **Git** - Already have this since you're using it + +## Step 1: Prepare Your Code for Deployment + +### 1.1 Make sure all files are committed + +```bash +cd /home/user/whisper +git status +git add . +git commit -m "Ready for Railway deployment" +``` + +### 1.2 Push to GitHub + +If you haven't already, push your fork to GitHub: + +```bash +git push origin claude/review-repo-011CV3PVcA7ZSCTW2YquuMB8 +``` + +--- + +## Step 2: Create Railway Account + +1. Go to **https://railway.app** +2. Click **"Login with GitHub"** +3. Authorize Railway to access your GitHub account +4. You'll get **$5 monthly credit** for free ✅ + +--- + +## Step 3: Create Backend Service (Flask API) + +### 3.1 Create a new project + +1. In Railway dashboard, click **"Create New Project"** +2. Select **"GitHub Repo"** +3. Select your **whisper** repository +4. Railway will auto-detect it as Python +5. Configure: + - **Root Directory:** `farsi_transcriber_web/backend` + - **Start Command:** `gunicorn --workers 2 --bind 0.0.0.0:$PORT app:app` + +### 3.2 Set environment variables + +In the Railway dashboard for your backend: + +1. Go to **Variables** +2. Add: + ``` + FLASK_ENV=production + FLASK_DEBUG=False + PYTHONUNBUFFERED=1 + PORT=5000 + ``` + +### 3.3 Deploy + +1. Click **Deploy** +2. Wait for deployment (takes 2-3 minutes) +3. Your backend URL will appear (e.g., `https://farsi-api-prod.railway.app`) +4. **Copy this URL** - you'll need it for the frontend + +--- + +## Step 4: Create Frontend Service (React) + +### 4.1 Create another service in same project + +1. Go to your Railway project +2. Click **"Create New Service"** +3. Select **"GitHub Repo"** +4. Select your **whisper** repository +5. Configure: + - **Root Directory:** `farsi_transcriber_web` + - **Build Command:** `npm install && npm run build` + - **Start Command:** `npm run preview` + +### 4.2 Set environment variables + +In the Railway dashboard for your frontend: + +1. Go to **Variables** +2. Add: + ``` + VITE_API_URL=https://your-backend-url-here.railway.app + VITE_APP_NAME=Farsi Transcriber + ``` + (Replace with your actual backend URL from Step 3.3) + +### 4.3 Deploy + +1. Click **Deploy** +2. Wait for deployment (2-5 minutes depending on npm install) +3. Your frontend URL will appear (e.g., `https://farsi-web-prod.railway.app`) + +--- + +## Step 5: Configure Services to Communicate + +### 5.1 Link backend to frontend + +1. In Railway dashboard, select frontend service +2. Go to **Variables** +3. Update `VITE_API_URL` with your backend service domain +4. Deploy again + +### 5.2 Test the connection + +1. Open your frontend URL +2. Try to add a file and transcribe +3. Check browser console for any errors +4. If errors, check Railway logs (click service → Logs) + +--- + +## Step 6: Monitor Your Deployment + +### 6.1 View logs + +In Railway dashboard: +- Click your service +- Go to **Logs** tab +- See real-time logs as users interact with your app + +### 6.2 Check health + +```bash +# Check if backend is running +curl https://your-backend-url.railway.app/health + +# Should return: +# {"status": "healthy", "model_loaded": true, "environment": "production"} +``` + +### 6.3 Monitor usage + +- Railway dashboard shows RAM, CPU, bandwidth usage +- Your $5 credit should last 1-3 months for personal use + +--- + +## Step 7: Custom Domain (Optional) + +If you want a custom domain like `farsi.yourdomain.com`: + +1. Buy a domain on GoDaddy, Namecheap, etc. +2. In Railway dashboard → Your app → Settings → Domains +3. Add custom domain +4. Update DNS records at your domain provider +5. Railway will handle SSL certificate automatically + +--- + +## Troubleshooting + +### Issue: Backend showing error "Model not loaded" + +**Solution:** First transcription loads the 769MB model (takes 1-2 min). Wait and try again. + +### Issue: Frontend can't reach backend + +**Solution:** +1. Check backend URL is correct in frontend variables +2. Backend must be running (check Railway logs) +3. CORS should be enabled (already configured) + +### Issue: Build fails + +**Solution:** +1. Check Railway build logs for errors +2. Ensure `package.json` has all required dependencies +3. Run locally first: `npm install && npm run build` + +### Issue: App runs slow + +**Solution:** +1. You're on free tier with limited resources +2. Upgrade to paid tier ($5/month) for better performance +3. Or wait for model to cache (subsequent transcriptions are fast) + +### Issue: Out of memory + +**Solution:** +1. Free tier has limited RAM +2. Close unused tabs/apps +3. Use smaller Whisper model (edit backend to use 'small' instead of 'medium') + +--- + +## Next Steps: Custom Domain Setup + +Once stable, add your custom domain: + +1. Purchase domain +2. Railway → Settings → Domains → Add Domain +3. Update DNS CNAME records +4. Railway auto-generates SSL certificate + +--- + +## Cost Breakdown + +### Free Tier ($5/month credit) + +- ✅ 500 build minutes/month +- ✅ 100 GB bandwidth/month +- ✅ 6,000 compute unit hours +- ✅ More than enough for personal use + +### Your app will cost: + +- **Backend (Flask):** ~$1-2/month +- **Frontend (React):** ~$0.50/month +- **Total:** ~$2/month (with free credit covering 2-3 months) + +--- + +## Useful Commands + +### Check if Railway CLI is installed + +```bash +railway --version +``` + +### Install Railway CLI + +```bash +npm i -g @railway/cli +``` + +### Deploy from command line + +```bash +railway up +``` + +### View logs + +```bash +railway logs +``` + +--- + +## What Happens Now + +1. ✅ Your app is live on Railway +2. ✅ Free $5 monthly credit +3. ✅ Auto-scaling (if you get traffic) +4. ✅ 24/7 uptime +5. ✅ Automatic SSL/HTTPS +6. ✅ No infrastructure to manage + +--- + +## Monitor Your App + +Visit your Railway dashboard regularly to: +- Check resource usage +- View logs +- Update environment variables +- Scale services if needed +- Monitor costs + +--- + +## After Deployment + +Your app is now online! Share the URL with friends: + +``` +https://your-app-name.railway.app +``` + +--- + +## Further Reading + +- [Railway Documentation](https://docs.railway.app) +- [Railway GitHub Integration](https://docs.railway.app/guides/github) +- [Railway Environment Variables](https://docs.railway.app/develop/variables) +- [Whisper API Docs](https://github.com/openai/whisper) + +--- + +## Support + +If you have issues: + +1. Check Railway logs (click service → Logs) +2. Check browser console (F12 → Console tab) +3. Visit Railway docs: https://docs.railway.app +4. Check Flask logs for backend errors + +--- + +**Congratulations! Your Farsi Transcriber is now live!** 🎉 diff --git a/farsi_transcriber_web/README.md b/farsi_transcriber_web/README.md new file mode 100644 index 0000000..1737d96 --- /dev/null +++ b/farsi_transcriber_web/README.md @@ -0,0 +1,384 @@ +# Farsi Transcriber - Web Application + +A professional web-based application for transcribing Farsi audio and video files using OpenAI's Whisper model. + +## Features + +✨ **Core Features** +- 🎙️ Transcribe audio files (MP3, WAV, M4A, FLAC, OGG, AAC, WMA) +- 🎬 Extract audio from video files (MP4, MKV, MOV, WebM, AVI, FLV, WMV) +- 🇮🇷 High-accuracy Farsi/Persian language transcription +- ⏱️ Word-level timestamps for precise timing +- 📤 Export to multiple formats (TXT, SRT, VTT, JSON) +- 💻 Clean, intuitive React-based UI with Figma design +- 🎨 Dark/Light theme toggle +- 🔍 Search and text highlighting in transcriptions +- 📋 File queue management +- 💾 Copy individual transcription segments +- 🚀 GPU acceleration support (CUDA) +- 🎯 Resizable window for flexible workspace + +## Tech Stack + +**Frontend:** +- React 18+ with TypeScript +- Vite (fast build tool) +- Tailwind CSS v4.0 +- Lucide React (icons) +- re-resizable (window resizing) +- Sonner (toast notifications) + +**Backend:** +- Flask (Python web framework) +- OpenAI Whisper (speech recognition) +- PyTorch (deep learning) +- Flask-CORS (cross-origin requests) + +## System Requirements + +**Frontend:** +- Node.js 16+ +- npm/yarn/pnpm + +**Backend:** +- Python 3.8+ +- 4GB RAM minimum +- 8GB+ recommended +- ffmpeg installed +- Optional: NVIDIA GPU with CUDA support + +## Installation + +### Step 1: Install ffmpeg + +Choose your operating system: + +**Ubuntu/Debian:** +```bash +sudo apt update && sudo apt install ffmpeg +``` + +**macOS (Homebrew):** +```bash +brew install ffmpeg +``` + +**Windows (Chocolatey):** +```bash +choco install ffmpeg +``` + +### Step 2: Backend Setup + +```bash +# Navigate to backend directory +cd backend + +# Create virtual environment +python3 -m venv venv +source venv/bin/activate # On Windows: venv\Scripts\activate + +# Install dependencies +pip install -r requirements.txt +``` + +### Step 3: Frontend Setup + +```bash +# Navigate to root directory +cd .. + +# Install Node dependencies +npm install + +# Or use yarn/pnpm +yarn install +# or +pnpm install +``` + +## Running the Application + +### Step 1: Start Backend API + +```bash +cd backend +source venv/bin/activate # Activate virtual environment +python app.py +``` + +The API will be available at `http://localhost:5000` + +### Step 2: Start Frontend Dev Server + +In a new terminal: + +```bash +npm run dev +``` + +The application will be available at `http://localhost:3000` + +## Building for Production + +### Frontend Build + +```bash +npm run build +``` + +This creates optimized production build in `dist/` directory. + +### Backend Deployment + +For production, use a production WSGI server: + +```bash +# Install Gunicorn +pip install gunicorn + +# Run with Gunicorn +gunicorn -w 4 -b 0.0.0.0:5000 app:app +``` + +## API Endpoints + +### `/health` (GET) +Health check endpoint + +**Response:** +```json +{ + "status": "healthy", + "model_loaded": true, + "device": "cuda|cpu" +} +``` + +### `/transcribe` (POST) +Transcribe audio/video file + +**Request:** +- `file`: Audio/video file (multipart/form-data) +- `language`: Language code (optional, default: "fa" for Farsi) + +**Response:** +```json +{ + "status": "success", + "filename": "audio.mp3", + "language": "fa", + "text": "Full transcription text...", + "segments": [ + { + "start": "00:00:00.000", + "end": "00:00:05.500", + "text": "سلام دنیا" + } + ] +} +``` + +### `/models` (GET) +Get available Whisper models + +**Response:** +```json +{ + "available_models": ["tiny", "base", "small", "medium", "large"], + "current_model": "medium", + "description": "..." +} +``` + +### `/export` (POST) +Export transcription + +**Request:** +```json +{ + "transcription": "Full text...", + "segments": [...], + "format": "txt|srt|vtt|json" +} +``` + +**Response:** +```json +{ + "status": "success", + "format": "srt", + "content": "...", + "mime_type": "text/plain" +} +``` + +## Usage Guide + +### 1. Add Files to Queue +- Click "Add Files" button in the left sidebar +- Select audio or video files +- Multiple files can be added to the queue + +### 2. Transcribe +- Select a file from the queue +- Click "Transcribe" button +- Watch the progress indicator +- Results appear with timestamps + +### 3. Search & Copy +- Use the search bar to find specific text +- Matching text is highlighted +- Click copy icon to copy individual segments + +### 4. Export Results +- Select export format (TXT, SRT, VTT, JSON) +- Click "Export" button +- File is downloaded or ready to save + +### 5. Theme Toggle +- Click sun/moon icon in header +- Switch between light and dark themes + +## Project Structure + +``` +farsi_transcriber_web/ +├── src/ +│ ├── App.tsx # Main application component +│ ├── main.tsx # React entry point +│ ├── index.css # Global styles +│ └── components/ +│ ├── Button.tsx +│ ├── Progress.tsx +│ ├── Input.tsx +│ └── Select.tsx +├── backend/ +│ ├── app.py # Flask API server +│ ├── requirements.txt # Python dependencies +│ └── .gitignore +├── public/ +├── package.json +├── vite.config.ts +├── tsconfig.json +├── tailwind.config.js +├── postcss.config.js +└── README.md +``` + +## Configuration + +### Environment Variables + +Create a `.env.local` file in the root directory: + +``` +VITE_API_URL=http://localhost:5000 +VITE_MAX_FILE_SIZE=500MB +``` + +### Backend Configuration + +Edit `backend/app.py` to customize: + +```python +# Change model size +model = whisper.load_model('large') # tiny, base, small, medium, large + +# Change upload folder +UPLOAD_FOLDER = '/custom/path' + +# Change max file size +MAX_FILE_SIZE = 1024 * 1024 * 1024 # 1GB +``` + +## Troubleshooting + +### Issue: "API connection failed" +**Solution**: Ensure backend is running on `http://localhost:5000` + +### Issue: "Whisper model not found" +**Solution**: First run downloads the model (~3GB). Ensure internet connection and disk space. + +### Issue: "CUDA out of memory" +**Solution**: Use smaller model or reduce batch size in `backend/app.py` + +### Issue: "ffmpeg not found" +**Solution**: Install ffmpeg using your package manager (see Installation section) + +### Issue: Port 3000 or 5000 already in use +**Solution**: Change ports in `vite.config.ts` and `backend/app.py` + +## Performance Tips + +1. **Use GPU** - Ensure NVIDIA CUDA is properly installed +2. **Choose appropriate model** - Balance speed vs accuracy +3. **Close other applications** - Free up RAM/VRAM +4. **Use SSD** - Faster model loading and file I/O +5. **Batch Processing** - Process multiple files sequentially + +## Future Enhancements + +- [ ] Drag-and-drop file upload +- [ ] Audio playback synchronized with transcription +- [ ] Edit segments inline +- [ ] Keyboard shortcuts +- [ ] Save/load sessions +- [ ] Speaker diarization +- [ ] Confidence scores +- [ ] Custom vocabulary support + +## Development + +### Code Style + +```bash +# Format code (if ESLint configured) +npm run lint + +# Build for development +npm run dev + +# Build for production +npm run build +``` + +### Adding Components + +New components go in `src/components/` and should: +- Use TypeScript +- Include prop interfaces +- Export as default +- Include JSDoc comments + +## Common Issues & Solutions + +| Issue | Solution | +|-------|----------| +| Models slow to load | GPU required for fast transcription | +| File not supported | Check file extension is in supported list | +| Transcription has errors | Try larger model (medium/large) | +| Application crashes | Check browser console and Flask logs | +| Export not working | Ensure segments data is complete | + +## License + +MIT License - Personal use and modifications allowed + +## Credits + +Built with: +- [OpenAI Whisper](https://github.com/openai/whisper) - Speech recognition +- [React](https://react.dev/) - UI framework +- [Vite](https://vitejs.dev/) - Build tool +- [Tailwind CSS](https://tailwindcss.com/) - Styling +- [Flask](https://flask.palletsprojects.com/) - Backend framework + +## Support + +For issues: +1. Check the troubleshooting section +2. Verify ffmpeg is installed +3. Check Flask backend logs +4. Review browser console for errors +5. Ensure Python 3.8+ and Node.js 16+ are installed diff --git a/farsi_transcriber_web/backend/.env.example b/farsi_transcriber_web/backend/.env.example new file mode 100644 index 0000000..faeb07f --- /dev/null +++ b/farsi_transcriber_web/backend/.env.example @@ -0,0 +1,21 @@ +# Backend environment variables +# Copy this to .env and update with your values + +# Flask environment +FLASK_ENV=production +FLASK_DEBUG=False + +# Server port (Railway sets PORT automatically) +PORT=5000 + +# CORS settings +FLASK_CORS_ORIGINS=* + +# Whisper model settings +WHISPER_MODEL=medium + +# File upload settings +MAX_FILE_SIZE=500000000 + +# Python path +PYTHONUNBUFFERED=1 diff --git a/farsi_transcriber_web/backend/.gitignore b/farsi_transcriber_web/backend/.gitignore new file mode 100644 index 0000000..801d628 --- /dev/null +++ b/farsi_transcriber_web/backend/.gitignore @@ -0,0 +1,42 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Virtual Environment +venv/ +ENV/ +env/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# ML Models +*.pt +*.pth +~/.cache/whisper/ + +# Uploads +/uploads +/tmp diff --git a/farsi_transcriber_web/backend/Procfile b/farsi_transcriber_web/backend/Procfile new file mode 100644 index 0000000..e7e7bf8 --- /dev/null +++ b/farsi_transcriber_web/backend/Procfile @@ -0,0 +1 @@ +web: gunicorn --workers 4 --worker-class sync --bind 0.0.0.0:$PORT app:app diff --git a/farsi_transcriber_web/backend/app.py b/farsi_transcriber_web/backend/app.py new file mode 100644 index 0000000..3388dbf --- /dev/null +++ b/farsi_transcriber_web/backend/app.py @@ -0,0 +1,228 @@ +""" +Farsi Transcriber Backend API + +Flask API for handling audio/video file transcription using Whisper model. +Configured for Railway deployment with lazy model loading. +""" + +import os +import sys +import tempfile +from pathlib import Path +from werkzeug.utils import secure_filename +from flask import Flask, request, jsonify +from flask_cors import CORS + +# Prevent model download during build +os.environ['WHISPER_CACHE'] = os.path.expanduser('~/.cache/whisper') + +# Add parent directory to path for imports +sys.path.insert(0, str(Path(__file__).parent.parent.parent)) + +app = Flask(__name__) +CORS(app, resources={r"/api/*": {"origins": "*"}}) + +# Configuration +UPLOAD_FOLDER = tempfile.gettempdir() +ALLOWED_EXTENSIONS = {'mp3', 'wav', 'm4a', 'flac', 'ogg', 'aac', 'wma', 'mp4', 'mkv', 'mov', 'webm', 'avi', 'flv', 'wmv'} +MAX_FILE_SIZE = 500 * 1024 * 1024 # 500MB + +# Production settings +app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER +app.config['MAX_CONTENT_LENGTH'] = MAX_FILE_SIZE +app.config['ENV'] = os.getenv('FLASK_ENV', 'production') + +# Load Whisper model (lazy load - only on first transcription request) +model = None + +def load_model(): + """Lazy load Whisper model on first use (not during build)""" + global model + if model is None: + try: + print("⏳ Loading Whisper model for first time...") + print(" This may take 1-2 minutes on first run...") + # Import here to avoid loading during build + import whisper + model = whisper.load_model('medium') + print("✓ Whisper model loaded successfully") + except Exception as e: + print(f"✗ Error loading Whisper model: {e}") + model = None + return model + + +def allowed_file(filename): + """Check if file has allowed extension""" + return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS + + +@app.route('/', methods=['GET']) +def index(): + """Root endpoint""" + return jsonify({ + 'message': 'Farsi Transcriber API', + 'version': '1.0.0', + 'status': 'running' + }) + + +@app.route('/health', methods=['GET']) +def health(): + """Health check endpoint - fast response without loading model""" + return jsonify({ + 'status': 'healthy', + 'model_loaded': model is not None, + 'environment': app.config['ENV'] + }) + + +@app.route('/transcribe', methods=['POST']) +def transcribe(): + """ + Transcribe audio/video file + + Request: + - file: Audio/video file + - language: Language code (default: 'fa' for Farsi) + + Response: + - transcription results with segments and timestamps + """ + try: + # Load model if not already loaded + whisper_model = load_model() + if not whisper_model: + return jsonify({'error': 'Failed to load Whisper model'}), 500 + + # Check if file is in request + if 'file' not in request.files: + return jsonify({'error': 'No file provided'}), 400 + + file = request.files['file'] + + if file.filename == '': + return jsonify({'error': 'No file selected'}), 400 + + if not allowed_file(file.filename): + return jsonify({'error': 'File type not allowed'}), 400 + + # Save file + filename = secure_filename(file.filename) + filepath = os.path.join(app.config['UPLOAD_FOLDER'], filename) + file.save(filepath) + + # Get language code from request (default: Farsi) + language = request.form.get('language', 'fa') + + # Transcribe + result = whisper_model.transcribe(filepath, language=language, verbose=False) + + # Format response + segments = [] + for segment in result.get('segments', []): + segments.append({ + 'start': f"{int(segment['start'] // 3600):02d}:{int((segment['start'] % 3600) // 60):02d}:{int(segment['start'] % 60):02d}.{int((segment['start'] % 1) * 1000):03d}", + 'end': f"{int(segment['end'] // 3600):02d}:{int((segment['end'] % 3600) // 60):02d}:{int(segment['end'] % 60):02d}.{int((segment['end'] % 1) * 1000):03d}", + 'text': segment['text'].strip(), + }) + + # Clean up uploaded file + try: + os.remove(filepath) + except: + pass + + return jsonify({ + 'status': 'success', + 'filename': filename, + 'language': result.get('language', 'unknown'), + 'text': result.get('text', ''), + 'segments': segments + }) + + except Exception as e: + return jsonify({'error': str(e)}), 500 + + +@app.route('/models', methods=['GET']) +def get_models(): + """Get available Whisper models""" + return jsonify({ + 'available_models': ['tiny', 'base', 'small', 'medium', 'large'], + 'current_model': 'medium', + 'description': 'List of available Whisper models. Larger models are more accurate but slower.' + }) + + +@app.route('/export', methods=['POST']) +def export(): + """ + Export transcription in specified format + + Request: + - transcription: Full transcription text + - segments: Array of segments with timestamps + - format: Export format (txt, srt, vtt, json) + + Response: + - Exported file content + """ + try: + data = request.json + transcription = data.get('transcription', '') + segments = data.get('segments', []) + format_type = data.get('format', 'txt').lower() + + if format_type == 'txt': + content = transcription + mime_type = 'text/plain' + elif format_type == 'srt': + content = _format_srt(segments) + mime_type = 'text/plain' + elif format_type == 'vtt': + content = _format_vtt(segments) + mime_type = 'text/plain' + elif format_type == 'json': + import json + content = json.dumps({'text': transcription, 'segments': segments}, ensure_ascii=False, indent=2) + mime_type = 'application/json' + else: + return jsonify({'error': 'Unsupported format'}), 400 + + return jsonify({ + 'status': 'success', + 'format': format_type, + 'content': content, + 'mime_type': mime_type + }) + + except Exception as e: + return jsonify({'error': str(e)}), 500 + + +def _format_srt(segments): + """Format transcription as SRT subtitle format""" + lines = [] + for i, segment in enumerate(segments, 1): + lines.append(str(i)) + lines.append(f"{segment['start']} --> {segment['end']}") + lines.append(segment['text']) + lines.append('') + return '\n'.join(lines) + + +def _format_vtt(segments): + """Format transcription as WebVTT subtitle format""" + lines = ['WEBVTT', ''] + for segment in segments: + lines.append(f"{segment['start']} --> {segment['end']}") + lines.append(segment['text']) + lines.append('') + return '\n'.join(lines) + + +if __name__ == '__main__': + port = int(os.getenv('PORT', 5000)) + debug = os.getenv('FLASK_ENV', 'production') == 'development' + app.run(debug=debug, host='0.0.0.0', port=port, threaded=True) diff --git a/farsi_transcriber_web/backend/nixpacks.toml b/farsi_transcriber_web/backend/nixpacks.toml new file mode 100644 index 0000000..7a1f464 --- /dev/null +++ b/farsi_transcriber_web/backend/nixpacks.toml @@ -0,0 +1,11 @@ +# Backend Nixpacks configuration +# Ensures ffmpeg is available for Whisper audio processing + +[phases.setup] +nixPkgs = ["ffmpeg"] + +[phases.install] +cmds = ["pip install -r requirements.txt"] + +[start] +cmd = "gunicorn --workers 2 --worker-class sync --timeout 120 --bind 0.0.0.0:$PORT app:app" diff --git a/farsi_transcriber_web/backend/requirements.txt b/farsi_transcriber_web/backend/requirements.txt new file mode 100644 index 0000000..341c19d --- /dev/null +++ b/farsi_transcriber_web/backend/requirements.txt @@ -0,0 +1,10 @@ +Flask==2.3.3 +Flask-CORS==4.0.0 +python-dotenv==1.0.0 +openai-whisper>=20230314 +torch>=1.10.1 +numpy>=1.21.0 +python-multipart==0.0.6 +gunicorn==21.2.0 + + diff --git a/farsi_transcriber_web/index.html b/farsi_transcriber_web/index.html new file mode 100644 index 0000000..8cf5754 --- /dev/null +++ b/farsi_transcriber_web/index.html @@ -0,0 +1,13 @@ + + + + + + + Farsi Audio/Video Transcriber + + +
+ + + diff --git a/farsi_transcriber_web/nixpacks.toml b/farsi_transcriber_web/nixpacks.toml new file mode 100644 index 0000000..18bdf0c --- /dev/null +++ b/farsi_transcriber_web/nixpacks.toml @@ -0,0 +1,11 @@ +# Frontend Nixpacks configuration +# Node.js React app with Vite + +[phases.install] +cmds = ["npm install"] + +[phases.build] +cmds = ["npm run build"] + +[start] +cmd = "npm run preview" diff --git a/farsi_transcriber_web/package-lock.json b/farsi_transcriber_web/package-lock.json new file mode 100644 index 0000000..1b646d7 --- /dev/null +++ b/farsi_transcriber_web/package-lock.json @@ -0,0 +1,2456 @@ +{ + "name": "farsi-transcriber-web", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "farsi-transcriber-web", + "version": "0.1.0", + "dependencies": { + "@tailwindcss/postcss": "^4.1.17", + "lucide-react": "^0.263.1", + "re-resizable": "^6.9.9", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "sonner": "^1.2.0" + }, + "devDependencies": { + "@types/node": "^20.8.0", + "@types/react": "^18.2.37", + "@types/react-dom": "^18.2.15", + "@vitejs/plugin-react": "^4.2.0", + "autoprefixer": "^10.4.16", + "postcss": "^8.4.31", + "tailwindcss": "^4.0.0", + "terser": "^5.44.1", + "typescript": "^5.2.2", + "vite": "^5.0.0" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.5.tgz", + "integrity": "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", + "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.28.3", + "@babel/helpers": "^7.28.4", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.5", + "@babel/types": "^7.28.5", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz", + "integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@babel/types": "^7.28.5", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", + "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.27.2", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", + "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", + "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.28.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", + "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", + "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.4" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", + "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.5" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz", + "integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.5", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", + "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", + "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.2.tgz", + "integrity": "sha512-yDPzwsgiFO26RJA4nZo8I+xqzh7sJTZIWQOxn+/XOdPE31lAvLIYCKqjV+lNH/vxE2L2iH3plKxDCRK6i+CwhA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.2.tgz", + "integrity": "sha512-k8FontTxIE7b0/OGKeSN5B6j25EuppBcWM33Z19JoVT7UTXFSo3D9CdU39wGTeb29NO3XxpMNauh09B+Ibw+9g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.2.tgz", + "integrity": "sha512-A6s4gJpomNBtJ2yioj8bflM2oogDwzUiMl2yNJ2v9E7++sHrSrsQ29fOfn5DM/iCzpWcebNYEdXpaK4tr2RhfQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.2.tgz", + "integrity": "sha512-e6XqVmXlHrBlG56obu9gDRPW3O3hLxpwHpLsBJvuI8qqnsrtSZ9ERoWUXtPOkY8c78WghyPHZdmPhHLWNdAGEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.2.tgz", + "integrity": "sha512-v0E9lJW8VsrwPux5Qe5CwmH/CF/2mQs6xU1MF3nmUxmZUCHazCjLgYvToOk+YuuUqLQBio1qkkREhxhc656ViA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.2.tgz", + "integrity": "sha512-ClAmAPx3ZCHtp6ysl4XEhWU69GUB1D+s7G9YjHGhIGCSrsg00nEGRRZHmINYxkdoJehde8VIsDC5t9C0gb6yqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.2.tgz", + "integrity": "sha512-EPlb95nUsz6Dd9Qy13fI5kUPXNSljaG9FiJ4YUGU1O/Q77i5DYFW5KR8g1OzTcdZUqQQ1KdDqsTohdFVwCwjqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.2.tgz", + "integrity": "sha512-BOmnVW+khAUX+YZvNfa0tGTEMVVEerOxN0pDk2E6N6DsEIa2Ctj48FOMfNDdrwinocKaC7YXUZ1pHlKpnkja/Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.2.tgz", + "integrity": "sha512-Xt2byDZ+6OVNuREgBXr4+CZDJtrVso5woFtpKdGPhpTPHcNG7D8YXeQzpNbFRxzTVqJf7kvPMCub/pcGUWgBjA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.2.tgz", + "integrity": "sha512-+LdZSldy/I9N8+klim/Y1HsKbJ3BbInHav5qE9Iy77dtHC/pibw1SR/fXlWyAk0ThnpRKoODwnAuSjqxFRDHUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.2.tgz", + "integrity": "sha512-8ms8sjmyc1jWJS6WdNSA23rEfdjWB30LH8Wqj0Cqvv7qSHnvw6kgMMXRdop6hkmGPlyYBdRPkjJnj3KCUHV/uQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.2.tgz", + "integrity": "sha512-3HRQLUQbpBDMmzoxPJYd3W6vrVHOo2cVW8RUo87Xz0JPJcBLBr5kZ1pGcQAhdZgX9VV7NbGNipah1omKKe23/g==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.2.tgz", + "integrity": "sha512-fMjKi+ojnmIvhk34gZP94vjogXNNUKMEYs+EDaB/5TG/wUkoeua7p7VCHnE6T2Tx+iaghAqQX8teQzcvrYpaQA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.2.tgz", + "integrity": "sha512-XuGFGU+VwUUV5kLvoAdi0Wz5Xbh2SrjIxCtZj6Wq8MDp4bflb/+ThZsVxokM7n0pcbkEr2h5/pzqzDYI7cCgLQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.2.tgz", + "integrity": "sha512-w6yjZF0P+NGzWR3AXWX9zc0DNEGdtvykB03uhonSHMRa+oWA6novflo2WaJr6JZakG2ucsyb+rvhrKac6NIy+w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.2.tgz", + "integrity": "sha512-yo8d6tdfdeBArzC7T/PnHd7OypfI9cbuZzPnzLJIyKYFhAQ8SvlkKtKBMbXDxe1h03Rcr7u++nFS7tqXz87Gtw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.2.tgz", + "integrity": "sha512-ah59c1YkCxKExPP8O9PwOvs+XRLKwh/mV+3YdKqQ5AMQ0r4M4ZDuOrpWkUaqO7fzAHdINzV9tEVu8vNw48z0lA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.2.tgz", + "integrity": "sha512-4VEd19Wmhr+Zy7hbUsFZ6YXEiP48hE//KPLCSVNY5RMGX2/7HZ+QkN55a3atM1C/BZCGIgqN+xrVgtdak2S9+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.2.tgz", + "integrity": "sha512-IlbHFYc/pQCgew/d5fslcy1KEaYVCJ44G8pajugd8VoOEI8ODhtb/j8XMhLpwHCMB3yk2J07ctup10gpw2nyMA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.2.tgz", + "integrity": "sha512-lNlPEGgdUfSzdCWU176ku/dQRnA7W+Gp8d+cWv73jYrb8uT7HTVVxq62DUYxjbaByuf1Yk0RIIAbDzp+CnOTFg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.2.tgz", + "integrity": "sha512-S6YojNVrHybQis2lYov1sd+uj7K0Q05NxHcGktuMMdIQ2VixGwAfbJ23NnlvvVV1bdpR2m5MsNBViHJKcA4ADw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.2.tgz", + "integrity": "sha512-k+/Rkcyx//P6fetPoLMb8pBeqJBNGx81uuf7iljX9++yNBVRDQgD04L+SVXmXmh5ZP4/WOp4mWF0kmi06PW2tA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tailwindcss/node": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.17.tgz", + "integrity": "sha512-csIkHIgLb3JisEFQ0vxr2Y57GUNYh447C8xzwj89U/8fdW8LhProdxvnVH6U8M2Y73QKiTIH+LWbK3V2BBZsAg==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "enhanced-resolve": "^5.18.3", + "jiti": "^2.6.1", + "lightningcss": "1.30.2", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.1.17" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.17.tgz", + "integrity": "sha512-F0F7d01fmkQhsTjXezGBLdrl1KresJTcI3DB8EkScCldyKp3Msz4hub4uyYaVnk88BAS1g5DQjjF6F5qczheLA==", + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.1.17", + "@tailwindcss/oxide-darwin-arm64": "4.1.17", + "@tailwindcss/oxide-darwin-x64": "4.1.17", + "@tailwindcss/oxide-freebsd-x64": "4.1.17", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.17", + "@tailwindcss/oxide-linux-arm64-gnu": "4.1.17", + "@tailwindcss/oxide-linux-arm64-musl": "4.1.17", + "@tailwindcss/oxide-linux-x64-gnu": "4.1.17", + "@tailwindcss/oxide-linux-x64-musl": "4.1.17", + "@tailwindcss/oxide-wasm32-wasi": "4.1.17", + "@tailwindcss/oxide-win32-arm64-msvc": "4.1.17", + "@tailwindcss/oxide-win32-x64-msvc": "4.1.17" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.17.tgz", + "integrity": "sha512-BMqpkJHgOZ5z78qqiGE6ZIRExyaHyuxjgrJ6eBO5+hfrfGkuya0lYfw8fRHG77gdTjWkNWEEm+qeG2cDMxArLQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.17.tgz", + "integrity": "sha512-EquyumkQweUBNk1zGEU/wfZo2qkp/nQKRZM8bUYO0J+Lums5+wl2CcG1f9BgAjn/u9pJzdYddHWBiFXJTcxmOg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.17.tgz", + "integrity": "sha512-gdhEPLzke2Pog8s12oADwYu0IAw04Y2tlmgVzIN0+046ytcgx8uZmCzEg4VcQh+AHKiS7xaL8kGo/QTiNEGRog==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.17.tgz", + "integrity": "sha512-hxGS81KskMxML9DXsaXT1H0DyA+ZBIbyG/sSAjWNe2EDl7TkPOBI42GBV3u38itzGUOmFfCzk1iAjDXds8Oh0g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.17.tgz", + "integrity": "sha512-k7jWk5E3ldAdw0cNglhjSgv501u7yrMf8oeZ0cElhxU6Y2o7f8yqelOp3fhf7evjIS6ujTI3U8pKUXV2I4iXHQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.17.tgz", + "integrity": "sha512-HVDOm/mxK6+TbARwdW17WrgDYEGzmoYayrCgmLEw7FxTPLcp/glBisuyWkFz/jb7ZfiAXAXUACfyItn+nTgsdQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.17.tgz", + "integrity": "sha512-HvZLfGr42i5anKtIeQzxdkw/wPqIbpeZqe7vd3V9vI3RQxe3xU1fLjss0TjyhxWcBaipk7NYwSrwTwK1hJARMg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.17.tgz", + "integrity": "sha512-M3XZuORCGB7VPOEDH+nzpJ21XPvK5PyjlkSFkFziNHGLc5d6g3di2McAAblmaSUNl8IOmzYwLx9NsE7bplNkwQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.17.tgz", + "integrity": "sha512-k7f+pf9eXLEey4pBlw+8dgfJHY4PZ5qOUFDyNf7SI6lHjQ9Zt7+NcscjpwdCEbYi6FI5c2KDTDWyf2iHcCSyyQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.17.tgz", + "integrity": "sha512-cEytGqSSoy7zK4JRWiTCx43FsKP/zGr0CsuMawhH67ONlH+T79VteQeJQRO/X7L0juEUA8ZyuYikcRBf0vsxhg==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.6.0", + "@emnapi/runtime": "^1.6.0", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.0.7", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.17.tgz", + "integrity": "sha512-JU5AHr7gKbZlOGvMdb4722/0aYbU+tN6lv1kONx0JK2cGsh7g148zVWLM0IKR3NeKLv+L90chBVYcJ8uJWbC9A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.17.tgz", + "integrity": "sha512-SKWM4waLuqx0IH+FMDUw6R66Hu4OuTALFgnleKbqhgGU30DY20NORZMZUKgLRjQXNN2TLzKvh48QXTig4h4bGw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/postcss": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.1.17.tgz", + "integrity": "sha512-+nKl9N9mN5uJ+M7dBOOCzINw94MPstNR/GtIhz1fpZysxL/4a+No64jCBD6CPN+bIHWFx3KWuu8XJRrj/572Dw==", + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "@tailwindcss/node": "4.1.17", + "@tailwindcss/oxide": "4.1.17", + "postcss": "^8.4.41", + "tailwindcss": "4.1.17" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.25", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.25.tgz", + "integrity": "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.26", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.26.tgz", + "integrity": "sha512-RFA/bURkcKzx/X9oumPG9Vp3D3JUgus/d0b67KB0t5S/raciymilkOa66olh78MUI92QLbEJevO7rvqU/kjwKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.0.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/autoprefixer": { + "version": "10.4.22", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.22.tgz", + "integrity": "sha512-ARe0v/t9gO28Bznv6GgqARmVqcWOV3mfgUPn9becPHMiD3o9BwlRgaeccZnwTpZ7Zwqrm+c1sUSsMxIzQzc8Xg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.27.0", + "caniuse-lite": "^1.0.30001754", + "fraction.js": "^5.3.4", + "normalize-range": "^0.1.2", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.8.28", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.28.tgz", + "integrity": "sha512-gYjt7OIqdM0PcttNYP2aVrr2G0bMALkBaoehD4BuRGjAOtipg0b6wHg1yNL+s5zSnLZZrGHOw4IrND8CD+3oIQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/browserslist": { + "version": "4.28.0", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.0.tgz", + "integrity": "sha512-tbydkR/CxfMwelN0vwdP/pLkDwyAASZ+VfWm4EOwlB6SWhx1sYnWLqo8N5j0rAzPfzfRaxt0mM/4wPU/Su84RQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.8.25", + "caniuse-lite": "^1.0.30001754", + "electron-to-chromium": "^1.5.249", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.1.4" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001754", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001754.tgz", + "integrity": "sha512-x6OeBXueoAceOmotzx3PO4Zpt4rzpeIFsSr6AAePTZxSkXiYDUmpypEl7e2+8NCd9bD7bXjqyef8CJYPC1jfxg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.1.tgz", + "integrity": "sha512-98XGutrXoh75MlgLihlNxAGbUuFQc7l1cqcnEZlLNKc0UrVdPndgmaDmYTDDh929VS/eqTZV0rozmhu2qqT1/g==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.254", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.254.tgz", + "integrity": "sha512-DcUsWpVhv9svsKRxnSCZ86SjD+sp32SGidNB37KpqXJncp1mfUgKbHvBomE89WJDbfVKw1mdv5+ikrvd43r+Bg==", + "dev": true, + "license": "ISC" + }, + "node_modules/enhanced-resolve": { + "version": "5.18.3", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz", + "integrity": "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.2.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lightningcss": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz", + "integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.30.2", + "lightningcss-darwin-arm64": "1.30.2", + "lightningcss-darwin-x64": "1.30.2", + "lightningcss-freebsd-x64": "1.30.2", + "lightningcss-linux-arm-gnueabihf": "1.30.2", + "lightningcss-linux-arm64-gnu": "1.30.2", + "lightningcss-linux-arm64-musl": "1.30.2", + "lightningcss-linux-x64-gnu": "1.30.2", + "lightningcss-linux-x64-musl": "1.30.2", + "lightningcss-win32-arm64-msvc": "1.30.2", + "lightningcss-win32-x64-msvc": "1.30.2" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz", + "integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz", + "integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz", + "integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz", + "integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz", + "integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz", + "integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz", + "integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz", + "integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz", + "integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz", + "integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz", + "integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.263.1", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.263.1.tgz", + "integrity": "sha512-keqxAx97PlaEN89PXZ6ki1N8nRjGWtDa4021GFYLNj0RgruM5odbpl8GHTExj0hhPq3sF6Up0gnxt6TSHu+ovw==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-range": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", + "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/re-resizable": { + "version": "6.11.2", + "resolved": "https://registry.npmjs.org/re-resizable/-/re-resizable-6.11.2.tgz", + "integrity": "sha512-2xI2P3OHs5qw7K0Ud1aLILK6MQxW50TcO+DetD9eIV58j84TqYeHoZcL9H4GXFXXIh7afhH8mv5iUCXII7OW7A==", + "license": "MIT", + "peerDependencies": { + "react": "^16.13.1 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.13.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.53.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.2.tgz", + "integrity": "sha512-MHngMYwGJVi6Fmnk6ISmnk7JAHRNF0UkuucA0CUW3N3a4KnONPEZz+vUanQP/ZC/iY1Qkf3bwPWzyY84wEks1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.53.2", + "@rollup/rollup-android-arm64": "4.53.2", + "@rollup/rollup-darwin-arm64": "4.53.2", + "@rollup/rollup-darwin-x64": "4.53.2", + "@rollup/rollup-freebsd-arm64": "4.53.2", + "@rollup/rollup-freebsd-x64": "4.53.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.53.2", + "@rollup/rollup-linux-arm-musleabihf": "4.53.2", + "@rollup/rollup-linux-arm64-gnu": "4.53.2", + "@rollup/rollup-linux-arm64-musl": "4.53.2", + "@rollup/rollup-linux-loong64-gnu": "4.53.2", + "@rollup/rollup-linux-ppc64-gnu": "4.53.2", + "@rollup/rollup-linux-riscv64-gnu": "4.53.2", + "@rollup/rollup-linux-riscv64-musl": "4.53.2", + "@rollup/rollup-linux-s390x-gnu": "4.53.2", + "@rollup/rollup-linux-x64-gnu": "4.53.2", + "@rollup/rollup-linux-x64-musl": "4.53.2", + "@rollup/rollup-openharmony-arm64": "4.53.2", + "@rollup/rollup-win32-arm64-msvc": "4.53.2", + "@rollup/rollup-win32-ia32-msvc": "4.53.2", + "@rollup/rollup-win32-x64-gnu": "4.53.2", + "@rollup/rollup-win32-x64-msvc": "4.53.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/sonner": { + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/sonner/-/sonner-1.7.4.tgz", + "integrity": "sha512-DIS8z4PfJRbIyfVFDVnK9rO3eYDtse4Omcm6bt0oEr5/jtLgysmjuBl1frJ9E/EQZrFmKx2A8m/s5s9CRXIzhw==", + "license": "MIT", + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/tailwindcss": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.17.tgz", + "integrity": "sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q==", + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", + "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/terser": { + "version": "5.44.1", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.44.1.tgz", + "integrity": "sha512-t/R3R/n0MSwnnazuPpPNVO60LX0SKL45pyl9YlvxIdkH0Of7D5qM2EVe+yASRIlY5pZ73nclYJfNANGWPwFDZw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.15.0", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.4.tgz", + "integrity": "sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/farsi_transcriber_web/package.json b/farsi_transcriber_web/package.json new file mode 100644 index 0000000..321c588 --- /dev/null +++ b/farsi_transcriber_web/package.json @@ -0,0 +1,32 @@ +{ + "name": "farsi-transcriber-web", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", + "preview": "vite preview" + }, + "dependencies": { + "@tailwindcss/postcss": "^4.1.17", + "lucide-react": "^0.263.1", + "re-resizable": "^6.9.9", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "sonner": "^1.2.0" + }, + "devDependencies": { + "@types/node": "^20.8.0", + "@types/react": "^18.2.37", + "@types/react-dom": "^18.2.15", + "@vitejs/plugin-react": "^4.2.0", + "autoprefixer": "^10.4.16", + "postcss": "^8.4.31", + "tailwindcss": "^4.0.0", + "terser": "^5.44.1", + "typescript": "^5.2.2", + "vite": "^5.0.0" + } +} diff --git a/farsi_transcriber_web/postcss.config.js b/farsi_transcriber_web/postcss.config.js new file mode 100644 index 0000000..1c87846 --- /dev/null +++ b/farsi_transcriber_web/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + '@tailwindcss/postcss': {}, + autoprefixer: {}, + }, +} diff --git a/farsi_transcriber_web/railway.toml b/farsi_transcriber_web/railway.toml new file mode 100644 index 0000000..0df5716 --- /dev/null +++ b/farsi_transcriber_web/railway.toml @@ -0,0 +1,13 @@ +# Railway configuration file +# https://docs.railway.app/reference/nixpacks + +[build] +builder = "nixpacks" + +[[services]] +name = "backend" +startCommand = "cd backend && gunicorn --workers 2 --worker-class sync --timeout 120 --bind 0.0.0.0:5000 app:app" + +[[services]] +name = "frontend" +startCommand = "npm run build && npm run preview" diff --git a/farsi_transcriber_web/src/App.tsx b/farsi_transcriber_web/src/App.tsx new file mode 100644 index 0000000..ab59ff2 --- /dev/null +++ b/farsi_transcriber_web/src/App.tsx @@ -0,0 +1,537 @@ +import { useState, useRef } from 'react'; +import { + FileAudio, + Upload, + Moon, + Sun, + Search, + Copy, + X, + CheckCircle2, + Clock, + Loader2, + Download +} from 'lucide-react'; +import { Resizable } from 're-resizable'; +import { Toaster, toast } from 'sonner'; +import Button from './components/Button'; +import Progress from './components/Progress'; +import Input from './components/Input'; +import Select from './components/Select'; + +interface FileItem { + id: string; + name: string; + status: 'pending' | 'processing' | 'completed' | 'error'; + progress?: number; + transcription?: TranscriptionSegment[]; + file?: File; + fullText?: string; +} + +interface TranscriptionSegment { + start: string; + end: string; + text: string; +} + +// Get API URL from environment variable +const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:5000'; + +export default function App() { + const [fileQueue, setFileQueue] = useState([]); + const [selectedFileId, setSelectedFileId] = useState(null); + const [isDark, setIsDark] = useState(false); + const [windowSize, setWindowSize] = useState({ width: 1100, height: 700 }); + const [searchQuery, setSearchQuery] = useState(''); + const [exportFormat, setExportFormat] = useState('txt'); + const fileInputRef = useRef(null); + + // Theme colors + const theme = { + bg: isDark ? '#1a1a1a' : '#f5f5f5', + cardBg: isDark ? '#2d2d2d' : '#ffffff', + inputBg: isDark ? '#3a3a3a' : '#f9f9f9', + border: isDark ? '#4a4a4a' : '#d0d0d0', + text: isDark ? '#e0e0e0' : '#333333', + textSecondary: isDark ? '#a0a0a0' : '#666666', + progressBg: isDark ? '#404040' : '#e0e0e0', + sidebarBg: isDark ? '#252525' : '#fafafa', + hoverBg: isDark ? '#3a3a3a' : '#f0f0f0', + selectedBg: isDark ? '#4a4a4a' : '#e8f5e9', + }; + + const handleAddFiles = () => { + fileInputRef.current?.click(); + }; + + const handleFileInputChange = (e: React.ChangeEvent) => { + const files = e.currentTarget.files; + if (!files) return; + + const newFiles: FileItem[] = []; + for (let i = 0; i < files.length; i++) { + const file = files[i]; + const newFileItem: FileItem = { + id: `${Date.now()}-${i}`, + name: file.name, + status: 'pending', + file: file, + }; + newFiles.push(newFileItem); + } + + setFileQueue([...fileQueue, ...newFiles]); + if (!selectedFileId && newFiles.length > 0) { + setSelectedFileId(newFiles[0].id); + } + toast.success(`${newFiles.length} file(s) added to queue`); + + // Reset input + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + }; + + const handleRemoveFile = (id: string) => { + setFileQueue(fileQueue.filter(f => f.id !== id)); + if (selectedFileId === id) { + setSelectedFileId(fileQueue[0]?.id || null); + } + toast.info('File removed from queue'); + }; + + const handleTranscribe = async () => { + if (!selectedFileId) return; + + const fileIndex = fileQueue.findIndex(f => f.id === selectedFileId); + if (fileIndex === -1 || !fileQueue[fileIndex].file) return; + + // Update status to processing + const updatedQueue = [...fileQueue]; + updatedQueue[fileIndex].status = 'processing'; + updatedQueue[fileIndex].progress = 0; + setFileQueue(updatedQueue); + + try { + const file = fileQueue[fileIndex].file!; + const formData = new FormData(); + formData.append('file', file); + formData.append('language', 'fa'); // Farsi by default + + // Show loading toast + const loadingToastId = toast.loading('Loading Whisper model (first time only)...'); + + const response = await fetch(`${API_URL}/transcribe`, { + method: 'POST', + body: formData, + }); + + if (!response.ok) { + throw new Error(`API error: ${response.statusText}`); + } + + const result = await response.json(); + + // Dismiss loading toast + toast.dismiss(loadingToastId); + + if (result.status === 'success') { + const updated = [...fileQueue]; + updated[fileIndex].status = 'completed'; + updated[fileIndex].progress = 100; + updated[fileIndex].transcription = result.segments; + updated[fileIndex].fullText = result.text; + setFileQueue(updated); + toast.success('Transcription completed!'); + } else { + throw new Error(result.error || 'Unknown error'); + } + } catch (error) { + const updated = [...fileQueue]; + updated[fileIndex].status = 'error'; + setFileQueue(updated); + const errorMsg = error instanceof Error ? error.message : 'Failed to transcribe file'; + toast.error(errorMsg); + } + }; + + const handleCopySegment = (text: string) => { + navigator.clipboard.writeText(text); + toast.success('Copied to clipboard'); + }; + + const handleExport = async () => { + const selectedFile = fileQueue.find(f => f.id === selectedFileId); + if (!selectedFile?.transcription || !selectedFile.fullText) { + toast.error('No transcription to export'); + return; + } + + try { + const toastId = toast.loading('Preparing export...'); + + const response = await fetch(`${API_URL}/export`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + transcription: selectedFile.fullText, + segments: selectedFile.transcription, + format: exportFormat, + }), + }); + + if (!response.ok) { + throw new Error(`Export failed: ${response.statusText}`); + } + + const result = await response.json(); + toast.dismiss(toastId); + + if (result.status === 'success') { + // Create a blob and download + const blob = new Blob([result.content], { type: result.mime_type }); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${selectedFile.name.split('.')[0]}.${exportFormat === 'json' ? 'json' : exportFormat}`; + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); + document.body.removeChild(a); + toast.success(`Exported as ${exportFormat.toUpperCase()}`); + } else { + throw new Error(result.error || 'Export failed'); + } + } catch (error) { + const errorMsg = error instanceof Error ? error.message : 'Failed to export'; + toast.error(errorMsg); + } + }; + + const handleClearAll = () => { + setFileQueue([]); + setSelectedFileId(null); + setSearchQuery(''); + toast.info('All files cleared'); + }; + + const selectedFile = fileQueue.find(f => f.id === selectedFileId); + const currentTranscription = selectedFile?.transcription || []; + + // Filter transcription based on search + const filteredTranscription = searchQuery + ? currentTranscription.filter(seg => + seg.text.toLowerCase().includes(searchQuery.toLowerCase()) + ) + : currentTranscription; + + // Function to highlight search text + const highlightText = (text: string, query: string) => { + if (!query) return text; + + const parts = text.split(new RegExp(`(${query})`, 'gi')); + return parts.map((part) => + part.toLowerCase() === query.toLowerCase() + ? `${part}` + : part + ).join(''); + }; + + const getStatusIcon = (status: FileItem['status']) => { + switch (status) { + case 'completed': + return ; + case 'processing': + return ; + case 'error': + return ; + default: + return ; + } + }; + + return ( +
+ + + {/* Hidden file input */} + + + { + setWindowSize({ + width: windowSize.width + d.width, + height: windowSize.height + d.height, + }); + }} + minWidth={900} + minHeight={600} + className="rounded-lg shadow-2xl overflow-hidden" + style={{ + backgroundColor: theme.cardBg, + border: `2px solid ${theme.border}`, + }} + handleStyles={{ + right: { cursor: 'ew-resize' }, + bottom: { cursor: 'ns-resize' }, + bottomRight: { cursor: 'nwse-resize' }, + }} + > +
+ {/* Left Sidebar - File Queue */} +
+
+

+ File Queue +

+ +
+ +
+ {fileQueue.length === 0 ? ( +

+ No files in queue +

+ ) : ( + fileQueue.map((file) => ( +
setSelectedFileId(file.id)} + > +
+
+ {getStatusIcon(file.status)} + + {file.name} + +
+ +
+ {file.status === 'processing' && ( +
+ +

+ {file.progress}% +

+
+ )} +
+ )) + )} +
+
+ + {/* Main Content Area */} +
+ {/* Header */} +
+
+

+ Farsi Audio/Video Transcriber +

+ + {windowSize.width}×{windowSize.height} + +
+ +
+ +
+ {/* File Info & Actions */} +
+
+
+ +
+

+ {selectedFile ? selectedFile.name : 'No file selected'} +

+ {selectedFile?.status === 'processing' && ( +

+ Processing... {selectedFile.progress}% +

+ )} + {selectedFile?.status === 'completed' && ( +

Completed

+ )} +
+
+ +
+
+ + {/* Search & Export Controls */} + {selectedFile?.transcription && ( +
+
+ + setSearchQuery(e.target.value)} + style={{ + backgroundColor: theme.inputBg, + borderColor: theme.border, + color: theme.text, + paddingLeft: '2.25rem', + }} + /> +
+ + +
+ )} + + {/* Transcription Results */} +
+
+ + {searchQuery && ( + + {filteredTranscription.length} results found + + )} +
+ +
+ {currentTranscription.length === 0 ? ( +

+ Transcription results will appear here... +

+ ) : ( +
+ {filteredTranscription.map((segment, index) => ( +
+
+ + [{segment.start} - {segment.end}] + + +
+

+

+ ))} +
+ )} +
+
+ + {/* Bottom Actions */} +
+

+ {selectedFile?.status === 'completed' && `${currentTranscription.length} segments`} +

+ +
+
+
+
+
+
+ ); +} diff --git a/farsi_transcriber_web/src/components/Button.tsx b/farsi_transcriber_web/src/components/Button.tsx new file mode 100644 index 0000000..f1abd72 --- /dev/null +++ b/farsi_transcriber_web/src/components/Button.tsx @@ -0,0 +1,36 @@ +import React from 'react'; + +interface ButtonProps extends React.ButtonHTMLAttributes { + variant?: 'default' | 'outline'; + size?: 'sm' | 'md' | 'lg'; + children: React.ReactNode; +} + +const Button = React.forwardRef( + ({ variant = 'default', size = 'md', className, ...props }, ref) => { + const baseStyles = 'font-medium rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed inline-flex items-center justify-center'; + + const variantStyles = { + default: 'bg-green-500 hover:bg-green-600 text-white', + outline: 'border border-gray-300 hover:bg-gray-100 text-gray-900', + }; + + const sizeStyles = { + sm: 'px-3 py-1.5 text-sm', + md: 'px-4 py-2 text-base', + lg: 'px-6 py-3 text-lg', + }; + + return ( +