mirror of
https://github.com/openai/whisper.git
synced 2025-11-23 22:15:58 +00:00
feat: Create React web application with Figma design and Flask backend
Frontend: - Initialize React 18 + TypeScript project with Vite - Implement complete App.tsx matching Figma design - Add dark/light theme toggle support - Create file queue management UI - Implement search with text highlighting - Add segment copy functionality - Create reusable UI components (Button, Progress, Input, Select) - Configure Tailwind CSS v4.0 for styling - Setup window resizing functionality - Implement RTL support for Farsi text Backend: - Create Flask API server with CORS support - Implement /transcribe endpoint for audio/video processing - Add /models endpoint for available models info - Implement /export endpoint for multiple formats (TXT, SRT, VTT, JSON) - Setup Whisper model integration - Handle file uploads with validation - Format transcription results with timestamps Configuration: - Setup Vite dev server with API proxy - Configure Tailwind CSS with custom colors - Setup TypeScript strict mode - Add PostCSS with autoprefixer - Configure Flask for development Documentation: - Write comprehensive README with setup instructions - Include API endpoint documentation - Add troubleshooting guide - Include performance tips Includes everything ready to run with: npm install && npm run dev (frontend) and python backend/app.py (backend)
This commit is contained in:
parent
efdcf42ffd
commit
22ddbf4796
34
farsi_transcriber_web/.gitignore
vendored
Normal file
34
farsi_transcriber_web/.gitignore
vendored
Normal file
@ -0,0 +1,34 @@
|
||||
# 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
|
||||
384
farsi_transcriber_web/README.md
Normal file
384
farsi_transcriber_web/README.md
Normal file
@ -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
|
||||
42
farsi_transcriber_web/backend/.gitignore
vendored
Normal file
42
farsi_transcriber_web/backend/.gitignore
vendored
Normal file
@ -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
|
||||
199
farsi_transcriber_web/backend/app.py
Normal file
199
farsi_transcriber_web/backend/app.py
Normal file
@ -0,0 +1,199 @@
|
||||
"""
|
||||
Farsi Transcriber Backend API
|
||||
|
||||
Flask API for handling audio/video file transcription using Whisper model.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from werkzeug.utils import secure_filename
|
||||
import whisper
|
||||
from flask import Flask, request, jsonify
|
||||
from flask_cors import CORS
|
||||
|
||||
# Add parent directory to path for imports
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
|
||||
|
||||
app = Flask(__name__)
|
||||
CORS(app)
|
||||
|
||||
# Configuration
|
||||
UPLOAD_FOLDER = '/tmp/farsi_transcriber_uploads'
|
||||
ALLOWED_EXTENSIONS = {'mp3', 'wav', 'm4a', 'flac', 'ogg', 'aac', 'wma', 'mp4', 'mkv', 'mov', 'webm', 'avi', 'flv', 'wmv'}
|
||||
MAX_FILE_SIZE = 500 * 1024 * 1024 # 500MB
|
||||
|
||||
os.makedirs(UPLOAD_FOLDER, exist_ok=True)
|
||||
app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER
|
||||
app.config['MAX_CONTENT_LENGTH'] = MAX_FILE_SIZE
|
||||
|
||||
# Load Whisper model
|
||||
try:
|
||||
model = whisper.load_model('medium')
|
||||
print("✓ Whisper model loaded successfully")
|
||||
except Exception as e:
|
||||
print(f"✗ Error loading Whisper model: {e}")
|
||||
model = None
|
||||
|
||||
|
||||
def allowed_file(filename):
|
||||
"""Check if file has allowed extension"""
|
||||
return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
|
||||
|
||||
|
||||
@app.route('/health', methods=['GET'])
|
||||
def health():
|
||||
"""Health check endpoint"""
|
||||
return jsonify({
|
||||
'status': 'healthy',
|
||||
'model_loaded': model is not None,
|
||||
'device': 'cuda' if model else 'N/A'
|
||||
})
|
||||
|
||||
|
||||
@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:
|
||||
# Check if model is loaded
|
||||
if not model:
|
||||
return jsonify({'error': 'Whisper model not loaded'}), 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 = 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__':
|
||||
app.run(debug=True, host='0.0.0.0', port=5000)
|
||||
6
farsi_transcriber_web/backend/requirements.txt
Normal file
6
farsi_transcriber_web/backend/requirements.txt
Normal file
@ -0,0 +1,6 @@
|
||||
Flask==2.3.3
|
||||
Flask-CORS==4.0.0
|
||||
python-dotenv==1.0.0
|
||||
openai-whisper==20230314
|
||||
torch>=1.10.1
|
||||
python-multipart==0.0.6
|
||||
13
farsi_transcriber_web/index.html
Normal file
13
farsi_transcriber_web/index.html
Normal file
@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Farsi Audio/Video Transcriber</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
30
farsi_transcriber_web/package.json
Normal file
30
farsi_transcriber_web/package.json
Normal file
@ -0,0 +1,30 @@
|
||||
{
|
||||
"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": {
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"lucide-react": "^0.263.1",
|
||||
"re-resizable": "^6.9.9",
|
||||
"sonner": "^1.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.37",
|
||||
"@types/react-dom": "^18.2.15",
|
||||
"@vitejs/plugin-react": "^4.2.0",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^5.0.0",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"postcss": "^8.4.31",
|
||||
"@types/node": "^20.8.0"
|
||||
}
|
||||
}
|
||||
6
farsi_transcriber_web/postcss.config.js
Normal file
6
farsi_transcriber_web/postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
449
farsi_transcriber_web/src/App.tsx
Normal file
449
farsi_transcriber_web/src/App.tsx
Normal file
@ -0,0 +1,449 @@
|
||||
import { useState } 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[];
|
||||
}
|
||||
|
||||
interface TranscriptionSegment {
|
||||
start: string;
|
||||
end: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const [fileQueue, setFileQueue] = useState<FileItem[]>([]);
|
||||
const [selectedFileId, setSelectedFileId] = useState<string | null>(null);
|
||||
const [isDark, setIsDark] = useState(false);
|
||||
const [windowSize, setWindowSize] = useState({ width: 1100, height: 700 });
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [exportFormat, setExportFormat] = useState('txt');
|
||||
|
||||
// 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 = () => {
|
||||
// Simulated file addition for now
|
||||
// TODO: Implement real file picker
|
||||
const newFile: FileItem = {
|
||||
id: Date.now().toString(),
|
||||
name: `recording_${fileQueue.length + 1}.mp3`,
|
||||
status: 'pending',
|
||||
};
|
||||
setFileQueue([...fileQueue, newFile]);
|
||||
if (!selectedFileId) {
|
||||
setSelectedFileId(newFile.id);
|
||||
}
|
||||
toast.success('File added to queue');
|
||||
};
|
||||
|
||||
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) return;
|
||||
|
||||
// Update status to processing
|
||||
const updatedQueue = [...fileQueue];
|
||||
updatedQueue[fileIndex].status = 'processing';
|
||||
updatedQueue[fileIndex].progress = 0;
|
||||
setFileQueue(updatedQueue);
|
||||
|
||||
try {
|
||||
// TODO: Call real Whisper API
|
||||
// Simulate progress for now
|
||||
let progress = 0;
|
||||
const interval = setInterval(() => {
|
||||
progress += 10;
|
||||
const updated = [...fileQueue];
|
||||
updated[fileIndex].progress = progress;
|
||||
|
||||
if (progress >= 100) {
|
||||
clearInterval(interval);
|
||||
updated[fileIndex].status = 'completed';
|
||||
updated[fileIndex].transcription = [
|
||||
{ start: '00:00:00.000', end: '00:00:05.500', text: 'سلام دنیا، این یک تست است' },
|
||||
{ start: '00:00:05.500', end: '00:00:10.200', text: 'خوش آمدید به برنامه تجزیه صوت' },
|
||||
{ start: '00:00:10.200', end: '00:00:15.800', text: 'این برنامه با استفاده از مدل ویسپر کار میکند' },
|
||||
{ start: '00:00:15.800', end: '00:00:22.300', text: 'شما میتوانید فایلهای صوتی و تصویری خود را به متن تبدیل کنید' },
|
||||
{ start: '00:00:22.300', end: '00:00:28.100', text: 'این ابزار برای تحقیقات علمی و سخنرانیها مفید است' },
|
||||
];
|
||||
toast.success('Transcription completed!');
|
||||
}
|
||||
setFileQueue(updated);
|
||||
}, 300);
|
||||
} catch (error) {
|
||||
const updated = [...fileQueue];
|
||||
updated[fileIndex].status = 'error';
|
||||
setFileQueue(updated);
|
||||
toast.error('Failed to transcribe file');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopySegment = (text: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
toast.success('Copied to clipboard');
|
||||
};
|
||||
|
||||
const handleExport = () => {
|
||||
const selectedFile = fileQueue.find(f => f.id === selectedFileId);
|
||||
if (selectedFile?.transcription) {
|
||||
// TODO: Implement real export
|
||||
toast.success(`Exporting as ${exportFormat.toUpperCase()}...`);
|
||||
} else {
|
||||
toast.error('No transcription to export');
|
||||
}
|
||||
};
|
||||
|
||||
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, i) =>
|
||||
part.toLowerCase() === query.toLowerCase()
|
||||
? `<mark style="background-color: ${isDark ? '#4CAF50' : '#FFEB3B'}; color: ${isDark ? '#000' : '#000'}; padding: 2px 4px; border-radius: 2px;">${part}</mark>`
|
||||
: part
|
||||
).join('');
|
||||
};
|
||||
|
||||
const getStatusIcon = (status: FileItem['status']) => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return <CheckCircle2 className="w-4 h-4 text-green-500" />;
|
||||
case 'processing':
|
||||
return <Loader2 className="w-4 h-4 text-blue-500 animate-spin" />;
|
||||
case 'error':
|
||||
return <X className="w-4 h-4 text-red-500" />;
|
||||
default:
|
||||
return <Clock className="w-4 h-4" style={{ color: theme.textSecondary }} />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-8" style={{ backgroundColor: theme.bg }}>
|
||||
<Toaster theme={isDark ? 'dark' : 'light'} position="top-right" />
|
||||
|
||||
<Resizable
|
||||
size={windowSize}
|
||||
onResizeStop={(e, direction, ref, d) => {
|
||||
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' },
|
||||
}}
|
||||
>
|
||||
<div className="flex h-full">
|
||||
{/* Left Sidebar - File Queue */}
|
||||
<div
|
||||
className="w-64 border-r flex flex-col overflow-hidden"
|
||||
style={{ borderColor: theme.border, backgroundColor: theme.sidebarBg }}
|
||||
>
|
||||
<div className="p-4 border-b" style={{ borderColor: theme.border }}>
|
||||
<h3 className="mb-3 font-semibold" style={{ color: theme.text }}>
|
||||
File Queue
|
||||
</h3>
|
||||
<Button
|
||||
onClick={handleAddFiles}
|
||||
className="w-full bg-green-500 hover:bg-green-600 text-white"
|
||||
>
|
||||
<Upload className="w-4 h-4 mr-2" />
|
||||
Add Files
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto p-2">
|
||||
{fileQueue.length === 0 ? (
|
||||
<p className="text-center text-xs p-4" style={{ color: theme.textSecondary }}>
|
||||
No files in queue
|
||||
</p>
|
||||
) : (
|
||||
fileQueue.map((file) => (
|
||||
<div
|
||||
key={file.id}
|
||||
className="mb-2 p-3 rounded-lg cursor-pointer transition-colors border"
|
||||
style={{
|
||||
backgroundColor: selectedFileId === file.id ? theme.selectedBg : theme.cardBg,
|
||||
borderColor: selectedFileId === file.id ? '#4CAF50' : theme.border,
|
||||
}}
|
||||
onClick={() => setSelectedFileId(file.id)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2 mb-2">
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
{getStatusIcon(file.status)}
|
||||
<span className="text-xs truncate" style={{ color: theme.text }}>
|
||||
{file.name}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleRemoveFile(file.id);
|
||||
}}
|
||||
className="hover:opacity-70"
|
||||
>
|
||||
<X className="w-3 h-3" style={{ color: theme.textSecondary }} />
|
||||
</button>
|
||||
</div>
|
||||
{file.status === 'processing' && (
|
||||
<div className="space-y-1">
|
||||
<Progress value={file.progress || 0} />
|
||||
<p className="text-xs" style={{ color: theme.textSecondary }}>
|
||||
{file.progress}%
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content Area */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* Header */}
|
||||
<div
|
||||
className="p-5 border-b flex items-center justify-between"
|
||||
style={{ borderColor: theme.border }}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 style={{ color: theme.text }} className="text-lg font-semibold">
|
||||
Farsi Audio/Video Transcriber
|
||||
</h1>
|
||||
<span className="text-xs" style={{ color: theme.textSecondary }}>
|
||||
{windowSize.width}×{windowSize.height}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => setIsDark(!isDark)}
|
||||
variant="outline"
|
||||
style={{ borderColor: theme.border, backgroundColor: theme.cardBg }}
|
||||
>
|
||||
{isDark ? (
|
||||
<Sun className="w-4 h-4" style={{ color: theme.text }} />
|
||||
) : (
|
||||
<Moon className="w-4 h-4" style={{ color: theme.text }} />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex flex-col p-5 overflow-hidden">
|
||||
{/* File Info & Actions */}
|
||||
<div
|
||||
className="mb-4 p-4 rounded-lg border"
|
||||
style={{ backgroundColor: theme.inputBg, borderColor: theme.border }}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<FileAudio className="w-5 h-5" style={{ color: theme.textSecondary }} />
|
||||
<div>
|
||||
<p className="text-sm" style={{ color: theme.text }}>
|
||||
{selectedFile ? selectedFile.name : 'No file selected'}
|
||||
</p>
|
||||
{selectedFile?.status === 'processing' && (
|
||||
<p className="text-xs" style={{ color: theme.textSecondary }}>
|
||||
Processing... {selectedFile.progress}%
|
||||
</p>
|
||||
)}
|
||||
{selectedFile?.status === 'completed' && (
|
||||
<p className="text-xs text-green-500">Completed</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleTranscribe}
|
||||
disabled={!selectedFile || selectedFile.status === 'processing' || selectedFile.status === 'completed'}
|
||||
className="bg-green-500 hover:bg-green-600 text-white disabled:bg-gray-400 disabled:cursor-not-allowed"
|
||||
>
|
||||
{selectedFile?.status === 'processing' ? 'Transcribing...' : 'Transcribe'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search & Export Controls */}
|
||||
{selectedFile?.transcription && (
|
||||
<div className="mb-4 flex gap-2">
|
||||
<div className="flex-1 relative">
|
||||
<Search
|
||||
className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2"
|
||||
style={{ color: theme.textSecondary }}
|
||||
/>
|
||||
<Input
|
||||
placeholder="Search in transcription..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
style={{
|
||||
backgroundColor: theme.inputBg,
|
||||
borderColor: theme.border,
|
||||
color: theme.text,
|
||||
paddingLeft: '2.25rem',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Select value={exportFormat} onValueChange={setExportFormat}>
|
||||
<option value="txt">TXT</option>
|
||||
<option value="docx">DOCX</option>
|
||||
<option value="pdf">PDF</option>
|
||||
<option value="srt">SRT</option>
|
||||
</Select>
|
||||
<Button
|
||||
onClick={handleExport}
|
||||
variant="outline"
|
||||
style={{ borderColor: theme.border, backgroundColor: theme.cardBg, color: theme.text }}
|
||||
>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Export
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Transcription Results */}
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label style={{ color: theme.text }} className="text-sm font-medium">
|
||||
Transcription Results:
|
||||
</label>
|
||||
{searchQuery && (
|
||||
<span className="text-xs" style={{ color: theme.textSecondary }}>
|
||||
{filteredTranscription.length} results found
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex-1 rounded-lg border p-4 overflow-auto"
|
||||
style={{ backgroundColor: theme.cardBg, borderColor: theme.border }}
|
||||
>
|
||||
{currentTranscription.length === 0 ? (
|
||||
<p className="text-center" style={{ color: theme.textSecondary }}>
|
||||
Transcription results will appear here...
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{filteredTranscription.map((segment, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="p-3 rounded-md border group hover:shadow-sm transition-shadow"
|
||||
style={{
|
||||
backgroundColor: theme.inputBg,
|
||||
borderColor: theme.border,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3 mb-2">
|
||||
<span
|
||||
className="text-xs font-mono"
|
||||
style={{ color: theme.textSecondary }}
|
||||
>
|
||||
[{segment.start} - {segment.end}]
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handleCopySegment(segment.text)}
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
title="Copy segment"
|
||||
>
|
||||
<Copy className="w-3 h-3" style={{ color: theme.textSecondary }} />
|
||||
</button>
|
||||
</div>
|
||||
<p
|
||||
className="text-sm leading-relaxed"
|
||||
style={{ color: theme.text }}
|
||||
dir="rtl"
|
||||
dangerouslySetInnerHTML={{ __html: highlightText(segment.text, searchQuery) }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom Actions */}
|
||||
<div className="flex justify-between items-center mt-4">
|
||||
<p className="text-xs" style={{ color: theme.textSecondary }}>
|
||||
{selectedFile?.status === 'completed' && `${currentTranscription.length} segments`}
|
||||
</p>
|
||||
<Button
|
||||
onClick={handleClearAll}
|
||||
variant="outline"
|
||||
style={{ borderColor: theme.border, backgroundColor: theme.cardBg, color: theme.text }}
|
||||
>
|
||||
Clear All
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Resizable>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
36
farsi_transcriber_web/src/components/Button.tsx
Normal file
36
farsi_transcriber_web/src/components/Button.tsx
Normal file
@ -0,0 +1,36 @@
|
||||
import React from 'react';
|
||||
|
||||
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: 'default' | 'outline';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ 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 (
|
||||
<button
|
||||
ref={ref}
|
||||
className={`${baseStyles} ${variantStyles[variant]} ${sizeStyles[size]} ${className || ''}`}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Button.displayName = 'Button';
|
||||
|
||||
export default Button;
|
||||
24
farsi_transcriber_web/src/components/Input.tsx
Normal file
24
farsi_transcriber_web/src/components/Input.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import React from 'react';
|
||||
|
||||
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
label?: string;
|
||||
}
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ label, className, ...props }, ref) => {
|
||||
return (
|
||||
<div className="w-full">
|
||||
{label && <label className="block text-sm font-medium mb-1">{label}</label>}
|
||||
<input
|
||||
ref={ref}
|
||||
className={`w-full px-3 py-2 border rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-green-500 ${className || ''}`}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Input.displayName = 'Input';
|
||||
|
||||
export default Input;
|
||||
15
farsi_transcriber_web/src/components/Progress.tsx
Normal file
15
farsi_transcriber_web/src/components/Progress.tsx
Normal file
@ -0,0 +1,15 @@
|
||||
interface ProgressProps {
|
||||
value: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function Progress({ value, className }: ProgressProps) {
|
||||
return (
|
||||
<div className={`w-full bg-gray-200 rounded-full h-1.5 overflow-hidden ${className || ''}`}>
|
||||
<div
|
||||
className="bg-green-500 h-full transition-all duration-300"
|
||||
style={{ width: `${Math.min(100, Math.max(0, value))}%` }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
27
farsi_transcriber_web/src/components/Select.tsx
Normal file
27
farsi_transcriber_web/src/components/Select.tsx
Normal file
@ -0,0 +1,27 @@
|
||||
import React from 'react';
|
||||
|
||||
interface SelectProps extends React.SelectHTMLAttributes<HTMLSelectElement> {
|
||||
label?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const Select = React.forwardRef<HTMLSelectElement, SelectProps>(
|
||||
({ label, className, children, ...props }, ref) => {
|
||||
return (
|
||||
<div className="w-full">
|
||||
{label && <label className="block text-sm font-medium mb-1">{label}</label>}
|
||||
<select
|
||||
ref={ref}
|
||||
className={`w-full px-3 py-2 border rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-green-500 bg-white ${className || ''}`}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Select.displayName = 'Select';
|
||||
|
||||
export default Select;
|
||||
4
farsi_transcriber_web/src/components/__init__.ts
Normal file
4
farsi_transcriber_web/src/components/__init__.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export { default as Button } from './Button';
|
||||
export { default as Progress } from './Progress';
|
||||
export { default as Input } from './Input';
|
||||
export { default as Select } from './Select';
|
||||
46
farsi_transcriber_web/src/index.css
Normal file
46
farsi_transcriber_web/src/index.css
Normal file
@ -0,0 +1,46 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html, body, #root {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
mark {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/* RTL Support */
|
||||
[dir="rtl"] {
|
||||
text-align: right;
|
||||
direction: rtl;
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #4CAF50;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #45a049;
|
||||
}
|
||||
10
farsi_transcriber_web/src/main.tsx
Normal file
10
farsi_transcriber_web/src/main.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App.tsx'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
17
farsi_transcriber_web/tailwind.config.js
Normal file
17
farsi_transcriber_web/tailwind.config.js
Normal file
@ -0,0 +1,17 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: '#4CAF50',
|
||||
'primary-hover': '#45a049',
|
||||
'primary-active': '#3d8b40',
|
||||
}
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
27
farsi_transcriber_web/tsconfig.json
Normal file
27
farsi_transcriber_web/tsconfig.json
Normal file
@ -0,0 +1,27 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
10
farsi_transcriber_web/tsconfig.node.json
Normal file
10
farsi_transcriber_web/tsconfig.node.json
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
17
farsi_transcriber_web/vite.config.ts
Normal file
17
farsi_transcriber_web/vite.config.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 3000,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:5000',
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/api/, '')
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
Loading…
x
Reference in New Issue
Block a user