Compare commits
22 Commits
793d94c93e
...
production
| Author | SHA1 | Date | |
|---|---|---|---|
| 3d37ce4582 | |||
| 550f3516db | |||
| c4d6cae735 | |||
| 721b18dbbd | |||
| dfd45d9f07 | |||
| 80318a8d43 | |||
| d31a132808 | |||
| 3346817a8a | |||
| b65e683d32 | |||
| d2b820c6f1 | |||
| fcabe882cd | |||
| 29178a75a5 | |||
| 8f9ca669cf | |||
| 54d85f0b34 | |||
| 500502440c | |||
| 8c7716bf4d | |||
| a4ecd3e0ec | |||
| e48ea07e44 | |||
| 465cab8a61 | |||
| 9410b07512 | |||
| 1d2784602b | |||
| abe77e3b29 |
139
.env.example
139
.env.example
@@ -1,8 +1,135 @@
|
||||
# Translation Service Configuration
|
||||
TRANSLATION_SERVICE=google # Options: google, deepl, libre
|
||||
DEEPL_API_KEY=your_deepl_api_key_here
|
||||
# Document Translation API - Environment Configuration
|
||||
# Copy this file to .env and configure your settings
|
||||
# ⚠️ NEVER commit .env to version control!
|
||||
|
||||
# API Configuration
|
||||
# ============== Translation Services ==============
|
||||
# Default provider: google, ollama, deepl, libre, openai, openrouter
|
||||
TRANSLATION_SERVICE=google
|
||||
|
||||
# DeepL API Key (required for DeepL provider)
|
||||
# Get from: https://www.deepl.com/pro-api
|
||||
DEEPL_API_KEY=
|
||||
|
||||
# OpenAI API Key (required for OpenAI provider)
|
||||
# Get from: https://platform.openai.com/api-keys
|
||||
OPENAI_API_KEY=
|
||||
|
||||
# OpenRouter API Key (required for OpenRouter provider)
|
||||
# Get from: https://openrouter.ai/keys
|
||||
OPENROUTER_API_KEY=
|
||||
|
||||
# Ollama Configuration (for local LLM-based translation)
|
||||
OLLAMA_BASE_URL=http://localhost:11434
|
||||
OLLAMA_MODEL=llama3
|
||||
OLLAMA_VISION_MODEL=llava
|
||||
|
||||
# ============== File Limits ==============
|
||||
# Maximum file size in MB
|
||||
MAX_FILE_SIZE_MB=50
|
||||
UPLOAD_DIR=./uploads
|
||||
OUTPUT_DIR=./outputs
|
||||
|
||||
# ============== Rate Limiting (SaaS) ==============
|
||||
# Enable/disable rate limiting
|
||||
RATE_LIMIT_ENABLED=true
|
||||
|
||||
# Request limits
|
||||
RATE_LIMIT_PER_MINUTE=30
|
||||
RATE_LIMIT_PER_HOUR=200
|
||||
|
||||
# Translation-specific limits
|
||||
TRANSLATIONS_PER_MINUTE=10
|
||||
TRANSLATIONS_PER_HOUR=50
|
||||
MAX_CONCURRENT_TRANSLATIONS=5
|
||||
|
||||
# ============== Cleanup Service ==============
|
||||
# Enable automatic file cleanup
|
||||
CLEANUP_ENABLED=true
|
||||
|
||||
# Cleanup interval in minutes
|
||||
CLEANUP_INTERVAL_MINUTES=15
|
||||
|
||||
# File time-to-live in minutes
|
||||
FILE_TTL_MINUTES=60
|
||||
INPUT_FILE_TTL_MINUTES=30
|
||||
OUTPUT_FILE_TTL_MINUTES=120
|
||||
|
||||
# Disk space warning thresholds (GB)
|
||||
DISK_WARNING_THRESHOLD_GB=5.0
|
||||
DISK_CRITICAL_THRESHOLD_GB=1.0
|
||||
|
||||
# ============== Security ==============
|
||||
# Enable HSTS (only for HTTPS deployments)
|
||||
ENABLE_HSTS=false
|
||||
|
||||
# CORS allowed origins (comma-separated)
|
||||
# ⚠️ IMPORTANT: Set to your actual frontend domain(s) in production!
|
||||
# Example: https://yourdomain.com,https://www.yourdomain.com
|
||||
# Use "*" ONLY for local development
|
||||
CORS_ORIGINS=http://localhost:3000
|
||||
|
||||
# Maximum request size in MB
|
||||
MAX_REQUEST_SIZE_MB=100
|
||||
|
||||
# Request timeout in seconds
|
||||
REQUEST_TIMEOUT_SECONDS=300
|
||||
|
||||
# ============== Database ==============
|
||||
# PostgreSQL connection string (recommended for production)
|
||||
# Format: postgresql://user:password@host:port/database
|
||||
# DATABASE_URL=postgresql://translate_user:secure_password@localhost:5432/translate_db
|
||||
|
||||
# SQLite path (used when DATABASE_URL is not set - for development)
|
||||
SQLITE_PATH=data/translate.db
|
||||
|
||||
# Enable SQL query logging (for debugging)
|
||||
DATABASE_ECHO=false
|
||||
|
||||
# Redis for sessions and caching (recommended for production)
|
||||
# REDIS_URL=redis://localhost:6379/0
|
||||
|
||||
# ============== Admin Authentication ==============
|
||||
# ⚠️ REQUIRED: These must be set for admin endpoints to work!
|
||||
ADMIN_USERNAME=admin
|
||||
|
||||
# Use SHA256 hash of password (recommended for production)
|
||||
# Generate with: python -c "import hashlib; print(hashlib.sha256(b'your_password').hexdigest())"
|
||||
ADMIN_PASSWORD_HASH=
|
||||
|
||||
# Or use plain password (NOT recommended for production)
|
||||
# ADMIN_PASSWORD=
|
||||
|
||||
# Token secret for session management
|
||||
# Generate with: python -c "import secrets; print(secrets.token_hex(32))"
|
||||
ADMIN_TOKEN_SECRET=
|
||||
|
||||
# ============== User Authentication ==============
|
||||
# JWT secret key for user tokens
|
||||
# Generate with: python -c "import secrets; print(secrets.token_urlsafe(64))"
|
||||
JWT_SECRET_KEY=
|
||||
|
||||
# Frontend URL for redirects
|
||||
FRONTEND_URL=http://localhost:3000
|
||||
|
||||
# ============== Stripe Payments ==============
|
||||
# Get your keys from https://dashboard.stripe.com/apikeys
|
||||
STRIPE_PUBLISHABLE_KEY=pk_test_...
|
||||
STRIPE_SECRET_KEY=sk_test_...
|
||||
STRIPE_WEBHOOK_SECRET=whsec_...
|
||||
|
||||
# Stripe Price IDs (create products in Stripe Dashboard)
|
||||
# https://dashboard.stripe.com/products
|
||||
STRIPE_PRICE_STARTER_MONTHLY=price_xxx
|
||||
STRIPE_PRICE_STARTER_YEARLY=price_xxx
|
||||
STRIPE_PRICE_PRO_MONTHLY=price_xxx
|
||||
STRIPE_PRICE_PRO_YEARLY=price_xxx
|
||||
STRIPE_PRICE_BUSINESS_MONTHLY=price_xxx
|
||||
STRIPE_PRICE_BUSINESS_YEARLY=price_xxx
|
||||
|
||||
# ============== Monitoring ==============
|
||||
# Log level: DEBUG, INFO, WARNING, ERROR
|
||||
LOG_LEVEL=INFO
|
||||
|
||||
# Enable request logging
|
||||
ENABLE_REQUEST_LOGGING=true
|
||||
|
||||
# Memory usage threshold (percentage)
|
||||
MAX_MEMORY_PERCENT=80
|
||||
75
.env.production
Normal file
75
.env.production
Normal file
@@ -0,0 +1,75 @@
|
||||
# ============================================
|
||||
# Document Translation API - Production Environment
|
||||
# ============================================
|
||||
# IMPORTANT: Review and update all values before deployment
|
||||
|
||||
# ===========================================
|
||||
# Application Settings
|
||||
# ===========================================
|
||||
APP_NAME=Document Translation API
|
||||
APP_ENV=production
|
||||
DEBUG=false
|
||||
LOG_LEVEL=INFO
|
||||
|
||||
# ===========================================
|
||||
# Server Configuration
|
||||
# ===========================================
|
||||
HTTP_PORT=80
|
||||
HTTPS_PORT=443
|
||||
BACKEND_PORT=8000
|
||||
FRONTEND_PORT=3000
|
||||
|
||||
# ===========================================
|
||||
# Domain Configuration
|
||||
# ===========================================
|
||||
DOMAIN=translate.yourdomain.com
|
||||
NEXT_PUBLIC_API_URL=https://translate.yourdomain.com
|
||||
|
||||
# ===========================================
|
||||
# Translation Service Configuration
|
||||
# ===========================================
|
||||
TRANSLATION_SERVICE=ollama
|
||||
OLLAMA_BASE_URL=http://ollama:11434
|
||||
OLLAMA_MODEL=llama3
|
||||
|
||||
# DeepL API (optional)
|
||||
DEEPL_API_KEY=
|
||||
|
||||
# OpenAI API (optional)
|
||||
OPENAI_API_KEY=
|
||||
OPENAI_MODEL=gpt-4o-mini
|
||||
|
||||
# ===========================================
|
||||
# File Upload Settings
|
||||
# ===========================================
|
||||
MAX_FILE_SIZE_MB=50
|
||||
ALLOWED_EXTENSIONS=.docx,.xlsx,.pptx
|
||||
|
||||
# ===========================================
|
||||
# Rate Limiting
|
||||
# ===========================================
|
||||
RATE_LIMIT_ENABLED=true
|
||||
RATE_LIMIT_REQUESTS_PER_MINUTE=60
|
||||
RATE_LIMIT_TRANSLATIONS_PER_MINUTE=10
|
||||
RATE_LIMIT_TRANSLATIONS_PER_HOUR=100
|
||||
RATE_LIMIT_TRANSLATIONS_PER_DAY=500
|
||||
|
||||
# ===========================================
|
||||
# Security (CHANGE THESE!)
|
||||
# ===========================================
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=CHANGE_THIS_SECURE_PASSWORD
|
||||
|
||||
# CORS Configuration
|
||||
CORS_ORIGINS=https://translate.yourdomain.com
|
||||
|
||||
# ===========================================
|
||||
# Monitoring (Optional)
|
||||
# ===========================================
|
||||
GRAFANA_USER=admin
|
||||
GRAFANA_PASSWORD=CHANGE_THIS_TOO
|
||||
|
||||
# ===========================================
|
||||
# SSL Configuration
|
||||
# ===========================================
|
||||
LETSENCRYPT_EMAIL=admin@yourdomain.com
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -40,9 +40,11 @@ outputs/
|
||||
temp/
|
||||
translated_files/
|
||||
translated_test.*
|
||||
data/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
logs/
|
||||
|
||||
# UV / UV lock
|
||||
.venv/
|
||||
|
||||
458
COMPREHENSIVE_REVIEW_AND_PLAN.md
Normal file
458
COMPREHENSIVE_REVIEW_AND_PLAN.md
Normal file
@@ -0,0 +1,458 @@
|
||||
# 📊 Comprehensive Code Review & Deployment Plan
|
||||
|
||||
## Executive Summary
|
||||
|
||||
Your **Document Translation API** is a well-architected SaaS application for translating Office documents (Excel, Word, PowerPoint) while preserving formatting. After a thorough code review, here's a complete assessment and actionable deployment/monetization plan.
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Code Review Summary
|
||||
|
||||
### ✅ Backend Strengths
|
||||
|
||||
| Component | Status | Notes |
|
||||
|-----------|--------|-------|
|
||||
| **FastAPI Architecture** | ✅ Excellent | Clean lifespan management, proper middleware stack |
|
||||
| **Translation Service Layer** | ✅ Excellent | Pluggable provider pattern, thread-safe caching (LRU) |
|
||||
| **Rate Limiting** | ✅ Excellent | Token bucket + sliding window algorithms |
|
||||
| **File Translators** | ✅ Good | Batch translation optimization (5-10x faster) |
|
||||
| **Authentication** | ✅ Good | JWT with refresh tokens, bcrypt fallback |
|
||||
| **Payment Integration** | ✅ Good | Stripe checkout, webhooks, subscriptions |
|
||||
| **Middleware Stack** | ✅ Excellent | Security headers, request logging, cleanup |
|
||||
|
||||
### ✅ Frontend Strengths
|
||||
|
||||
| Component | Status | Notes |
|
||||
|-----------|--------|-------|
|
||||
| **Next.js 16** | ✅ Modern | Latest version with App Router |
|
||||
| **UI Components** | ✅ Excellent | shadcn/ui + Radix UI primitives |
|
||||
| **State Management** | ✅ Good | Zustand for global state |
|
||||
| **WebLLM Integration** | ✅ Innovative | Browser-based translation option |
|
||||
| **Responsive Design** | ✅ Good | Tailwind CSS v4 |
|
||||
|
||||
### ⚠️ Issues to Address
|
||||
|
||||
#### Critical (Must Fix Before Production)
|
||||
|
||||
1. **Hardcoded Admin Credentials**
|
||||
- File: `main.py` line 44
|
||||
- Issue: `ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD", "changeme123")`
|
||||
- Fix: Remove default, require env var
|
||||
|
||||
2. **File-Based User Storage**
|
||||
- File: `services/auth_service.py`
|
||||
- Issue: JSON file storage not scalable
|
||||
- Fix: Migrate to PostgreSQL/MongoDB
|
||||
|
||||
3. **CORS Configuration Too Permissive**
|
||||
- File: `main.py` line 170
|
||||
- Issue: `allow_origins=allowed_origins` defaults to `*`
|
||||
- Fix: Restrict to specific domains
|
||||
|
||||
4. **API Keys in Frontend**
|
||||
- File: `frontend/src/lib/api.ts`
|
||||
- Issue: OpenAI API key passed from client
|
||||
- Fix: Proxy through backend
|
||||
|
||||
5. **Missing Input Sanitization**
|
||||
- File: `translators/*.py`
|
||||
- Issue: No malware scanning for uploads
|
||||
- Fix: Add ClamAV or VirusTotal integration
|
||||
|
||||
#### Important (Should Fix)
|
||||
|
||||
6. **No Database Migrations**
|
||||
- Issue: No Alembic/migration setup
|
||||
- Fix: Add proper migration system
|
||||
|
||||
7. **Incomplete Error Handling in WebLLM**
|
||||
- File: `frontend/src/lib/webllm.ts`
|
||||
- Issue: Generic error messages
|
||||
|
||||
8. **Missing Retry Logic**
|
||||
- File: `services/translation_service.py`
|
||||
- Issue: No exponential backoff for API calls
|
||||
|
||||
9. **Session Storage in Memory**
|
||||
- File: `main.py` line 50: `admin_sessions: dict = {}`
|
||||
- Issue: Lost on restart
|
||||
- Fix: Redis for session storage
|
||||
|
||||
10. **Stripe Price IDs are Placeholders**
|
||||
- File: `models/subscription.py`
|
||||
- Issue: `"price_starter_monthly"` etc.
|
||||
- Fix: Create real Stripe products
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ Recommended Architecture Improvements
|
||||
|
||||
### 1. Database Layer (Priority: HIGH)
|
||||
|
||||
```
|
||||
Current: JSON files (data/users.json)
|
||||
Target: PostgreSQL + Redis
|
||||
|
||||
Stack:
|
||||
├── PostgreSQL (users, subscriptions, usage tracking)
|
||||
├── Redis (sessions, rate limiting, cache)
|
||||
└── S3/MinIO (document storage)
|
||||
```
|
||||
|
||||
### 2. Background Job Processing (Priority: HIGH)
|
||||
|
||||
```
|
||||
Current: Synchronous processing
|
||||
Target: Celery + Redis
|
||||
|
||||
Benefits:
|
||||
├── Large file processing in background
|
||||
├── Email notifications
|
||||
├── Usage report generation
|
||||
└── Cleanup tasks
|
||||
```
|
||||
|
||||
### 3. Monitoring & Observability (Priority: MEDIUM)
|
||||
|
||||
```
|
||||
Stack:
|
||||
├── Prometheus (metrics)
|
||||
├── Grafana (dashboards)
|
||||
├── Sentry (error tracking)
|
||||
└── ELK/Loki (log aggregation)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 💰 Monetization Strategy
|
||||
|
||||
### Pricing Tiers (Based on Market Research)
|
||||
|
||||
Your current pricing is competitive but needs refinement:
|
||||
|
||||
| Plan | Current Price | Recommended | Market Comparison |
|
||||
|------|--------------|-------------|-------------------|
|
||||
| Free | $0 | $0 | Keep as lead gen |
|
||||
| Starter | $9/mo | **$12/mo** | DeepL: €8.99, Azure: Pay-per-use |
|
||||
| Pro | $29/mo | **$39/mo** | DeepL: €29.99 |
|
||||
| Business | $79/mo | **$99/mo** | Competitive |
|
||||
| Enterprise | Custom | Custom | On-request |
|
||||
|
||||
### Revenue Projections
|
||||
|
||||
```
|
||||
Conservative (Year 1):
|
||||
├── 1000 Free users → 5% convert → 50 paid
|
||||
├── 30 Starter × $12 = $360/mo
|
||||
├── 15 Pro × $39 = $585/mo
|
||||
├── 5 Business × $99 = $495/mo
|
||||
└── Total: $1,440/mo = $17,280/year
|
||||
|
||||
Optimistic (Year 1):
|
||||
├── 5000 Free users → 8% convert → 400 paid
|
||||
├── 250 Starter × $12 = $3,000/mo
|
||||
├── 100 Pro × $39 = $3,900/mo
|
||||
├── 40 Business × $99 = $3,960/mo
|
||||
├── 10 Enterprise × $500 = $5,000/mo
|
||||
└── Total: $15,860/mo = $190,320/year
|
||||
```
|
||||
|
||||
### Additional Revenue Streams
|
||||
|
||||
1. **Pay-as-you-go Credits**
|
||||
- Already implemented in `CREDIT_PACKAGES`
|
||||
- Add volume discounts
|
||||
|
||||
2. **API Access Fees**
|
||||
- Charge per 1000 API calls beyond quota
|
||||
- Enterprise: dedicated endpoint
|
||||
|
||||
3. **White-Label Licensing**
|
||||
- $5,000-20,000 one-time + monthly fee
|
||||
- Custom branding, on-premise
|
||||
|
||||
4. **Translation Memory Add-on**
|
||||
- Store/reuse translations
|
||||
- $10-25/mo premium feature
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Deployment Plan
|
||||
|
||||
### Phase 1: Pre-Launch Checklist (Week 1-2)
|
||||
|
||||
- [ ] **Security Hardening**
|
||||
- [ ] Remove default credentials
|
||||
- [ ] Implement proper secrets management (Vault/AWS Secrets)
|
||||
- [ ] Enable HTTPS everywhere
|
||||
- [ ] Add file upload virus scanning
|
||||
- [ ] Implement CSRF protection
|
||||
|
||||
- [ ] **Database Migration**
|
||||
- [ ] Set up PostgreSQL (Supabase/Neon for quick start)
|
||||
- [ ] Migrate user data
|
||||
- [ ] Add Redis for caching
|
||||
|
||||
- [ ] **Stripe Integration**
|
||||
- [ ] Create actual Stripe products
|
||||
- [ ] Test webhook handling
|
||||
- [ ] Implement subscription lifecycle
|
||||
|
||||
### Phase 2: Infrastructure Setup (Week 2-3)
|
||||
|
||||
#### Option A: Managed Cloud (Recommended for Start)
|
||||
|
||||
```yaml
|
||||
# Recommended Stack
|
||||
Provider: Railway / Render / Fly.io
|
||||
Database: Supabase (PostgreSQL)
|
||||
Cache: Upstash Redis
|
||||
Storage: Cloudflare R2 / AWS S3
|
||||
CDN: Cloudflare
|
||||
|
||||
Estimated Cost: $50-150/month
|
||||
```
|
||||
|
||||
#### Option B: Self-Hosted (Current Docker Setup)
|
||||
|
||||
```yaml
|
||||
# Your docker-compose.yml is ready
|
||||
Server: Hetzner / DigitalOcean VPS ($20-50/month)
|
||||
Add:
|
||||
- Let's Encrypt SSL (free)
|
||||
- Watchtower (auto-updates)
|
||||
- Portainer (management)
|
||||
```
|
||||
|
||||
#### Option C: Kubernetes (Scale Later)
|
||||
|
||||
```yaml
|
||||
# When you need it (>1000 active users)
|
||||
Provider: DigitalOcean Kubernetes / GKE
|
||||
Cost: $100-500/month
|
||||
```
|
||||
|
||||
### Phase 3: Launch Preparation (Week 3-4)
|
||||
|
||||
- [ ] **Legal & Compliance**
|
||||
- [ ] Privacy Policy (GDPR compliant)
|
||||
- [ ] Terms of Service
|
||||
- [ ] Cookie consent banner
|
||||
- [ ] DPA for enterprise customers
|
||||
|
||||
- [ ] **Marketing Setup**
|
||||
- [ ] Landing page optimization (you have good sections!)
|
||||
- [ ] SEO meta tags
|
||||
- [ ] Google Analytics / Plausible
|
||||
- [ ] Social proof (testimonials)
|
||||
|
||||
- [ ] **Support Infrastructure**
|
||||
- [ ] Help Center (Intercom/Crisp)
|
||||
- [ ] Email support (support@yourdomain.com)
|
||||
- [ ] Status page (Statuspage.io / BetterStack)
|
||||
|
||||
### Phase 4: Soft Launch (Week 4-5)
|
||||
|
||||
1. **Beta Testing**
|
||||
- Invite 50-100 users
|
||||
- Monitor error rates
|
||||
- Collect feedback
|
||||
|
||||
2. **Performance Testing**
|
||||
- Load test with k6/Locust
|
||||
- Target: 100 concurrent translations
|
||||
|
||||
3. **Documentation**
|
||||
- API docs (already have Swagger!)
|
||||
- User guide
|
||||
- Integration examples
|
||||
|
||||
### Phase 5: Public Launch (Week 6+)
|
||||
|
||||
1. **Announcement**
|
||||
- Product Hunt launch
|
||||
- Hacker News "Show HN"
|
||||
- Dev.to / Medium articles
|
||||
|
||||
2. **Marketing Channels**
|
||||
- Google Ads (document translation keywords)
|
||||
- LinkedIn (business customers)
|
||||
- Reddit (r/translation, r/localization)
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Technical Improvements
|
||||
|
||||
### Immediate Code Changes
|
||||
|
||||
#### 1. Add Retry Logic to Translation Service
|
||||
|
||||
```python
|
||||
# services/translation_service.py
|
||||
from tenacity import retry, stop_after_attempt, wait_exponential
|
||||
|
||||
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=2, max=10))
|
||||
def translate(self, text: str, target_language: str, source_language: str = 'auto') -> str:
|
||||
# existing implementation
|
||||
```
|
||||
|
||||
#### 2. Add Health Check Endpoint Enhancement
|
||||
|
||||
```python
|
||||
# main.py - enhance health endpoint
|
||||
@app.get("/health")
|
||||
async def health_check():
|
||||
checks = {
|
||||
"database": await check_db_connection(),
|
||||
"redis": await check_redis_connection(),
|
||||
"stripe": check_stripe_configured(),
|
||||
"ollama": await check_ollama_available(),
|
||||
}
|
||||
all_healthy = all(checks.values())
|
||||
return JSONResponse(
|
||||
status_code=200 if all_healthy else 503,
|
||||
content={"status": "healthy" if all_healthy else "degraded", "checks": checks}
|
||||
)
|
||||
```
|
||||
|
||||
#### 3. Add Request ID Tracking
|
||||
|
||||
```python
|
||||
# Already partially implemented, ensure full tracing
|
||||
@app.middleware("http")
|
||||
async def add_request_id(request: Request, call_next):
|
||||
request_id = request.headers.get("X-Request-ID", str(uuid.uuid4()))
|
||||
request.state.request_id = request_id
|
||||
response = await call_next(request)
|
||||
response.headers["X-Request-ID"] = request_id
|
||||
return response
|
||||
```
|
||||
|
||||
### Environment Variables Template
|
||||
|
||||
Create `.env.production`:
|
||||
|
||||
```env
|
||||
# API Configuration
|
||||
API_HOST=0.0.0.0
|
||||
API_PORT=8000
|
||||
LOG_LEVEL=INFO
|
||||
|
||||
# Security (REQUIRED - No defaults!)
|
||||
ADMIN_USERNAME=
|
||||
ADMIN_PASSWORD_HASH= # Use: python -c "import hashlib; print(hashlib.sha256('yourpassword'.encode()).hexdigest())"
|
||||
JWT_SECRET_KEY= # Generate: python -c "import secrets; print(secrets.token_urlsafe(64))"
|
||||
CORS_ORIGINS=https://yourdomain.com,https://www.yourdomain.com
|
||||
|
||||
# Database
|
||||
DATABASE_URL=postgresql://user:pass@host:5432/translate
|
||||
REDIS_URL=redis://localhost:6379
|
||||
|
||||
# Stripe (REQUIRED for payments)
|
||||
STRIPE_SECRET_KEY=sk_live_xxx
|
||||
STRIPE_PUBLISHABLE_KEY=pk_live_xxx
|
||||
STRIPE_WEBHOOK_SECRET=whsec_xxx
|
||||
|
||||
# Translation APIs
|
||||
DEEPL_API_KEY=
|
||||
OPENAI_API_KEY=
|
||||
OPENROUTER_API_KEY=
|
||||
|
||||
# File Handling
|
||||
MAX_FILE_SIZE_MB=50
|
||||
FILE_TTL_MINUTES=60
|
||||
|
||||
# Rate Limiting
|
||||
RATE_LIMIT_PER_MINUTE=30
|
||||
TRANSLATIONS_PER_MINUTE=10
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📋 Git Repository Status
|
||||
|
||||
✅ **Git is initialized** on branch `production-deployment`
|
||||
|
||||
```
|
||||
Remote: https://sepehr@gitea.parsanet.org/sepehr/office_translator.git
|
||||
Status: 3 commits ahead of origin
|
||||
Changes: 18 modified files, 3 untracked files
|
||||
```
|
||||
|
||||
### Recommended Git Actions
|
||||
|
||||
```powershell
|
||||
# Stage all changes
|
||||
git add .
|
||||
|
||||
# Commit with descriptive message
|
||||
git commit -m "Pre-production: Updated frontend UI, added notification components"
|
||||
|
||||
# Push to remote
|
||||
git push origin production-deployment
|
||||
|
||||
# Create a release tag when ready
|
||||
git tag -a v1.0.0 -m "Production release v1.0.0"
|
||||
git push origin v1.0.0
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 Competitive Analysis
|
||||
|
||||
| Feature | Your App | DeepL API | Google Cloud | Azure |
|
||||
|---------|----------|-----------|--------------|-------|
|
||||
| Format Preservation | ✅ Excellent | ✅ Good | ⚠️ Basic | ✅ Good |
|
||||
| Self-Hosted Option | ✅ Yes | ❌ No | ❌ No | ❌ No |
|
||||
| Browser-based (WebLLM) | ✅ Unique! | ❌ No | ❌ No | ❌ No |
|
||||
| Vision Translation | ✅ Yes | ⚠️ Limited | ❌ No | ✅ Yes |
|
||||
| Custom Glossaries | ✅ Yes | ✅ Yes | ⚠️ Manual | ✅ Yes |
|
||||
| Pricing | 💰 Lower | 💰💰 | 💰💰 | 💰💰 |
|
||||
|
||||
### Your Unique Selling Points
|
||||
|
||||
1. **Self-hosting option** - Privacy-focused enterprises love this
|
||||
2. **WebLLM in-browser** - No data leaves the device
|
||||
3. **Multi-provider flexibility** - Not locked to one service
|
||||
4. **Format preservation** - Industry-leading for Office docs
|
||||
5. **Lower pricing** - Undercut enterprise competitors
|
||||
|
||||
---
|
||||
|
||||
## 🎯 30-60-90 Day Plan
|
||||
|
||||
### Days 1-30: Foundation
|
||||
|
||||
- [ ] Fix all critical security issues
|
||||
- [ ] Set up PostgreSQL database
|
||||
- [ ] Configure real Stripe products
|
||||
- [ ] Deploy to staging environment
|
||||
- [ ] Beta test with 20 users
|
||||
|
||||
### Days 31-60: Launch
|
||||
|
||||
- [ ] Public launch on chosen platforms
|
||||
- [ ] Set up customer support
|
||||
- [ ] Monitor and fix bugs
|
||||
- [ ] First 100 paying customers goal
|
||||
- [ ] Collect testimonials
|
||||
|
||||
### Days 61-90: Growth
|
||||
|
||||
- [ ] SEO optimization
|
||||
- [ ] Content marketing (blog)
|
||||
- [ ] Partnership with translation agencies
|
||||
- [ ] Feature requests implementation
|
||||
- [ ] First $1,000 MRR milestone
|
||||
|
||||
---
|
||||
|
||||
## 📞 Next Steps
|
||||
|
||||
1. **Immediate**: Fix security issues (admin credentials, CORS)
|
||||
2. **This Week**: Set up PostgreSQL, Redis, real Stripe
|
||||
3. **Next Week**: Deploy to staging, begin beta testing
|
||||
4. **2 Weeks**: Soft launch to early adopters
|
||||
5. **1 Month**: Public launch
|
||||
|
||||
Would you like me to help implement any of these improvements?
|
||||
455
DEPLOYMENT_GUIDE.md
Normal file
455
DEPLOYMENT_GUIDE.md
Normal file
@@ -0,0 +1,455 @@
|
||||
# 🚀 Document Translation API - Production Deployment Guide
|
||||
|
||||
Complete guide for deploying the Document Translation API in production environments.
|
||||
|
||||
## 📋 Table of Contents
|
||||
|
||||
1. [Quick Start](#quick-start)
|
||||
2. [Architecture Overview](#architecture-overview)
|
||||
3. [Docker Deployment](#docker-deployment)
|
||||
4. [Kubernetes Deployment](#kubernetes-deployment)
|
||||
5. [SSL/TLS Configuration](#ssltls-configuration)
|
||||
6. [Environment Configuration](#environment-configuration)
|
||||
7. [Monitoring & Logging](#monitoring--logging)
|
||||
8. [Scaling & Performance](#scaling--performance)
|
||||
9. [Backup & Recovery](#backup--recovery)
|
||||
10. [Troubleshooting](#troubleshooting)
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Docker & Docker Compose v2.0+
|
||||
- Domain name (for production)
|
||||
- SSL certificate (or use Let's Encrypt)
|
||||
|
||||
### One-Command Deployment
|
||||
|
||||
```bash
|
||||
# Clone and deploy
|
||||
git clone https://github.com/your-repo/translate-api.git
|
||||
cd translate-api
|
||||
git checkout production-deployment
|
||||
|
||||
# Configure environment
|
||||
cp .env.example .env.production
|
||||
# Edit .env.production with your settings
|
||||
|
||||
# Deploy!
|
||||
./scripts/deploy.sh production
|
||||
```
|
||||
|
||||
Your application will be available at `https://your-domain.com`
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ Architecture Overview
|
||||
|
||||
```
|
||||
┌─────────────────┐
|
||||
│ Internet │
|
||||
└────────┬────────┘
|
||||
│
|
||||
┌────────▼────────┐
|
||||
│ Nginx │
|
||||
│ (Reverse Proxy)│
|
||||
│ - SSL/TLS │
|
||||
│ - Rate Limit │
|
||||
│ - Caching │
|
||||
└────────┬────────┘
|
||||
┌──────────────┼──────────────┐
|
||||
│ │ │
|
||||
┌────────▼────┐ ┌──────▼─────┐ ┌─────▼─────┐
|
||||
│ Frontend │ │ Backend │ │ Admin │
|
||||
│ (Next.js) │ │ (FastAPI) │ │ Dashboard│
|
||||
│ Port 3000 │ │ Port 8000 │ │ │
|
||||
└─────────────┘ └──────┬─────┘ └───────────┘
|
||||
│
|
||||
┌────────────────────────┼────────────────────────┐
|
||||
│ │ │
|
||||
┌────────▼────────┐ ┌──────────▼──────────┐ ┌───────▼───────┐
|
||||
│ Ollama │ │ Google/DeepL/ │ │ Redis │
|
||||
│ (Local LLM) │ │ OpenAI APIs │ │ (Cache) │
|
||||
└─────────────────┘ └─────────────────────┘ └───────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🐳 Docker Deployment
|
||||
|
||||
### Directory Structure
|
||||
|
||||
```
|
||||
translate-api/
|
||||
├── docker/
|
||||
│ ├── backend/
|
||||
│ │ └── Dockerfile
|
||||
│ ├── frontend/
|
||||
│ │ └── Dockerfile
|
||||
│ ├── nginx/
|
||||
│ │ ├── nginx.conf
|
||||
│ │ ├── conf.d/
|
||||
│ │ │ └── default.conf
|
||||
│ │ └── ssl/
|
||||
│ │ ├── fullchain.pem
|
||||
│ │ └── privkey.pem
|
||||
│ └── prometheus/
|
||||
│ └── prometheus.yml
|
||||
├── scripts/
|
||||
│ ├── deploy.sh
|
||||
│ ├── backup.sh
|
||||
│ ├── health-check.sh
|
||||
│ └── setup-ssl.sh
|
||||
├── docker-compose.yml
|
||||
├── docker-compose.dev.yml
|
||||
├── .env.production
|
||||
└── .env.example
|
||||
```
|
||||
|
||||
### Basic Deployment
|
||||
|
||||
```bash
|
||||
# Production (with nginx, SSL)
|
||||
docker compose --env-file .env.production up -d
|
||||
|
||||
# View logs
|
||||
docker compose logs -f
|
||||
|
||||
# Check status
|
||||
docker compose ps
|
||||
```
|
||||
|
||||
### With Optional Services
|
||||
|
||||
```bash
|
||||
# With local Ollama LLM
|
||||
docker compose --profile with-ollama up -d
|
||||
|
||||
# With Redis caching
|
||||
docker compose --profile with-cache up -d
|
||||
|
||||
# With monitoring (Prometheus + Grafana)
|
||||
docker compose --profile with-monitoring up -d
|
||||
|
||||
# All services
|
||||
docker compose --profile with-ollama --profile with-cache --profile with-monitoring up -d
|
||||
```
|
||||
|
||||
### Service Endpoints
|
||||
|
||||
| Service | Internal Port | External Access |
|
||||
|---------|--------------|-----------------|
|
||||
| Frontend | 3000 | https://domain.com/ |
|
||||
| Backend API | 8000 | https://domain.com/api/ |
|
||||
| Admin Dashboard | 8000 | https://domain.com/admin |
|
||||
| Health Check | 8000 | https://domain.com/health |
|
||||
| Prometheus | 9090 | Internal only |
|
||||
| Grafana | 3001 | https://domain.com:3001 |
|
||||
|
||||
---
|
||||
|
||||
## ☸️ Kubernetes Deployment
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Kubernetes cluster (1.25+)
|
||||
- kubectl configured
|
||||
- Ingress controller (nginx-ingress)
|
||||
- cert-manager (for SSL)
|
||||
|
||||
### Deploy to Kubernetes
|
||||
|
||||
```bash
|
||||
# Create namespace and deploy
|
||||
kubectl apply -f k8s/deployment.yaml
|
||||
|
||||
# Check status
|
||||
kubectl get pods -n translate-api
|
||||
kubectl get services -n translate-api
|
||||
kubectl get ingress -n translate-api
|
||||
|
||||
# View logs
|
||||
kubectl logs -f deployment/backend -n translate-api
|
||||
```
|
||||
|
||||
### Scaling
|
||||
|
||||
```bash
|
||||
# Manual scaling
|
||||
kubectl scale deployment/backend --replicas=5 -n translate-api
|
||||
|
||||
# Auto-scaling is configured via HPA
|
||||
kubectl get hpa -n translate-api
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔒 SSL/TLS Configuration
|
||||
|
||||
### Option 1: Let's Encrypt (Recommended)
|
||||
|
||||
```bash
|
||||
# Automated setup
|
||||
./scripts/setup-ssl.sh translate.yourdomain.com admin@yourdomain.com
|
||||
```
|
||||
|
||||
### Option 2: Custom Certificate
|
||||
|
||||
```bash
|
||||
# Place your certificates in:
|
||||
docker/nginx/ssl/fullchain.pem # Full certificate chain
|
||||
docker/nginx/ssl/privkey.pem # Private key
|
||||
docker/nginx/ssl/chain.pem # CA chain (optional)
|
||||
```
|
||||
|
||||
### Option 3: Self-Signed (Development Only)
|
||||
|
||||
```bash
|
||||
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
|
||||
-keyout docker/nginx/ssl/privkey.pem \
|
||||
-out docker/nginx/ssl/fullchain.pem \
|
||||
-subj "/CN=localhost"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ Environment Configuration
|
||||
|
||||
### Required Variables
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `DOMAIN` | Your domain name | - |
|
||||
| `ADMIN_USERNAME` | Admin login | admin |
|
||||
| `ADMIN_PASSWORD` | Admin password | changeme123 |
|
||||
| `TRANSLATION_SERVICE` | Default provider | ollama |
|
||||
|
||||
### Translation Providers
|
||||
|
||||
```bash
|
||||
# Ollama (Local LLM)
|
||||
TRANSLATION_SERVICE=ollama
|
||||
OLLAMA_BASE_URL=http://ollama:11434
|
||||
OLLAMA_MODEL=llama3
|
||||
|
||||
# Google Translate (Free)
|
||||
TRANSLATION_SERVICE=google
|
||||
|
||||
# DeepL (Requires API key)
|
||||
TRANSLATION_SERVICE=deepl
|
||||
DEEPL_API_KEY=your-api-key
|
||||
|
||||
# OpenAI (Requires API key)
|
||||
TRANSLATION_SERVICE=openai
|
||||
OPENAI_API_KEY=your-api-key
|
||||
OPENAI_MODEL=gpt-4o-mini
|
||||
```
|
||||
|
||||
### Rate Limiting
|
||||
|
||||
```bash
|
||||
RATE_LIMIT_REQUESTS_PER_MINUTE=60
|
||||
RATE_LIMIT_TRANSLATIONS_PER_MINUTE=10
|
||||
RATE_LIMIT_TRANSLATIONS_PER_HOUR=100
|
||||
RATE_LIMIT_TRANSLATIONS_PER_DAY=500
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 Monitoring & Logging
|
||||
|
||||
### Enable Monitoring
|
||||
|
||||
```bash
|
||||
docker compose --profile with-monitoring up -d
|
||||
```
|
||||
|
||||
### Access Dashboards
|
||||
|
||||
- **Prometheus**: http://localhost:9090
|
||||
- **Grafana**: http://localhost:3001 (admin/admin)
|
||||
|
||||
### Log Locations
|
||||
|
||||
```bash
|
||||
# Docker logs
|
||||
docker compose logs backend
|
||||
docker compose logs frontend
|
||||
docker compose logs nginx
|
||||
|
||||
# Application logs (inside container)
|
||||
docker exec translate-backend cat /app/logs/app.log
|
||||
```
|
||||
|
||||
### Health Checks
|
||||
|
||||
```bash
|
||||
# Manual check
|
||||
./scripts/health-check.sh --verbose
|
||||
|
||||
# API health endpoint
|
||||
curl https://your-domain.com/health
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📈 Scaling & Performance
|
||||
|
||||
### Horizontal Scaling
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml
|
||||
services:
|
||||
backend:
|
||||
deploy:
|
||||
replicas: 4
|
||||
```
|
||||
|
||||
### Performance Tuning
|
||||
|
||||
```bash
|
||||
# Backend workers (in Dockerfile CMD)
|
||||
CMD ["uvicorn", "main:app", "--workers", "4"]
|
||||
|
||||
# Nginx connections
|
||||
worker_connections 2048;
|
||||
|
||||
# File upload limits
|
||||
client_max_body_size 100M;
|
||||
```
|
||||
|
||||
### Resource Limits
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 2G
|
||||
cpus: '1'
|
||||
reservations:
|
||||
memory: 512M
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 💾 Backup & Recovery
|
||||
|
||||
### Automated Backup
|
||||
|
||||
```bash
|
||||
# Run backup
|
||||
./scripts/backup.sh /path/to/backups
|
||||
|
||||
# Add to crontab (daily at 2 AM)
|
||||
0 2 * * * /path/to/scripts/backup.sh /backups
|
||||
```
|
||||
|
||||
### Restore from Backup
|
||||
|
||||
```bash
|
||||
# Extract backup
|
||||
tar xzf translate_backup_20241130.tar.gz
|
||||
|
||||
# Restore files
|
||||
docker cp translate_backup/uploads translate-backend:/app/
|
||||
docker cp translate_backup/outputs translate-backend:/app/
|
||||
|
||||
# Restart services
|
||||
docker compose restart
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
#### Port Already in Use
|
||||
```bash
|
||||
# Find process
|
||||
netstat -tulpn | grep :8000
|
||||
# Kill process
|
||||
kill -9 <PID>
|
||||
```
|
||||
|
||||
#### Container Won't Start
|
||||
```bash
|
||||
# Check logs
|
||||
docker compose logs backend --tail 100
|
||||
|
||||
# Check resources
|
||||
docker stats
|
||||
```
|
||||
|
||||
#### SSL Certificate Issues
|
||||
```bash
|
||||
# Test certificate
|
||||
openssl s_client -connect your-domain.com:443
|
||||
|
||||
# Renew Let's Encrypt
|
||||
./scripts/renew-ssl.sh
|
||||
```
|
||||
|
||||
#### Memory Issues
|
||||
```bash
|
||||
# Increase limits in docker-compose.yml
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 4G
|
||||
```
|
||||
|
||||
### Getting Help
|
||||
|
||||
1. Check logs: `docker compose logs -f`
|
||||
2. Run health check: `./scripts/health-check.sh`
|
||||
3. Review configuration: `.env.production`
|
||||
4. Check container status: `docker compose ps`
|
||||
|
||||
---
|
||||
|
||||
## 📝 Maintenance Commands
|
||||
|
||||
```bash
|
||||
# Update application
|
||||
git pull origin production-deployment
|
||||
docker compose build
|
||||
docker compose up -d
|
||||
|
||||
# Prune unused resources
|
||||
docker system prune -a
|
||||
|
||||
# View resource usage
|
||||
docker stats
|
||||
|
||||
# Enter container shell
|
||||
docker exec -it translate-backend /bin/bash
|
||||
|
||||
# Database backup (if using)
|
||||
docker exec translate-db pg_dump -U user database > backup.sql
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔐 Security Checklist
|
||||
|
||||
- [ ] Change default admin password
|
||||
- [ ] Configure SSL/TLS
|
||||
- [ ] Set up firewall rules
|
||||
- [ ] Enable rate limiting
|
||||
- [ ] Configure CORS properly
|
||||
- [ ] Regular security updates
|
||||
- [ ] Backup encryption
|
||||
- [ ] Monitor access logs
|
||||
|
||||
---
|
||||
|
||||
## 📞 Support
|
||||
|
||||
For issues and feature requests, please open a GitHub issue or contact support.
|
||||
|
||||
**Version**: 2.0.0
|
||||
**Last Updated**: November 2025
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 Sepehr
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
557
README.md
557
README.md
@@ -1,303 +1,382 @@
|
||||
# Document Translation API
|
||||
# 📄 Document Translation API
|
||||
|
||||
A powerful Python API for translating complex structured documents (Excel, Word, PowerPoint) while **strictly preserving** the original formatting, layout, and embedded media.
|
||||
A powerful SaaS-ready Python API for translating complex structured documents (Excel, Word, PowerPoint) while **strictly preserving** the original formatting, layout, and embedded media.
|
||||
|
||||
## 🎯 Features
|
||||
## ✨ Features
|
||||
|
||||
### Excel Translation (.xlsx)
|
||||
### 🔄 Multiple Translation Providers
|
||||
| Provider | Type | Description |
|
||||
|----------|------|-------------|
|
||||
| **Google Translate** | Cloud | Free, fast, reliable |
|
||||
| **Ollama** | Local LLM | Privacy-focused, customizable with system prompts |
|
||||
| **WebLLM** | Browser | Runs entirely in browser using WebGPU |
|
||||
| **DeepL** | Cloud | High-quality translations (API key required) |
|
||||
| **LibreTranslate** | Self-hosted | Open-source alternative |
|
||||
| **OpenAI** | Cloud | GPT-4o/4o-mini with vision support |
|
||||
|
||||
### 📊 Excel Translation (.xlsx)
|
||||
- ✅ Translates all cell content and sheet names
|
||||
- ✅ Preserves cell merging
|
||||
- ✅ Maintains font styles (size, bold, italic, color)
|
||||
- ✅ Keeps background colors and borders
|
||||
- ✅ Translates text within formulas while preserving formula structure
|
||||
- ✅ Retains embedded images in original positions
|
||||
- ✅ Preserves cell merging, formulas, and styles
|
||||
- ✅ Maintains font styles, colors, and borders
|
||||
- ✅ Image text extraction with vision models
|
||||
- ✅ Adds translated image text as comments
|
||||
|
||||
### Word Translation (.docx)
|
||||
### 📝 Word Translation (.docx)
|
||||
- ✅ Translates body text, headers, footers, and tables
|
||||
- ✅ Preserves heading styles and paragraph formatting
|
||||
- ✅ Maintains lists (numbered/bulleted)
|
||||
- ✅ Keeps embedded images, charts, and SmartArt in place
|
||||
- ✅ Preserves table structures and cell formatting
|
||||
- ✅ Maintains lists, images, charts, and SmartArt
|
||||
- ✅ Image text extraction and translation
|
||||
|
||||
### PowerPoint Translation (.pptx)
|
||||
### 📽️ PowerPoint Translation (.pptx)
|
||||
- ✅ Translates slide titles, body text, and speaker notes
|
||||
- ✅ Preserves slide layouts and transitions
|
||||
- ✅ Maintains animations
|
||||
- ✅ Keeps images, videos, and shapes in exact positions
|
||||
- ✅ Preserves layering order
|
||||
- ✅ Preserves slide layouts, transitions, and animations
|
||||
- ✅ Image text extraction with text boxes added below images
|
||||
- ✅ Keeps layering order and positions
|
||||
|
||||
### 🧠 LLM Features (Ollama/WebLLM/OpenAI)
|
||||
- ✅ **Custom System Prompts**: Provide context for better translations
|
||||
- ✅ **Technical Glossary**: Define term mappings (e.g., `batterie=coil`)
|
||||
- ✅ **Presets**: HVAC, IT, Legal, Medical terminology
|
||||
- ✅ **Vision Models**: Translate text within images (gemma3, qwen3-vl, gpt-4o)
|
||||
|
||||
### 🏢 SaaS-Ready Features
|
||||
- 🚦 **Rate Limiting**: Per-client IP with token bucket and sliding window algorithms
|
||||
- 🔒 **Security Headers**: CSP, XSS protection, HSTS support
|
||||
- 🧹 **Auto Cleanup**: Automatic file cleanup with TTL tracking
|
||||
- 📊 **Monitoring**: Health checks, metrics, and system status
|
||||
- 🔐 **Admin Dashboard**: Secure admin panel with authentication
|
||||
- 📝 **Request Logging**: Structured logging with unique request IDs
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### Installation
|
||||
|
||||
1. **Clone the repository:**
|
||||
```powershell
|
||||
git clone <repository-url>
|
||||
cd Translate
|
||||
```
|
||||
# Clone the repository
|
||||
git clone https://gitea.parsanet.org/sepehr/office_translator.git
|
||||
cd office_translator
|
||||
|
||||
2. **Create a virtual environment:**
|
||||
```powershell
|
||||
# Create virtual environment
|
||||
python -m venv venv
|
||||
.\venv\Scripts\Activate.ps1
|
||||
```
|
||||
|
||||
3. **Install dependencies:**
|
||||
```powershell
|
||||
# Install dependencies
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
4. **Configure environment:**
|
||||
```powershell
|
||||
cp .env.example .env
|
||||
# Edit .env with your preferred settings
|
||||
```
|
||||
|
||||
5. **Run the API:**
|
||||
```powershell
|
||||
# Run the API
|
||||
python main.py
|
||||
```
|
||||
|
||||
The API will start on `http://localhost:8000`
|
||||
The API starts on `http://localhost:8000`
|
||||
|
||||
### Frontend Setup
|
||||
|
||||
```powershell
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Frontend runs on `http://localhost:3000`
|
||||
|
||||
## 📚 API Documentation
|
||||
|
||||
Once the server is running, visit:
|
||||
- **Swagger UI**: http://localhost:8000/docs
|
||||
- **ReDoc**: http://localhost:8000/redoc
|
||||
|
||||
## 🔧 API Endpoints
|
||||
|
||||
### POST /translate
|
||||
Translate a single document
|
||||
### Translation
|
||||
|
||||
#### POST /translate
|
||||
Translate a document with full customization.
|
||||
|
||||
**Request:**
|
||||
```bash
|
||||
curl -X POST "http://localhost:8000/translate" \
|
||||
-F "file=@document.xlsx" \
|
||||
-F "target_language=es" \
|
||||
-F "source_language=auto"
|
||||
-F "target_language=en" \
|
||||
-F "provider=ollama" \
|
||||
-F "ollama_model=gemma3:12b" \
|
||||
-F "translate_images=true" \
|
||||
-F "system_prompt=You are translating HVAC documents."
|
||||
```
|
||||
|
||||
**Response:**
|
||||
Returns the translated document file
|
||||
### Monitoring
|
||||
|
||||
### POST /translate-batch
|
||||
Translate multiple documents at once
|
||||
|
||||
**Request:**
|
||||
```bash
|
||||
curl -X POST "http://localhost:8000/translate-batch" \
|
||||
-F "files=@document1.docx" \
|
||||
-F "files=@document2.pptx" \
|
||||
-F "target_language=fr"
|
||||
```
|
||||
|
||||
### GET /languages
|
||||
Get list of supported language codes
|
||||
|
||||
### GET /health
|
||||
Health check endpoint
|
||||
|
||||
## 💻 Usage Examples
|
||||
|
||||
### Python Example
|
||||
|
||||
```python
|
||||
import requests
|
||||
|
||||
# Translate a document
|
||||
with open('document.xlsx', 'rb') as f:
|
||||
files = {'file': f}
|
||||
data = {
|
||||
'target_language': 'es',
|
||||
'source_language': 'auto'
|
||||
}
|
||||
response = requests.post('http://localhost:8000/translate', files=files, data=data)
|
||||
|
||||
# Save translated file
|
||||
with open('translated_document.xlsx', 'wb') as output:
|
||||
output.write(response.content)
|
||||
```
|
||||
|
||||
### JavaScript/TypeScript Example
|
||||
|
||||
```javascript
|
||||
const formData = new FormData();
|
||||
formData.append('file', fileInput.files[0]);
|
||||
formData.append('target_language', 'fr');
|
||||
formData.append('source_language', 'auto');
|
||||
|
||||
const response = await fetch('http://localhost:8000/translate', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'translated_document.docx';
|
||||
a.click();
|
||||
```
|
||||
|
||||
### PowerShell Example
|
||||
|
||||
```powershell
|
||||
$file = Get-Item "document.pptx"
|
||||
$uri = "http://localhost:8000/translate"
|
||||
|
||||
$form = @{
|
||||
file = $file
|
||||
target_language = "de"
|
||||
source_language = "auto"
|
||||
}
|
||||
|
||||
Invoke-RestMethod -Uri $uri -Method Post -Form $form -OutFile "translated_document.pptx"
|
||||
```
|
||||
|
||||
## 🌐 Supported Languages
|
||||
|
||||
The API supports 25+ languages including:
|
||||
- Spanish (es), French (fr), German (de)
|
||||
- Italian (it), Portuguese (pt), Russian (ru)
|
||||
- Chinese (zh), Japanese (ja), Korean (ko)
|
||||
- Arabic (ar), Hindi (hi), Dutch (nl)
|
||||
- And many more...
|
||||
|
||||
Full list available at: `GET /languages`
|
||||
|
||||
## ⚙️ Configuration
|
||||
|
||||
Edit `.env` file to configure:
|
||||
|
||||
```env
|
||||
# Translation Service (google, deepl, libre)
|
||||
TRANSLATION_SERVICE=google
|
||||
|
||||
# DeepL API Key (if using DeepL)
|
||||
DEEPL_API_KEY=your_api_key_here
|
||||
|
||||
# File Upload Limits
|
||||
MAX_FILE_SIZE_MB=50
|
||||
|
||||
# Directory Configuration
|
||||
UPLOAD_DIR=./uploads
|
||||
OUTPUT_DIR=./outputs
|
||||
```
|
||||
|
||||
## 🔌 Model Context Protocol (MCP) Integration
|
||||
|
||||
This API is designed to be easily wrapped as an MCP server for future integration with AI assistants and tools.
|
||||
|
||||
### MCP Server Structure (Future Implementation)
|
||||
#### GET /health
|
||||
Comprehensive health check with system status.
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"status": "healthy",
|
||||
"translation_service": "google",
|
||||
"memory": {"system_percent": 34.1, "system_available_gb": 61.7},
|
||||
"disk": {"total_files": 0, "total_size_mb": 0},
|
||||
"cleanup_service": {"is_running": true}
|
||||
}
|
||||
```
|
||||
|
||||
#### GET /metrics
|
||||
System metrics and statistics.
|
||||
|
||||
#### GET /rate-limit/status
|
||||
Current rate limit status for the requesting client.
|
||||
|
||||
### Admin Endpoints (Authentication Required)
|
||||
|
||||
#### POST /admin/login
|
||||
Login to admin dashboard.
|
||||
|
||||
```bash
|
||||
curl -X POST "http://localhost:8000/admin/login" \
|
||||
-F "username=admin" \
|
||||
-F "password=your_password"
|
||||
```
|
||||
|
||||
Response:
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"token": "your_bearer_token",
|
||||
"expires_in": 86400
|
||||
}
|
||||
```
|
||||
|
||||
#### GET /admin/dashboard
|
||||
Get comprehensive dashboard data (requires Bearer token).
|
||||
|
||||
```bash
|
||||
curl "http://localhost:8000/admin/dashboard" \
|
||||
-H "Authorization: Bearer your_token"
|
||||
```
|
||||
|
||||
#### POST /admin/cleanup/trigger
|
||||
Manually trigger file cleanup.
|
||||
|
||||
#### GET /admin/files/tracked
|
||||
List currently tracked files.
|
||||
|
||||
## 🌐 Supported Languages
|
||||
|
||||
| Code | Language | Code | Language |
|
||||
|------|----------|------|----------|
|
||||
| en | English | fr | French |
|
||||
| fa | Persian/Farsi | es | Spanish |
|
||||
| de | German | it | Italian |
|
||||
| pt | Portuguese | ru | Russian |
|
||||
| zh | Chinese | ja | Japanese |
|
||||
| ko | Korean | ar | Arabic |
|
||||
|
||||
## ⚙️ Configuration
|
||||
|
||||
### Environment Variables (.env)
|
||||
|
||||
```env
|
||||
# ============== Translation Services ==============
|
||||
TRANSLATION_SERVICE=google
|
||||
DEEPL_API_KEY=your_deepl_api_key_here
|
||||
|
||||
# Ollama Configuration
|
||||
OLLAMA_BASE_URL=http://localhost:11434
|
||||
OLLAMA_MODEL=llama3
|
||||
OLLAMA_VISION_MODEL=llava
|
||||
|
||||
# ============== File Limits ==============
|
||||
MAX_FILE_SIZE_MB=50
|
||||
|
||||
# ============== Rate Limiting (SaaS) ==============
|
||||
RATE_LIMIT_ENABLED=true
|
||||
RATE_LIMIT_PER_MINUTE=30
|
||||
RATE_LIMIT_PER_HOUR=200
|
||||
TRANSLATIONS_PER_MINUTE=10
|
||||
TRANSLATIONS_PER_HOUR=50
|
||||
MAX_CONCURRENT_TRANSLATIONS=5
|
||||
|
||||
# ============== Cleanup Service ==============
|
||||
CLEANUP_ENABLED=true
|
||||
CLEANUP_INTERVAL_MINUTES=15
|
||||
FILE_TTL_MINUTES=60
|
||||
INPUT_FILE_TTL_MINUTES=30
|
||||
OUTPUT_FILE_TTL_MINUTES=120
|
||||
|
||||
# ============== Security ==============
|
||||
ENABLE_HSTS=false
|
||||
CORS_ORIGINS=*
|
||||
|
||||
# ============== Admin Authentication ==============
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=changeme123 # Change in production!
|
||||
# Or use a SHA256 hash:
|
||||
# ADMIN_PASSWORD_HASH=your_sha256_hash
|
||||
|
||||
# ============== Monitoring ==============
|
||||
LOG_LEVEL=INFO
|
||||
ENABLE_REQUEST_LOGGING=true
|
||||
MAX_MEMORY_PERCENT=80
|
||||
```
|
||||
|
||||
### Ollama Setup
|
||||
|
||||
```bash
|
||||
# Install Ollama (Windows)
|
||||
winget install Ollama.Ollama
|
||||
|
||||
# Pull a model
|
||||
ollama pull llama3.2
|
||||
|
||||
# For vision/image translation
|
||||
ollama pull gemma3:12b
|
||||
# or
|
||||
ollama pull qwen3-vl:8b
|
||||
```
|
||||
|
||||
## 🎯 Using System Prompts & Glossary
|
||||
|
||||
### Example: HVAC Translation
|
||||
|
||||
**System Prompt:**
|
||||
```
|
||||
You are translating HVAC technical documents.
|
||||
Use precise technical terminology.
|
||||
Keep unit measurements (kW, m³/h, Pa) unchanged.
|
||||
```
|
||||
|
||||
**Glossary:**
|
||||
```
|
||||
batterie=coil
|
||||
groupe froid=chiller
|
||||
CTA=AHU (Air Handling Unit)
|
||||
échangeur=heat exchanger
|
||||
vanne 3 voies=3-way valve
|
||||
```
|
||||
|
||||
### Presets Available
|
||||
- 🔧 **HVAC**: Heating, Ventilation, Air Conditioning
|
||||
- 💻 **IT**: Software and technology
|
||||
- ⚖️ **Legal**: Legal documents
|
||||
- 🏥 **Medical**: Healthcare terminology
|
||||
|
||||
## <20> Admin Dashboard
|
||||
|
||||
Access the admin dashboard at `/admin` in the frontend. Features:
|
||||
|
||||
- **System Status**: Health, uptime, and issues
|
||||
- **Memory & Disk Monitoring**: Real-time usage stats
|
||||
- **Translation Statistics**: Total translations, success rate
|
||||
- **Rate Limit Management**: View active clients and limits
|
||||
- **Cleanup Service**: Monitor and trigger manual cleanup
|
||||
|
||||
### Default Credentials
|
||||
- **Username**: admin
|
||||
- **Password**: changeme123
|
||||
|
||||
⚠️ **Change the default password in production!**
|
||||
|
||||
## 🏗️ Project Structure
|
||||
|
||||
```
|
||||
Translate/
|
||||
├── main.py # FastAPI application with SaaS features
|
||||
├── config.py # Configuration with SaaS settings
|
||||
├── requirements.txt # Dependencies
|
||||
├── mcp_server.py # MCP server implementation
|
||||
├── middleware/ # SaaS middleware
|
||||
│ ├── __init__.py
|
||||
│ ├── rate_limiting.py # Rate limiting with token bucket
|
||||
│ ├── validation.py # Input validation
|
||||
│ ├── security.py # Security headers & logging
|
||||
│ └── cleanup.py # Auto cleanup service
|
||||
├── services/
|
||||
│ └── translation_service.py # Translation providers
|
||||
├── translators/
|
||||
│ ├── excel_translator.py # Excel with image support
|
||||
│ ├── word_translator.py # Word with image support
|
||||
│ └── pptx_translator.py # PowerPoint with image support
|
||||
├── frontend/ # Next.js frontend
|
||||
│ ├── src/
|
||||
│ │ ├── app/
|
||||
│ │ │ ├── page.tsx # Main translation page
|
||||
│ │ │ ├── admin/ # Admin dashboard
|
||||
│ │ │ └── settings/ # Settings pages
|
||||
│ │ └── components/
|
||||
│ └── package.json
|
||||
├── static/
|
||||
│ └── webllm.html # WebLLM standalone interface
|
||||
├── uploads/ # Temporary uploads (auto-cleaned)
|
||||
└── outputs/ # Translated files (auto-cleaned)
|
||||
```
|
||||
|
||||
## 🛠️ Tech Stack
|
||||
|
||||
### Backend
|
||||
- **FastAPI**: Modern async web framework
|
||||
- **openpyxl**: Excel manipulation
|
||||
- **python-docx**: Word documents
|
||||
- **python-pptx**: PowerPoint presentations
|
||||
- **deep-translator**: Google/DeepL/Libre translation
|
||||
- **psutil**: System monitoring
|
||||
- **python-magic**: File type validation
|
||||
|
||||
### Frontend
|
||||
- **Next.js 15**: React framework
|
||||
- **Tailwind CSS**: Styling
|
||||
- **Lucide Icons**: Icon library
|
||||
- **WebLLM**: Browser-based LLM
|
||||
|
||||
## 🔌 MCP Integration
|
||||
|
||||
This API can be used as an MCP (Model Context Protocol) server for AI assistants.
|
||||
|
||||
### VS Code Configuration
|
||||
|
||||
Add to your VS Code `settings.json` or `.vscode/mcp.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"servers": {
|
||||
"document-translator": {
|
||||
"type": "stdio",
|
||||
"command": "python",
|
||||
"args": ["-m", "mcp_server"],
|
||||
"args": ["mcp_server.py"],
|
||||
"cwd": "D:/Translate",
|
||||
"env": {
|
||||
"API_URL": "http://localhost:8000"
|
||||
"PYTHONPATH": "D:/Translate"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Example MCP Tools
|
||||
## 🚀 Production Deployment
|
||||
|
||||
The MCP wrapper will expose these tools:
|
||||
### Security Checklist
|
||||
- [ ] Change `ADMIN_PASSWORD` or set `ADMIN_PASSWORD_HASH`
|
||||
- [ ] Set `CORS_ORIGINS` to your frontend domain
|
||||
- [ ] Enable `ENABLE_HSTS=true` if using HTTPS
|
||||
- [ ] Configure rate limits appropriately
|
||||
- [ ] Set up log rotation for `logs/` directory
|
||||
- [ ] Use a reverse proxy (nginx/traefik) for HTTPS
|
||||
|
||||
1. **translate_document** - Translate a single document
|
||||
2. **translate_batch** - Translate multiple documents
|
||||
3. **get_supported_languages** - List supported languages
|
||||
4. **check_translation_status** - Check status of translation
|
||||
|
||||
## 🏗️ Project Structure
|
||||
### Docker Deployment (Coming Soon)
|
||||
|
||||
```dockerfile
|
||||
FROM python:3.11-slim
|
||||
WORKDIR /app
|
||||
COPY requirements.txt .
|
||||
RUN pip install -r requirements.txt
|
||||
COPY . .
|
||||
EXPOSE 8000
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
```
|
||||
Translate/
|
||||
├── main.py # FastAPI application
|
||||
├── config.py # Configuration management
|
||||
├── requirements.txt # Dependencies
|
||||
├── .env.example # Environment template
|
||||
├── services/
|
||||
│ ├── __init__.py
|
||||
│ └── translation_service.py # Translation abstraction layer
|
||||
├── translators/
|
||||
│ ├── __init__.py
|
||||
│ ├── excel_translator.py # Excel translation logic
|
||||
│ ├── word_translator.py # Word translation logic
|
||||
│ └── pptx_translator.py # PowerPoint translation logic
|
||||
├── utils/
|
||||
│ ├── __init__.py
|
||||
│ ├── file_handler.py # File operations
|
||||
│ └── exceptions.py # Custom exceptions
|
||||
├── uploads/ # Temporary upload storage
|
||||
└── outputs/ # Translated files
|
||||
```
|
||||
|
||||
## 🧪 Testing
|
||||
|
||||
### Manual Testing
|
||||
|
||||
1. Start the API server
|
||||
2. Navigate to http://localhost:8000/docs
|
||||
3. Use the interactive Swagger UI to test endpoints
|
||||
|
||||
### Test Files
|
||||
|
||||
Prepare test files with:
|
||||
- Complex formatting (multiple fonts, colors, styles)
|
||||
- Embedded images and media
|
||||
- Tables and merged cells
|
||||
- Formulas (for Excel)
|
||||
- Multiple sections/slides
|
||||
|
||||
## 🛠️ Technical Details
|
||||
|
||||
### Libraries Used
|
||||
|
||||
- **FastAPI**: Modern web framework for building APIs
|
||||
- **openpyxl**: Excel file manipulation with formatting preservation
|
||||
- **python-docx**: Word document handling
|
||||
- **python-pptx**: PowerPoint presentation processing
|
||||
- **deep-translator**: Multi-provider translation service
|
||||
- **Uvicorn**: ASGI server for running FastAPI
|
||||
|
||||
### Design Principles
|
||||
|
||||
1. **Modular Architecture**: Each file type has its own translator module
|
||||
2. **Provider Abstraction**: Easy to swap translation services (Google, DeepL, LibreTranslate)
|
||||
3. **Format Preservation**: All translators maintain original document structure
|
||||
4. **Error Handling**: Comprehensive error handling and logging
|
||||
5. **Scalability**: Ready for MCP integration and microservices architecture
|
||||
|
||||
## 🔐 Security Considerations
|
||||
|
||||
For production deployment:
|
||||
|
||||
1. **Configure CORS** properly in `main.py`
|
||||
2. **Add authentication** for API endpoints
|
||||
3. **Implement rate limiting** to prevent abuse
|
||||
4. **Use HTTPS** for secure file transmission
|
||||
5. **Sanitize file uploads** to prevent malicious files
|
||||
6. **Set appropriate file size limits**
|
||||
|
||||
## 📝 License
|
||||
|
||||
MIT License - Feel free to use this project for your needs.
|
||||
MIT License
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
Contributions are welcome! Please feel free to submit a Pull Request.
|
||||
|
||||
## 📧 Support
|
||||
|
||||
For issues and questions, please open an issue on the repository.
|
||||
Contributions welcome! Please submit a Pull Request.
|
||||
|
||||
---
|
||||
|
||||
**Built with ❤️ using Python and FastAPI**
|
||||
**Built with ❤️ using Python, FastAPI, Next.js, and Ollama**
|
||||
|
||||
78
alembic.ini
Normal file
78
alembic.ini
Normal file
@@ -0,0 +1,78 @@
|
||||
# Alembic configuration file
|
||||
|
||||
[alembic]
|
||||
# path to migration scripts
|
||||
script_location = alembic
|
||||
|
||||
# template used to generate migration files
|
||||
# file_template = %%(rev)s_%%(slug)s
|
||||
|
||||
# sys.path path, will be prepended to sys.path if present.
|
||||
prepend_sys_path = .
|
||||
|
||||
# timezone to use when rendering the date within the migration file
|
||||
# as well as the filename.
|
||||
# timezone =
|
||||
|
||||
# max length of characters to apply to the "slug" field
|
||||
# truncate_slug_length = 40
|
||||
|
||||
# set to 'true' to run the environment during the 'revision' command,
|
||||
# regardless of autogenerate
|
||||
# revision_environment = false
|
||||
|
||||
# set to 'true' to allow .pyc and .pyo files without a source .py file
|
||||
# to be detected as revisions in the versions/ directory
|
||||
# sourceless = false
|
||||
|
||||
# version location specification; This defaults to alembic/versions
|
||||
# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions
|
||||
|
||||
# version path separator
|
||||
# version_path_separator = :
|
||||
|
||||
# the output encoding used when revision files are written
|
||||
# output_encoding = utf-8
|
||||
|
||||
sqlalchemy.url = driver://user:pass@localhost/dbname
|
||||
|
||||
|
||||
[post_write_hooks]
|
||||
# hooks = black
|
||||
# black.type = console_scripts
|
||||
# black.entrypoint = black
|
||||
# black.options = -q
|
||||
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
86
alembic/env.py
Normal file
86
alembic/env.py
Normal file
@@ -0,0 +1,86 @@
|
||||
"""
|
||||
Alembic environment configuration
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
from logging.config import fileConfig
|
||||
|
||||
from sqlalchemy import engine_from_config
|
||||
from sqlalchemy import pool
|
||||
|
||||
from alembic import context
|
||||
|
||||
# Add parent directory to path for imports
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
# this is the Alembic Config object
|
||||
config = context.config
|
||||
|
||||
# Interpret the config file for Python logging
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
# Import models for autogenerate support
|
||||
from database.models import Base
|
||||
target_metadata = Base.metadata
|
||||
|
||||
# Get database URL from environment
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv()
|
||||
|
||||
DATABASE_URL = os.getenv("DATABASE_URL", "")
|
||||
if not DATABASE_URL:
|
||||
SQLITE_PATH = os.getenv("SQLITE_PATH", "data/translate.db")
|
||||
DATABASE_URL = f"sqlite:///./{SQLITE_PATH}"
|
||||
|
||||
# Override sqlalchemy.url with environment variable
|
||||
config.set_main_option("sqlalchemy.url", DATABASE_URL)
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
"""Run migrations in 'offline' mode.
|
||||
|
||||
This configures the context with just a URL
|
||||
and not an Engine, though an Engine is acceptable
|
||||
here as well. By skipping the Engine creation
|
||||
we don't even need a DBAPI to be available.
|
||||
"""
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
context.configure(
|
||||
url=url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
"""Run migrations in 'online' mode.
|
||||
|
||||
In this scenario we need to create an Engine
|
||||
and associate a connection with the context.
|
||||
"""
|
||||
connectable = engine_from_config(
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
|
||||
with connectable.connect() as connection:
|
||||
context.configure(
|
||||
connection=connection,
|
||||
target_metadata=target_metadata,
|
||||
compare_type=True,
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
27
alembic/script.py.mako
Normal file
27
alembic/script.py.mako
Normal file
@@ -0,0 +1,27 @@
|
||||
"""
|
||||
Alembic migration script template
|
||||
${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
${downgrades if downgrades else "pass"}
|
||||
126
alembic/versions/001_initial.py
Normal file
126
alembic/versions/001_initial.py
Normal file
@@ -0,0 +1,126 @@
|
||||
"""Initial database schema
|
||||
|
||||
Revision ID: 001_initial
|
||||
Revises:
|
||||
Create Date: 2024-12-31
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '001_initial'
|
||||
down_revision: Union[str, None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Create users table
|
||||
op.create_table(
|
||||
'users',
|
||||
sa.Column('id', sa.String(36), primary_key=True),
|
||||
sa.Column('email', sa.String(255), unique=True, nullable=False),
|
||||
sa.Column('name', sa.String(255), nullable=False),
|
||||
sa.Column('password_hash', sa.String(255), nullable=False),
|
||||
sa.Column('email_verified', sa.Boolean(), default=False),
|
||||
sa.Column('is_active', sa.Boolean(), default=True),
|
||||
sa.Column('avatar_url', sa.String(500), nullable=True),
|
||||
sa.Column('plan', sa.String(20), default='free'),
|
||||
sa.Column('subscription_status', sa.String(20), default='active'),
|
||||
sa.Column('stripe_customer_id', sa.String(255), nullable=True),
|
||||
sa.Column('stripe_subscription_id', sa.String(255), nullable=True),
|
||||
sa.Column('docs_translated_this_month', sa.Integer(), default=0),
|
||||
sa.Column('pages_translated_this_month', sa.Integer(), default=0),
|
||||
sa.Column('api_calls_this_month', sa.Integer(), default=0),
|
||||
sa.Column('extra_credits', sa.Integer(), default=0),
|
||||
sa.Column('usage_reset_date', sa.DateTime()),
|
||||
sa.Column('created_at', sa.DateTime()),
|
||||
sa.Column('updated_at', sa.DateTime()),
|
||||
sa.Column('last_login_at', sa.DateTime(), nullable=True),
|
||||
)
|
||||
op.create_index('ix_users_email', 'users', ['email'])
|
||||
op.create_index('ix_users_email_active', 'users', ['email', 'is_active'])
|
||||
op.create_index('ix_users_stripe_customer', 'users', ['stripe_customer_id'])
|
||||
|
||||
# Create translations table
|
||||
op.create_table(
|
||||
'translations',
|
||||
sa.Column('id', sa.String(36), primary_key=True),
|
||||
sa.Column('user_id', sa.String(36), sa.ForeignKey('users.id', ondelete='CASCADE'), nullable=False),
|
||||
sa.Column('original_filename', sa.String(255), nullable=False),
|
||||
sa.Column('file_type', sa.String(10), nullable=False),
|
||||
sa.Column('file_size_bytes', sa.BigInteger(), default=0),
|
||||
sa.Column('page_count', sa.Integer(), default=0),
|
||||
sa.Column('source_language', sa.String(10), default='auto'),
|
||||
sa.Column('target_language', sa.String(10), nullable=False),
|
||||
sa.Column('provider', sa.String(50), nullable=False),
|
||||
sa.Column('status', sa.String(20), default='pending'),
|
||||
sa.Column('error_message', sa.Text(), nullable=True),
|
||||
sa.Column('processing_time_ms', sa.Integer(), nullable=True),
|
||||
sa.Column('characters_translated', sa.Integer(), default=0),
|
||||
sa.Column('estimated_cost_usd', sa.Float(), default=0.0),
|
||||
sa.Column('created_at', sa.DateTime()),
|
||||
sa.Column('completed_at', sa.DateTime(), nullable=True),
|
||||
)
|
||||
op.create_index('ix_translations_user_date', 'translations', ['user_id', 'created_at'])
|
||||
op.create_index('ix_translations_status', 'translations', ['status'])
|
||||
|
||||
# Create api_keys table
|
||||
op.create_table(
|
||||
'api_keys',
|
||||
sa.Column('id', sa.String(36), primary_key=True),
|
||||
sa.Column('user_id', sa.String(36), sa.ForeignKey('users.id', ondelete='CASCADE'), nullable=False),
|
||||
sa.Column('name', sa.String(100), nullable=False),
|
||||
sa.Column('key_hash', sa.String(255), nullable=False),
|
||||
sa.Column('key_prefix', sa.String(10), nullable=False),
|
||||
sa.Column('is_active', sa.Boolean(), default=True),
|
||||
sa.Column('scopes', sa.JSON(), default=list),
|
||||
sa.Column('last_used_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('usage_count', sa.Integer(), default=0),
|
||||
sa.Column('created_at', sa.DateTime()),
|
||||
sa.Column('expires_at', sa.DateTime(), nullable=True),
|
||||
)
|
||||
op.create_index('ix_api_keys_prefix', 'api_keys', ['key_prefix'])
|
||||
op.create_index('ix_api_keys_hash', 'api_keys', ['key_hash'])
|
||||
|
||||
# Create usage_logs table
|
||||
op.create_table(
|
||||
'usage_logs',
|
||||
sa.Column('id', sa.String(36), primary_key=True),
|
||||
sa.Column('user_id', sa.String(36), sa.ForeignKey('users.id', ondelete='CASCADE'), nullable=False),
|
||||
sa.Column('date', sa.DateTime(), nullable=False),
|
||||
sa.Column('documents_count', sa.Integer(), default=0),
|
||||
sa.Column('pages_count', sa.Integer(), default=0),
|
||||
sa.Column('characters_count', sa.BigInteger(), default=0),
|
||||
sa.Column('api_calls_count', sa.Integer(), default=0),
|
||||
sa.Column('provider_breakdown', sa.JSON(), default=dict),
|
||||
)
|
||||
op.create_index('ix_usage_logs_user_date', 'usage_logs', ['user_id', 'date'], unique=True)
|
||||
|
||||
# Create payment_history table
|
||||
op.create_table(
|
||||
'payment_history',
|
||||
sa.Column('id', sa.String(36), primary_key=True),
|
||||
sa.Column('user_id', sa.String(36), sa.ForeignKey('users.id', ondelete='CASCADE'), nullable=False),
|
||||
sa.Column('stripe_payment_intent_id', sa.String(255), nullable=True),
|
||||
sa.Column('stripe_invoice_id', sa.String(255), nullable=True),
|
||||
sa.Column('amount_cents', sa.Integer(), nullable=False),
|
||||
sa.Column('currency', sa.String(3), default='usd'),
|
||||
sa.Column('payment_type', sa.String(50), nullable=False),
|
||||
sa.Column('status', sa.String(20), nullable=False),
|
||||
sa.Column('description', sa.String(255), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime()),
|
||||
)
|
||||
op.create_index('ix_payment_history_user', 'payment_history', ['user_id', 'created_at'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table('payment_history')
|
||||
op.drop_table('usage_logs')
|
||||
op.drop_table('api_keys')
|
||||
op.drop_table('translations')
|
||||
op.drop_table('users')
|
||||
280
benchmark.py
Normal file
280
benchmark.py
Normal file
@@ -0,0 +1,280 @@
|
||||
"""
|
||||
Translation Benchmark Script
|
||||
Tests translation performance for 200 pages equivalent of text
|
||||
"""
|
||||
import time
|
||||
import random
|
||||
import statistics
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Add project root to path
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from services.translation_service import (
|
||||
GoogleTranslationProvider,
|
||||
TranslationService,
|
||||
_translation_cache
|
||||
)
|
||||
|
||||
|
||||
# Sample texts of varying complexity (simulating real document content)
|
||||
SAMPLE_TEXTS = [
|
||||
"Welcome to our company",
|
||||
"Please review the attached document",
|
||||
"The quarterly results exceeded expectations",
|
||||
"Meeting scheduled for next Monday at 10 AM",
|
||||
"Thank you for your continued support",
|
||||
"This report contains confidential information",
|
||||
"Please contact customer support for assistance",
|
||||
"The project deadline has been extended",
|
||||
"Annual revenue increased by 15% compared to last year",
|
||||
"Our team is committed to delivering excellence",
|
||||
"The new product launch was a great success",
|
||||
"Please find the updated specifications attached",
|
||||
"We appreciate your patience during this transition",
|
||||
"The contract terms have been finalized",
|
||||
"Quality assurance testing is now complete",
|
||||
"The budget allocation has been approved",
|
||||
"Employee satisfaction survey results are available",
|
||||
"The system maintenance is scheduled for this weekend",
|
||||
"Our partnership continues to grow stronger",
|
||||
"The training program will begin next month",
|
||||
"Customer feedback has been overwhelmingly positive",
|
||||
"The risk assessment has been completed",
|
||||
"Strategic planning session is confirmed",
|
||||
"Performance metrics indicate steady improvement",
|
||||
"The compliance audit was successful",
|
||||
"Innovation remains our top priority",
|
||||
"Market analysis shows promising trends",
|
||||
"The implementation phase is on track",
|
||||
"Stakeholder engagement continues to increase",
|
||||
"Operational efficiency has improved significantly",
|
||||
# Longer paragraphs
|
||||
"In accordance with the terms of our agreement, we are pleased to inform you that all deliverables have been completed on schedule and within budget.",
|
||||
"The comprehensive analysis of market trends indicates that our strategic positioning remains strong, with continued growth expected in the coming quarters.",
|
||||
"We would like to express our sincere gratitude for your partnership and look forward to continuing our successful collaboration in the future.",
|
||||
"Following a thorough review of the project requirements, our team has identified several opportunities for optimization and cost reduction.",
|
||||
"The executive summary provides an overview of key findings, recommendations, and next steps for the proposed initiative.",
|
||||
]
|
||||
|
||||
# Average words per page (standard document)
|
||||
WORDS_PER_PAGE = 250
|
||||
# Target: 200 pages
|
||||
TARGET_PAGES = 200
|
||||
TARGET_WORDS = WORDS_PER_PAGE * TARGET_PAGES # 50,000 words
|
||||
|
||||
|
||||
def generate_document_content(target_words: int) -> list[str]:
|
||||
"""Generate a list of text segments simulating a multi-page document"""
|
||||
segments = []
|
||||
current_words = 0
|
||||
|
||||
while current_words < target_words:
|
||||
# Pick a random sample text
|
||||
text = random.choice(SAMPLE_TEXTS)
|
||||
segments.append(text)
|
||||
current_words += len(text.split())
|
||||
|
||||
return segments
|
||||
|
||||
|
||||
def run_benchmark(target_language: str = "fr", use_cache: bool = True):
|
||||
"""Run the translation benchmark"""
|
||||
print("=" * 60)
|
||||
print("TRANSLATION BENCHMARK - 200 PAGES")
|
||||
print("=" * 60)
|
||||
print(f"Target: {TARGET_PAGES} pages (~{TARGET_WORDS:,} words)")
|
||||
print(f"Target language: {target_language}")
|
||||
print(f"Cache enabled: {use_cache}")
|
||||
print()
|
||||
|
||||
# Clear cache if needed
|
||||
if not use_cache:
|
||||
_translation_cache.clear()
|
||||
|
||||
# Generate document content
|
||||
print("Generating document content...")
|
||||
segments = generate_document_content(TARGET_WORDS)
|
||||
total_words = sum(len(s.split()) for s in segments)
|
||||
total_chars = sum(len(s) for s in segments)
|
||||
|
||||
print(f"Generated {len(segments):,} text segments")
|
||||
print(f"Total words: {total_words:,}")
|
||||
print(f"Total characters: {total_chars:,}")
|
||||
print(f"Estimated pages: {total_words / WORDS_PER_PAGE:.1f}")
|
||||
print()
|
||||
|
||||
# Initialize translation service
|
||||
provider = GoogleTranslationProvider()
|
||||
service = TranslationService(provider)
|
||||
|
||||
# Warm-up (optional)
|
||||
print("Warming up...")
|
||||
_ = service.translate_text("Hello world", target_language)
|
||||
print()
|
||||
|
||||
# Benchmark 1: Individual translations
|
||||
print("-" * 40)
|
||||
print("TEST 1: Individual Translations")
|
||||
print("-" * 40)
|
||||
|
||||
start_time = time.time()
|
||||
translated_individual = []
|
||||
|
||||
for i, text in enumerate(segments):
|
||||
result = service.translate_text(text, target_language)
|
||||
translated_individual.append(result)
|
||||
if (i + 1) % 500 == 0:
|
||||
elapsed = time.time() - start_time
|
||||
rate = (i + 1) / elapsed
|
||||
print(f" Progress: {i + 1:,}/{len(segments):,} ({rate:.1f} segments/sec)")
|
||||
|
||||
individual_time = time.time() - start_time
|
||||
individual_rate = len(segments) / individual_time
|
||||
individual_words_per_sec = total_words / individual_time
|
||||
individual_pages_per_min = (total_words / WORDS_PER_PAGE) / (individual_time / 60)
|
||||
|
||||
print(f"\n Total time: {individual_time:.2f} seconds")
|
||||
print(f" Rate: {individual_rate:.1f} segments/second")
|
||||
print(f" Words/second: {individual_words_per_sec:.1f}")
|
||||
print(f" Pages/minute: {individual_pages_per_min:.1f}")
|
||||
|
||||
# Get cache stats after individual translations
|
||||
cache_stats_1 = _translation_cache.stats()
|
||||
print(f" Cache: {cache_stats_1}")
|
||||
print()
|
||||
|
||||
# Clear cache for fair comparison
|
||||
_translation_cache.clear()
|
||||
|
||||
# Benchmark 2: Batch translations
|
||||
print("-" * 40)
|
||||
print("TEST 2: Batch Translations")
|
||||
print("-" * 40)
|
||||
|
||||
batch_sizes = [50, 100, 200]
|
||||
|
||||
for batch_size in batch_sizes:
|
||||
_translation_cache.clear()
|
||||
|
||||
start_time = time.time()
|
||||
translated_batch = []
|
||||
|
||||
for i in range(0, len(segments), batch_size):
|
||||
batch = segments[i:i + batch_size]
|
||||
results = service.translate_batch(batch, target_language)
|
||||
translated_batch.extend(results)
|
||||
|
||||
if len(translated_batch) % 1000 < batch_size:
|
||||
elapsed = time.time() - start_time
|
||||
rate = len(translated_batch) / elapsed if elapsed > 0 else 0
|
||||
print(f" [batch={batch_size}] Progress: {len(translated_batch):,}/{len(segments):,} ({rate:.1f} seg/sec)")
|
||||
|
||||
batch_time = time.time() - start_time
|
||||
batch_rate = len(segments) / batch_time
|
||||
batch_words_per_sec = total_words / batch_time
|
||||
batch_pages_per_min = (total_words / WORDS_PER_PAGE) / (batch_time / 60)
|
||||
speedup = individual_time / batch_time if batch_time > 0 else 0
|
||||
|
||||
cache_stats = _translation_cache.stats()
|
||||
|
||||
print(f"\n Batch size: {batch_size}")
|
||||
print(f" Total time: {batch_time:.2f} seconds")
|
||||
print(f" Rate: {batch_rate:.1f} segments/second")
|
||||
print(f" Words/second: {batch_words_per_sec:.1f}")
|
||||
print(f" Pages/minute: {batch_pages_per_min:.1f}")
|
||||
print(f" Speedup vs individual: {speedup:.2f}x")
|
||||
print(f" Cache: {cache_stats}")
|
||||
print()
|
||||
|
||||
# Benchmark 3: With cache (simulating re-translation of similar content)
|
||||
print("-" * 40)
|
||||
print("TEST 3: Cache Performance (Re-translation)")
|
||||
print("-" * 40)
|
||||
|
||||
# First pass - populate cache
|
||||
_translation_cache.clear()
|
||||
print(" First pass (populating cache)...")
|
||||
start_time = time.time()
|
||||
_ = service.translate_batch(segments, target_language)
|
||||
first_pass_time = time.time() - start_time
|
||||
|
||||
cache_after_first = _translation_cache.stats()
|
||||
print(f" First pass time: {first_pass_time:.2f} seconds")
|
||||
print(f" Cache after first pass: {cache_after_first}")
|
||||
|
||||
# Second pass - should use cache
|
||||
print("\n Second pass (using cache)...")
|
||||
start_time = time.time()
|
||||
_ = service.translate_batch(segments, target_language)
|
||||
second_pass_time = time.time() - start_time
|
||||
|
||||
cache_after_second = _translation_cache.stats()
|
||||
cache_speedup = first_pass_time / second_pass_time if second_pass_time > 0 else float('inf')
|
||||
|
||||
print(f" Second pass time: {second_pass_time:.2f} seconds")
|
||||
print(f" Cache after second pass: {cache_after_second}")
|
||||
print(f" Cache speedup: {cache_speedup:.1f}x")
|
||||
print()
|
||||
|
||||
# Summary
|
||||
print("=" * 60)
|
||||
print("BENCHMARK SUMMARY")
|
||||
print("=" * 60)
|
||||
print(f"Document size: {TARGET_PAGES} pages ({total_words:,} words)")
|
||||
print(f"Text segments: {len(segments):,}")
|
||||
print()
|
||||
print(f"Individual translation: {individual_time:.1f}s ({individual_pages_per_min:.1f} pages/min)")
|
||||
print(f"Batch translation (50): ~{individual_time/3:.1f}s estimated")
|
||||
print(f"With cache (2nd pass): {second_pass_time:.2f}s ({cache_speedup:.1f}x faster)")
|
||||
print()
|
||||
print("Recommendations:")
|
||||
print(" - Use batch_size=50 for optimal API performance")
|
||||
print(" - Enable caching for documents with repetitive content")
|
||||
print(" - For 200 pages, expect ~2-5 minutes with Google Translate")
|
||||
print("=" * 60)
|
||||
|
||||
|
||||
def quick_benchmark(num_segments: int = 100, target_language: str = "fr"):
|
||||
"""Quick benchmark with fewer segments for testing"""
|
||||
print(f"Quick benchmark: {num_segments} segments to {target_language}")
|
||||
print("-" * 40)
|
||||
|
||||
provider = GoogleTranslationProvider()
|
||||
service = TranslationService(provider)
|
||||
|
||||
# Generate test content
|
||||
segments = [random.choice(SAMPLE_TEXTS) for _ in range(num_segments)]
|
||||
|
||||
# Test batch translation
|
||||
_translation_cache.clear()
|
||||
start = time.time()
|
||||
results = service.translate_batch(segments, target_language)
|
||||
elapsed = time.time() - start
|
||||
|
||||
print(f"Translated {len(results)} segments in {elapsed:.2f}s")
|
||||
print(f"Rate: {len(results)/elapsed:.1f} segments/second")
|
||||
print(f"Cache: {_translation_cache.stats()}")
|
||||
|
||||
# Show sample translations
|
||||
print("\nSample translations:")
|
||||
for i in range(min(3, len(results))):
|
||||
print(f" '{segments[i]}' -> '{results[i]}'")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(description="Translation Benchmark")
|
||||
parser.add_argument("--quick", action="store_true", help="Run quick benchmark (100 segments)")
|
||||
parser.add_argument("--full", action="store_true", help="Run full 200-page benchmark")
|
||||
parser.add_argument("--segments", type=int, default=100, help="Number of segments for quick test")
|
||||
parser.add_argument("--lang", type=str, default="fr", help="Target language code")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.full:
|
||||
run_benchmark(target_language=args.lang)
|
||||
else:
|
||||
quick_benchmark(num_segments=args.segments, target_language=args.lang)
|
||||
53
config.py
53
config.py
@@ -1,5 +1,6 @@
|
||||
"""
|
||||
Configuration module for the Document Translation API
|
||||
SaaS-ready with comprehensive settings for production deployment
|
||||
"""
|
||||
import os
|
||||
from pathlib import Path
|
||||
@@ -8,24 +9,60 @@ from dotenv import load_dotenv
|
||||
load_dotenv()
|
||||
|
||||
class Config:
|
||||
# Translation Service
|
||||
# ============== Translation Service ==============
|
||||
TRANSLATION_SERVICE = os.getenv("TRANSLATION_SERVICE", "google")
|
||||
DEEPL_API_KEY = os.getenv("DEEPL_API_KEY", "")
|
||||
|
||||
# File Upload Configuration
|
||||
# Ollama Configuration
|
||||
OLLAMA_BASE_URL = os.getenv("OLLAMA_BASE_URL", "http://localhost:11434")
|
||||
OLLAMA_MODEL = os.getenv("OLLAMA_MODEL", "llama3")
|
||||
OLLAMA_VISION_MODEL = os.getenv("OLLAMA_VISION_MODEL", "llava")
|
||||
|
||||
# ============== File Upload Configuration ==============
|
||||
MAX_FILE_SIZE_MB = int(os.getenv("MAX_FILE_SIZE_MB", "50"))
|
||||
MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024
|
||||
|
||||
# Directories
|
||||
BASE_DIR = Path(__file__).parent.parent
|
||||
BASE_DIR = Path(__file__).parent
|
||||
UPLOAD_DIR = BASE_DIR / "uploads"
|
||||
OUTPUT_DIR = BASE_DIR / "outputs"
|
||||
TEMP_DIR = BASE_DIR / "temp"
|
||||
LOGS_DIR = BASE_DIR / "logs"
|
||||
|
||||
# Supported file types
|
||||
SUPPORTED_EXTENSIONS = {".xlsx", ".docx", ".pptx"}
|
||||
|
||||
# API Configuration
|
||||
# ============== Rate Limiting (SaaS) ==============
|
||||
RATE_LIMIT_ENABLED = os.getenv("RATE_LIMIT_ENABLED", "true").lower() == "true"
|
||||
RATE_LIMIT_PER_MINUTE = int(os.getenv("RATE_LIMIT_PER_MINUTE", "30"))
|
||||
RATE_LIMIT_PER_HOUR = int(os.getenv("RATE_LIMIT_PER_HOUR", "200"))
|
||||
TRANSLATIONS_PER_MINUTE = int(os.getenv("TRANSLATIONS_PER_MINUTE", "10"))
|
||||
TRANSLATIONS_PER_HOUR = int(os.getenv("TRANSLATIONS_PER_HOUR", "50"))
|
||||
MAX_CONCURRENT_TRANSLATIONS = int(os.getenv("MAX_CONCURRENT_TRANSLATIONS", "5"))
|
||||
|
||||
# ============== Cleanup Service ==============
|
||||
CLEANUP_ENABLED = os.getenv("CLEANUP_ENABLED", "true").lower() == "true"
|
||||
CLEANUP_INTERVAL_MINUTES = int(os.getenv("CLEANUP_INTERVAL_MINUTES", "15"))
|
||||
FILE_TTL_MINUTES = int(os.getenv("FILE_TTL_MINUTES", "60"))
|
||||
INPUT_FILE_TTL_MINUTES = int(os.getenv("INPUT_FILE_TTL_MINUTES", "30"))
|
||||
OUTPUT_FILE_TTL_MINUTES = int(os.getenv("OUTPUT_FILE_TTL_MINUTES", "120"))
|
||||
|
||||
# Disk space thresholds
|
||||
DISK_WARNING_THRESHOLD_GB = float(os.getenv("DISK_WARNING_THRESHOLD_GB", "5.0"))
|
||||
DISK_CRITICAL_THRESHOLD_GB = float(os.getenv("DISK_CRITICAL_THRESHOLD_GB", "1.0"))
|
||||
|
||||
# ============== Security ==============
|
||||
ENABLE_HSTS = os.getenv("ENABLE_HSTS", "false").lower() == "true"
|
||||
CORS_ORIGINS = os.getenv("CORS_ORIGINS", "*").split(",")
|
||||
MAX_REQUEST_SIZE_MB = int(os.getenv("MAX_REQUEST_SIZE_MB", "100"))
|
||||
REQUEST_TIMEOUT_SECONDS = int(os.getenv("REQUEST_TIMEOUT_SECONDS", "300"))
|
||||
|
||||
# ============== Monitoring ==============
|
||||
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO")
|
||||
ENABLE_REQUEST_LOGGING = os.getenv("ENABLE_REQUEST_LOGGING", "true").lower() == "true"
|
||||
MAX_MEMORY_PERCENT = float(os.getenv("MAX_MEMORY_PERCENT", "80"))
|
||||
|
||||
# ============== API Configuration ==============
|
||||
API_TITLE = "Document Translation API"
|
||||
API_VERSION = "1.0.0"
|
||||
API_DESCRIPTION = """
|
||||
@@ -35,6 +72,12 @@ class Config:
|
||||
- Excel (.xlsx) - Preserves cell formatting, formulas, merged cells, images
|
||||
- Word (.docx) - Preserves styles, tables, images, headers/footers
|
||||
- PowerPoint (.pptx) - Preserves layouts, animations, embedded media
|
||||
|
||||
SaaS Features:
|
||||
- Rate limiting per client IP
|
||||
- Automatic file cleanup
|
||||
- Health monitoring
|
||||
- Request logging
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
@@ -43,5 +86,7 @@ class Config:
|
||||
cls.UPLOAD_DIR.mkdir(exist_ok=True, parents=True)
|
||||
cls.OUTPUT_DIR.mkdir(exist_ok=True, parents=True)
|
||||
cls.TEMP_DIR.mkdir(exist_ok=True, parents=True)
|
||||
cls.LOGS_DIR.mkdir(exist_ok=True, parents=True)
|
||||
|
||||
|
||||
config = Config()
|
||||
|
||||
17
database/__init__.py
Normal file
17
database/__init__.py
Normal file
@@ -0,0 +1,17 @@
|
||||
"""
|
||||
Database module for the Document Translation API
|
||||
Provides PostgreSQL support with async SQLAlchemy
|
||||
"""
|
||||
from database.connection import get_db, engine, SessionLocal, init_db
|
||||
from database.models import User, Subscription, Translation, ApiKey
|
||||
|
||||
__all__ = [
|
||||
"get_db",
|
||||
"engine",
|
||||
"SessionLocal",
|
||||
"init_db",
|
||||
"User",
|
||||
"Subscription",
|
||||
"Translation",
|
||||
"ApiKey"
|
||||
]
|
||||
139
database/connection.py
Normal file
139
database/connection.py
Normal file
@@ -0,0 +1,139 @@
|
||||
"""
|
||||
Database connection and session management
|
||||
Supports both PostgreSQL (production) and SQLite (development/testing)
|
||||
"""
|
||||
import os
|
||||
import logging
|
||||
from typing import Generator, Optional
|
||||
from contextlib import contextmanager
|
||||
|
||||
from sqlalchemy import create_engine, event
|
||||
from sqlalchemy.orm import sessionmaker, Session
|
||||
from sqlalchemy.pool import QueuePool, StaticPool
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Database URL from environment
|
||||
# PostgreSQL: postgresql://user:password@host:port/database
|
||||
# SQLite: sqlite:///./data/translate.db
|
||||
DATABASE_URL = os.getenv("DATABASE_URL", "")
|
||||
|
||||
# Determine if we're using SQLite or PostgreSQL
|
||||
_is_sqlite = DATABASE_URL.startswith("sqlite") if DATABASE_URL else True
|
||||
|
||||
# Create engine based on database type
|
||||
if DATABASE_URL and not _is_sqlite:
|
||||
# PostgreSQL configuration
|
||||
engine = create_engine(
|
||||
DATABASE_URL,
|
||||
poolclass=QueuePool,
|
||||
pool_size=5,
|
||||
max_overflow=10,
|
||||
pool_timeout=30,
|
||||
pool_recycle=1800, # Recycle connections after 30 minutes
|
||||
pool_pre_ping=True, # Check connection health before use
|
||||
echo=os.getenv("DATABASE_ECHO", "false").lower() == "true",
|
||||
)
|
||||
logger.info("✅ Database configured with PostgreSQL")
|
||||
else:
|
||||
# SQLite configuration (for development/testing or when no DATABASE_URL)
|
||||
sqlite_path = os.getenv("SQLITE_PATH", "data/translate.db")
|
||||
os.makedirs(os.path.dirname(sqlite_path), exist_ok=True)
|
||||
|
||||
sqlite_url = f"sqlite:///./{sqlite_path}"
|
||||
engine = create_engine(
|
||||
sqlite_url,
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
echo=os.getenv("DATABASE_ECHO", "false").lower() == "true",
|
||||
)
|
||||
|
||||
# Enable foreign keys for SQLite
|
||||
@event.listens_for(engine, "connect")
|
||||
def set_sqlite_pragma(dbapi_connection, connection_record):
|
||||
cursor = dbapi_connection.cursor()
|
||||
cursor.execute("PRAGMA foreign_keys=ON")
|
||||
cursor.close()
|
||||
|
||||
if not DATABASE_URL:
|
||||
logger.warning("⚠️ DATABASE_URL not set, using SQLite for development")
|
||||
else:
|
||||
logger.info(f"✅ Database configured with SQLite: {sqlite_path}")
|
||||
|
||||
# Session factory
|
||||
SessionLocal = sessionmaker(
|
||||
autocommit=False,
|
||||
autoflush=False,
|
||||
bind=engine,
|
||||
expire_on_commit=False,
|
||||
)
|
||||
|
||||
|
||||
def get_db() -> Generator[Session, None, None]:
|
||||
"""
|
||||
Dependency for FastAPI to get database session.
|
||||
Usage: db: Session = Depends(get_db)
|
||||
"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def get_db_session() -> Generator[Session, None, None]:
|
||||
"""
|
||||
Context manager for database session.
|
||||
Usage: with get_db_session() as db: ...
|
||||
"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
db.commit()
|
||||
except Exception:
|
||||
db.rollback()
|
||||
raise
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# Alias for backward compatibility
|
||||
get_sync_session = get_db_session
|
||||
|
||||
|
||||
def init_db():
|
||||
"""
|
||||
Initialize database tables.
|
||||
Call this on application startup.
|
||||
"""
|
||||
from database.models import Base
|
||||
Base.metadata.create_all(bind=engine)
|
||||
logger.info("✅ Database tables initialized")
|
||||
|
||||
|
||||
def check_db_connection() -> bool:
|
||||
"""
|
||||
Check if database connection is healthy.
|
||||
Returns True if connection works, False otherwise.
|
||||
"""
|
||||
try:
|
||||
with engine.connect() as conn:
|
||||
conn.execute("SELECT 1")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Database connection check failed: {e}")
|
||||
return False
|
||||
|
||||
|
||||
# Connection pool stats (for monitoring)
|
||||
def get_pool_stats() -> dict:
|
||||
"""Get database connection pool statistics"""
|
||||
if hasattr(engine.pool, 'status'):
|
||||
return {
|
||||
"pool_size": engine.pool.size(),
|
||||
"checked_in": engine.pool.checkedin(),
|
||||
"checked_out": engine.pool.checkedout(),
|
||||
"overflow": engine.pool.overflow(),
|
||||
}
|
||||
return {"status": "pool stats not available"}
|
||||
259
database/models.py
Normal file
259
database/models.py
Normal file
@@ -0,0 +1,259 @@
|
||||
"""
|
||||
SQLAlchemy models for the Document Translation API
|
||||
"""
|
||||
import os
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Optional, List
|
||||
|
||||
from sqlalchemy import (
|
||||
Column, String, Integer, Float, Boolean, DateTime, Text,
|
||||
ForeignKey, Enum, Index, JSON, BigInteger
|
||||
)
|
||||
from sqlalchemy.orm import relationship, declarative_base
|
||||
from sqlalchemy.dialects.postgresql import UUID as PG_UUID
|
||||
import enum
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
def generate_uuid():
|
||||
"""Generate a new UUID string"""
|
||||
return str(uuid.uuid4())
|
||||
|
||||
|
||||
class PlanType(str, enum.Enum):
|
||||
FREE = "free"
|
||||
STARTER = "starter"
|
||||
PRO = "pro"
|
||||
BUSINESS = "business"
|
||||
ENTERPRISE = "enterprise"
|
||||
|
||||
|
||||
class SubscriptionStatus(str, enum.Enum):
|
||||
ACTIVE = "active"
|
||||
CANCELED = "canceled"
|
||||
PAST_DUE = "past_due"
|
||||
TRIALING = "trialing"
|
||||
PAUSED = "paused"
|
||||
|
||||
|
||||
class User(Base):
|
||||
"""User model for authentication and billing"""
|
||||
__tablename__ = "users"
|
||||
|
||||
id = Column(String(36), primary_key=True, default=generate_uuid)
|
||||
email = Column(String(255), unique=True, nullable=False, index=True)
|
||||
name = Column(String(255), nullable=False)
|
||||
password_hash = Column(String(255), nullable=False)
|
||||
|
||||
# Account status
|
||||
email_verified = Column(Boolean, default=False)
|
||||
is_active = Column(Boolean, default=True)
|
||||
avatar_url = Column(String(500), nullable=True)
|
||||
|
||||
# Subscription info
|
||||
plan = Column(Enum(PlanType), default=PlanType.FREE)
|
||||
subscription_status = Column(Enum(SubscriptionStatus), default=SubscriptionStatus.ACTIVE)
|
||||
|
||||
# Stripe integration
|
||||
stripe_customer_id = Column(String(255), nullable=True, index=True)
|
||||
stripe_subscription_id = Column(String(255), nullable=True)
|
||||
|
||||
# Usage tracking (reset monthly)
|
||||
docs_translated_this_month = Column(Integer, default=0)
|
||||
pages_translated_this_month = Column(Integer, default=0)
|
||||
api_calls_this_month = Column(Integer, default=0)
|
||||
extra_credits = Column(Integer, default=0) # Purchased credits
|
||||
usage_reset_date = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
# Timestamps
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
last_login_at = Column(DateTime, nullable=True)
|
||||
|
||||
# Relationships
|
||||
translations = relationship("Translation", back_populates="user", lazy="dynamic")
|
||||
api_keys = relationship("ApiKey", back_populates="user", lazy="dynamic")
|
||||
|
||||
# Indexes
|
||||
__table_args__ = (
|
||||
Index('ix_users_email_active', 'email', 'is_active'),
|
||||
Index('ix_users_stripe_customer', 'stripe_customer_id'),
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert user to dictionary for API response"""
|
||||
return {
|
||||
"id": self.id,
|
||||
"email": self.email,
|
||||
"name": self.name,
|
||||
"avatar_url": self.avatar_url,
|
||||
"plan": self.plan.value if self.plan else "free",
|
||||
"subscription_status": self.subscription_status.value if self.subscription_status else "active",
|
||||
"docs_translated_this_month": self.docs_translated_this_month,
|
||||
"pages_translated_this_month": self.pages_translated_this_month,
|
||||
"api_calls_this_month": self.api_calls_this_month,
|
||||
"extra_credits": self.extra_credits,
|
||||
"email_verified": self.email_verified,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
class Translation(Base):
|
||||
"""Translation history for analytics and billing"""
|
||||
__tablename__ = "translations"
|
||||
|
||||
id = Column(String(36), primary_key=True, default=generate_uuid)
|
||||
user_id = Column(String(36), ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||
|
||||
# File info
|
||||
original_filename = Column(String(255), nullable=False)
|
||||
file_type = Column(String(10), nullable=False) # xlsx, docx, pptx
|
||||
file_size_bytes = Column(BigInteger, default=0)
|
||||
page_count = Column(Integer, default=0)
|
||||
|
||||
# Translation details
|
||||
source_language = Column(String(10), default="auto")
|
||||
target_language = Column(String(10), nullable=False)
|
||||
provider = Column(String(50), nullable=False) # google, deepl, ollama, etc.
|
||||
|
||||
# Processing info
|
||||
status = Column(String(20), default="pending") # pending, processing, completed, failed
|
||||
error_message = Column(Text, nullable=True)
|
||||
processing_time_ms = Column(Integer, nullable=True)
|
||||
|
||||
# Cost tracking (for paid providers)
|
||||
characters_translated = Column(Integer, default=0)
|
||||
estimated_cost_usd = Column(Float, default=0.0)
|
||||
|
||||
# Timestamps
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
completed_at = Column(DateTime, nullable=True)
|
||||
|
||||
# Relationship
|
||||
user = relationship("User", back_populates="translations")
|
||||
|
||||
# Indexes
|
||||
__table_args__ = (
|
||||
Index('ix_translations_user_date', 'user_id', 'created_at'),
|
||||
Index('ix_translations_status', 'status'),
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"id": self.id,
|
||||
"original_filename": self.original_filename,
|
||||
"file_type": self.file_type,
|
||||
"file_size_bytes": self.file_size_bytes,
|
||||
"page_count": self.page_count,
|
||||
"source_language": self.source_language,
|
||||
"target_language": self.target_language,
|
||||
"provider": self.provider,
|
||||
"status": self.status,
|
||||
"processing_time_ms": self.processing_time_ms,
|
||||
"characters_translated": self.characters_translated,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
"completed_at": self.completed_at.isoformat() if self.completed_at else None,
|
||||
}
|
||||
|
||||
|
||||
class ApiKey(Base):
|
||||
"""API keys for programmatic access"""
|
||||
__tablename__ = "api_keys"
|
||||
|
||||
id = Column(String(36), primary_key=True, default=generate_uuid)
|
||||
user_id = Column(String(36), ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||
|
||||
# Key info
|
||||
name = Column(String(100), nullable=False) # User-friendly name
|
||||
key_hash = Column(String(255), nullable=False) # SHA256 of the key
|
||||
key_prefix = Column(String(10), nullable=False) # First 8 chars for identification
|
||||
|
||||
# Permissions
|
||||
is_active = Column(Boolean, default=True)
|
||||
scopes = Column(JSON, default=list) # ["translate", "read", "write"]
|
||||
|
||||
# Usage tracking
|
||||
last_used_at = Column(DateTime, nullable=True)
|
||||
usage_count = Column(Integer, default=0)
|
||||
|
||||
# Timestamps
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
expires_at = Column(DateTime, nullable=True)
|
||||
|
||||
# Relationship
|
||||
user = relationship("User", back_populates="api_keys")
|
||||
|
||||
# Indexes
|
||||
__table_args__ = (
|
||||
Index('ix_api_keys_prefix', 'key_prefix'),
|
||||
Index('ix_api_keys_hash', 'key_hash'),
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"id": self.id,
|
||||
"name": self.name,
|
||||
"key_prefix": self.key_prefix,
|
||||
"is_active": self.is_active,
|
||||
"scopes": self.scopes,
|
||||
"last_used_at": self.last_used_at.isoformat() if self.last_used_at else None,
|
||||
"usage_count": self.usage_count,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
"expires_at": self.expires_at.isoformat() if self.expires_at else None,
|
||||
}
|
||||
|
||||
|
||||
class UsageLog(Base):
|
||||
"""Daily usage aggregation for billing and analytics"""
|
||||
__tablename__ = "usage_logs"
|
||||
|
||||
id = Column(String(36), primary_key=True, default=generate_uuid)
|
||||
user_id = Column(String(36), ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||
|
||||
# Date (for daily aggregation)
|
||||
date = Column(DateTime, nullable=False, index=True)
|
||||
|
||||
# Aggregated counts
|
||||
documents_count = Column(Integer, default=0)
|
||||
pages_count = Column(Integer, default=0)
|
||||
characters_count = Column(BigInteger, default=0)
|
||||
api_calls_count = Column(Integer, default=0)
|
||||
|
||||
# By provider breakdown (JSON)
|
||||
provider_breakdown = Column(JSON, default=dict)
|
||||
|
||||
# Indexes
|
||||
__table_args__ = (
|
||||
Index('ix_usage_logs_user_date', 'user_id', 'date', unique=True),
|
||||
)
|
||||
|
||||
|
||||
class PaymentHistory(Base):
|
||||
"""Payment and invoice history"""
|
||||
__tablename__ = "payment_history"
|
||||
|
||||
id = Column(String(36), primary_key=True, default=generate_uuid)
|
||||
user_id = Column(String(36), ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||
|
||||
# Stripe info
|
||||
stripe_payment_intent_id = Column(String(255), nullable=True)
|
||||
stripe_invoice_id = Column(String(255), nullable=True)
|
||||
|
||||
# Payment details
|
||||
amount_cents = Column(Integer, nullable=False)
|
||||
currency = Column(String(3), default="usd")
|
||||
payment_type = Column(String(50), nullable=False) # subscription, credits, one_time
|
||||
status = Column(String(20), nullable=False) # succeeded, failed, pending, refunded
|
||||
|
||||
# Description
|
||||
description = Column(String(255), nullable=True)
|
||||
|
||||
# Timestamps
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
# Indexes
|
||||
__table_args__ = (
|
||||
Index('ix_payment_history_user', 'user_id', 'created_at'),
|
||||
)
|
||||
341
database/repositories.py
Normal file
341
database/repositories.py
Normal file
@@ -0,0 +1,341 @@
|
||||
"""
|
||||
Repository layer for database operations
|
||||
Provides clean interface for CRUD operations
|
||||
"""
|
||||
import hashlib
|
||||
import secrets
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, List, Dict, Any
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import and_, func, or_
|
||||
|
||||
from database.models import (
|
||||
User, Translation, ApiKey, UsageLog, PaymentHistory,
|
||||
PlanType, SubscriptionStatus
|
||||
)
|
||||
|
||||
|
||||
class UserRepository:
|
||||
"""Repository for User database operations"""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
|
||||
def get_by_id(self, user_id: str) -> Optional[User]:
|
||||
"""Get user by ID"""
|
||||
return self.db.query(User).filter(User.id == user_id).first()
|
||||
|
||||
def get_by_email(self, email: str) -> Optional[User]:
|
||||
"""Get user by email (case-insensitive)"""
|
||||
return self.db.query(User).filter(
|
||||
func.lower(User.email) == email.lower()
|
||||
).first()
|
||||
|
||||
def get_by_stripe_customer(self, stripe_customer_id: str) -> Optional[User]:
|
||||
"""Get user by Stripe customer ID"""
|
||||
return self.db.query(User).filter(
|
||||
User.stripe_customer_id == stripe_customer_id
|
||||
).first()
|
||||
|
||||
def create(
|
||||
self,
|
||||
email: str,
|
||||
name: str,
|
||||
password_hash: str,
|
||||
plan: PlanType = PlanType.FREE
|
||||
) -> User:
|
||||
"""Create a new user"""
|
||||
user = User(
|
||||
email=email.lower(),
|
||||
name=name,
|
||||
password_hash=password_hash,
|
||||
plan=plan,
|
||||
subscription_status=SubscriptionStatus.ACTIVE,
|
||||
)
|
||||
self.db.add(user)
|
||||
self.db.commit()
|
||||
self.db.refresh(user)
|
||||
return user
|
||||
|
||||
def update(self, user_id: str, **kwargs) -> Optional[User]:
|
||||
"""Update user fields"""
|
||||
user = self.get_by_id(user_id)
|
||||
if not user:
|
||||
return None
|
||||
|
||||
for key, value in kwargs.items():
|
||||
if hasattr(user, key):
|
||||
setattr(user, key, value)
|
||||
|
||||
user.updated_at = datetime.utcnow()
|
||||
self.db.commit()
|
||||
self.db.refresh(user)
|
||||
return user
|
||||
|
||||
def delete(self, user_id: str) -> bool:
|
||||
"""Delete a user"""
|
||||
user = self.get_by_id(user_id)
|
||||
if not user:
|
||||
return False
|
||||
|
||||
self.db.delete(user)
|
||||
self.db.commit()
|
||||
return True
|
||||
|
||||
def increment_usage(
|
||||
self,
|
||||
user_id: str,
|
||||
docs: int = 0,
|
||||
pages: int = 0,
|
||||
api_calls: int = 0
|
||||
) -> Optional[User]:
|
||||
"""Increment usage counters"""
|
||||
user = self.get_by_id(user_id)
|
||||
if not user:
|
||||
return None
|
||||
|
||||
# Check if usage needs to be reset (monthly)
|
||||
if user.usage_reset_date:
|
||||
now = datetime.utcnow()
|
||||
if now.month != user.usage_reset_date.month or now.year != user.usage_reset_date.year:
|
||||
user.docs_translated_this_month = 0
|
||||
user.pages_translated_this_month = 0
|
||||
user.api_calls_this_month = 0
|
||||
user.usage_reset_date = now
|
||||
|
||||
user.docs_translated_this_month += docs
|
||||
user.pages_translated_this_month += pages
|
||||
user.api_calls_this_month += api_calls
|
||||
|
||||
self.db.commit()
|
||||
self.db.refresh(user)
|
||||
return user
|
||||
|
||||
def add_credits(self, user_id: str, credits: int) -> Optional[User]:
|
||||
"""Add extra credits to user"""
|
||||
user = self.get_by_id(user_id)
|
||||
if not user:
|
||||
return None
|
||||
|
||||
user.extra_credits += credits
|
||||
self.db.commit()
|
||||
self.db.refresh(user)
|
||||
return user
|
||||
|
||||
def use_credits(self, user_id: str, credits: int) -> bool:
|
||||
"""Use credits from user balance"""
|
||||
user = self.get_by_id(user_id)
|
||||
if not user or user.extra_credits < credits:
|
||||
return False
|
||||
|
||||
user.extra_credits -= credits
|
||||
self.db.commit()
|
||||
return True
|
||||
|
||||
def get_all_users(
|
||||
self,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
plan: Optional[PlanType] = None
|
||||
) -> List[User]:
|
||||
"""Get all users with pagination"""
|
||||
query = self.db.query(User)
|
||||
if plan:
|
||||
query = query.filter(User.plan == plan)
|
||||
return query.offset(skip).limit(limit).all()
|
||||
|
||||
def count_users(self, plan: Optional[PlanType] = None) -> int:
|
||||
"""Count total users"""
|
||||
query = self.db.query(func.count(User.id))
|
||||
if plan:
|
||||
query = query.filter(User.plan == plan)
|
||||
return query.scalar()
|
||||
|
||||
|
||||
class TranslationRepository:
|
||||
"""Repository for Translation database operations"""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
|
||||
def create(
|
||||
self,
|
||||
user_id: str,
|
||||
original_filename: str,
|
||||
file_type: str,
|
||||
target_language: str,
|
||||
provider: str,
|
||||
source_language: str = "auto",
|
||||
file_size_bytes: int = 0,
|
||||
page_count: int = 0,
|
||||
) -> Translation:
|
||||
"""Create a new translation record"""
|
||||
translation = Translation(
|
||||
user_id=user_id,
|
||||
original_filename=original_filename,
|
||||
file_type=file_type,
|
||||
file_size_bytes=file_size_bytes,
|
||||
page_count=page_count,
|
||||
source_language=source_language,
|
||||
target_language=target_language,
|
||||
provider=provider,
|
||||
status="pending",
|
||||
)
|
||||
self.db.add(translation)
|
||||
self.db.commit()
|
||||
self.db.refresh(translation)
|
||||
return translation
|
||||
|
||||
def update_status(
|
||||
self,
|
||||
translation_id: str,
|
||||
status: str,
|
||||
error_message: Optional[str] = None,
|
||||
processing_time_ms: Optional[int] = None,
|
||||
characters_translated: Optional[int] = None,
|
||||
) -> Optional[Translation]:
|
||||
"""Update translation status"""
|
||||
translation = self.db.query(Translation).filter(
|
||||
Translation.id == translation_id
|
||||
).first()
|
||||
|
||||
if not translation:
|
||||
return None
|
||||
|
||||
translation.status = status
|
||||
if error_message:
|
||||
translation.error_message = error_message
|
||||
if processing_time_ms:
|
||||
translation.processing_time_ms = processing_time_ms
|
||||
if characters_translated:
|
||||
translation.characters_translated = characters_translated
|
||||
if status == "completed":
|
||||
translation.completed_at = datetime.utcnow()
|
||||
|
||||
self.db.commit()
|
||||
self.db.refresh(translation)
|
||||
return translation
|
||||
|
||||
def get_user_translations(
|
||||
self,
|
||||
user_id: str,
|
||||
skip: int = 0,
|
||||
limit: int = 50,
|
||||
status: Optional[str] = None,
|
||||
) -> List[Translation]:
|
||||
"""Get user's translation history"""
|
||||
query = self.db.query(Translation).filter(Translation.user_id == user_id)
|
||||
if status:
|
||||
query = query.filter(Translation.status == status)
|
||||
return query.order_by(Translation.created_at.desc()).offset(skip).limit(limit).all()
|
||||
|
||||
def get_user_stats(self, user_id: str, days: int = 30) -> Dict[str, Any]:
|
||||
"""Get user's translation statistics"""
|
||||
since = datetime.utcnow() - timedelta(days=days)
|
||||
|
||||
result = self.db.query(
|
||||
func.count(Translation.id).label("total_translations"),
|
||||
func.sum(Translation.page_count).label("total_pages"),
|
||||
func.sum(Translation.characters_translated).label("total_characters"),
|
||||
).filter(
|
||||
and_(
|
||||
Translation.user_id == user_id,
|
||||
Translation.created_at >= since,
|
||||
Translation.status == "completed",
|
||||
)
|
||||
).first()
|
||||
|
||||
return {
|
||||
"total_translations": result.total_translations or 0,
|
||||
"total_pages": result.total_pages or 0,
|
||||
"total_characters": result.total_characters or 0,
|
||||
"period_days": days,
|
||||
}
|
||||
|
||||
|
||||
class ApiKeyRepository:
|
||||
"""Repository for API Key database operations"""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
|
||||
@staticmethod
|
||||
def hash_key(key: str) -> str:
|
||||
"""Hash an API key"""
|
||||
return hashlib.sha256(key.encode()).hexdigest()
|
||||
|
||||
def create(
|
||||
self,
|
||||
user_id: str,
|
||||
name: str,
|
||||
scopes: List[str] = None,
|
||||
expires_in_days: Optional[int] = None,
|
||||
) -> tuple[ApiKey, str]:
|
||||
"""Create a new API key. Returns (ApiKey, raw_key)"""
|
||||
# Generate a secure random key
|
||||
raw_key = f"tr_{secrets.token_urlsafe(32)}"
|
||||
key_hash = self.hash_key(raw_key)
|
||||
key_prefix = raw_key[:10]
|
||||
|
||||
expires_at = None
|
||||
if expires_in_days:
|
||||
expires_at = datetime.utcnow() + timedelta(days=expires_in_days)
|
||||
|
||||
api_key = ApiKey(
|
||||
user_id=user_id,
|
||||
name=name,
|
||||
key_hash=key_hash,
|
||||
key_prefix=key_prefix,
|
||||
scopes=scopes or ["translate"],
|
||||
expires_at=expires_at,
|
||||
)
|
||||
self.db.add(api_key)
|
||||
self.db.commit()
|
||||
self.db.refresh(api_key)
|
||||
|
||||
return api_key, raw_key
|
||||
|
||||
def get_by_key(self, raw_key: str) -> Optional[ApiKey]:
|
||||
"""Get API key by raw key value"""
|
||||
key_hash = self.hash_key(raw_key)
|
||||
api_key = self.db.query(ApiKey).filter(
|
||||
and_(
|
||||
ApiKey.key_hash == key_hash,
|
||||
ApiKey.is_active == True,
|
||||
)
|
||||
).first()
|
||||
|
||||
if api_key:
|
||||
# Check expiration
|
||||
if api_key.expires_at and api_key.expires_at < datetime.utcnow():
|
||||
return None
|
||||
|
||||
# Update last used
|
||||
api_key.last_used_at = datetime.utcnow()
|
||||
api_key.usage_count += 1
|
||||
self.db.commit()
|
||||
|
||||
return api_key
|
||||
|
||||
def get_user_keys(self, user_id: str) -> List[ApiKey]:
|
||||
"""Get all API keys for a user"""
|
||||
return self.db.query(ApiKey).filter(
|
||||
ApiKey.user_id == user_id
|
||||
).order_by(ApiKey.created_at.desc()).all()
|
||||
|
||||
def revoke(self, key_id: str, user_id: str) -> bool:
|
||||
"""Revoke an API key"""
|
||||
api_key = self.db.query(ApiKey).filter(
|
||||
and_(
|
||||
ApiKey.id == key_id,
|
||||
ApiKey.user_id == user_id,
|
||||
)
|
||||
).first()
|
||||
|
||||
if not api_key:
|
||||
return False
|
||||
|
||||
api_key.is_active = False
|
||||
self.db.commit()
|
||||
return True
|
||||
40
docker-compose.dev.yml
Normal file
40
docker-compose.dev.yml
Normal file
@@ -0,0 +1,40 @@
|
||||
# Document Translation API - Development Docker Compose
|
||||
# Usage: docker-compose -f docker-compose.yml -f docker-compose.dev.yml up
|
||||
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
backend:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/backend/Dockerfile
|
||||
target: builder # Use builder stage for dev
|
||||
volumes:
|
||||
- .:/app
|
||||
- /app/venv # Don't override venv
|
||||
environment:
|
||||
- DEBUG=true
|
||||
- LOG_LEVEL=DEBUG
|
||||
command: uvicorn main:app --host 0.0.0.0 --port 8000 --reload
|
||||
ports:
|
||||
- "8000:8000"
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/frontend/Dockerfile
|
||||
target: builder
|
||||
volumes:
|
||||
- ./frontend:/app
|
||||
- /app/node_modules
|
||||
- /app/.next
|
||||
environment:
|
||||
- NODE_ENV=development
|
||||
command: npm run dev
|
||||
ports:
|
||||
- "3000:3000"
|
||||
|
||||
# No nginx in dev - direct access to services
|
||||
nginx:
|
||||
profiles:
|
||||
- disabled
|
||||
259
docker-compose.yml
Normal file
259
docker-compose.yml
Normal file
@@ -0,0 +1,259 @@
|
||||
# Document Translation API - Production Docker Compose
|
||||
# Usage: docker-compose -f docker-compose.yml -f docker-compose.prod.yml up -d
|
||||
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
# ===========================================
|
||||
# PostgreSQL Database
|
||||
# ===========================================
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: translate-postgres
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- POSTGRES_USER=${POSTGRES_USER:-translate}
|
||||
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-translate_secret_123}
|
||||
- POSTGRES_DB=${POSTGRES_DB:-translate_db}
|
||||
- PGDATA=/var/lib/postgresql/data/pgdata
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
networks:
|
||||
- translate-network
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-translate} -d ${POSTGRES_DB:-translate_db}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 10s
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 512M
|
||||
reservations:
|
||||
memory: 128M
|
||||
|
||||
# ===========================================
|
||||
# Backend API Service
|
||||
# ===========================================
|
||||
backend:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/backend/Dockerfile
|
||||
container_name: translate-backend
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
# Database
|
||||
- DATABASE_URL=postgresql+asyncpg://${POSTGRES_USER:-translate}:${POSTGRES_PASSWORD:-translate_secret_123}@postgres:5432/${POSTGRES_DB:-translate_db}
|
||||
# Redis
|
||||
- REDIS_URL=redis://redis:6379/0
|
||||
# Translation Services
|
||||
- TRANSLATION_SERVICE=${TRANSLATION_SERVICE:-ollama}
|
||||
- OLLAMA_BASE_URL=${OLLAMA_BASE_URL:-http://ollama:11434}
|
||||
- OLLAMA_MODEL=${OLLAMA_MODEL:-llama3}
|
||||
- DEEPL_API_KEY=${DEEPL_API_KEY:-}
|
||||
- OPENAI_API_KEY=${OPENAI_API_KEY:-}
|
||||
- OPENROUTER_API_KEY=${OPENROUTER_API_KEY:-}
|
||||
# File Limits
|
||||
- MAX_FILE_SIZE_MB=${MAX_FILE_SIZE_MB:-50}
|
||||
# Rate Limiting
|
||||
- RATE_LIMIT_REQUESTS_PER_MINUTE=${RATE_LIMIT_REQUESTS_PER_MINUTE:-60}
|
||||
- RATE_LIMIT_TRANSLATIONS_PER_MINUTE=${RATE_LIMIT_TRANSLATIONS_PER_MINUTE:-10}
|
||||
# Admin Auth (CHANGE IN PRODUCTION!)
|
||||
- ADMIN_USERNAME=${ADMIN_USERNAME}
|
||||
- ADMIN_PASSWORD=${ADMIN_PASSWORD}
|
||||
# Security
|
||||
- JWT_SECRET=${JWT_SECRET}
|
||||
- CORS_ORIGINS=${CORS_ORIGINS:-https://yourdomain.com}
|
||||
# Stripe Payments
|
||||
- STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY:-}
|
||||
- STRIPE_WEBHOOK_SECRET=${STRIPE_WEBHOOK_SECRET:-}
|
||||
- STRIPE_STARTER_PRICE_ID=${STRIPE_STARTER_PRICE_ID:-}
|
||||
- STRIPE_PRO_PRICE_ID=${STRIPE_PRO_PRICE_ID:-}
|
||||
- STRIPE_BUSINESS_PRICE_ID=${STRIPE_BUSINESS_PRICE_ID:-}
|
||||
volumes:
|
||||
- uploads_data:/app/uploads
|
||||
- outputs_data:/app/outputs
|
||||
- logs_data:/app/logs
|
||||
networks:
|
||||
- translate-network
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 2G
|
||||
reservations:
|
||||
memory: 512M
|
||||
|
||||
# ===========================================
|
||||
# Frontend Web Service
|
||||
# ===========================================
|
||||
frontend:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/frontend/Dockerfile
|
||||
args:
|
||||
- NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL:-http://backend:8000}
|
||||
container_name: translate-frontend
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL:-http://backend:8000}
|
||||
networks:
|
||||
- translate-network
|
||||
depends_on:
|
||||
backend:
|
||||
condition: service_healthy
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 512M
|
||||
reservations:
|
||||
memory: 128M
|
||||
|
||||
# ===========================================
|
||||
# Nginx Reverse Proxy
|
||||
# ===========================================
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
container_name: translate-nginx
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${HTTP_PORT:-80}:80"
|
||||
- "${HTTPS_PORT:-443}:443"
|
||||
volumes:
|
||||
- ./docker/nginx/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
- ./docker/nginx/conf.d:/etc/nginx/conf.d:ro
|
||||
- ./docker/nginx/ssl:/etc/nginx/ssl:ro
|
||||
- nginx_cache:/var/cache/nginx
|
||||
networks:
|
||||
- translate-network
|
||||
depends_on:
|
||||
- frontend
|
||||
- backend
|
||||
healthcheck:
|
||||
test: ["CMD", "nginx", "-t"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
# ===========================================
|
||||
# Ollama (Optional - Local LLM)
|
||||
# ===========================================
|
||||
ollama:
|
||||
image: ollama/ollama:latest
|
||||
container_name: translate-ollama
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ollama_data:/root/.ollama
|
||||
networks:
|
||||
- translate-network
|
||||
deploy:
|
||||
resources:
|
||||
reservations:
|
||||
devices:
|
||||
- driver: nvidia
|
||||
count: all
|
||||
capabilities: [gpu]
|
||||
profiles:
|
||||
- with-ollama
|
||||
|
||||
# ===========================================
|
||||
# Redis (Caching & Sessions)
|
||||
# ===========================================
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: translate-redis
|
||||
restart: unless-stopped
|
||||
command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
networks:
|
||||
- translate-network
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
# ===========================================
|
||||
# Prometheus (Optional - Monitoring)
|
||||
# ===========================================
|
||||
prometheus:
|
||||
image: prom/prometheus:latest
|
||||
container_name: translate-prometheus
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./docker/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro
|
||||
- prometheus_data:/prometheus
|
||||
command:
|
||||
- '--config.file=/etc/prometheus/prometheus.yml'
|
||||
- '--storage.tsdb.path=/prometheus'
|
||||
- '--web.enable-lifecycle'
|
||||
networks:
|
||||
- translate-network
|
||||
profiles:
|
||||
- with-monitoring
|
||||
|
||||
# ===========================================
|
||||
# Grafana (Optional - Dashboards)
|
||||
# ===========================================
|
||||
grafana:
|
||||
image: grafana/grafana:latest
|
||||
container_name: translate-grafana
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- GF_SECURITY_ADMIN_USER=${GRAFANA_USER:-admin}
|
||||
- GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_PASSWORD:-admin}
|
||||
- GF_USERS_ALLOW_SIGN_UP=false
|
||||
volumes:
|
||||
- grafana_data:/var/lib/grafana
|
||||
- ./docker/grafana/dashboards:/etc/grafana/provisioning/dashboards:ro
|
||||
networks:
|
||||
- translate-network
|
||||
depends_on:
|
||||
- prometheus
|
||||
profiles:
|
||||
- with-monitoring
|
||||
|
||||
# ===========================================
|
||||
# Networks
|
||||
# ===========================================
|
||||
networks:
|
||||
translate-network:
|
||||
driver: bridge
|
||||
ipam:
|
||||
config:
|
||||
- subnet: 172.28.0.0/16
|
||||
|
||||
# ===========================================
|
||||
# Volumes
|
||||
# ===========================================
|
||||
volumes:
|
||||
postgres_data:
|
||||
driver: local
|
||||
uploads_data:
|
||||
driver: local
|
||||
outputs_data:
|
||||
driver: local
|
||||
logs_data:
|
||||
driver: local
|
||||
nginx_cache:
|
||||
driver: local
|
||||
ollama_data:
|
||||
driver: local
|
||||
redis_data:
|
||||
driver: local
|
||||
prometheus_data:
|
||||
driver: local
|
||||
grafana_data:
|
||||
driver: local
|
||||
71
docker/backend/Dockerfile
Normal file
71
docker/backend/Dockerfile
Normal file
@@ -0,0 +1,71 @@
|
||||
# Document Translation API - Backend Dockerfile
|
||||
# Multi-stage build for optimized production image
|
||||
|
||||
FROM python:3.11-slim as builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install build dependencies
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
libmagic1 \
|
||||
libpq-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy requirements first for better caching
|
||||
COPY requirements.txt .
|
||||
|
||||
# Create virtual environment and install dependencies
|
||||
RUN python -m venv /opt/venv
|
||||
ENV PATH="/opt/venv/bin:$PATH"
|
||||
RUN pip install --no-cache-dir --upgrade pip && \
|
||||
pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Production stage
|
||||
FROM python:3.11-slim as production
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install runtime dependencies only
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libmagic1 \
|
||||
libpq5 \
|
||||
curl \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& apt-get clean
|
||||
|
||||
# Copy virtual environment from builder
|
||||
COPY --from=builder /opt/venv /opt/venv
|
||||
ENV PATH="/opt/venv/bin:$PATH"
|
||||
|
||||
# Create non-root user for security
|
||||
RUN groupadd -r translator && useradd -r -g translator translator
|
||||
|
||||
# Create necessary directories
|
||||
RUN mkdir -p /app/uploads /app/outputs /app/logs /app/temp \
|
||||
&& chown -R translator:translator /app
|
||||
|
||||
# Copy application code
|
||||
COPY --chown=translator:translator . .
|
||||
|
||||
# Make entrypoint executable
|
||||
RUN chmod +x docker/backend/entrypoint.sh
|
||||
|
||||
# Switch to non-root user
|
||||
USER translator
|
||||
|
||||
# Environment variables
|
||||
ENV PYTHONUNBUFFERED=1 \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
PORT=8000 \
|
||||
WORKERS=4
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \
|
||||
CMD curl -f http://localhost:${PORT}/health || exit 1
|
||||
|
||||
# Expose port
|
||||
EXPOSE ${PORT}
|
||||
|
||||
# Use entrypoint script to handle migrations and startup
|
||||
ENTRYPOINT ["docker/backend/entrypoint.sh"]
|
||||
66
docker/backend/entrypoint.sh
Normal file
66
docker/backend/entrypoint.sh
Normal file
@@ -0,0 +1,66 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
echo "🚀 Starting Document Translation API..."
|
||||
|
||||
# Wait for database to be ready (if DATABASE_URL is set)
|
||||
if [ -n "$DATABASE_URL" ]; then
|
||||
echo "⏳ Waiting for database to be ready..."
|
||||
|
||||
# Extract host and port from DATABASE_URL
|
||||
# postgresql://user:pass@host:port/db
|
||||
DB_HOST=$(echo $DATABASE_URL | sed -e 's/.*@\([^:]*\):.*/\1/')
|
||||
DB_PORT=$(echo $DATABASE_URL | sed -e 's/.*:\([0-9]*\)\/.*/\1/')
|
||||
|
||||
# Wait up to 30 seconds for database
|
||||
for i in {1..30}; do
|
||||
if python -c "
|
||||
import socket
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
try:
|
||||
s.connect(('$DB_HOST', $DB_PORT))
|
||||
s.close()
|
||||
exit(0)
|
||||
except:
|
||||
exit(1)
|
||||
" 2>/dev/null; then
|
||||
echo "✅ Database is ready!"
|
||||
break
|
||||
fi
|
||||
echo " Waiting for database... ($i/30)"
|
||||
sleep 1
|
||||
done
|
||||
|
||||
# Run database migrations
|
||||
echo "📦 Running database migrations..."
|
||||
alembic upgrade head || echo "⚠️ Migration skipped (may already be up to date)"
|
||||
fi
|
||||
|
||||
# Wait for Redis if configured
|
||||
if [ -n "$REDIS_URL" ]; then
|
||||
echo "⏳ Waiting for Redis..."
|
||||
REDIS_HOST=$(echo $REDIS_URL | sed -e 's/redis:\/\/\([^:]*\):.*/\1/')
|
||||
REDIS_PORT=$(echo $REDIS_URL | sed -e 's/.*:\([0-9]*\)\/.*/\1/')
|
||||
|
||||
for i in {1..10}; do
|
||||
if python -c "
|
||||
import socket
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
try:
|
||||
s.connect(('$REDIS_HOST', $REDIS_PORT))
|
||||
s.close()
|
||||
exit(0)
|
||||
except:
|
||||
exit(1)
|
||||
" 2>/dev/null; then
|
||||
echo "✅ Redis is ready!"
|
||||
break
|
||||
fi
|
||||
echo " Waiting for Redis... ($i/10)"
|
||||
sleep 1
|
||||
done
|
||||
fi
|
||||
|
||||
# Start the application
|
||||
echo "🎯 Starting uvicorn..."
|
||||
exec uvicorn main:app --host 0.0.0.0 --port ${PORT:-8000} --workers ${WORKERS:-4}
|
||||
51
docker/frontend/Dockerfile
Normal file
51
docker/frontend/Dockerfile
Normal file
@@ -0,0 +1,51 @@
|
||||
# Document Translation Frontend - Dockerfile
|
||||
# Multi-stage build for optimized production
|
||||
|
||||
# Build stage
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY frontend/package*.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm ci --only=production=false
|
||||
|
||||
# Copy source code
|
||||
COPY frontend/ .
|
||||
|
||||
# Build arguments for environment
|
||||
ARG NEXT_PUBLIC_API_URL=http://localhost:8000
|
||||
ENV NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL}
|
||||
|
||||
# Build the application
|
||||
RUN npm run build
|
||||
|
||||
# Production stage
|
||||
FROM node:20-alpine AS production
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Create non-root user
|
||||
RUN addgroup -g 1001 -S nodejs && \
|
||||
adduser -S nextjs -u 1001
|
||||
|
||||
# Copy built assets from builder
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder /app/.next/standalone ./
|
||||
COPY --from=builder /app/.next/static ./.next/static
|
||||
|
||||
# Set correct permissions
|
||||
RUN chown -R nextjs:nodejs /app
|
||||
|
||||
USER nextjs
|
||||
|
||||
# Environment
|
||||
ENV NODE_ENV=production \
|
||||
PORT=3000 \
|
||||
HOSTNAME="0.0.0.0"
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
150
docker/nginx/conf.d/default.conf
Normal file
150
docker/nginx/conf.d/default.conf
Normal file
@@ -0,0 +1,150 @@
|
||||
# Document Translation API - Main Server Block
|
||||
# HTTP to HTTPS redirect and main application routing
|
||||
|
||||
# HTTP server - redirect to HTTPS
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name _;
|
||||
|
||||
# Allow health checks on HTTP
|
||||
location /health {
|
||||
proxy_pass http://backend/health;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection "";
|
||||
}
|
||||
|
||||
# ACME challenge for Let's Encrypt
|
||||
location /.well-known/acme-challenge/ {
|
||||
root /var/www/certbot;
|
||||
}
|
||||
|
||||
# Redirect all other traffic to HTTPS
|
||||
location / {
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
}
|
||||
|
||||
# HTTPS server - main application
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
listen [::]:443 ssl http2;
|
||||
server_name _;
|
||||
|
||||
# SSL certificates (replace with your paths)
|
||||
ssl_certificate /etc/nginx/ssl/fullchain.pem;
|
||||
ssl_certificate_key /etc/nginx/ssl/privkey.pem;
|
||||
ssl_trusted_certificate /etc/nginx/ssl/chain.pem;
|
||||
|
||||
# SSL configuration
|
||||
ssl_session_timeout 1d;
|
||||
ssl_session_cache shared:SSL:50m;
|
||||
ssl_session_tickets off;
|
||||
|
||||
# Modern SSL configuration
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
|
||||
ssl_prefer_server_ciphers off;
|
||||
|
||||
# OCSP Stapling
|
||||
ssl_stapling on;
|
||||
ssl_stapling_verify on;
|
||||
resolver 8.8.8.8 8.8.4.4 valid=300s;
|
||||
resolver_timeout 5s;
|
||||
|
||||
# Security headers
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' ws: wss:;" always;
|
||||
|
||||
# API routes - proxy to backend
|
||||
location /api/ {
|
||||
rewrite ^/api/(.*)$ /$1 break;
|
||||
|
||||
limit_req zone=api_limit burst=20 nodelay;
|
||||
limit_conn conn_limit 10;
|
||||
|
||||
proxy_pass http://backend;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Connection "";
|
||||
|
||||
# CORS headers for API
|
||||
add_header Access-Control-Allow-Origin $http_origin always;
|
||||
add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" always;
|
||||
add_header Access-Control-Allow-Headers "Authorization, Content-Type, X-Requested-With" always;
|
||||
add_header Access-Control-Allow-Credentials "true" always;
|
||||
|
||||
if ($request_method = 'OPTIONS') {
|
||||
return 204;
|
||||
}
|
||||
}
|
||||
|
||||
# File upload endpoint - special handling
|
||||
location /translate {
|
||||
limit_req zone=upload_limit burst=5 nodelay;
|
||||
limit_conn conn_limit 5;
|
||||
|
||||
proxy_pass http://backend/translate;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# Increased timeouts for file processing
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 600s;
|
||||
proxy_read_timeout 600s;
|
||||
}
|
||||
|
||||
# Health check endpoint
|
||||
location /health {
|
||||
proxy_pass http://backend/health;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection "";
|
||||
}
|
||||
|
||||
# Admin endpoints
|
||||
location /admin {
|
||||
limit_req zone=api_limit burst=10 nodelay;
|
||||
|
||||
proxy_pass http://backend/admin;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Frontend - Next.js application
|
||||
location / {
|
||||
proxy_pass http://frontend;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
}
|
||||
|
||||
# Static files caching
|
||||
location /_next/static/ {
|
||||
proxy_pass http://frontend;
|
||||
proxy_cache_valid 200 365d;
|
||||
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||
}
|
||||
|
||||
# Error pages
|
||||
error_page 500 502 503 504 /50x.html;
|
||||
location = /50x.html {
|
||||
root /usr/share/nginx/html;
|
||||
}
|
||||
}
|
||||
74
docker/nginx/nginx.conf
Normal file
74
docker/nginx/nginx.conf
Normal file
@@ -0,0 +1,74 @@
|
||||
# Nginx Configuration for Document Translation API
|
||||
# Production-ready with SSL, caching, and security headers
|
||||
|
||||
user nginx;
|
||||
worker_processes auto;
|
||||
error_log /var/log/nginx/error.log warn;
|
||||
pid /var/run/nginx.pid;
|
||||
|
||||
events {
|
||||
worker_connections 1024;
|
||||
use epoll;
|
||||
multi_accept on;
|
||||
}
|
||||
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
|
||||
# Logging format
|
||||
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
|
||||
'$status $body_bytes_sent "$http_referer" '
|
||||
'"$http_user_agent" "$http_x_forwarded_for" '
|
||||
'rt=$request_time uct="$upstream_connect_time" '
|
||||
'uht="$upstream_header_time" urt="$upstream_response_time"';
|
||||
|
||||
access_log /var/log/nginx/access.log main;
|
||||
|
||||
# Performance optimizations
|
||||
sendfile on;
|
||||
tcp_nopush on;
|
||||
tcp_nodelay on;
|
||||
keepalive_timeout 65;
|
||||
types_hash_max_size 2048;
|
||||
|
||||
# Gzip compression
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_proxied any;
|
||||
gzip_comp_level 6;
|
||||
gzip_types text/plain text/css text/xml application/json application/javascript
|
||||
application/xml application/rss+xml application/atom+xml image/svg+xml;
|
||||
|
||||
# File upload size (for document translation)
|
||||
client_max_body_size 100M;
|
||||
client_body_timeout 300s;
|
||||
client_header_timeout 60s;
|
||||
|
||||
# Proxy settings
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 300s;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_buffer_size 128k;
|
||||
proxy_buffers 4 256k;
|
||||
proxy_busy_buffers_size 256k;
|
||||
|
||||
# Rate limiting zones
|
||||
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s;
|
||||
limit_req_zone $binary_remote_addr zone=upload_limit:10m rate=2r/s;
|
||||
limit_conn_zone $binary_remote_addr zone=conn_limit:10m;
|
||||
|
||||
# Upstream definitions
|
||||
upstream backend {
|
||||
server backend:8000;
|
||||
keepalive 32;
|
||||
}
|
||||
|
||||
upstream frontend {
|
||||
server frontend:3000;
|
||||
keepalive 32;
|
||||
}
|
||||
|
||||
# Include additional configs
|
||||
include /etc/nginx/conf.d/*.conf;
|
||||
}
|
||||
8
docker/nginx/ssl/.gitkeep
Normal file
8
docker/nginx/ssl/.gitkeep
Normal file
@@ -0,0 +1,8 @@
|
||||
# Self-signed SSL certificate placeholder
|
||||
# Replace with real certificates in production!
|
||||
|
||||
# Generate self-signed certificate:
|
||||
# openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
|
||||
# -keyout privkey.pem \
|
||||
# -out fullchain.pem \
|
||||
# -subj "/CN=localhost"
|
||||
37
docker/prometheus/prometheus.yml
Normal file
37
docker/prometheus/prometheus.yml
Normal file
@@ -0,0 +1,37 @@
|
||||
# Prometheus Configuration for Document Translation API
|
||||
|
||||
global:
|
||||
scrape_interval: 15s
|
||||
evaluation_interval: 15s
|
||||
external_labels:
|
||||
monitor: 'translate-api'
|
||||
|
||||
alerting:
|
||||
alertmanagers:
|
||||
- static_configs:
|
||||
- targets: []
|
||||
|
||||
rule_files: []
|
||||
|
||||
scrape_configs:
|
||||
# Backend API metrics
|
||||
- job_name: 'translate-backend'
|
||||
static_configs:
|
||||
- targets: ['backend:8000']
|
||||
metrics_path: /metrics
|
||||
scrape_interval: 10s
|
||||
|
||||
# Nginx metrics (requires nginx-prometheus-exporter)
|
||||
- job_name: 'nginx'
|
||||
static_configs:
|
||||
- targets: ['nginx-exporter:9113']
|
||||
|
||||
# Node exporter for system metrics
|
||||
- job_name: 'node'
|
||||
static_configs:
|
||||
- targets: ['node-exporter:9100']
|
||||
|
||||
# Docker metrics
|
||||
- job_name: 'docker'
|
||||
static_configs:
|
||||
- targets: ['cadvisor:8080']
|
||||
41
frontend/.gitignore
vendored
Normal file
41
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,41 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
36
frontend/README.md
Normal file
36
frontend/README.md
Normal file
@@ -0,0 +1,36 @@
|
||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||
|
||||
## Getting Started
|
||||
|
||||
First, run the development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
|
||||
## Deploy on Vercel
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
22
frontend/components.json
Normal file
22
frontend/components.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/app/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"registries": {}
|
||||
}
|
||||
18
frontend/eslint.config.mjs
Normal file
18
frontend/eslint.config.mjs
Normal file
@@ -0,0 +1,18 @@
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||
import nextTs from "eslint-config-next/typescript";
|
||||
|
||||
const eslintConfig = defineConfig([
|
||||
...nextVitals,
|
||||
...nextTs,
|
||||
// Override default ignores of eslint-config-next.
|
||||
globalIgnores([
|
||||
// Default ignores of eslint-config-next:
|
||||
".next/**",
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
]),
|
||||
]);
|
||||
|
||||
export default eslintConfig;
|
||||
7
frontend/next.config.ts
Normal file
7
frontend/next.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
8093
frontend/package-lock.json
generated
Normal file
8093
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
50
frontend/package.json
Normal file
50
frontend/package.json
Normal file
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mlc-ai/web-llm": "^0.2.80",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-progress": "^1.1.8",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"axios": "^1.13.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"framer-motion": "^12.23.24",
|
||||
"lightningcss-win32-x64-msvc": "^1.30.2",
|
||||
"lucide-react": "^0.555.0",
|
||||
"next": "16.0.6",
|
||||
"react": "19.2.0",
|
||||
"react-dom": "19.2.0",
|
||||
"react-dropzone": "^14.3.8",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"zustand": "^5.0.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.0.6",
|
||||
"lightningcss": "^1.30.2",
|
||||
"tailwindcss": "^4",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
7
frontend/postcss.config.mjs
Normal file
7
frontend/postcss.config.mjs
Normal file
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
1
frontend/public/file.svg
Normal file
1
frontend/public/file.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 391 B |
1
frontend/public/globe.svg
Normal file
1
frontend/public/globe.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
1
frontend/public/next.svg
Normal file
1
frontend/public/next.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
1
frontend/public/vercel.svg
Normal file
1
frontend/public/vercel.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 128 B |
1
frontend/public/window.svg
Normal file
1
frontend/public/window.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||
|
After Width: | Height: | Size: 385 B |
132
frontend/src/app/admin/login/page.tsx
Normal file
132
frontend/src/app/admin/login/page.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
"use client";
|
||||
|
||||
import { useState, Suspense } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useTranslationStore } from "@/lib/store";
|
||||
import { Shield, Lock, Eye, EyeOff, AlertCircle } from "lucide-react";
|
||||
|
||||
function AdminLoginContent() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { setAdminToken } = useTranslationStore();
|
||||
|
||||
const [password, setPassword] = useState("");
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000";
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/admin/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ password }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.detail || "Mot de passe incorrect");
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setAdminToken(data.access_token);
|
||||
|
||||
const redirect = searchParams.get("redirect") || "/admin";
|
||||
router.push(redirect);
|
||||
} catch (err: any) {
|
||||
const errorMessage = typeof err.message === 'string' ? err.message : "Erreur de connexion";
|
||||
setError(errorMessage);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-purple-900 to-slate-900 flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md">
|
||||
{/* Logo */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 bg-purple-600/20 rounded-2xl mb-4">
|
||||
<Shield className="w-8 h-8 text-purple-400" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-white">Administration</h1>
|
||||
<p className="text-gray-400 mt-2">Connexion requise</p>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleSubmit} className="bg-black/30 backdrop-blur-xl rounded-2xl border border-white/10 p-8">
|
||||
{error && (
|
||||
<div className="flex items-center gap-3 p-4 mb-6 bg-red-500/10 border border-red-500/20 rounded-xl text-red-400">
|
||||
<AlertCircle className="w-5 h-5 flex-shrink-0" />
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<label className="block text-gray-400 text-sm mb-2">Mot de passe administrateur</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
|
||||
<input
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
className="w-full pl-12 pr-12 py-3 bg-black/30 border border-white/10 rounded-xl text-white placeholder:text-gray-500 focus:outline-none focus:border-purple-500 transition-all"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2 text-gray-400 hover:text-white transition-colors"
|
||||
>
|
||||
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !password}
|
||||
className="w-full py-3 bg-purple-600 hover:bg-purple-700 disabled:bg-purple-600/50 disabled:cursor-not-allowed text-white font-medium rounded-xl transition-all flex items-center justify-center gap-2"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||
Connexion...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Shield className="w-5 h-5" />
|
||||
Accéder au panneau admin
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<p className="text-center text-gray-500 text-sm mt-6">
|
||||
Accès réservé aux administrateurs
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AdminLoginPage() {
|
||||
return (
|
||||
<Suspense fallback={
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-purple-900 to-slate-900 flex items-center justify-center">
|
||||
<div className="text-white text-xl">Chargement...</div>
|
||||
</div>
|
||||
}>
|
||||
<AdminLoginContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
614
frontend/src/app/admin/page.tsx
Normal file
614
frontend/src/app/admin/page.tsx
Normal file
@@ -0,0 +1,614 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, Suspense } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useTranslationStore } from "@/lib/store";
|
||||
import { motion } from "framer-motion";
|
||||
import { Users, Activity, Settings, FileText, TrendingUp, Server, Key, LogOut, RefreshCw, Search, ChevronRight, Shield, Zap, Globe, DollarSign } from "lucide-react";
|
||||
|
||||
interface DashboardData {
|
||||
translations_today: number;
|
||||
translations_total: number;
|
||||
active_users: number;
|
||||
popular_languages: { [key: string]: number };
|
||||
average_processing_time: number;
|
||||
cache_hit_rate: number;
|
||||
openrouter_usage?: {
|
||||
total_cost: number;
|
||||
requests_count: number;
|
||||
models_used: { [key: string]: number };
|
||||
};
|
||||
}
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
username: string;
|
||||
plan: string;
|
||||
translations_count: number;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
last_login?: string;
|
||||
}
|
||||
|
||||
interface AdminSettings {
|
||||
default_provider: string;
|
||||
openrouter_enabled: boolean;
|
||||
google_enabled: boolean;
|
||||
max_file_size_mb: number;
|
||||
rate_limit_per_minute: number;
|
||||
cache_enabled: boolean;
|
||||
}
|
||||
|
||||
function AdminContent() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { adminToken } = useTranslationStore();
|
||||
|
||||
const [activeTab, setActiveTab] = useState<"overview" | "users" | "config" | "settings">("overview");
|
||||
const [dashboardData, setDashboardData] = useState<DashboardData | null>(null);
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [settings, setSettings] = useState<AdminSettings>({
|
||||
default_provider: "google",
|
||||
openrouter_enabled: true,
|
||||
google_enabled: true,
|
||||
max_file_size_mb: 10,
|
||||
rate_limit_per_minute: 60,
|
||||
cache_enabled: true
|
||||
});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000";
|
||||
|
||||
useEffect(() => {
|
||||
const tab = searchParams.get("tab");
|
||||
if (tab && ["overview", "users", "config", "settings"].includes(tab)) {
|
||||
setActiveTab(tab as any);
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!adminToken) {
|
||||
router.push("/admin/login");
|
||||
return;
|
||||
}
|
||||
fetchDashboardData();
|
||||
}, [adminToken]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === "users" && users.length === 0) {
|
||||
fetchUsers();
|
||||
}
|
||||
}, [activeTab]);
|
||||
|
||||
const fetchDashboardData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetch(`${API_BASE}/admin/dashboard`, {
|
||||
headers: { Authorization: `Bearer ${adminToken}` }
|
||||
});
|
||||
if (!response.ok) throw new Error("Failed to fetch dashboard data");
|
||||
const data = await response.json();
|
||||
setDashboardData(data);
|
||||
} catch (err) {
|
||||
setError("Erreur de chargement des données");
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchUsers = async () => {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/admin/users`, {
|
||||
headers: { Authorization: `Bearer ${adminToken}` }
|
||||
});
|
||||
if (!response.ok) throw new Error("Failed to fetch users");
|
||||
const data = await response.json();
|
||||
setUsers(data.users || []);
|
||||
} catch (err) {
|
||||
console.error("Error fetching users:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const refreshData = async () => {
|
||||
setRefreshing(true);
|
||||
await fetchDashboardData();
|
||||
if (activeTab === "users") {
|
||||
await fetchUsers();
|
||||
}
|
||||
setRefreshing(false);
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
useTranslationStore.getState().setAdminToken(null);
|
||||
router.push("/admin/login");
|
||||
};
|
||||
|
||||
const filteredUsers = users.filter(user =>
|
||||
user.email?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
user.username?.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-purple-900 to-slate-900 flex items-center justify-center">
|
||||
<div className="text-white text-xl flex items-center gap-3">
|
||||
<RefreshCw className="animate-spin" />
|
||||
Chargement...
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-purple-900 to-slate-900">
|
||||
{/* Header */}
|
||||
<header className="bg-black/30 backdrop-blur-xl border-b border-white/10">
|
||||
<div className="max-w-7xl mx-auto px-6 py-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Shield className="w-8 h-8 text-purple-400" />
|
||||
<h1 className="text-2xl font-bold text-white">Administration</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={refreshData}
|
||||
disabled={refreshing}
|
||||
className="p-2 rounded-lg bg-white/10 hover:bg-white/20 text-white transition-all"
|
||||
>
|
||||
<RefreshCw className={`w-5 h-5 ${refreshing ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-red-500/20 hover:bg-red-500/30 text-red-400 transition-all"
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
Déconnexion
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="max-w-7xl mx-auto px-6 py-8">
|
||||
{/* Tab Navigation */}
|
||||
<div className="flex gap-2 mb-8 bg-black/20 p-2 rounded-xl w-fit">
|
||||
{[
|
||||
{ id: "overview", label: "Vue d'ensemble", icon: Activity },
|
||||
{ id: "users", label: "Utilisateurs", icon: Users },
|
||||
{ id: "config", label: "Configuration", icon: Server },
|
||||
{ id: "settings", label: "Paramètres", icon: Settings }
|
||||
].map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id as any)}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-lg transition-all ${
|
||||
activeTab === tab.id
|
||||
? "bg-purple-600 text-white shadow-lg"
|
||||
: "text-gray-400 hover:text-white hover:bg-white/10"
|
||||
}`}
|
||||
>
|
||||
<tab.icon className="w-4 h-4" />
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Overview Tab */}
|
||||
{activeTab === "overview" && dashboardData && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="space-y-6"
|
||||
>
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<StatCard
|
||||
title="Traductions Aujourd'hui"
|
||||
value={dashboardData.translations_today ?? 0}
|
||||
icon={FileText}
|
||||
color="purple"
|
||||
/>
|
||||
<StatCard
|
||||
title="Total Traductions"
|
||||
value={dashboardData.translations_total ?? 0}
|
||||
icon={TrendingUp}
|
||||
color="blue"
|
||||
/>
|
||||
<StatCard
|
||||
title="Utilisateurs Actifs"
|
||||
value={dashboardData.active_users ?? 0}
|
||||
icon={Users}
|
||||
color="green"
|
||||
/>
|
||||
<StatCard
|
||||
title="Taux Cache"
|
||||
value={`${((dashboardData.cache_hit_rate ?? 0) * 100).toFixed(1)}%`}
|
||||
icon={Zap}
|
||||
color="yellow"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* OpenRouter Usage */}
|
||||
{dashboardData.openrouter_usage && (
|
||||
<div className="bg-black/20 backdrop-blur-xl rounded-2xl border border-white/10 p-6">
|
||||
<h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
|
||||
<Globe className="w-5 h-5 text-purple-400" />
|
||||
Utilisation OpenRouter
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="bg-white/5 rounded-xl p-4">
|
||||
<p className="text-gray-400 text-sm">Coût Total</p>
|
||||
<p className="text-2xl font-bold text-green-400">
|
||||
${dashboardData.openrouter_usage.total_cost?.toFixed(4) ?? '0.0000'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white/5 rounded-xl p-4">
|
||||
<p className="text-gray-400 text-sm">Requêtes</p>
|
||||
<p className="text-2xl font-bold text-blue-400">
|
||||
{dashboardData.openrouter_usage.requests_count ?? 0}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white/5 rounded-xl p-4">
|
||||
<p className="text-gray-400 text-sm">Temps Moyen</p>
|
||||
<p className="text-2xl font-bold text-yellow-400">
|
||||
{(dashboardData.average_processing_time ?? 0).toFixed(2)}s
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Popular Languages */}
|
||||
<div className="bg-black/20 backdrop-blur-xl rounded-2xl border border-white/10 p-6">
|
||||
<h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
|
||||
<Globe className="w-5 h-5 text-purple-400" />
|
||||
Langues Populaires
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{Object.entries(dashboardData.popular_languages || {}).slice(0, 8).map(([lang, count]) => (
|
||||
<div key={lang} className="bg-white/5 rounded-xl p-4 text-center">
|
||||
<p className="text-2xl font-bold text-white">{count}</p>
|
||||
<p className="text-gray-400 text-sm uppercase">{lang}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Users Tab */}
|
||||
{activeTab === "users" && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="space-y-6"
|
||||
>
|
||||
{/* Search */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative flex-1 max-w-md">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Rechercher un utilisateur..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-3 bg-black/30 border border-white/10 rounded-xl text-white placeholder:text-gray-500 focus:outline-none focus:border-purple-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-gray-400">
|
||||
{filteredUsers.length} utilisateur(s)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Users Table */}
|
||||
<div className="bg-black/20 backdrop-blur-xl rounded-2xl border border-white/10 overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-white/10">
|
||||
<th className="text-left p-4 text-gray-400 font-medium">Utilisateur</th>
|
||||
<th className="text-left p-4 text-gray-400 font-medium">Plan</th>
|
||||
<th className="text-left p-4 text-gray-400 font-medium">Traductions</th>
|
||||
<th className="text-left p-4 text-gray-400 font-medium">Statut</th>
|
||||
<th className="text-left p-4 text-gray-400 font-medium">Inscrit le</th>
|
||||
<th className="text-left p-4 text-gray-400 font-medium"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredUsers.map((user) => (
|
||||
<tr key={user.id} className="border-b border-white/5 hover:bg-white/5 transition-colors">
|
||||
<td className="p-4">
|
||||
<div>
|
||||
<p className="text-white font-medium">{user.username || 'N/A'}</p>
|
||||
<p className="text-gray-400 text-sm">{user.email}</p>
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-4">
|
||||
<span className={`px-3 py-1 rounded-full text-xs font-medium ${
|
||||
user.plan === 'premium'
|
||||
? 'bg-purple-500/20 text-purple-400'
|
||||
: user.plan === 'pro'
|
||||
? 'bg-blue-500/20 text-blue-400'
|
||||
: 'bg-gray-500/20 text-gray-400'
|
||||
}`}>
|
||||
{user.plan || 'free'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="p-4 text-white">{user.translations_count ?? 0}</td>
|
||||
<td className="p-4">
|
||||
<span className={`px-3 py-1 rounded-full text-xs font-medium ${
|
||||
user.is_active
|
||||
? 'bg-green-500/20 text-green-400'
|
||||
: 'bg-red-500/20 text-red-400'
|
||||
}`}>
|
||||
{user.is_active ? 'Actif' : 'Inactif'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="p-4 text-gray-400 text-sm">
|
||||
{user.created_at ? new Date(user.created_at).toLocaleDateString('fr-FR') : 'N/A'}
|
||||
</td>
|
||||
<td className="p-4">
|
||||
<button className="p-2 rounded-lg hover:bg-white/10 text-gray-400 hover:text-white transition-all">
|
||||
<ChevronRight className="w-5 h-5" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{filteredUsers.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={6} className="p-8 text-center text-gray-400">
|
||||
Aucun utilisateur trouvé
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Config Tab */}
|
||||
{activeTab === "config" && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="space-y-6"
|
||||
>
|
||||
{/* Translation Providers */}
|
||||
<div className="bg-black/20 backdrop-blur-xl rounded-2xl border border-white/10 p-6">
|
||||
<h3 className="text-lg font-semibold text-white mb-6 flex items-center gap-2">
|
||||
<Server className="w-5 h-5 text-purple-400" />
|
||||
Fournisseurs de Traduction
|
||||
</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Google Translate */}
|
||||
<div className="flex items-center justify-between p-4 bg-white/5 rounded-xl">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 bg-blue-500/20 rounded-xl flex items-center justify-center">
|
||||
<Globe className="w-6 h-6 text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-white font-medium">Google Translate</h4>
|
||||
<p className="text-gray-400 text-sm">API officielle Google Cloud</p>
|
||||
</div>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.google_enabled}
|
||||
onChange={(e) => setSettings({ ...settings, google_enabled: e.target.checked })}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-gray-700 peer-focus:ring-4 peer-focus:ring-purple-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-purple-600"></div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* OpenRouter */}
|
||||
<div className="flex items-center justify-between p-4 bg-white/5 rounded-xl">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 bg-purple-500/20 rounded-xl flex items-center justify-center">
|
||||
<Zap className="w-6 h-6 text-purple-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-white font-medium">OpenRouter</h4>
|
||||
<p className="text-gray-400 text-sm">Modèles IA avancés (GPT-4, Claude, etc.)</p>
|
||||
</div>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.openrouter_enabled}
|
||||
onChange={(e) => setSettings({ ...settings, openrouter_enabled: e.target.checked })}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-gray-700 peer-focus:ring-4 peer-focus:ring-purple-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-purple-600"></div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* API Keys */}
|
||||
<div className="bg-black/20 backdrop-blur-xl rounded-2xl border border-white/10 p-6">
|
||||
<h3 className="text-lg font-semibold text-white mb-6 flex items-center gap-2">
|
||||
<Key className="w-5 h-5 text-purple-400" />
|
||||
Clés API
|
||||
</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 bg-white/5 rounded-xl">
|
||||
<label className="block text-gray-400 text-sm mb-2">Google Cloud API Key</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="password"
|
||||
placeholder="••••••••••••••••"
|
||||
className="flex-1 px-4 py-2 bg-black/30 border border-white/10 rounded-lg text-white placeholder:text-gray-500 focus:outline-none focus:border-purple-500"
|
||||
/>
|
||||
<button className="px-4 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded-lg transition-all">
|
||||
Sauvegarder
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-white/5 rounded-xl">
|
||||
<label className="block text-gray-400 text-sm mb-2">OpenRouter API Key</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="password"
|
||||
placeholder="••••••••••••••••"
|
||||
className="flex-1 px-4 py-2 bg-black/30 border border-white/10 rounded-lg text-white placeholder:text-gray-500 focus:outline-none focus:border-purple-500"
|
||||
/>
|
||||
<button className="px-4 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded-lg transition-all">
|
||||
Sauvegarder
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Default Provider */}
|
||||
<div className="bg-black/20 backdrop-blur-xl rounded-2xl border border-white/10 p-6">
|
||||
<h3 className="text-lg font-semibold text-white mb-6 flex items-center gap-2">
|
||||
<Settings className="w-5 h-5 text-purple-400" />
|
||||
Fournisseur par Défaut
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<button
|
||||
onClick={() => setSettings({ ...settings, default_provider: 'google' })}
|
||||
className={`p-4 rounded-xl border-2 transition-all ${
|
||||
settings.default_provider === 'google'
|
||||
? 'border-purple-500 bg-purple-500/10'
|
||||
: 'border-white/10 bg-white/5 hover:border-white/20'
|
||||
}`}
|
||||
>
|
||||
<Globe className={`w-8 h-8 mb-2 ${settings.default_provider === 'google' ? 'text-purple-400' : 'text-gray-400'}`} />
|
||||
<h4 className="text-white font-medium">Google Translate</h4>
|
||||
<p className="text-gray-400 text-sm">Rapide et fiable</p>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setSettings({ ...settings, default_provider: 'openrouter' })}
|
||||
className={`p-4 rounded-xl border-2 transition-all ${
|
||||
settings.default_provider === 'openrouter'
|
||||
? 'border-purple-500 bg-purple-500/10'
|
||||
: 'border-white/10 bg-white/5 hover:border-white/20'
|
||||
}`}
|
||||
>
|
||||
<Zap className={`w-8 h-8 mb-2 ${settings.default_provider === 'openrouter' ? 'text-purple-400' : 'text-gray-400'}`} />
|
||||
<h4 className="text-white font-medium">OpenRouter</h4>
|
||||
<p className="text-gray-400 text-sm">IA avancée</p>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Settings Tab */}
|
||||
{activeTab === "settings" && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="space-y-6"
|
||||
>
|
||||
{/* Limits */}
|
||||
<div className="bg-black/20 backdrop-blur-xl rounded-2xl border border-white/10 p-6">
|
||||
<h3 className="text-lg font-semibold text-white mb-6 flex items-center gap-2">
|
||||
<Settings className="w-5 h-5 text-purple-400" />
|
||||
Limites
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="block text-gray-400 text-sm mb-2">Taille max fichier (MB)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={settings.max_file_size_mb}
|
||||
onChange={(e) => setSettings({ ...settings, max_file_size_mb: parseInt(e.target.value) || 10 })}
|
||||
className="w-full px-4 py-3 bg-black/30 border border-white/10 rounded-xl text-white focus:outline-none focus:border-purple-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-gray-400 text-sm mb-2">Requêtes/minute</label>
|
||||
<input
|
||||
type="number"
|
||||
value={settings.rate_limit_per_minute}
|
||||
onChange={(e) => setSettings({ ...settings, rate_limit_per_minute: parseInt(e.target.value) || 60 })}
|
||||
className="w-full px-4 py-3 bg-black/30 border border-white/10 rounded-xl text-white focus:outline-none focus:border-purple-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cache */}
|
||||
<div className="bg-black/20 backdrop-blur-xl rounded-2xl border border-white/10 p-6">
|
||||
<h3 className="text-lg font-semibold text-white mb-6 flex items-center gap-2">
|
||||
<Zap className="w-5 h-5 text-purple-400" />
|
||||
Cache
|
||||
</h3>
|
||||
|
||||
<div className="flex items-center justify-between p-4 bg-white/5 rounded-xl">
|
||||
<div>
|
||||
<h4 className="text-white font-medium">Cache des traductions</h4>
|
||||
<p className="text-gray-400 text-sm">Améliore les performances et réduit les coûts</p>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.cache_enabled}
|
||||
onChange={(e) => setSettings({ ...settings, cache_enabled: e.target.checked })}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-gray-700 peer-focus:ring-4 peer-focus:ring-purple-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-purple-600"></div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Save Button */}
|
||||
<div className="flex justify-end">
|
||||
<button className="px-6 py-3 bg-purple-600 hover:bg-purple-700 text-white rounded-xl font-medium transition-all flex items-center gap-2">
|
||||
Sauvegarder les paramètres
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({ title, value, icon: Icon, color }: { title: string; value: string | number; icon: any; color: string }) {
|
||||
const colorClasses = {
|
||||
purple: 'bg-purple-500/20 text-purple-400',
|
||||
blue: 'bg-blue-500/20 text-blue-400',
|
||||
green: 'bg-green-500/20 text-green-400',
|
||||
yellow: 'bg-yellow-500/20 text-yellow-400'
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-black/20 backdrop-blur-xl rounded-2xl border border-white/10 p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className={`p-3 rounded-xl ${colorClasses[color as keyof typeof colorClasses]}`}>
|
||||
<Icon className="w-6 h-6" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-3xl font-bold text-white mb-1">{value}</p>
|
||||
<p className="text-gray-400 text-sm">{title}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AdminPage() {
|
||||
return (
|
||||
<Suspense fallback={
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-purple-900 to-slate-900 flex items-center justify-center">
|
||||
<div className="text-white text-xl">Chargement...</div>
|
||||
</div>
|
||||
}>
|
||||
<AdminContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
386
frontend/src/app/auth/login/page.tsx
Normal file
386
frontend/src/app/auth/login/page.tsx
Normal file
@@ -0,0 +1,386 @@
|
||||
"use client";
|
||||
|
||||
import { useState, Suspense } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Eye, EyeOff, Mail, Lock, ArrowRight, Loader2, Shield, CheckCircle, AlertTriangle } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function LoginForm() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const redirect = searchParams.get("redirect") || "/";
|
||||
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [isValidating, setIsValidating] = useState({
|
||||
email: false,
|
||||
password: false,
|
||||
});
|
||||
const [isFocused, setIsFocused] = useState({
|
||||
email: false,
|
||||
password: false,
|
||||
});
|
||||
const [showSuccess, setShowSuccess] = useState(false);
|
||||
|
||||
const validateEmail = (email: string) => {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
return emailRegex.test(email);
|
||||
};
|
||||
|
||||
const validatePassword = (password: string) => {
|
||||
return password.length >= 8;
|
||||
};
|
||||
|
||||
const handleEmailChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value;
|
||||
setEmail(value);
|
||||
setIsValidating(prev => ({ ...prev, email: value.length > 0 }));
|
||||
};
|
||||
|
||||
const handlePasswordChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value;
|
||||
setPassword(value);
|
||||
setIsValidating(prev => ({ ...prev, password: value.length > 0 }));
|
||||
};
|
||||
|
||||
const handleEmailBlur = () => {
|
||||
setIsValidating(prev => ({ ...prev, email: false }));
|
||||
setIsFocused(prev => ({ ...prev, email: false }));
|
||||
};
|
||||
|
||||
const handlePasswordBlur = () => {
|
||||
setIsValidating(prev => ({ ...prev, password: false }));
|
||||
setIsFocused(prev => ({ ...prev, password: false }));
|
||||
};
|
||||
|
||||
const handleEmailFocus = () => {
|
||||
setIsFocused(prev => ({ ...prev, email: true }));
|
||||
};
|
||||
|
||||
const handlePasswordFocus = () => {
|
||||
setIsFocused(prev => ({ ...prev, password: true }));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const res = await fetch("http://localhost:8000/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(data.detail || "Login failed");
|
||||
}
|
||||
|
||||
// Store tokens
|
||||
localStorage.setItem("token", data.access_token);
|
||||
localStorage.setItem("refresh_token", data.refresh_token);
|
||||
localStorage.setItem("user", JSON.stringify(data.user));
|
||||
|
||||
// Show success animation
|
||||
setShowSuccess(true);
|
||||
setTimeout(() => {
|
||||
router.push(redirect);
|
||||
}, 1000);
|
||||
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Login failed");
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getEmailValidationState = () => {
|
||||
if (!isValidating.email) return "";
|
||||
if (email.length === 0) return "";
|
||||
return validateEmail(email) ? "valid" : "invalid";
|
||||
};
|
||||
|
||||
const getPasswordValidationState = () => {
|
||||
if (!isValidating.password) return "";
|
||||
if (password.length === 0) return "";
|
||||
return validatePassword(password) ? "valid" : "invalid";
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Enhanced Login Card */}
|
||||
<Card
|
||||
variant="elevated"
|
||||
className="w-full max-w-md mx-auto overflow-hidden animate-fade-in"
|
||||
>
|
||||
<CardHeader className="text-center pb-6">
|
||||
{/* Logo */}
|
||||
<Link href="/" className="inline-flex items-center gap-3 mb-6 group">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-gradient-to-br from-primary to-accent text-white font-bold text-xl shadow-lg group-hover:shadow-xl group-hover:shadow-primary/25 transition-all duration-300 group-hover:-translate-y-0.5">
|
||||
文A
|
||||
</div>
|
||||
<span className="text-2xl font-semibold text-white group-hover:text-primary transition-colors duration-300">
|
||||
Translate Co.
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<CardTitle className="text-2xl font-bold text-white mb-2">
|
||||
Welcome back
|
||||
</CardTitle>
|
||||
<CardDescription className="text-text-secondary">
|
||||
Sign in to continue translating
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-6">
|
||||
{/* Success Message */}
|
||||
{showSuccess && (
|
||||
<div className="rounded-lg bg-success/10 border border-success/30 p-4 animate-slide-up">
|
||||
<div className="flex items-center gap-3">
|
||||
<CheckCircle className="h-5 w-5 text-success" />
|
||||
<span className="text-success font-medium">Login successful! Redirecting...</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="rounded-lg bg-destructive/10 border border-destructive/30 p-4 animate-slide-up">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="h-5 w-5 text-destructive flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-destructive font-medium">Authentication Error</p>
|
||||
<p className="text-destructive/80 text-sm mt-1">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Email Field */}
|
||||
<div className="space-y-3">
|
||||
<Label htmlFor="email" className="text-text-secondary font-medium">
|
||||
Email Address
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="you@example.com"
|
||||
value={email}
|
||||
onChange={handleEmailChange}
|
||||
onBlur={handleEmailBlur}
|
||||
onFocus={handleEmailFocus}
|
||||
required
|
||||
className={cn(
|
||||
"pl-12 h-12 text-lg",
|
||||
getEmailValidationState() === "valid" && "border-success focus:border-success",
|
||||
getEmailValidationState() === "invalid" && "border-destructive focus:border-destructive",
|
||||
isFocused.email && "ring-2 ring-primary/20"
|
||||
)}
|
||||
leftIcon={<Mail className="h-5 w-5" />}
|
||||
/>
|
||||
|
||||
{/* Validation Indicator */}
|
||||
{isValidating.email && (
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2">
|
||||
{getEmailValidationState() === "valid" && (
|
||||
<CheckCircle className="h-5 w-5 text-success animate-fade-in" />
|
||||
)}
|
||||
{getEmailValidationState() === "invalid" && (
|
||||
<AlertTriangle className="h-5 w-5 text-destructive animate-fade-in" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Password Field */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="password" className="text-text-secondary font-medium">
|
||||
Password
|
||||
</Label>
|
||||
<Link
|
||||
href="/auth/forgot-password"
|
||||
className="text-sm text-primary hover:text-primary/80 transition-colors duration-200"
|
||||
>
|
||||
Forgot password?
|
||||
</Link>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
placeholder="••••••••••"
|
||||
value={password}
|
||||
onChange={handlePasswordChange}
|
||||
onBlur={handlePasswordBlur}
|
||||
onFocus={handlePasswordFocus}
|
||||
required
|
||||
className={cn(
|
||||
"pl-12 pr-12 h-12 text-lg",
|
||||
getPasswordValidationState() === "valid" && "border-success focus:border-success",
|
||||
getPasswordValidationState() === "invalid" && "border-destructive focus:border-destructive",
|
||||
isFocused.password && "ring-2 ring-primary/20"
|
||||
)}
|
||||
leftIcon={<Lock className="h-5 w-5" />}
|
||||
rightIcon={
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors duration-200"
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="h-5 w-5" />
|
||||
) : (
|
||||
<Eye className="h-5 w-5" />
|
||||
)}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Validation Indicator */}
|
||||
{isValidating.password && (
|
||||
<div className="absolute right-12 top-1/2 -translate-y-1/2">
|
||||
{getPasswordValidationState() === "valid" && (
|
||||
<CheckCircle className="h-5 w-5 text-success animate-fade-in" />
|
||||
)}
|
||||
{getPasswordValidationState() === "invalid" && (
|
||||
<AlertTriangle className="h-5 w-5 text-destructive animate-fade-in" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Password Strength Indicator */}
|
||||
{isValidating.password && password.length > 0 && (
|
||||
<div className="mt-2 space-y-1">
|
||||
<div className="flex justify-between text-xs text-text-tertiary">
|
||||
<span>Password strength</span>
|
||||
<span className={cn(
|
||||
password.length < 8 && "text-destructive",
|
||||
password.length >= 8 && password.length < 12 && "text-warning",
|
||||
password.length >= 12 && "text-success"
|
||||
)}>
|
||||
{password.length < 8 && "Weak"}
|
||||
{password.length >= 8 && password.length < 12 && "Fair"}
|
||||
{password.length >= 12 && "Strong"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-border-subtle rounded-full h-1 overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
"h-full transition-all duration-300 ease-out",
|
||||
password.length < 8 && "bg-destructive w-1/3",
|
||||
password.length >= 8 && password.length < 12 && "bg-warning w-2/3",
|
||||
password.length >= 12 && "bg-success w-full"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={loading || !email || !password}
|
||||
variant="premium"
|
||||
size="lg"
|
||||
className="w-full h-12 text-lg group"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
|
||||
Signing in...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Sign In
|
||||
<ArrowRight className="ml-2 h-5 w-5 transition-transform duration-200 group-hover:translate-x-1" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Enhanced Footer */}
|
||||
<div className="mt-8 text-center">
|
||||
<p className="text-sm text-text-tertiary mb-6">
|
||||
Don't have an account?{" "}
|
||||
<Link
|
||||
href={`/auth/register${redirect !== "/" ? `?redirect=${redirect}` : ""}`}
|
||||
className="text-primary hover:text-primary/80 font-medium transition-colors duration-200"
|
||||
>
|
||||
Sign up for free
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
{/* Trust Indicators */}
|
||||
<div className="flex flex-wrap justify-center gap-6 text-xs text-text-tertiary">
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="h-4 w-4 text-success" />
|
||||
<span>Secure login</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle className="h-4 w-4 text-primary" />
|
||||
<span>SSL encrypted</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingFallback() {
|
||||
return (
|
||||
<Card variant="elevated" className="w-full max-w-md mx-auto">
|
||||
<CardContent className="flex items-center justify-center py-16">
|
||||
<div className="text-center space-y-4">
|
||||
<Loader2 className="h-12 w-12 animate-spin text-primary mx-auto" />
|
||||
<p className="text-lg font-medium text-foreground">Loading...</p>
|
||||
<div className="w-16 h-1 bg-border-subtle rounded-full overflow-hidden">
|
||||
<div className="h-full bg-primary animate-loading-shimmer" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-surface via-surface-elevated to-background flex items-center justify-center p-4 relative overflow-hidden">
|
||||
{/* Background Effects */}
|
||||
<div className="absolute inset-0">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-primary/5 via-transparent to-accent/5" />
|
||||
<div className="absolute inset-0 bg-[url('/grid.svg')] opacity-5" />
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-background via-background/90 to-background/50" />
|
||||
</div>
|
||||
|
||||
{/* Animated Background Elements */}
|
||||
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
||||
<div className="absolute top-20 left-10 w-32 h-32 bg-primary/5 rounded-full blur-3xl animate-float" />
|
||||
<div className="absolute bottom-20 right-20 w-24 h-24 bg-accent/5 rounded-full blur-2xl animate-float-delayed" />
|
||||
<div className="absolute top-1/2 right-1/4 w-16 h-16 bg-success/5 rounded-full blur-xl animate-float-slow" />
|
||||
</div>
|
||||
|
||||
<Suspense fallback={<LoadingFallback />}>
|
||||
<LoginForm />
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
605
frontend/src/app/auth/register/page.tsx
Normal file
605
frontend/src/app/auth/register/page.tsx
Normal file
@@ -0,0 +1,605 @@
|
||||
"use client";
|
||||
|
||||
import { useState, Suspense } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
Eye,
|
||||
EyeOff,
|
||||
Mail,
|
||||
Lock,
|
||||
User,
|
||||
ArrowRight,
|
||||
Loader2,
|
||||
Shield,
|
||||
CheckCircle,
|
||||
AlertTriangle,
|
||||
UserPlus,
|
||||
Info
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function RegisterForm() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const redirect = searchParams.get("redirect") || "/";
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [step, setStep] = useState(1);
|
||||
const [showSuccess, setShowSuccess] = useState(false);
|
||||
|
||||
const [isValidating, setIsValidating] = useState({
|
||||
name: false,
|
||||
email: false,
|
||||
password: false,
|
||||
confirmPassword: false,
|
||||
});
|
||||
|
||||
const [isFocused, setIsFocused] = useState({
|
||||
name: false,
|
||||
email: false,
|
||||
password: false,
|
||||
confirmPassword: false,
|
||||
});
|
||||
|
||||
const validateName = (name: string) => {
|
||||
return name.trim().length >= 2;
|
||||
};
|
||||
|
||||
const validateEmail = (email: string) => {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
return emailRegex.test(email);
|
||||
};
|
||||
|
||||
const validatePassword = (password: string) => {
|
||||
return password.length >= 8;
|
||||
};
|
||||
|
||||
const validateConfirmPassword = (password: string, confirmPassword: string) => {
|
||||
return password === confirmPassword && password.length > 0;
|
||||
};
|
||||
|
||||
// Real-time validation
|
||||
const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value;
|
||||
setName(value);
|
||||
setIsValidating(prev => ({ ...prev, name: value.length > 0 }));
|
||||
};
|
||||
|
||||
const handleEmailChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value;
|
||||
setEmail(value);
|
||||
setIsValidating(prev => ({ ...prev, email: value.length > 0 }));
|
||||
};
|
||||
|
||||
const handlePasswordChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value;
|
||||
setPassword(value);
|
||||
setIsValidating(prev => ({ ...prev, password: value.length > 0 }));
|
||||
};
|
||||
|
||||
const handleConfirmPasswordChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value;
|
||||
setConfirmPassword(value);
|
||||
setIsValidating(prev => ({ ...prev, confirmPassword: value.length > 0 }));
|
||||
};
|
||||
|
||||
const handleNameBlur = () => {
|
||||
setIsValidating(prev => ({ ...prev, name: false }));
|
||||
setIsFocused(prev => ({ ...prev, name: false }));
|
||||
};
|
||||
|
||||
const handleEmailBlur = () => {
|
||||
setIsValidating(prev => ({ ...prev, email: false }));
|
||||
setIsFocused(prev => ({ ...prev, email: false }));
|
||||
};
|
||||
|
||||
const handlePasswordBlur = () => {
|
||||
setIsValidating(prev => ({ ...prev, password: false }));
|
||||
setIsFocused(prev => ({ ...prev, password: false }));
|
||||
};
|
||||
|
||||
const handleConfirmPasswordBlur = () => {
|
||||
setIsValidating(prev => ({ ...prev, confirmPassword: false }));
|
||||
setIsFocused(prev => ({ ...prev, confirmPassword: false }));
|
||||
};
|
||||
|
||||
const handleNameFocus = () => {
|
||||
setIsFocused(prev => ({ ...prev, name: true }));
|
||||
};
|
||||
|
||||
const handleEmailFocus = () => {
|
||||
setIsFocused(prev => ({ ...prev, email: true }));
|
||||
};
|
||||
|
||||
const handlePasswordFocus = () => {
|
||||
setIsFocused(prev => ({ ...prev, password: true }));
|
||||
};
|
||||
|
||||
const handleConfirmPasswordFocus = () => {
|
||||
setIsFocused(prev => ({ ...prev, confirmPassword: true }));
|
||||
};
|
||||
|
||||
const getNameValidationState = () => {
|
||||
if (!isValidating.name) return "";
|
||||
if (name.length === 0) return "";
|
||||
return validateName(name) ? "valid" : "invalid";
|
||||
};
|
||||
|
||||
const getEmailValidationState = () => {
|
||||
if (!isValidating.email) return "";
|
||||
if (email.length === 0) return "";
|
||||
return validateEmail(email) ? "valid" : "invalid";
|
||||
};
|
||||
|
||||
const getPasswordValidationState = () => {
|
||||
if (!isValidating.password) return "";
|
||||
if (password.length === 0) return "";
|
||||
return validatePassword(password) ? "valid" : "invalid";
|
||||
};
|
||||
|
||||
const getConfirmPasswordValidationState = () => {
|
||||
if (!isValidating.confirmPassword) return "";
|
||||
if (confirmPassword.length === 0) return "";
|
||||
return validateConfirmPassword(password, confirmPassword) ? "valid" : "invalid";
|
||||
};
|
||||
|
||||
const getPasswordStrength = () => {
|
||||
if (password.length === 0) return { strength: 0, text: "", color: "" };
|
||||
|
||||
let strength = 0;
|
||||
let text = "";
|
||||
let color = "";
|
||||
|
||||
if (password.length >= 8) strength++;
|
||||
if (password.length >= 12) strength++;
|
||||
if (/[A-Z]/.test(password)) strength++;
|
||||
if (/[a-z]/.test(password)) strength++;
|
||||
if (/[0-9]/.test(password)) strength++;
|
||||
if (/[^A-Za-z0-9]/.test(password)) strength++;
|
||||
|
||||
if (strength <= 2) {
|
||||
text = "Weak";
|
||||
color = "text-destructive";
|
||||
} else if (strength <= 3) {
|
||||
text = "Fair";
|
||||
color = "text-warning";
|
||||
} else {
|
||||
text = "Strong";
|
||||
color = "text-success";
|
||||
}
|
||||
|
||||
return { strength, text, color };
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
|
||||
// Validate all fields
|
||||
if (!validateName(name)) {
|
||||
setError("Name must be at least 2 characters");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!validateEmail(email)) {
|
||||
setError("Please enter a valid email address");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!validatePassword(password)) {
|
||||
setError("Password must be at least 8 characters");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!validateConfirmPassword(password, confirmPassword)) {
|
||||
setError("Passwords do not match");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const res = await fetch("http://localhost:8000/api/auth/register", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name, email, password }),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(data.detail || "Registration failed");
|
||||
}
|
||||
|
||||
// Store tokens
|
||||
localStorage.setItem("token", data.access_token);
|
||||
localStorage.setItem("refresh_token", data.refresh_token);
|
||||
localStorage.setItem("user", JSON.stringify(data.user));
|
||||
|
||||
// Show success animation
|
||||
setShowSuccess(true);
|
||||
setTimeout(() => {
|
||||
router.push(redirect);
|
||||
}, 1500);
|
||||
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Registration failed");
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const passwordStrength = getPasswordStrength();
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Enhanced Registration Card */}
|
||||
<Card
|
||||
variant="elevated"
|
||||
className={cn(
|
||||
"w-full max-w-md mx-auto overflow-hidden animate-fade-in",
|
||||
showSuccess && "scale-95 opacity-0"
|
||||
)}
|
||||
>
|
||||
<CardHeader className="text-center pb-6">
|
||||
{/* Logo */}
|
||||
<Link href="/" className="inline-flex items-center gap-3 mb-6 group">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-gradient-to-br from-primary to-accent text-white font-bold text-xl shadow-lg group-hover:shadow-xl group-hover:shadow-primary/25 transition-all duration-300 group-hover:-translate-y-0.5">
|
||||
文A
|
||||
</div>
|
||||
<span className="text-2xl font-semibold text-white group-hover:text-primary transition-colors duration-300">
|
||||
Translate Co.
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<CardTitle className="text-2xl font-bold text-white mb-2">
|
||||
Create an account
|
||||
</CardTitle>
|
||||
<CardDescription className="text-text-secondary">
|
||||
Start translating documents for free
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-6">
|
||||
{/* Success Message */}
|
||||
{showSuccess && (
|
||||
<div className="rounded-lg bg-success/10 border border-success/30 p-6 mb-6 animate-slide-up">
|
||||
<div className="flex items-center gap-3">
|
||||
<CheckCircle className="h-8 w-8 text-success animate-pulse" />
|
||||
<div>
|
||||
<p className="text-lg font-medium text-success mb-1">Registration Successful!</p>
|
||||
<p className="text-sm text-success/80">Redirecting to your dashboard...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="rounded-lg bg-destructive/10 border border-destructive/30 p-4 mb-6 animate-slide-up">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="h-5 w-5 text-destructive flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-destructive mb-1">Registration Error</p>
|
||||
<p className="text-sm text-destructive/80">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Progress Steps */}
|
||||
<div className="flex items-center justify-center mb-8">
|
||||
{[1, 2, 3].map((stepNumber) => (
|
||||
<div
|
||||
key={stepNumber}
|
||||
className={cn(
|
||||
"flex items-center justify-center w-8 h-8 rounded-full transition-all duration-300",
|
||||
step === stepNumber
|
||||
? "bg-primary text-white scale-110"
|
||||
: "bg-surface text-text-tertiary"
|
||||
)}
|
||||
>
|
||||
<span className="text-sm font-medium">{stepNumber}</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="h-0.5 bg-border-subtle flex-1 mx-2" />
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Name Field */}
|
||||
<div className="space-y-3">
|
||||
<Label htmlFor="name" className="text-text-secondary font-medium">
|
||||
Full Name
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="name"
|
||||
type="text"
|
||||
placeholder="John Doe"
|
||||
value={name}
|
||||
onChange={handleNameChange}
|
||||
onBlur={handleNameBlur}
|
||||
onFocus={handleNameFocus}
|
||||
required
|
||||
className={cn(
|
||||
"pl-12 h-12 text-lg",
|
||||
getNameValidationState() === "valid" && "border-success focus:border-success",
|
||||
getNameValidationState() === "invalid" && "border-destructive focus:border-destructive",
|
||||
isFocused.name && "ring-2 ring-primary/20"
|
||||
)}
|
||||
leftIcon={<User className="h-5 w-5" />}
|
||||
/>
|
||||
|
||||
{/* Validation Indicator */}
|
||||
{isValidating.name && (
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2">
|
||||
{getNameValidationState() === "valid" && (
|
||||
<CheckCircle className="h-5 w-5 text-success animate-fade-in" />
|
||||
)}
|
||||
{getNameValidationState() === "invalid" && (
|
||||
<AlertTriangle className="h-5 w-5 text-destructive animate-fade-in" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Email Field */}
|
||||
<div className="space-y-3">
|
||||
<Label htmlFor="email" className="text-text-secondary font-medium">
|
||||
Email Address
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="you@example.com"
|
||||
value={email}
|
||||
onChange={handleEmailChange}
|
||||
onBlur={handleEmailBlur}
|
||||
onFocus={handleEmailFocus}
|
||||
required
|
||||
className={cn(
|
||||
"pl-12 h-12 text-lg",
|
||||
getEmailValidationState() === "valid" && "border-success focus:border-success",
|
||||
getEmailValidationState() === "invalid" && "border-destructive focus:border-destructive",
|
||||
isFocused.email && "ring-2 ring-primary/20"
|
||||
)}
|
||||
leftIcon={<Mail className="h-5 w-5" />}
|
||||
/>
|
||||
|
||||
{/* Validation Indicator */}
|
||||
{isValidating.email && (
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2">
|
||||
{getEmailValidationState() === "valid" && (
|
||||
<CheckCircle className="h-5 w-5 text-success animate-fade-in" />
|
||||
)}
|
||||
{getEmailValidationState() === "invalid" && (
|
||||
<AlertTriangle className="h-5 w-5 text-destructive animate-fade-in" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Password Field */}
|
||||
<div className="space-y-3">
|
||||
<Label htmlFor="password" className="text-text-secondary font-medium">
|
||||
Password
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
placeholder="•••••••••••"
|
||||
value={password}
|
||||
onChange={handlePasswordChange}
|
||||
onBlur={handlePasswordBlur}
|
||||
onFocus={handlePasswordFocus}
|
||||
required
|
||||
minLength={8}
|
||||
className={cn(
|
||||
"pl-12 pr-12 h-12 text-lg",
|
||||
getPasswordValidationState() === "valid" && "border-success focus:border-success",
|
||||
getPasswordValidationState() === "invalid" && "border-destructive focus:border-destructive",
|
||||
isFocused.password && "ring-2 ring-primary/20"
|
||||
)}
|
||||
leftIcon={<Lock className="h-5 w-5" />}
|
||||
rightIcon={
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors duration-200"
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="h-5 w-5" />
|
||||
) : (
|
||||
<Eye className="h-5 w-5" />
|
||||
)}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Password Strength Indicator */}
|
||||
{password.length > 0 && (
|
||||
<div className="absolute right-12 top-1/2 -translate-y-1/2">
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="flex space-x-1">
|
||||
{[1, 2, 3, 4].map((level) => (
|
||||
<div
|
||||
key={level}
|
||||
className={cn(
|
||||
"w-1 h-1 rounded-full",
|
||||
level <= passwordStrength.strength ? "bg-success" : "bg-border"
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<span className={cn("text-xs", passwordStrength.color)}>
|
||||
{passwordStrength.text}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Confirm Password Field */}
|
||||
<div className="space-y-3">
|
||||
<Label htmlFor="confirmPassword" className="text-text-secondary font-medium">
|
||||
Confirm Password
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
type={showConfirmPassword ? "text" : "password"}
|
||||
placeholder="•••••••••••"
|
||||
value={confirmPassword}
|
||||
onChange={handleConfirmPasswordChange}
|
||||
onBlur={handleConfirmPasswordBlur}
|
||||
onFocus={handleConfirmPasswordFocus}
|
||||
required
|
||||
className={cn(
|
||||
"pl-12 pr-12 h-12 text-lg",
|
||||
getConfirmPasswordValidationState() === "valid" && "border-success focus:border-success",
|
||||
getConfirmPasswordValidationState() === "invalid" && "border-destructive focus:border-destructive",
|
||||
isFocused.confirmPassword && "ring-2 ring-primary/20"
|
||||
)}
|
||||
leftIcon={<Lock className="h-5 w-5" />}
|
||||
rightIcon={
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors duration-200"
|
||||
>
|
||||
{showConfirmPassword ? (
|
||||
<EyeOff className="h-5 w-5" />
|
||||
) : (
|
||||
<Eye className="h-5 w-5" />
|
||||
)}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Validation Indicator */}
|
||||
{isValidating.confirmPassword && (
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2">
|
||||
{getConfirmPasswordValidationState() === "valid" && (
|
||||
<CheckCircle className="h-5 w-5 text-success animate-fade-in" />
|
||||
)}
|
||||
{getConfirmPasswordValidationState() === "invalid" && (
|
||||
<AlertTriangle className="h-5 w-5 text-destructive animate-fade-in" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={loading || !name || !email || !password || !confirmPassword}
|
||||
variant="premium"
|
||||
size="lg"
|
||||
className="w-full h-12 text-lg group"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
|
||||
Creating Account...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<UserPlus className="mr-2 h-5 w-5 transition-transform duration-200 group-hover:scale-110" />
|
||||
Create Account
|
||||
<ArrowRight className="ml-2 h-5 w-5 transition-transform duration-200 group-hover:translate-x-1" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{/* Sign In Link */}
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-text-tertiary mb-4">
|
||||
Already have an account?{" "}
|
||||
<Link
|
||||
href={`/auth/login${redirect !== "/" ? `?redirect=${redirect}` : ""}`}
|
||||
className="text-primary hover:text-primary/80 font-medium transition-colors duration-200"
|
||||
>
|
||||
Sign in
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Terms and Privacy */}
|
||||
<div className="text-center text-xs text-text-tertiary space-y-2">
|
||||
<p>
|
||||
By creating an account, you agree to our{" "}
|
||||
<Link href="/terms" className="text-primary hover:text-primary/80 transition-colors duration-200">
|
||||
Terms of Service
|
||||
</Link>
|
||||
{" "} and{" "}
|
||||
<Link href="/privacy" className="text-primary hover:text-primary/80 transition-colors duration-200">
|
||||
Privacy Policy
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingFallback() {
|
||||
return (
|
||||
<Card variant="elevated" className="w-full max-w-md mx-auto">
|
||||
<CardContent className="flex items-center justify-center py-16">
|
||||
<div className="text-center space-y-4">
|
||||
<Loader2 className="h-12 w-12 animate-spin text-primary mx-auto" />
|
||||
<p className="text-lg font-medium text-foreground">Creating your account...</p>
|
||||
<div className="w-16 h-1 bg-border-subtle rounded-full overflow-hidden">
|
||||
<div className="h-full bg-primary animate-loading-shimmer" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default function RegisterPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-surface via-surface-elevated to-background flex items-center justify-center p-4 relative overflow-hidden">
|
||||
{/* Background Effects */}
|
||||
<div className="absolute inset-0">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-primary/5 via-transparent to-accent/5" />
|
||||
<div className="absolute inset-0 bg-[url('/grid.svg')] opacity-5" />
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-background via-background/90 to-background/50" />
|
||||
</div>
|
||||
|
||||
{/* Floating Elements */}
|
||||
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
||||
<div className="absolute top-20 left-10 w-20 h-20 bg-primary/10 rounded-full blur-xl animate-pulse" />
|
||||
<div className="absolute top-40 right-20 w-32 h-32 bg-accent/10 rounded-full blur-2xl animate-pulse animation-delay-2000" />
|
||||
<div className="absolute bottom-20 left-1/4 w-16 h-16 bg-success/10 rounded-full blur-lg animate-pulse animation-delay-4000" />
|
||||
</div>
|
||||
|
||||
<div className="w-full max-w-md">
|
||||
<Suspense fallback={<LoadingFallback />}>
|
||||
<RegisterForm />
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
615
frontend/src/app/dashboard/page.tsx
Normal file
615
frontend/src/app/dashboard/page.tsx
Normal file
@@ -0,0 +1,615 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
FileText,
|
||||
CreditCard,
|
||||
Settings,
|
||||
LogOut,
|
||||
ChevronRight,
|
||||
Zap,
|
||||
TrendingUp,
|
||||
Clock,
|
||||
Check,
|
||||
ExternalLink,
|
||||
Crown,
|
||||
Users,
|
||||
BarChart3,
|
||||
Shield,
|
||||
Globe2,
|
||||
FileSpreadsheet,
|
||||
Presentation,
|
||||
AlertTriangle,
|
||||
Download,
|
||||
Eye,
|
||||
RefreshCw,
|
||||
Calendar,
|
||||
Activity,
|
||||
Target,
|
||||
Award,
|
||||
ArrowUpRight,
|
||||
ArrowDownRight,
|
||||
Upload,
|
||||
LogIn,
|
||||
UserPlus
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardStats, CardFeature } from "@/components/ui/card";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
plan: string;
|
||||
subscription_status: string;
|
||||
docs_translated_this_month: number;
|
||||
pages_translated_this_month: number;
|
||||
extra_credits: number;
|
||||
plan_limits: {
|
||||
docs_per_month: number;
|
||||
max_pages_per_doc: number;
|
||||
features: string[];
|
||||
providers: string[];
|
||||
};
|
||||
}
|
||||
|
||||
interface UsageStats {
|
||||
docs_used: number;
|
||||
docs_limit: number;
|
||||
docs_remaining: number;
|
||||
pages_used: number;
|
||||
extra_credits: number;
|
||||
max_pages_per_doc: number;
|
||||
allowed_providers: string[];
|
||||
}
|
||||
|
||||
interface ActivityItem {
|
||||
id: string;
|
||||
type: "translation" | "upload" | "download" | "login" | "signup";
|
||||
title: string;
|
||||
description: string;
|
||||
timestamp: string;
|
||||
status: "success" | "pending" | "error";
|
||||
amount?: number;
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const router = useRouter();
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [usage, setUsage] = useState<UsageStats | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [recentActivity, setRecentActivity] = useState<ActivityItem[]>([]);
|
||||
const [timeRange, setTimeRange] = useState<"7d" | "30d" | "24h">("30d");
|
||||
const [selectedMetric, setSelectedMetric] = useState<"documents" | "pages" | "users" | "revenue">("documents");
|
||||
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem("token");
|
||||
if (!token) {
|
||||
router.push("/auth/login?redirect=/dashboard");
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const [userRes, usageRes] = await Promise.all([
|
||||
fetch("http://localhost:8000/api/auth/me", {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
}),
|
||||
fetch("http://localhost:8000/api/auth/usage", {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
}),
|
||||
]);
|
||||
|
||||
if (!userRes.ok) {
|
||||
throw new Error("Session expired");
|
||||
}
|
||||
|
||||
const userData = await userRes.json();
|
||||
const usageData = await usageRes.json();
|
||||
|
||||
setUser(userData);
|
||||
setUsage(usageData);
|
||||
|
||||
// Mock recent activity
|
||||
setRecentActivity([
|
||||
{
|
||||
id: "1",
|
||||
type: "translation",
|
||||
title: "Document translated",
|
||||
description: "Q4 Financial Report.xlsx",
|
||||
timestamp: new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(),
|
||||
status: "success",
|
||||
amount: 15
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
type: "upload",
|
||||
title: "Document uploaded",
|
||||
description: "Marketing_Presentation.pptx",
|
||||
timestamp: new Date(Date.now() - 4 * 60 * 60 * 1000).toISOString(),
|
||||
status: "success"
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
type: "download",
|
||||
title: "Document downloaded",
|
||||
description: "Translated_Q4_Report.xlsx",
|
||||
timestamp: new Date(Date.now() - 6 * 60 * 60 * 1000).toISOString(),
|
||||
status: "success"
|
||||
},
|
||||
{
|
||||
id: "4",
|
||||
type: "login",
|
||||
title: "User login",
|
||||
description: "Login from new device",
|
||||
timestamp: new Date(Date.now() - 8 * 60 * 60 * 1000).toISOString(),
|
||||
status: "success"
|
||||
}
|
||||
]);
|
||||
} catch (error) {
|
||||
console.error("Dashboard data fetch error:", error);
|
||||
localStorage.removeItem("token");
|
||||
localStorage.removeItem("user");
|
||||
router.push("/auth/login?redirect=/dashboard");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, [router]);
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.removeItem("token");
|
||||
localStorage.removeItem("refresh_token");
|
||||
localStorage.removeItem("user");
|
||||
router.push("/");
|
||||
};
|
||||
|
||||
const handleUpgrade = () => {
|
||||
router.push("/pricing");
|
||||
};
|
||||
|
||||
const handleManageBilling = async () => {
|
||||
const token = localStorage.getItem("token");
|
||||
try {
|
||||
const res = await fetch("http://localhost:8000/api/auth/billing-portal", {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.url) {
|
||||
window.open(data.url, "_blank");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to open billing portal:", error);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex items-center justify-center">
|
||||
<div className="text-center space-y-4">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-4 border-border-subtle border-t-primary"></div>
|
||||
<p className="text-lg font-medium text-foreground">Loading your dashboard...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user || !usage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const docsPercentage = usage.docs_limit > 0
|
||||
? Math.min(100, (usage.docs_used / usage.docs_limit) * 100)
|
||||
: 0;
|
||||
|
||||
const planColors: Record<string, string> = {
|
||||
free: "bg-zinc-600",
|
||||
starter: "bg-blue-500",
|
||||
pro: "bg-teal-500",
|
||||
business: "bg-purple-500",
|
||||
enterprise: "bg-amber-500",
|
||||
};
|
||||
|
||||
const getActivityIcon = (type: ActivityItem["type"]) => {
|
||||
switch (type) {
|
||||
case "translation": return <FileText className="h-4 w-4" />;
|
||||
case "upload": return <Upload className="h-4 w-4" />;
|
||||
case "download": return <Download className="h-4 w-4" />;
|
||||
case "login": return <LogIn className="h-4 w-4" />;
|
||||
case "signup": return <UserPlus className="h-4 w-4" />;
|
||||
default: return <Activity className="h-4 w-4" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status: ActivityItem["status"]) => {
|
||||
switch (status) {
|
||||
case "success": return "text-success";
|
||||
case "pending": return "text-warning";
|
||||
case "error": return "text-destructive";
|
||||
default: return "text-text-tertiary";
|
||||
}
|
||||
};
|
||||
|
||||
const formatTimeAgo = (timestamp: string) => {
|
||||
const now = new Date();
|
||||
const past = new Date(timestamp);
|
||||
const diffInSeconds = Math.floor((now.getTime() - past.getTime()) / 1000);
|
||||
|
||||
if (diffInSeconds < 60) return `${diffInSeconds}s ago`;
|
||||
if (diffInSeconds < 3600) return `${Math.floor(diffInSeconds / 60)}m ago`;
|
||||
if (diffInSeconds < 86400) return `${Math.floor(diffInSeconds / 3600)}h ago`;
|
||||
return `${Math.floor(diffInSeconds / 86400)}d ago`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-surface via-surface-elevated to-background">
|
||||
{/* Header */}
|
||||
<header className="sticky top-0 z-50 glass border-b border-border/20">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex items-center justify-between h-16">
|
||||
<Link href="/" className="flex items-center gap-3 group">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-gradient-to-br from-primary to-accent text-white font-bold shadow-lg group-hover:shadow-xl group-hover:shadow-primary/25 transition-all duration-300 group-hover:-translate-y-0.5">
|
||||
文A
|
||||
</div>
|
||||
<span className="text-lg font-semibold text-white group-hover:text-primary transition-colors duration-300">
|
||||
Translate Co.
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href="/">
|
||||
<Button variant="glass" size="sm" className="group">
|
||||
<FileText className="h-4 w-4 mr-2 transition-transform duration-200 group-hover:scale-110" />
|
||||
Translate
|
||||
<ChevronRight className="h-4 w-4 ml-1 transition-transform duration-200 group-hover:translate-x-1" />
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-8 w-8 rounded-full bg-gradient-to-br from-primary to-accent text-white text-sm font-bold flex items-center justify-center">
|
||||
{user.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-medium text-foreground">{user.name}</p>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn("ml-2", planColors[user.plan])}
|
||||
>
|
||||
{user.plan.charAt(0).toUpperCase() + user.plan.slice(1)}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleLogout}
|
||||
className="text-text-tertiary hover:text-destructive transition-colors duration-200"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{/* Welcome Section */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-4xl font-bold text-white mb-2">
|
||||
Welcome back, <span className="text-primary">{user.name.split(" ")[0]}</span>!
|
||||
</h1>
|
||||
<p className="text-lg text-text-secondary">
|
||||
Here's an overview of your translation usage
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||
{/* Current Plan */}
|
||||
<CardStats
|
||||
title="Current Plan"
|
||||
value={user.plan.charAt(0).toUpperCase() + user.plan.slice(1)}
|
||||
change={undefined}
|
||||
icon={<Crown className="h-5 w-5 text-amber-400" />}
|
||||
/>
|
||||
|
||||
{/* Documents Used */}
|
||||
<CardStats
|
||||
title="Documents This Month"
|
||||
value={`${usage.docs_used} / ${usage.docs_limit === -1 ? "∞" : usage.docs_limit}`}
|
||||
change={{
|
||||
value: 15,
|
||||
type: "increase",
|
||||
period: "this month"
|
||||
}}
|
||||
icon={<FileText className="h-5 w-5 text-primary" />}
|
||||
/>
|
||||
|
||||
{/* Pages Translated */}
|
||||
<CardStats
|
||||
title="Pages Translated"
|
||||
value={usage.pages_used}
|
||||
icon={<TrendingUp className="h-5 w-5 text-teal-400" />}
|
||||
/>
|
||||
|
||||
{/* Extra Credits */}
|
||||
<CardStats
|
||||
title="Extra Credits"
|
||||
value={usage.extra_credits}
|
||||
icon={<Zap className="h-5 w-5 text-amber-400" />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions & Recent Activity */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
||||
{/* Available Features */}
|
||||
<Card variant="elevated" className="animate-fade-in-up animation-delay-200">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-3">
|
||||
<Shield className="h-5 w-5 text-primary" />
|
||||
Your Plan Features
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<ul className="space-y-3">
|
||||
{user.plan_limits.features.map((feature, idx) => (
|
||||
<li key={idx} className="flex items-start gap-3">
|
||||
<Check className="h-5 w-5 text-success flex-shrink-0 mt-0.5" />
|
||||
<span className="text-sm text-text-secondary">{feature}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<Card variant="elevated" className="animate-fade-in-up animation-delay-400">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-3">
|
||||
<Settings className="h-5 w-5 text-primary" />
|
||||
Quick Actions
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<Link href="/">
|
||||
<button className="w-full flex items-center justify-between p-4 rounded-lg bg-surface hover:bg-surface-hover transition-colors group">
|
||||
<div className="flex items-center gap-3">
|
||||
<FileText className="h-5 w-5 text-teal-400" />
|
||||
<span className="text-white">Translate a Document</span>
|
||||
</div>
|
||||
<ChevronRight className="h-4 w-4 text-text-tertiary group-hover:text-primary transition-colors duration-200" />
|
||||
</button>
|
||||
</Link>
|
||||
|
||||
<Link href="/settings/services">
|
||||
<button className="w-full flex items-center justify-between p-4 rounded-lg bg-surface hover:bg-surface-hover transition-colors group">
|
||||
<div className="flex items-center gap-3">
|
||||
<Settings className="h-5 w-5 text-blue-400" />
|
||||
<span className="text-white">Configure Providers</span>
|
||||
</div>
|
||||
<ChevronRight className="h-4 w-4 text-text-tertiary group-hover:text-primary transition-colors duration-200" />
|
||||
</button>
|
||||
</Link>
|
||||
|
||||
{user.plan !== "free" && (
|
||||
<button
|
||||
onClick={handleUpgrade}
|
||||
className="w-full flex items-center justify-between p-4 rounded-lg bg-gradient-to-r from-amber-500 to-orange-600 text-white hover:from-amber-600 hover:to-orange-700 transition-all duration-300 group"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Crown className="h-5 w-5" />
|
||||
<span>Upgrade Plan</span>
|
||||
</div>
|
||||
<ArrowUpRight className="h-4 w-4 transition-transform duration-200 group-hover:translate-x-1" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{user.plan !== "free" && (
|
||||
<button
|
||||
onClick={handleManageBilling}
|
||||
className="w-full flex items-center justify-between p-4 rounded-lg bg-surface hover:bg-surface-hover transition-colors group"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<CreditCard className="h-5 w-5 text-purple-400" />
|
||||
<span>Manage Billing</span>
|
||||
</div>
|
||||
<ExternalLink className="h-4 w-4 text-text-tertiary group-hover:text-primary transition-colors duration-200" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<Link href="/pricing">
|
||||
<button className="w-full flex items-center justify-between p-4 rounded-lg bg-surface hover:bg-surface-hover transition-colors group">
|
||||
<div className="flex items-center gap-3">
|
||||
<Crown className="h-5 w-5 text-amber-400" />
|
||||
<span>View Plans & Pricing</span>
|
||||
</div>
|
||||
<ChevronRight className="h-4 w-4 text-text-tertiary group-hover:text-primary transition-colors duration-200" />
|
||||
</button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Charts Section */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
||||
{/* Usage Chart */}
|
||||
<Card variant="elevated" className="animate-fade-in-up animation-delay-600">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-3">
|
||||
<BarChart3 className="h-5 w-5 text-primary" />
|
||||
Usage Overview
|
||||
</CardTitle>
|
||||
<div className="flex items-center gap-2 ml-auto">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setTimeRange(timeRange === "7d" ? "30d" : "7d")}
|
||||
className={cn("text-xs", timeRange === "7d" && "text-primary")}
|
||||
>
|
||||
7D
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setTimeRange(timeRange === "30d" ? "24h" : "30d")}
|
||||
className={cn("text-xs", timeRange === "30d" && "text-primary")}
|
||||
>
|
||||
30D
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setTimeRange("24h")}
|
||||
className={cn("text-xs", timeRange === "24h" && "text-primary")}
|
||||
>
|
||||
24H
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-64 flex items-center justify-center">
|
||||
{/* Mock Chart */}
|
||||
<div className="relative w-full h-full flex items-center justify-center">
|
||||
<svg className="w-full h-full" viewBox="0 0 100 100">
|
||||
<defs>
|
||||
<linearGradient id="gradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stopColor="#3b82f6" />
|
||||
<stop offset="100%" stopColor="#8b5cf6" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<circle
|
||||
cx="50"
|
||||
cy="50"
|
||||
r="40"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
className="text-border"
|
||||
/>
|
||||
<circle
|
||||
cx="50"
|
||||
cy="50"
|
||||
r="40"
|
||||
fill="none"
|
||||
stroke="url(#gradient)"
|
||||
strokeWidth="2"
|
||||
className="opacity-80"
|
||||
style={{
|
||||
strokeDasharray: `${2 * Math.PI * 40}`,
|
||||
strokeDashoffset: `${2 * Math.PI * 40 * 0.25}`,
|
||||
animation: "progress 2s ease-in-out infinite"
|
||||
}}
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||
<div className="text-6xl font-bold text-text-tertiary">85%</div>
|
||||
<div className="text-sm text-text-tertiary">Usage</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Recent Activity */}
|
||||
<Card variant="elevated" className="animate-fade-in-up animation-delay-800">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-3">
|
||||
<Activity className="h-5 w-5 text-primary" />
|
||||
Recent Activity
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setRecentActivity([])}
|
||||
className="ml-auto text-text-tertiary hover:text-primary transition-colors duration-200"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</Button>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{recentActivity.slice(0, 5).map((activity) => (
|
||||
<div key={activity.id} className="flex items-start gap-4 p-3 rounded-lg bg-surface/50 hover:bg-surface transition-colors">
|
||||
<div className="flex-shrink-0 mt-1">
|
||||
<div className={cn(
|
||||
"w-10 h-10 rounded-lg flex items-center justify-center",
|
||||
activity.status === "success" && "bg-success/20 text-success",
|
||||
activity.status === "pending" && "bg-warning/20 text-warning",
|
||||
activity.status === "error" && "bg-destructive/20 text-destructive"
|
||||
)}>
|
||||
{getActivityIcon(activity.type)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-foreground mb-1">{activity.title}</p>
|
||||
<p className="text-xs text-text-tertiary">{activity.description}</p>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<span className="text-xs text-text-tertiary">{formatTimeAgo(activity.timestamp)}</span>
|
||||
{activity.amount && (
|
||||
<Badge variant="outline" size="sm">
|
||||
{activity.amount}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Available Providers */}
|
||||
<Card variant="elevated" className="animate-fade-in-up animation-delay-1000">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-3">
|
||||
<Globe2 className="h-5 w-5 text-primary" />
|
||||
Available Translation Providers
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{usage && (
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{["ollama", "google", "deepl", "openai", "libre", "azure"].map((provider) => {
|
||||
const isAvailable = usage.allowed_providers.includes(provider);
|
||||
return (
|
||||
<Badge
|
||||
key={provider}
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"capitalize",
|
||||
isAvailable
|
||||
? "border-success/50 text-success bg-success/10"
|
||||
: "border-border text-text-tertiary"
|
||||
)}
|
||||
>
|
||||
{isAvailable && <Check className="h-3 w-3 mr-1" />}
|
||||
{provider}
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{user && user.plan === "free" && (
|
||||
<p className="text-sm text-text-tertiary mt-4">
|
||||
<Link href="/pricing" className="text-primary hover:text-primary/80">
|
||||
Upgrade your plan
|
||||
</Link>
|
||||
{" "}
|
||||
to access more translation providers including Google, DeepL, and OpenAI.
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
BIN
frontend/src/app/favicon.ico
Normal file
BIN
frontend/src/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
1853
frontend/src/app/globals.css
Normal file
1853
frontend/src/app/globals.css
Normal file
File diff suppressed because it is too large
Load Diff
32
frontend/src/app/layout.tsx
Normal file
32
frontend/src/app/layout.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Inter } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { Sidebar } from "@/components/sidebar";
|
||||
|
||||
const inter = Inter({
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Translate Co. - Document Translation",
|
||||
description: "Translate Excel, Word, and PowerPoint documents while preserving formatting",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en" className="dark">
|
||||
<body className={`${inter.className} bg-[#262626] text-zinc-100 antialiased`}>
|
||||
<Sidebar />
|
||||
<main className="ml-64 min-h-screen p-8">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
364
frontend/src/app/ollama-setup/page.tsx
Normal file
364
frontend/src/app/ollama-setup/page.tsx
Normal file
@@ -0,0 +1,364 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
Server,
|
||||
Check,
|
||||
AlertCircle,
|
||||
Loader2,
|
||||
ExternalLink,
|
||||
Copy,
|
||||
Terminal,
|
||||
Cpu,
|
||||
HardDrive
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface OllamaModel {
|
||||
name: string;
|
||||
size: string;
|
||||
quantization: string;
|
||||
}
|
||||
|
||||
const recommendedModels: OllamaModel[] = [
|
||||
{ name: "llama3.2:3b", size: "2 GB", quantization: "Q4_0" },
|
||||
{ name: "mistral:7b", size: "4.1 GB", quantization: "Q4_0" },
|
||||
{ name: "qwen2.5:7b", size: "4.7 GB", quantization: "Q4_K_M" },
|
||||
{ name: "llama3.1:8b", size: "4.7 GB", quantization: "Q4_0" },
|
||||
{ name: "gemma2:9b", size: "5.4 GB", quantization: "Q4_0" },
|
||||
];
|
||||
|
||||
export default function OllamaSetupPage() {
|
||||
const [endpoint, setEndpoint] = useState("http://localhost:11434");
|
||||
const [selectedModel, setSelectedModel] = useState("");
|
||||
const [availableModels, setAvailableModels] = useState<string[]>([]);
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [connectionStatus, setConnectionStatus] = useState<"idle" | "success" | "error">("idle");
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
const [copied, setCopied] = useState<string | null>(null);
|
||||
|
||||
const testConnection = async () => {
|
||||
setTesting(true);
|
||||
setConnectionStatus("idle");
|
||||
setErrorMessage("");
|
||||
|
||||
try {
|
||||
// Test connection to Ollama
|
||||
const res = await fetch(`${endpoint}/api/tags`);
|
||||
if (!res.ok) throw new Error("Failed to connect to Ollama");
|
||||
|
||||
const data = await res.json();
|
||||
const models = data.models?.map((m: any) => m.name) || [];
|
||||
setAvailableModels(models);
|
||||
setConnectionStatus("success");
|
||||
|
||||
// Auto-select first model if available
|
||||
if (models.length > 0 && !selectedModel) {
|
||||
setSelectedModel(models[0]);
|
||||
}
|
||||
} catch (error: any) {
|
||||
setConnectionStatus("error");
|
||||
setErrorMessage(error.message || "Failed to connect to Ollama");
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const copyToClipboard = (text: string, id: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
setCopied(id);
|
||||
setTimeout(() => setCopied(null), 2000);
|
||||
};
|
||||
|
||||
const saveSettings = () => {
|
||||
// Save to localStorage or user settings
|
||||
const settings = { ollamaEndpoint: endpoint, ollamaModel: selectedModel };
|
||||
localStorage.setItem("ollamaSettings", JSON.stringify(settings));
|
||||
|
||||
// Also save to user account if logged in
|
||||
const token = localStorage.getItem("token");
|
||||
if (token) {
|
||||
fetch("http://localhost:8000/api/auth/settings", {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
ollama_endpoint: endpoint,
|
||||
ollama_model: selectedModel,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
alert("Settings saved successfully!");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-[#1a1a1a] to-[#262626] py-16">
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-12">
|
||||
<Badge className="mb-4 bg-orange-500/20 text-orange-400 border-orange-500/30">
|
||||
Self-Hosted
|
||||
</Badge>
|
||||
<h1 className="text-4xl font-bold text-white mb-4">
|
||||
Configure Your Ollama Server
|
||||
</h1>
|
||||
<p className="text-xl text-zinc-400 max-w-2xl mx-auto">
|
||||
Connect your own Ollama instance for unlimited, free translations using local AI models.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* What is Ollama */}
|
||||
<div className="rounded-xl border border-zinc-800 bg-zinc-900/50 p-6 mb-8">
|
||||
<h2 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
|
||||
<Server className="h-5 w-5 text-orange-400" />
|
||||
What is Ollama?
|
||||
</h2>
|
||||
<p className="text-zinc-400 mb-4">
|
||||
Ollama is a free, open-source tool that lets you run large language models locally on your computer.
|
||||
With Ollama, you can translate documents without sending data to external servers, ensuring complete privacy.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<a
|
||||
href="https://ollama.ai"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 text-teal-400 hover:text-teal-300"
|
||||
>
|
||||
Visit Ollama Website
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/ollama/ollama"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 text-teal-400 hover:text-teal-300"
|
||||
>
|
||||
GitHub Repository
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Installation Guide */}
|
||||
<div className="rounded-xl border border-zinc-800 bg-zinc-900/50 p-6 mb-8">
|
||||
<h2 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
|
||||
<Terminal className="h-5 w-5 text-blue-400" />
|
||||
Quick Installation Guide
|
||||
</h2>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Step 1 */}
|
||||
<div>
|
||||
<h3 className="text-white font-medium mb-2">1. Install Ollama</h3>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between p-3 rounded-lg bg-zinc-800">
|
||||
<div>
|
||||
<span className="text-zinc-400">macOS / Linux:</span>
|
||||
<code className="ml-2 text-teal-400">curl -fsSL https://ollama.ai/install.sh | sh</code>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => copyToClipboard("curl -fsSL https://ollama.ai/install.sh | sh", "install")}
|
||||
className="p-2 rounded hover:bg-zinc-700"
|
||||
>
|
||||
{copied === "install" ? (
|
||||
<Check className="h-4 w-4 text-green-400" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4 text-zinc-400" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-sm text-zinc-500">
|
||||
Windows: Download from <a href="https://ollama.ai/download" className="text-teal-400 hover:underline" target="_blank">ollama.ai/download</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step 2 */}
|
||||
<div>
|
||||
<h3 className="text-white font-medium mb-2">2. Pull a Translation Model</h3>
|
||||
<div className="flex items-center justify-between p-3 rounded-lg bg-zinc-800">
|
||||
<code className="text-teal-400">ollama pull llama3.2:3b</code>
|
||||
<button
|
||||
onClick={() => copyToClipboard("ollama pull llama3.2:3b", "pull")}
|
||||
className="p-2 rounded hover:bg-zinc-700"
|
||||
>
|
||||
{copied === "pull" ? (
|
||||
<Check className="h-4 w-4 text-green-400" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4 text-zinc-400" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step 3 */}
|
||||
<div>
|
||||
<h3 className="text-white font-medium mb-2">3. Start Ollama Server</h3>
|
||||
<div className="flex items-center justify-between p-3 rounded-lg bg-zinc-800">
|
||||
<code className="text-teal-400">ollama serve</code>
|
||||
<button
|
||||
onClick={() => copyToClipboard("ollama serve", "serve")}
|
||||
className="p-2 rounded hover:bg-zinc-700"
|
||||
>
|
||||
{copied === "serve" ? (
|
||||
<Check className="h-4 w-4 text-green-400" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4 text-zinc-400" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-sm text-zinc-500 mt-2">
|
||||
On macOS/Windows with the desktop app, Ollama runs automatically in the background.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recommended Models */}
|
||||
<div className="rounded-xl border border-zinc-800 bg-zinc-900/50 p-6 mb-8">
|
||||
<h2 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
|
||||
<Cpu className="h-5 w-5 text-purple-400" />
|
||||
Recommended Models for Translation
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{recommendedModels.map((model) => (
|
||||
<div
|
||||
key={model.name}
|
||||
className="flex items-center justify-between p-3 rounded-lg bg-zinc-800"
|
||||
>
|
||||
<div>
|
||||
<span className="text-white font-medium">{model.name}</span>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<Badge variant="outline" className="text-xs border-zinc-700 text-zinc-400">
|
||||
<HardDrive className="h-3 w-3 mr-1" />
|
||||
{model.size}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => copyToClipboard(`ollama pull ${model.name}`, model.name)}
|
||||
className="px-3 py-1.5 rounded bg-zinc-700 hover:bg-zinc-600 text-sm text-white"
|
||||
>
|
||||
{copied === model.name ? "Copied!" : "Copy command"}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-sm text-zinc-500 mt-4">
|
||||
💡 Tip: For best results with limited RAM (8GB), use <code className="text-teal-400">llama3.2:3b</code>.
|
||||
With 16GB+ RAM, try <code className="text-teal-400">mistral:7b</code> or larger.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Configuration */}
|
||||
<div className="rounded-xl border border-zinc-800 bg-zinc-900/50 p-6 mb-8">
|
||||
<h2 className="text-lg font-semibold text-white mb-4">Configure Connection</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="endpoint" className="text-zinc-300">Ollama Server URL</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="endpoint"
|
||||
type="url"
|
||||
value={endpoint}
|
||||
onChange={(e) => setEndpoint(e.target.value)}
|
||||
placeholder="http://localhost:11434"
|
||||
className="bg-zinc-800 border-zinc-700 text-white"
|
||||
/>
|
||||
<Button
|
||||
onClick={testConnection}
|
||||
disabled={testing}
|
||||
className="bg-teal-500 hover:bg-teal-600 text-white whitespace-nowrap"
|
||||
>
|
||||
{testing ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
"Test Connection"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Connection Status */}
|
||||
{connectionStatus === "success" && (
|
||||
<div className="p-3 rounded-lg bg-green-500/10 border border-green-500/20 flex items-center gap-2">
|
||||
<Check className="h-5 w-5 text-green-400" />
|
||||
<span className="text-green-400">Connected successfully! Found {availableModels.length} model(s).</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{connectionStatus === "error" && (
|
||||
<div className="p-3 rounded-lg bg-red-500/10 border border-red-500/20 flex items-center gap-2">
|
||||
<AlertCircle className="h-5 w-5 text-red-400" />
|
||||
<span className="text-red-400">{errorMessage}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Model Selection */}
|
||||
{availableModels.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-zinc-300">Select Model</Label>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-2">
|
||||
{availableModels.map((model) => (
|
||||
<button
|
||||
key={model}
|
||||
onClick={() => setSelectedModel(model)}
|
||||
className={cn(
|
||||
"p-3 rounded-lg border text-left transition-colors",
|
||||
selectedModel === model
|
||||
? "border-teal-500 bg-teal-500/10 text-teal-400"
|
||||
: "border-zinc-700 bg-zinc-800 text-zinc-300 hover:border-zinc-600"
|
||||
)}
|
||||
>
|
||||
{model}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Save Button */}
|
||||
{connectionStatus === "success" && selectedModel && (
|
||||
<Button
|
||||
onClick={saveSettings}
|
||||
className="w-full bg-teal-500 hover:bg-teal-600 text-white"
|
||||
>
|
||||
Save Configuration
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Benefits */}
|
||||
<div className="rounded-xl border border-zinc-800 bg-gradient-to-r from-teal-500/10 to-purple-500/10 p-6">
|
||||
<h2 className="text-lg font-semibold text-white mb-4">Why Self-Host?</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="text-center">
|
||||
<div className="text-3xl mb-2">🔒</div>
|
||||
<h3 className="font-medium text-white mb-1">Complete Privacy</h3>
|
||||
<p className="text-sm text-zinc-400">Your documents never leave your computer</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-3xl mb-2">♾️</div>
|
||||
<h3 className="font-medium text-white mb-1">Unlimited Usage</h3>
|
||||
<p className="text-sm text-zinc-400">No monthly limits or quotas</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-3xl mb-2">💰</div>
|
||||
<h3 className="font-medium text-white mb-1">Free Forever</h3>
|
||||
<p className="text-sm text-zinc-400">No subscription or API costs</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
59
frontend/src/app/page.tsx
Normal file
59
frontend/src/app/page.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
"use client";
|
||||
|
||||
import { FileUploader } from "@/components/file-uploader";
|
||||
import {
|
||||
LandingHero,
|
||||
FeaturesSection,
|
||||
PricingPreview,
|
||||
SelfHostCTA
|
||||
} from "@/components/landing-sections";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="space-y-0 -m-8">
|
||||
{/* Hero Section */}
|
||||
<LandingHero />
|
||||
|
||||
{/* Upload Section */}
|
||||
<div id="upload" className="px-8 py-12 bg-zinc-900/30">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="mb-6">
|
||||
<h2 className="text-2xl font-bold text-white">Translate Your Document</h2>
|
||||
<p className="text-zinc-400 mt-1">
|
||||
Upload and translate Excel, Word, and PowerPoint files while preserving all formatting.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<FileUploader />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Features Section */}
|
||||
<FeaturesSection />
|
||||
|
||||
{/* Pricing Preview */}
|
||||
<PricingPreview />
|
||||
|
||||
{/* Self-Host CTA */}
|
||||
<SelfHostCTA />
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="border-t border-zinc-800 py-8 px-8">
|
||||
<div className="max-w-6xl mx-auto flex flex-col md:flex-row items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-teal-500 text-white font-bold text-sm">
|
||||
文A
|
||||
</div>
|
||||
<span className="text-sm text-zinc-400">© 2024 Translate Co. All rights reserved.</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-6 text-sm text-zinc-500">
|
||||
<Link href="/pricing" className="hover:text-zinc-300">Pricing</Link>
|
||||
<Link href="/terms" className="hover:text-zinc-300">Terms</Link>
|
||||
<Link href="/privacy" className="hover:text-zinc-300">Privacy</Link>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
600
frontend/src/app/pricing/page.tsx
Normal file
600
frontend/src/app/pricing/page.tsx
Normal file
@@ -0,0 +1,600 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Check, Zap, Building2, Crown, Sparkles, ArrowRight, Star, Shield, Rocket, Users, Headphones, Lock, Globe, Clock, ChevronDown } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface Plan {
|
||||
id: string;
|
||||
name: string;
|
||||
price_monthly: number;
|
||||
price_yearly: number;
|
||||
features: string[];
|
||||
docs_per_month: number;
|
||||
max_pages_per_doc: number;
|
||||
providers: string[];
|
||||
popular?: boolean;
|
||||
description?: string;
|
||||
highlight?: string;
|
||||
}
|
||||
|
||||
interface CreditPackage {
|
||||
credits: number;
|
||||
price: number;
|
||||
price_per_credit: number;
|
||||
popular?: boolean;
|
||||
}
|
||||
|
||||
interface FAQ {
|
||||
question: string;
|
||||
answer: string;
|
||||
category?: string;
|
||||
}
|
||||
|
||||
const planIcons: Record<string, any> = {
|
||||
free: Sparkles,
|
||||
starter: Zap,
|
||||
pro: Crown,
|
||||
business: Building2,
|
||||
enterprise: Building2,
|
||||
};
|
||||
|
||||
const planGradients: Record<string, string> = {
|
||||
free: "from-zinc-600 to-zinc-700",
|
||||
starter: "from-blue-600 to-blue-700",
|
||||
pro: "from-teal-600 to-teal-700",
|
||||
business: "from-purple-600 to-purple-700",
|
||||
enterprise: "from-amber-600 to-amber-700",
|
||||
};
|
||||
|
||||
export default function PricingPage() {
|
||||
const [isYearly, setIsYearly] = useState(false);
|
||||
const [plans, setPlans] = useState<Plan[]>([]);
|
||||
const [creditPackages, setCreditPackages] = useState<CreditPackage[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [expandedFAQ, setExpandedFAQ] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchPlans();
|
||||
}, []);
|
||||
|
||||
const fetchPlans = async () => {
|
||||
try {
|
||||
const res = await fetch("http://localhost:8000/api/auth/plans");
|
||||
const data = await res.json();
|
||||
setPlans(data.plans || []);
|
||||
setCreditPackages(data.credit_packages || []);
|
||||
} catch (error) {
|
||||
// Use default plans if API fails
|
||||
setPlans([
|
||||
{
|
||||
id: "free",
|
||||
name: "Free",
|
||||
price_monthly: 0,
|
||||
price_yearly: 0,
|
||||
description: "Perfect for trying out our service",
|
||||
features: [
|
||||
"3 documents per day",
|
||||
"Up to 10 pages per document",
|
||||
"Ollama (self-hosted) only",
|
||||
"Basic support via community",
|
||||
"Secure document processing",
|
||||
],
|
||||
docs_per_month: 3,
|
||||
max_pages_per_doc: 10,
|
||||
providers: ["ollama"],
|
||||
},
|
||||
{
|
||||
id: "starter",
|
||||
name: "Starter",
|
||||
price_monthly: 12,
|
||||
price_yearly: 120,
|
||||
description: "For individuals and small projects",
|
||||
features: [
|
||||
"50 documents per month",
|
||||
"Up to 50 pages per document",
|
||||
"Google Translate included",
|
||||
"LibreTranslate included",
|
||||
"Email support",
|
||||
"Document history (30 days)",
|
||||
],
|
||||
docs_per_month: 50,
|
||||
max_pages_per_doc: 50,
|
||||
providers: ["ollama", "google", "libre"],
|
||||
},
|
||||
{
|
||||
id: "pro",
|
||||
name: "Pro",
|
||||
price_monthly: 39,
|
||||
price_yearly: 390,
|
||||
description: "For professionals and growing teams",
|
||||
highlight: "Most Popular",
|
||||
features: [
|
||||
"200 documents per month",
|
||||
"Up to 200 pages per document",
|
||||
"All translation providers",
|
||||
"DeepL & OpenAI included",
|
||||
"API access (1000 calls/month)",
|
||||
"Priority email support",
|
||||
"Document history (90 days)",
|
||||
"Custom formatting options",
|
||||
],
|
||||
docs_per_month: 200,
|
||||
max_pages_per_doc: 200,
|
||||
providers: ["ollama", "google", "deepl", "openai", "libre", "openrouter"],
|
||||
popular: true,
|
||||
},
|
||||
{
|
||||
id: "business",
|
||||
name: "Business",
|
||||
price_monthly: 99,
|
||||
price_yearly: 990,
|
||||
description: "For teams and organizations",
|
||||
features: [
|
||||
"1000 documents per month",
|
||||
"Up to 500 pages per document",
|
||||
"All translation providers",
|
||||
"Azure Translator included",
|
||||
"Unlimited API access",
|
||||
"Priority processing queue",
|
||||
"Dedicated support",
|
||||
"Team management (up to 5 users)",
|
||||
"Document history (1 year)",
|
||||
"Advanced analytics",
|
||||
],
|
||||
docs_per_month: 1000,
|
||||
max_pages_per_doc: 500,
|
||||
providers: ["ollama", "google", "deepl", "openai", "libre", "openrouter", "azure"],
|
||||
},
|
||||
{
|
||||
id: "enterprise",
|
||||
name: "Enterprise",
|
||||
price_monthly: -1,
|
||||
price_yearly: -1,
|
||||
description: "Custom solutions for large organizations",
|
||||
highlight: "Custom",
|
||||
features: [
|
||||
"Unlimited documents",
|
||||
"Unlimited pages",
|
||||
"Custom integrations",
|
||||
"On-premise deployment",
|
||||
"SLA guarantee",
|
||||
"24/7 dedicated support",
|
||||
"Custom AI models",
|
||||
"White-label option",
|
||||
"Unlimited users",
|
||||
"Advanced security features",
|
||||
],
|
||||
docs_per_month: -1,
|
||||
max_pages_per_doc: -1,
|
||||
providers: ["all"],
|
||||
},
|
||||
]);
|
||||
setCreditPackages([
|
||||
{ credits: 50, price: 5, price_per_credit: 0.1 },
|
||||
{ credits: 100, price: 9, price_per_credit: 0.09, popular: true },
|
||||
{ credits: 250, price: 20, price_per_credit: 0.08 },
|
||||
{ credits: 500, price: 35, price_per_credit: 0.07 },
|
||||
{ credits: 1000, price: 60, price_per_credit: 0.06 },
|
||||
]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubscribe = async (planId: string) => {
|
||||
// Check if user is logged in
|
||||
const token = localStorage.getItem("token");
|
||||
if (!token) {
|
||||
window.location.href = "/auth/login?redirect=/pricing&plan=" + planId;
|
||||
return;
|
||||
}
|
||||
|
||||
// Create checkout session
|
||||
try {
|
||||
const res = await fetch("http://localhost:8000/api/auth/checkout/subscription", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
plan: planId,
|
||||
billing_period: isYearly ? "yearly" : "monthly",
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (data.url) {
|
||||
window.location.href = data.url;
|
||||
} else if (data.demo_mode) {
|
||||
alert("Upgraded to " + planId + " (demo mode)");
|
||||
window.location.href = "/dashboard";
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Checkout error:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const faqs: FAQ[] = [
|
||||
{
|
||||
question: "Can I use my own Ollama instance?",
|
||||
answer: "Yes! The Free plan lets you connect your own Ollama server for unlimited translations. You just need to configure your Ollama endpoint in settings.",
|
||||
category: "Technical"
|
||||
},
|
||||
{
|
||||
question: "What happens if I exceed my monthly limit?",
|
||||
answer: "You can either wait for the next month, upgrade to a higher plan, or purchase credit packages for additional pages. Credits never expire.",
|
||||
category: "Billing"
|
||||
},
|
||||
{
|
||||
question: "Can I cancel my subscription anytime?",
|
||||
answer: "Yes, you can cancel anytime. You'll continue to have access until the end of your billing period. No questions asked.",
|
||||
category: "Billing"
|
||||
},
|
||||
{
|
||||
question: "Do credits expire?",
|
||||
answer: "No, purchased credits never expire and can be used anytime. They remain in your account until you use them.",
|
||||
category: "Credits"
|
||||
},
|
||||
{
|
||||
question: "What file formats are supported?",
|
||||
answer: "We support Microsoft Office documents: Word (.docx), Excel (.xlsx), and PowerPoint (.pptx). The formatting is fully preserved during translation.",
|
||||
category: "Technical"
|
||||
},
|
||||
{
|
||||
question: "How secure are my documents?",
|
||||
answer: "All documents are encrypted in transit and at rest. We use industry-standard security practices and never share your data with third parties.",
|
||||
category: "Security"
|
||||
},
|
||||
{
|
||||
question: "Can I change plans anytime?",
|
||||
answer: "Yes, you can upgrade or downgrade your plan at any time. When upgrading, you'll be charged the prorated difference immediately.",
|
||||
category: "Billing"
|
||||
},
|
||||
{
|
||||
question: "Do you offer refunds?",
|
||||
answer: "We offer a 30-day money-back guarantee for all paid plans. If you're not satisfied, contact our support team for a full refund.",
|
||||
category: "Billing"
|
||||
}
|
||||
];
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-surface via-surface-elevated to-background flex items-center justify-center">
|
||||
<div className="text-center space-y-4">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-4 border-border-subtle border-t-primary"></div>
|
||||
<p className="text-lg font-medium text-foreground">Loading pricing plans...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-surface via-surface-elevated to-background">
|
||||
{/* Header */}
|
||||
<header className="relative overflow-hidden">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-primary/20 via-transparent to-accent/20"></div>
|
||||
<div className="relative max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pt-16 pb-20">
|
||||
<div className="text-center">
|
||||
<Badge variant="outline" className="mb-6 border-primary/30 text-primary bg-primary/10 backdrop-blur-sm">
|
||||
<Star className="h-3 w-3 mr-1" />
|
||||
Transparent Pricing
|
||||
</Badge>
|
||||
<h1 className="text-5xl md:text-6xl font-bold text-white mb-6">
|
||||
Choose Your
|
||||
<span className="text-transparent bg-clip-text bg-gradient-to-r from-primary to-accent ml-3">
|
||||
Perfect Plan
|
||||
</span>
|
||||
</h1>
|
||||
<p className="text-xl text-text-secondary max-w-3xl mx-auto mb-8">
|
||||
Start with our free plan and scale as your translation needs grow.
|
||||
No hidden fees, no surprises. Just powerful translation tools.
|
||||
</p>
|
||||
|
||||
{/* Billing Toggle */}
|
||||
<div className="flex items-center justify-center gap-6">
|
||||
<span className={cn(
|
||||
"text-sm font-medium transition-colors duration-200",
|
||||
!isYearly ? "text-white" : "text-text-tertiary"
|
||||
)}>
|
||||
Monthly Billing
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setIsYearly(!isYearly)}
|
||||
className={cn(
|
||||
"relative inline-flex h-8 w-14 items-center rounded-full transition-all duration-300 focus:outline-none focus:ring-2 focus:ring-primary/50",
|
||||
isYearly ? "bg-primary" : "bg-surface-hover"
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block h-6 w-6 transform rounded-full bg-white transition-transform duration-300 shadow-lg",
|
||||
isYearly ? "translate-x-7" : "translate-x-1"
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
<span className={cn(
|
||||
"text-sm font-medium transition-colors duration-200",
|
||||
isYearly ? "text-white" : "text-text-tertiary"
|
||||
)}>
|
||||
Yearly Billing
|
||||
<Badge className="ml-2 bg-success/20 text-success border-success/30 text-xs">
|
||||
Save 17%
|
||||
</Badge>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pb-20">
|
||||
{/* Plans Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-20">
|
||||
{plans.slice(0, 4).map((plan, index) => {
|
||||
const Icon = planIcons[plan.id] || Sparkles;
|
||||
const price = isYearly ? plan.price_yearly : plan.price_monthly;
|
||||
const isEnterprise = plan.id === "enterprise";
|
||||
const isPopular = plan.popular;
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={plan.id}
|
||||
variant={isPopular ? "gradient" : "elevated"}
|
||||
className={cn(
|
||||
"relative overflow-hidden group animate-fade-in-up",
|
||||
isPopular && "scale-105 shadow-2xl shadow-primary/20",
|
||||
`animation-delay-${index * 100}`
|
||||
)}
|
||||
>
|
||||
{isPopular && (
|
||||
<div className="absolute top-0 right-0 w-20 h-20 bg-gradient-to-br from-primary to-accent opacity-20 rounded-full -mr-10 -mt-10"></div>
|
||||
)}
|
||||
|
||||
<CardHeader className="relative">
|
||||
{isPopular && (
|
||||
<Badge className="absolute -top-3 left-1/2 -translate-x-1/2 bg-gradient-to-r from-primary to-accent text-white border-0">
|
||||
{plan.highlight}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className={cn(
|
||||
"p-3 rounded-xl",
|
||||
isPopular
|
||||
? "bg-gradient-to-br from-primary/20 to-accent/20"
|
||||
: "bg-surface"
|
||||
)}>
|
||||
<Icon className={cn(
|
||||
"h-6 w-6",
|
||||
isPopular ? "text-primary" : "text-text-secondary"
|
||||
)} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xl font-bold text-white">{plan.name}</h3>
|
||||
<p className="text-sm text-text-tertiary">{plan.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-2">
|
||||
{isEnterprise || price < 0 ? (
|
||||
<div className="text-3xl font-bold text-white">Custom</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className="text-4xl font-bold text-white">
|
||||
${isYearly ? Math.round(price / 12) : price}
|
||||
</span>
|
||||
<span className="text-text-tertiary">/month</span>
|
||||
</div>
|
||||
{isYearly && price > 0 && (
|
||||
<div className="text-sm text-text-tertiary">
|
||||
${price} billed yearly (save 17%)
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-6">
|
||||
<ul className="space-y-3">
|
||||
{plan.features.map((feature, idx) => (
|
||||
<li key={idx} className="flex items-start gap-3">
|
||||
<Check className="h-5 w-5 text-success flex-shrink-0 mt-0.5" />
|
||||
<span className="text-sm text-text-secondary">{feature}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<Button
|
||||
onClick={() => handleSubscribe(plan.id)}
|
||||
variant={isPopular ? "default" : "outline"}
|
||||
size="lg"
|
||||
className={cn(
|
||||
"w-full group",
|
||||
isPopular && "bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
|
||||
)}
|
||||
>
|
||||
{plan.id === "free" ? (
|
||||
<>
|
||||
Get Started
|
||||
<ArrowRight className="h-4 w-4 ml-2 transition-transform duration-200 group-hover:translate-x-1" />
|
||||
</>
|
||||
) : isEnterprise ? (
|
||||
<>
|
||||
Contact Sales
|
||||
<ArrowRight className="h-4 w-4 ml-2 transition-transform duration-200 group-hover:translate-x-1" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Subscribe Now
|
||||
<ArrowRight className="h-4 w-4 ml-2 transition-transform duration-200 group-hover:translate-x-1" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Enterprise Section */}
|
||||
<Card variant="gradient" className="mb-20 animate-fade-in-up animation-delay-400">
|
||||
<CardContent className="p-8 md:p-12">
|
||||
<div className="flex flex-col lg:flex-row items-center justify-between gap-8">
|
||||
<div className="flex-1">
|
||||
<Badge variant="outline" className="mb-4 border-amber-500/30 text-amber-400 bg-amber-500/10">
|
||||
<Crown className="h-3 w-3 mr-1" />
|
||||
Enterprise
|
||||
</Badge>
|
||||
<h3 className="text-3xl font-bold text-white mb-4">
|
||||
Need a Custom Solution?
|
||||
</h3>
|
||||
<p className="text-text-secondary text-lg mb-6 max-w-2xl">
|
||||
Get unlimited translations, custom integrations, on-premise deployment,
|
||||
dedicated support, and SLA guarantees. Perfect for large organizations
|
||||
with specific requirements.
|
||||
</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-8">
|
||||
{[
|
||||
{ icon: Shield, text: "Advanced Security & Compliance" },
|
||||
{ icon: Users, text: "Unlimited Users & Teams" },
|
||||
{ icon: Headphones, text: "24/7 Dedicated Support" },
|
||||
{ icon: Lock, text: "On-Premise Deployment Options" }
|
||||
].map((item, idx) => (
|
||||
<div key={idx} className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-surface">
|
||||
<item.icon className="h-5 w-5 text-amber-400" />
|
||||
</div>
|
||||
<span className="text-sm text-text-secondary">{item.text}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<Button
|
||||
size="lg"
|
||||
className="bg-gradient-to-r from-amber-500 to-orange-600 hover:from-amber-600 hover:to-orange-700 text-white group"
|
||||
>
|
||||
<Building2 className="h-5 w-5 mr-2" />
|
||||
Contact Sales Team
|
||||
<ArrowRight className="h-4 w-4 ml-2 transition-transform duration-200 group-hover:translate-x-1" />
|
||||
</Button>
|
||||
<Button variant="glass" size="lg">
|
||||
Schedule a Demo
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Credit Packages */}
|
||||
<div className="mb-20">
|
||||
<div className="text-center mb-12">
|
||||
<Badge variant="outline" className="mb-4 border-primary/30 text-primary bg-primary/10">
|
||||
<Zap className="h-3 w-3 mr-1" />
|
||||
Extra Credits
|
||||
</Badge>
|
||||
<h2 className="text-3xl font-bold text-white mb-4">
|
||||
Need More Pages?
|
||||
</h2>
|
||||
<p className="text-text-secondary text-lg max-w-2xl mx-auto">
|
||||
Purchase credit packages to translate additional pages.
|
||||
Credits never expire and can be used across all documents.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
|
||||
{creditPackages.map((pkg, idx) => (
|
||||
<Card
|
||||
key={idx}
|
||||
variant={pkg.popular ? "gradient" : "elevated"}
|
||||
className={cn(
|
||||
"text-center group hover:scale-105 transition-all duration-300 animate-fade-in-up",
|
||||
`animation-delay-${idx * 100}`
|
||||
)}
|
||||
>
|
||||
<CardContent className="p-6">
|
||||
{pkg.popular && (
|
||||
<Badge className="mb-3 bg-gradient-to-r from-primary to-accent text-white border-0">
|
||||
Best Value
|
||||
</Badge>
|
||||
)}
|
||||
<div className="text-3xl font-bold text-white mb-1">{pkg.credits}</div>
|
||||
<div className="text-sm text-text-tertiary mb-4">pages</div>
|
||||
<div className="text-2xl font-semibold text-white mb-1">${pkg.price}</div>
|
||||
<div className="text-xs text-text-tertiary mb-4">
|
||||
${pkg.price_per_credit.toFixed(2)}/page
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={pkg.popular ? "default" : "outline"}
|
||||
className={cn(
|
||||
"w-full group",
|
||||
pkg.popular && "bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
|
||||
)}
|
||||
>
|
||||
Buy Now
|
||||
<ArrowRight className="h-3 w-3 ml-1 transition-transform duration-200 group-hover:translate-x-1" />
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* FAQ Section */}
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="text-center mb-12">
|
||||
<Badge variant="outline" className="mb-4 border-primary/30 text-primary bg-primary/10">
|
||||
<Clock className="h-3 w-3 mr-1" />
|
||||
Questions
|
||||
</Badge>
|
||||
<h2 className="text-3xl font-bold text-white mb-4">
|
||||
Frequently Asked Questions
|
||||
</h2>
|
||||
<p className="text-text-secondary text-lg">
|
||||
Everything you need to know about our pricing and plans
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{faqs.map((faq, idx) => (
|
||||
<Card key={idx} variant="elevated" className="animate-fade-in-up animation-delay-100">
|
||||
<button
|
||||
onClick={() => setExpandedFAQ(expandedFAQ === idx ? null : idx)}
|
||||
className="w-full text-left p-6 focus:outline-none focus:ring-2 focus:ring-primary/50 rounded-xl"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-surface">
|
||||
<Globe className="h-4 w-4 text-primary" />
|
||||
</div>
|
||||
<h3 className="font-semibold text-white">{faq.question}</h3>
|
||||
</div>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"h-5 w-5 text-text-tertiary transition-transform duration-200",
|
||||
expandedFAQ === idx && "rotate-180"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
{expandedFAQ === idx && (
|
||||
<div className="px-6 pb-6">
|
||||
<p className="text-text-secondary pl-11">{faq.answer}</p>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
354
frontend/src/app/settings/context/page.tsx
Normal file
354
frontend/src/app/settings/context/page.tsx
Normal file
@@ -0,0 +1,354 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { useTranslationStore } from "@/lib/store";
|
||||
import { Save, Loader2, Brain, BookOpen, Sparkles, Trash2, ArrowRight, AlertCircle, CheckCircle, Zap } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export default function ContextGlossaryPage() {
|
||||
const { settings, updateSettings, applyPreset, clearContext } = useTranslationStore();
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const [localSettings, setLocalSettings] = useState({
|
||||
systemPrompt: settings.systemPrompt,
|
||||
glossary: settings.glossary,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setLocalSettings({
|
||||
systemPrompt: settings.systemPrompt,
|
||||
glossary: settings.glossary,
|
||||
});
|
||||
}, [settings]);
|
||||
|
||||
const handleSave = async () => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
updateSettings(localSettings);
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleApplyPreset = (preset: 'hvac' | 'it' | 'legal' | 'medical') => {
|
||||
applyPreset(preset);
|
||||
// Need to get updated values from store after applying preset
|
||||
setTimeout(() => {
|
||||
setLocalSettings({
|
||||
systemPrompt: useTranslationStore.getState().settings.systemPrompt,
|
||||
glossary: useTranslationStore.getState().settings.glossary,
|
||||
});
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
clearContext();
|
||||
setLocalSettings({
|
||||
systemPrompt: "",
|
||||
glossary: "",
|
||||
});
|
||||
};
|
||||
|
||||
// Check which LLM providers are configured
|
||||
const isOllamaConfigured = settings.ollamaUrl && settings.ollamaModel;
|
||||
const isOpenAIConfigured = !!settings.openaiApiKey;
|
||||
const isWebLLMAvailable = typeof window !== 'undefined' && 'gpu' in navigator;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-surface via-surface-elevated to-background">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<Badge variant="outline" className="mb-4 border-primary/30 text-primary bg-primary/10">
|
||||
<Brain className="h-3 w-3 mr-1" />
|
||||
Context & Glossary
|
||||
</Badge>
|
||||
<h1 className="text-4xl font-bold text-white mb-2">
|
||||
Context & Glossary
|
||||
</h1>
|
||||
<p className="text-lg text-text-secondary">
|
||||
Configure translation context and glossary for LLM-based providers
|
||||
</p>
|
||||
|
||||
{/* LLM Provider Status */}
|
||||
<div className="flex flex-wrap gap-3 mt-4">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"px-3 py-2",
|
||||
isOllamaConfigured
|
||||
? "border-success/50 text-success bg-success/10"
|
||||
: "border-border-subtle text-text-tertiary bg-surface/50"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{isOllamaConfigured && <CheckCircle className="h-4 w-4" />}
|
||||
<span>🤖 Ollama</span>
|
||||
</div>
|
||||
</Badge>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"px-3 py-2",
|
||||
isOpenAIConfigured
|
||||
? "border-success/50 text-success bg-success/10"
|
||||
: "border-border-subtle text-text-tertiary bg-surface/50"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{isOpenAIConfigured && <CheckCircle className="h-4 w-4" />}
|
||||
<span>🧠 OpenAI</span>
|
||||
</div>
|
||||
</Badge>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"px-3 py-2",
|
||||
isWebLLMAvailable
|
||||
? "border-success/50 text-success bg-success/10"
|
||||
: "border-border-subtle text-text-tertiary bg-surface/50"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{isWebLLMAvailable && <CheckCircle className="h-4 w-4" />}
|
||||
<span>💻 WebLLM</span>
|
||||
</div>
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info Banner */}
|
||||
<Card variant="gradient" className="mb-8 animate-fade-in-up">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="p-2 rounded-lg bg-primary/20">
|
||||
<Sparkles className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white mb-2">
|
||||
Context & Glossary Settings
|
||||
</h3>
|
||||
<p className="text-text-secondary leading-relaxed">
|
||||
These settings apply to all LLM providers: <strong>Ollama</strong>, <strong>OpenAI</strong>, and <strong>WebLLM</strong>.
|
||||
Use them to improve translation quality with domain-specific instructions and terminology.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 mb-8">
|
||||
{/* Left Column - System Prompt */}
|
||||
<div className="space-y-6">
|
||||
<Card variant="elevated" className="animate-fade-in-up animation-delay-100">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-primary/20">
|
||||
<Brain className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-white">System Prompt</CardTitle>
|
||||
<CardDescription>
|
||||
Instructions for LLM to follow during translation
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Textarea
|
||||
id="system-prompt"
|
||||
value={localSettings.systemPrompt}
|
||||
onChange={(e) =>
|
||||
setLocalSettings({ ...localSettings, systemPrompt: e.target.value })
|
||||
}
|
||||
placeholder="Example: You are translating technical HVAC documents. Use precise engineering terminology. Maintain consistency with industry standards..."
|
||||
className="bg-surface border-border-subtle text-white placeholder:text-text-tertiary min-h-[200px] resize-y focus:border-primary focus:ring-primary/20"
|
||||
/>
|
||||
<div className="p-4 rounded-lg bg-primary/10 border border-primary/30">
|
||||
<p className="text-sm text-primary flex items-start gap-2">
|
||||
<Zap className="h-4 w-4 mt-0.5 flex-shrink-0" />
|
||||
<span>
|
||||
<strong>Tip:</strong> Include domain context, tone preferences, or specific terminology rules for better translation accuracy.
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Quick Presets */}
|
||||
<Card variant="elevated" className="animate-fade-in-up animation-delay-200">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-accent/20">
|
||||
<Zap className="h-5 w-5 text-accent" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-white">Quick Presets</CardTitle>
|
||||
<CardDescription>
|
||||
Load pre-configured prompts & glossaries for common domains
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleApplyPreset("hvac")}
|
||||
className="border-border-subtle text-text-secondary hover:bg-surface-hover hover:text-primary justify-start h-auto p-4 group"
|
||||
>
|
||||
<div className="text-left">
|
||||
<div className="text-lg mb-1">🔧</div>
|
||||
<div className="font-medium">HVAC / Engineering</div>
|
||||
<div className="text-xs text-text-tertiary">Technical terminology</div>
|
||||
</div>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleApplyPreset("it")}
|
||||
className="border-border-subtle text-text-secondary hover:bg-surface-hover hover:text-primary justify-start h-auto p-4 group"
|
||||
>
|
||||
<div className="text-left">
|
||||
<div className="text-lg mb-1">💻</div>
|
||||
<div className="font-medium">IT / Software</div>
|
||||
<div className="text-xs text-text-tertiary">Development terms</div>
|
||||
</div>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleApplyPreset("legal")}
|
||||
className="border-border-subtle text-text-secondary hover:bg-surface-hover hover:text-primary justify-start h-auto p-4 group"
|
||||
>
|
||||
<div className="text-left">
|
||||
<div className="text-lg mb-1">⚖️</div>
|
||||
<div className="font-medium">Legal / Contracts</div>
|
||||
<div className="text-xs text-text-tertiary">Legal terminology</div>
|
||||
</div>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleApplyPreset("medical")}
|
||||
className="border-border-subtle text-text-secondary hover:bg-surface-hover hover:text-primary justify-start h-auto p-4 group"
|
||||
>
|
||||
<div className="text-left">
|
||||
<div className="text-lg mb-1">🏥</div>
|
||||
<div className="font-medium">Medical / Healthcare</div>
|
||||
<div className="text-xs text-text-tertiary">Medical terms</div>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={handleClear}
|
||||
className="w-full text-destructive hover:text-destructive/80 hover:bg-destructive/10 group"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4 transition-transform duration-200 group-hover:scale-110" />
|
||||
Clear All
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Right Column - Glossary */}
|
||||
<div className="space-y-6">
|
||||
<Card variant="elevated" className="animate-fade-in-up animation-delay-300">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-success/20">
|
||||
<BookOpen className="h-5 w-5 text-success" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-white">Technical Glossary</CardTitle>
|
||||
<CardDescription>
|
||||
Define specific term translations. Format: source=target (one per line)
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Textarea
|
||||
id="glossary"
|
||||
value={localSettings.glossary}
|
||||
onChange={(e) =>
|
||||
setLocalSettings({ ...localSettings, glossary: e.target.value })
|
||||
}
|
||||
placeholder="pression statique=static pressure récupérateur=heat recovery unit ventilo-connecteur=fan coil unit gaine=duct diffuseur=diffuser"
|
||||
className="bg-surface border-border-subtle text-white placeholder:text-text-tertiary min-h-[280px] resize-y font-mono text-sm focus:border-success focus:ring-success/20"
|
||||
/>
|
||||
<div className="p-4 rounded-lg bg-success/10 border border-success/30">
|
||||
<p className="text-sm text-success flex items-start gap-2">
|
||||
<Zap className="h-4 w-4 mt-0.5 flex-shrink-0" />
|
||||
<span>
|
||||
<strong>Pro Tip:</strong> The glossary is included in system prompt to guide translations and ensure consistent terminology.
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Usage Examples */}
|
||||
<Card variant="glass" className="animate-fade-in-up animation-delay-400">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-accent/20">
|
||||
<AlertCircle className="h-5 w-5 text-accent" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-white">Usage Examples</CardTitle>
|
||||
<CardDescription>
|
||||
See how context and glossary improve translations
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="p-4 rounded-lg bg-surface/50 border border-border-subtle">
|
||||
<h4 className="font-medium text-white mb-2">Before (Generic Translation)</h4>
|
||||
<p className="text-sm text-text-tertiary italic">
|
||||
"The pressure in the duct should be maintained."
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-4 rounded-lg bg-success/10 border border-success/30">
|
||||
<h4 className="font-medium text-white mb-2">After (With Context & Glossary)</h4>
|
||||
<p className="text-sm text-success italic">
|
||||
"La pression statique dans la gaine doit être maintenue."
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-xs text-text-tertiary">
|
||||
<strong>Key improvements:</strong> Technical terms are correctly translated, context is preserved, and industry-standard terminology is used.
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Save Button */}
|
||||
<div className="flex justify-end animate-fade-in-up animation-delay-500">
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
size="lg"
|
||||
className="bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90 text-white group"
|
||||
>
|
||||
{isSaving ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="mr-2 h-4 w-4 transition-transform duration-200 group-hover:scale-110" />
|
||||
Save Settings
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
394
frontend/src/app/settings/page.tsx
Normal file
394
frontend/src/app/settings/page.tsx
Normal file
@@ -0,0 +1,394 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { useTranslationStore } from "@/lib/store";
|
||||
import { languages } from "@/lib/api";
|
||||
import { Save, Loader2, Settings, Globe, Trash2, ArrowRight, Shield, Zap, Database } from "lucide-react";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function GeneralSettingsPage() {
|
||||
const { settings, updateSettings } = useTranslationStore();
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isClearing, setIsClearing] = useState(false);
|
||||
const [defaultLanguage, setDefaultLanguage] = useState(settings.defaultTargetLanguage);
|
||||
|
||||
useEffect(() => {
|
||||
setDefaultLanguage(settings.defaultTargetLanguage);
|
||||
}, [settings.defaultTargetLanguage]);
|
||||
|
||||
const handleSave = async () => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
updateSettings({ defaultTargetLanguage: defaultLanguage });
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearCache = async () => {
|
||||
setIsClearing(true);
|
||||
try {
|
||||
// Clear localStorage
|
||||
localStorage.removeItem('translation-settings');
|
||||
// Clear sessionStorage
|
||||
sessionStorage.clear();
|
||||
// Clear any cached files/blobs
|
||||
if ('caches' in window) {
|
||||
const cacheNames = await caches.keys();
|
||||
await Promise.all(cacheNames.map(name => caches.delete(name)));
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
// Reload to reset state
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
console.error('Error clearing cache:', error);
|
||||
setIsClearing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-surface via-surface-elevated to-background">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<Badge variant="outline" className="mb-4 border-primary/30 text-primary bg-primary/10">
|
||||
<Settings className="h-3 w-3 mr-1" />
|
||||
Settings
|
||||
</Badge>
|
||||
<h1 className="text-4xl font-bold text-white mb-2">
|
||||
General Settings
|
||||
</h1>
|
||||
<p className="text-lg text-text-secondary">
|
||||
Configure general application settings and preferences
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
||||
<Card variant="elevated" className="group hover:scale-105 transition-all duration-300 animate-fade-in-up">
|
||||
<Link href="/settings/services" className="block">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<div className="p-3 rounded-xl bg-primary/20 group-hover:bg-primary/30 transition-colors duration-300">
|
||||
<Zap className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white group-hover:text-primary transition-colors duration-300">
|
||||
Translation Services
|
||||
</h3>
|
||||
<p className="text-sm text-text-tertiary">Configure providers</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center text-primary">
|
||||
<span className="text-sm font-medium">Manage providers</span>
|
||||
<ArrowRight className="h-4 w-4 ml-2 transition-transform duration-200 group-hover:translate-x-1" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Link>
|
||||
</Card>
|
||||
|
||||
<Card variant="elevated" className="group hover:scale-105 transition-all duration-300 animate-fade-in-up animation-delay-100">
|
||||
<Link href="/settings/context" className="block">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<div className="p-3 rounded-xl bg-accent/20 group-hover:bg-accent/30 transition-colors duration-300">
|
||||
<Globe className="h-6 w-6 text-accent" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white group-hover:text-accent transition-colors duration-300">
|
||||
Context & Glossary
|
||||
</h3>
|
||||
<p className="text-sm text-text-tertiary">Domain-specific settings</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center text-accent">
|
||||
<span className="text-sm font-medium">Configure context</span>
|
||||
<ArrowRight className="h-4 w-4 ml-2 transition-transform duration-200 group-hover:translate-x-1" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Link>
|
||||
</Card>
|
||||
|
||||
<Card variant="elevated" className="group hover:scale-105 transition-all duration-300 animate-fade-in-up animation-delay-200">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<div className="p-3 rounded-xl bg-success/20 group-hover:bg-success/30 transition-colors duration-300">
|
||||
<Shield className="h-6 w-6 text-success" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white group-hover:text-success transition-colors duration-300">
|
||||
Privacy & Security
|
||||
</h3>
|
||||
<p className="text-sm text-text-tertiary">Data protection</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center text-success">
|
||||
<span className="text-sm font-medium">Coming soon</span>
|
||||
<ArrowRight className="h-4 w-4 ml-2 transition-transform duration-200 group-hover:translate-x-1" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Application Settings */}
|
||||
<Card variant="elevated" className="mb-8 animate-fade-in-up animation-delay-300">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-primary/20">
|
||||
<Settings className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-white">Application Settings</CardTitle>
|
||||
<CardDescription>
|
||||
General configuration options
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="space-y-3">
|
||||
<Label htmlFor="default-language" className="text-text-secondary font-medium">
|
||||
Default Target Language
|
||||
</Label>
|
||||
<Select value={defaultLanguage} onValueChange={setDefaultLanguage}>
|
||||
<SelectTrigger className="bg-surface border-border-subtle text-white focus:border-primary focus:ring-primary/20">
|
||||
<SelectValue placeholder="Select default language" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-surface-elevated border-border-subtle max-h-[300px]">
|
||||
{languages.map((lang) => (
|
||||
<SelectItem
|
||||
key={lang.code}
|
||||
value={lang.code}
|
||||
className="text-white hover:bg-surface-hover focus:bg-primary/20 focus:text-primary"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<span>{lang.flag}</span>
|
||||
<span>{lang.name}</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-sm text-text-tertiary">
|
||||
This language will be pre-selected when translating documents
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Supported Formats */}
|
||||
<Card variant="elevated" className="mb-8 animate-fade-in-up animation-delay-400">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-accent/20">
|
||||
<Globe className="h-5 w-5 text-accent" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-white">Supported Formats</CardTitle>
|
||||
<CardDescription>
|
||||
Document types that can be translated
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<Card variant="glass" className="group hover:scale-105 transition-all duration-300">
|
||||
<CardContent className="p-6 text-center">
|
||||
<div className="text-4xl mb-3 group-hover:scale-110 transition-transform duration-300">📊</div>
|
||||
<h3 className="font-semibold text-white mb-2">Excel</h3>
|
||||
<p className="text-sm text-text-tertiary mb-4">.xlsx, .xls</p>
|
||||
<div className="flex flex-wrap gap-2 justify-center">
|
||||
<Badge variant="outline" className="border-success/50 text-success bg-success/10 text-xs">
|
||||
Formulas
|
||||
</Badge>
|
||||
<Badge variant="outline" className="border-primary/50 text-primary bg-primary/10 text-xs">
|
||||
Styles
|
||||
</Badge>
|
||||
<Badge variant="outline" className="border-accent/50 text-accent bg-accent/10 text-xs">
|
||||
Images
|
||||
</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card variant="glass" className="group hover:scale-105 transition-all duration-300">
|
||||
<CardContent className="p-6 text-center">
|
||||
<div className="text-4xl mb-3 group-hover:scale-110 transition-transform duration-300">📝</div>
|
||||
<h3 className="font-semibold text-white mb-2">Word</h3>
|
||||
<p className="text-sm text-text-tertiary mb-4">.docx, .doc</p>
|
||||
<div className="flex flex-wrap gap-2 justify-center">
|
||||
<Badge variant="outline" className="border-success/50 text-success bg-success/10 text-xs">
|
||||
Headers
|
||||
</Badge>
|
||||
<Badge variant="outline" className="border-primary/50 text-primary bg-primary/10 text-xs">
|
||||
Tables
|
||||
</Badge>
|
||||
<Badge variant="outline" className="border-accent/50 text-accent bg-accent/10 text-xs">
|
||||
Images
|
||||
</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card variant="glass" className="group hover:scale-105 transition-all duration-300">
|
||||
<CardContent className="p-6 text-center">
|
||||
<div className="text-4xl mb-3 group-hover:scale-110 transition-transform duration-300">📽️</div>
|
||||
<h3 className="font-semibold text-white mb-2">PowerPoint</h3>
|
||||
<p className="text-sm text-text-tertiary mb-4">.pptx, .ppt</p>
|
||||
<div className="flex flex-wrap gap-2 justify-center">
|
||||
<Badge variant="outline" className="border-success/50 text-success bg-success/10 text-xs">
|
||||
Slides
|
||||
</Badge>
|
||||
<Badge variant="outline" className="border-primary/50 text-primary bg-primary/10 text-xs">
|
||||
Notes
|
||||
</Badge>
|
||||
<Badge variant="outline" className="border-accent/50 text-accent bg-accent/10 text-xs">
|
||||
Images
|
||||
</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* System Information */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
||||
<Card variant="elevated" className="animate-fade-in-up animation-delay-500">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-success/20">
|
||||
<Database className="h-5 w-5 text-success" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-white">API Information</CardTitle>
|
||||
<CardDescription>
|
||||
Backend server connection details
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between p-3 rounded-lg bg-surface/50 hover:bg-surface transition-colors duration-200">
|
||||
<span className="text-text-tertiary">API Endpoint</span>
|
||||
<code className="text-primary text-sm font-mono bg-surface px-2 py-1 rounded">http://localhost:8000</code>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-3 rounded-lg bg-surface/50 hover:bg-surface transition-colors duration-200">
|
||||
<span className="text-text-tertiary">Health Check</span>
|
||||
<code className="text-primary text-sm font-mono bg-surface px-2 py-1 rounded">/health</code>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-3 rounded-lg bg-surface/50 hover:bg-surface transition-colors duration-200">
|
||||
<span className="text-text-tertiary">Translate Endpoint</span>
|
||||
<code className="text-primary text-sm font-mono bg-surface px-2 py-1 rounded">/translate</code>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card variant="elevated" className="animate-fade-in-up animation-delay-600">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-warning/20">
|
||||
<Shield className="h-5 w-5 text-warning" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-white">System Status</CardTitle>
|
||||
<CardDescription>
|
||||
Application health and performance
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between p-3 rounded-lg bg-surface/50">
|
||||
<span className="text-text-tertiary">Connection Status</span>
|
||||
<Badge variant="outline" className="border-success/50 text-success bg-success/10">
|
||||
<div className="w-2 h-2 bg-success rounded-full mr-2 animate-pulse"></div>
|
||||
Connected
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-3 rounded-lg bg-surface/50">
|
||||
<span className="text-text-tertiary">Last Sync</span>
|
||||
<span className="text-sm text-text-secondary">Just now</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-3 rounded-lg bg-surface/50">
|
||||
<span className="text-text-tertiary">Version</span>
|
||||
<span className="text-sm text-text-secondary">v2.0.0</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-between items-start sm:items-center animate-fade-in-up animation-delay-700">
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-text-tertiary">
|
||||
Need help with settings? Check our documentation.
|
||||
</p>
|
||||
<Button variant="glass" size="sm" className="group">
|
||||
<Settings className="h-4 w-4 mr-2 transition-transform duration-200 group-hover:rotate-90" />
|
||||
View Documentation
|
||||
<ArrowRight className="h-4 w-4 ml-2 transition-transform duration-200 group-hover:translate-x-1" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
onClick={handleClearCache}
|
||||
disabled={isClearing}
|
||||
variant="outline"
|
||||
size="lg"
|
||||
className="border-destructive/50 text-destructive hover:bg-destructive/10 hover:border-destructive group"
|
||||
>
|
||||
{isClearing ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Clearing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Trash2 className="mr-2 h-4 w-4 transition-transform duration-200 group-hover:scale-110" />
|
||||
Clear Cache
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
size="lg"
|
||||
className="bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90 text-white group"
|
||||
>
|
||||
{isSaving ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="mr-2 h-4 w-4 transition-transform duration-200 group-hover:scale-110" />
|
||||
Save Settings
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
848
frontend/src/app/settings/services/page.tsx
Normal file
848
frontend/src/app/settings/services/page.tsx
Normal file
@@ -0,0 +1,848 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useTranslationStore, webllmModels, openaiModels, openrouterModels } from "@/lib/store";
|
||||
import { providers, testOpenAIConnection, testOllamaConnection, getOllamaModels, type OllamaModel } from "@/lib/api";
|
||||
import { useWebLLM } from "@/lib/webllm";
|
||||
import { Save, Loader2, Cloud, Check, ExternalLink, Wifi, CheckCircle, XCircle, Download, Trash2, Cpu, Server, RefreshCw, Zap, Shield, ArrowRight, AlertCircle } from "lucide-react";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export default function TranslationServicesPage() {
|
||||
const { settings, updateSettings } = useTranslationStore();
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [selectedProvider, setSelectedProvider] = useState(settings.defaultProvider);
|
||||
const [translateImages, setTranslateImages] = useState(settings.translateImages);
|
||||
|
||||
// Provider-specific states
|
||||
const [deeplApiKey, setDeeplApiKey] = useState(settings.deeplApiKey);
|
||||
const [openaiApiKey, setOpenaiApiKey] = useState(settings.openaiApiKey);
|
||||
const [openaiModel, setOpenaiModel] = useState(settings.openaiModel);
|
||||
const [openrouterApiKey, setOpenrouterApiKey] = useState(settings.openrouterApiKey);
|
||||
const [openrouterModel, setOpenrouterModel] = useState(settings.openrouterModel);
|
||||
const [libreUrl, setLibreUrl] = useState(settings.libreTranslateUrl);
|
||||
const [webllmModel, setWebllmModel] = useState(settings.webllmModel);
|
||||
|
||||
// Ollama states
|
||||
const [ollamaUrl, setOllamaUrl] = useState(settings.ollamaUrl);
|
||||
const [ollamaModel, setOllamaModel] = useState(settings.ollamaModel);
|
||||
const [ollamaModels, setOllamaModels] = useState<OllamaModel[]>([]);
|
||||
const [loadingOllamaModels, setLoadingOllamaModels] = useState(false);
|
||||
const [ollamaTestStatus, setOllamaTestStatus] = useState<"idle" | "testing" | "success" | "error">("idle");
|
||||
const [ollamaTestMessage, setOllamaTestMessage] = useState("");
|
||||
|
||||
// OpenAI connection test state
|
||||
const [openaiTestStatus, setOpenaiTestStatus] = useState<"idle" | "testing" | "success" | "error">("idle");
|
||||
const [openaiTestMessage, setOpenaiTestMessage] = useState("");
|
||||
|
||||
// OpenRouter connection test state
|
||||
const [openrouterTestStatus, setOpenrouterTestStatus] = useState<"idle" | "testing" | "success" | "error">("idle");
|
||||
const [openrouterTestMessage, setOpenrouterTestMessage] = useState("");
|
||||
|
||||
// WebLLM hook
|
||||
const webllm = useWebLLM();
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedProvider(settings.defaultProvider);
|
||||
setTranslateImages(settings.translateImages);
|
||||
setDeeplApiKey(settings.deeplApiKey);
|
||||
setOpenaiApiKey(settings.openaiApiKey);
|
||||
setOpenaiModel(settings.openaiModel);
|
||||
setOpenrouterApiKey(settings.openrouterApiKey);
|
||||
setOpenrouterModel(settings.openrouterModel);
|
||||
setLibreUrl(settings.libreTranslateUrl);
|
||||
setWebllmModel(settings.webllmModel);
|
||||
setOllamaUrl(settings.ollamaUrl);
|
||||
setOllamaModel(settings.ollamaModel);
|
||||
}, [settings]);
|
||||
|
||||
// Load Ollama models when provider is selected
|
||||
const loadOllamaModels = async () => {
|
||||
setLoadingOllamaModels(true);
|
||||
try {
|
||||
const models = await getOllamaModels(ollamaUrl);
|
||||
setOllamaModels(models);
|
||||
} catch (error) {
|
||||
console.error("Failed to load Ollama models:", error);
|
||||
} finally {
|
||||
setLoadingOllamaModels(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedProvider === "ollama") {
|
||||
loadOllamaModels();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedProvider]);
|
||||
|
||||
const handleTestOllama = async () => {
|
||||
setOllamaTestStatus("testing");
|
||||
setOllamaTestMessage("");
|
||||
|
||||
try {
|
||||
const result = await testOllamaConnection(ollamaUrl);
|
||||
setOllamaTestStatus(result.success ? "success" : "error");
|
||||
setOllamaTestMessage(result.message);
|
||||
|
||||
if (result.success) {
|
||||
await loadOllamaModels();
|
||||
updateSettings({ ollamaUrl, ollamaModel });
|
||||
setOllamaTestMessage(result.message + " - Settings saved!");
|
||||
}
|
||||
} catch {
|
||||
setOllamaTestStatus("error");
|
||||
setOllamaTestMessage("Connection test failed");
|
||||
}
|
||||
};
|
||||
|
||||
const handleTestOpenAI = async () => {
|
||||
if (!openaiApiKey.trim()) {
|
||||
setOpenaiTestStatus("error");
|
||||
setOpenaiTestMessage("Please enter an API key first");
|
||||
return;
|
||||
}
|
||||
|
||||
setOpenaiTestStatus("testing");
|
||||
setOpenaiTestMessage("");
|
||||
|
||||
try {
|
||||
const result = await testOpenAIConnection(openaiApiKey);
|
||||
setOpenaiTestStatus(result.success ? "success" : "error");
|
||||
setOpenaiTestMessage(result.message);
|
||||
|
||||
if (result.success) {
|
||||
updateSettings({ openaiApiKey, openaiModel });
|
||||
setOpenaiTestMessage(result.message + " - Settings saved!");
|
||||
}
|
||||
} catch {
|
||||
setOpenaiTestStatus("error");
|
||||
setOpenaiTestMessage("Connection test failed");
|
||||
}
|
||||
};
|
||||
|
||||
// Test OpenRouter connection
|
||||
const testOpenRouterConnection = async () => {
|
||||
if (!openrouterApiKey) {
|
||||
setOpenrouterTestStatus("error");
|
||||
setOpenrouterTestMessage("API key required");
|
||||
return;
|
||||
}
|
||||
setOpenrouterTestStatus("testing");
|
||||
try {
|
||||
const response = await fetch("https://openrouter.ai/api/v1/models", {
|
||||
headers: { Authorization: `Bearer ${openrouterApiKey}` }
|
||||
});
|
||||
if (response.ok) {
|
||||
setOpenrouterTestStatus("success");
|
||||
setOpenrouterTestMessage("Connected successfully!");
|
||||
} else {
|
||||
setOpenrouterTestStatus("error");
|
||||
setOpenrouterTestMessage("Invalid API key");
|
||||
}
|
||||
} catch {
|
||||
setOpenrouterTestStatus("error");
|
||||
setOpenrouterTestMessage("Connection test failed");
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
updateSettings({
|
||||
defaultProvider: selectedProvider,
|
||||
translateImages,
|
||||
deeplApiKey,
|
||||
openaiApiKey,
|
||||
openaiModel,
|
||||
openrouterApiKey,
|
||||
openrouterModel,
|
||||
libreTranslateUrl: libreUrl,
|
||||
webllmModel,
|
||||
ollamaUrl,
|
||||
ollamaModel,
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-surface via-surface-elevated to-background">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<Badge variant="outline" className="mb-4 border-primary/30 text-primary bg-primary/10">
|
||||
<Cloud className="h-3 w-3 mr-1" />
|
||||
Translation Services
|
||||
</Badge>
|
||||
<h1 className="text-4xl font-bold text-white mb-2">
|
||||
Translation Providers
|
||||
</h1>
|
||||
<p className="text-lg text-text-secondary">
|
||||
Select and configure your preferred translation service
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Provider Selection */}
|
||||
<Card variant="elevated" className="mb-8 animate-fade-in-up">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-primary/20">
|
||||
<Cloud className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-white">Choose Provider</CardTitle>
|
||||
<CardDescription>
|
||||
Select your default translation service
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{providers.map((provider) => (
|
||||
<Card
|
||||
key={provider.id}
|
||||
variant={selectedProvider === provider.id ? "gradient" : "glass"}
|
||||
className={cn(
|
||||
"cursor-pointer transition-all duration-300 hover:scale-105 group",
|
||||
selectedProvider === provider.id && "ring-2 ring-primary/50"
|
||||
)}
|
||||
onClick={() => setSelectedProvider(provider.id as typeof selectedProvider)}
|
||||
>
|
||||
<CardContent className="p-6 text-center">
|
||||
<div className="text-4xl mb-3 group-hover:scale-110 transition-transform duration-300">
|
||||
{provider.icon}
|
||||
</div>
|
||||
<h3 className="font-semibold text-white mb-2">{provider.name}</h3>
|
||||
<p className="text-sm text-text-tertiary mb-4">{provider.description}</p>
|
||||
{selectedProvider === provider.id && (
|
||||
<Badge className="bg-primary text-white border-0">
|
||||
<Check className="h-3 w-3 mr-1" />
|
||||
Selected
|
||||
</Badge>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Google - No config needed */}
|
||||
{selectedProvider === "google" && (
|
||||
<Card variant="gradient" className="mb-8 animate-fade-in-up border-l-4 border-l-success">
|
||||
<CardContent className="p-8">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-3 rounded-full bg-success/20">
|
||||
<CheckCircle className="h-8 w-8 text-success" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold text-white mb-2">Ready to Use!</h3>
|
||||
<p className="text-text-secondary">
|
||||
Google Translate works out of the box. No configuration needed.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Ollama Settings */}
|
||||
{selectedProvider === "ollama" && (
|
||||
<Card variant="elevated" className="mb-8 animate-fade-in-up">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-orange-500/20">
|
||||
<Server className="h-5 w-5 text-orange-400" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-white">Ollama Configuration</CardTitle>
|
||||
<CardDescription>
|
||||
Connect to your local Ollama server
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
{ollamaTestStatus !== "idle" && ollamaTestStatus !== "testing" && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={
|
||||
ollamaTestStatus === "success"
|
||||
? "border-success/50 text-success bg-success/10"
|
||||
: "border-destructive/50 text-destructive bg-destructive/10"
|
||||
}
|
||||
>
|
||||
{ollamaTestStatus === "success" && <CheckCircle className="h-3 w-3 mr-1" />}
|
||||
{ollamaTestStatus === "error" && <XCircle className="h-3 w-3 mr-1" />}
|
||||
{ollamaTestStatus === "success" ? "Connected" : "Error"}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="space-y-3">
|
||||
<Label htmlFor="ollama-url" className="text-text-secondary font-medium">
|
||||
Server URL
|
||||
</Label>
|
||||
<div className="flex gap-3">
|
||||
<Input
|
||||
id="ollama-url"
|
||||
value={ollamaUrl}
|
||||
onChange={(e) => setOllamaUrl(e.target.value)}
|
||||
placeholder="http://localhost:11434"
|
||||
className="bg-surface border-border-subtle text-white placeholder:text-text-tertiary focus:border-primary focus:ring-primary/20"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleTestOllama}
|
||||
disabled={ollamaTestStatus === "testing"}
|
||||
className="border-border-subtle text-text-secondary hover:bg-surface-hover hover:text-primary group"
|
||||
>
|
||||
{ollamaTestStatus === "testing" ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Wifi className="h-4 w-4 transition-transform duration-200 group-hover:scale-110" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
{ollamaTestMessage && (
|
||||
<div className={cn(
|
||||
"flex items-center gap-2 p-3 rounded-lg",
|
||||
ollamaTestStatus === "success"
|
||||
? "bg-success/10 text-success border border-success/30"
|
||||
: "bg-destructive/10 text-destructive border border-destructive/30"
|
||||
)}>
|
||||
{ollamaTestStatus === "success" ? (
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
) : (
|
||||
<XCircle className="h-4 w-4" />
|
||||
)}
|
||||
<span className="text-sm">{ollamaTestMessage}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="ollama-model" className="text-text-secondary font-medium">
|
||||
Model
|
||||
</Label>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={loadOllamaModels}
|
||||
disabled={loadingOllamaModels}
|
||||
className="text-text-tertiary hover:text-primary h-8 px-3 group"
|
||||
>
|
||||
<RefreshCw className={cn(
|
||||
"h-4 w-4 mr-2 transition-transform duration-200 group-hover:rotate-180",
|
||||
loadingOllamaModels && "animate-spin"
|
||||
)} />
|
||||
Refresh Models
|
||||
</Button>
|
||||
</div>
|
||||
<Select
|
||||
value={ollamaModel}
|
||||
onValueChange={setOllamaModel}
|
||||
>
|
||||
<SelectTrigger className="bg-surface border-border-subtle text-white focus:border-primary focus:ring-primary/20">
|
||||
<SelectValue placeholder="Select a model" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-surface-elevated border-border-subtle">
|
||||
{ollamaModels.length > 0 ? (
|
||||
ollamaModels.map((model) => (
|
||||
<SelectItem
|
||||
key={model.name}
|
||||
value={model.name}
|
||||
className="text-white hover:bg-surface-hover focus:bg-primary/20 focus:text-primary"
|
||||
>
|
||||
{model.name}
|
||||
</SelectItem>
|
||||
))
|
||||
) : (
|
||||
<SelectItem value={ollamaModel} className="text-white">
|
||||
{ollamaModel || "No models found"}
|
||||
</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="p-4 rounded-lg bg-primary/10 border border-primary/30">
|
||||
<p className="text-sm text-primary">
|
||||
<strong>💡 Tip:</strong> Don't have Ollama? Install it from{" "}
|
||||
<a
|
||||
href="https://ollama.ai"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:text-primary/80 underline"
|
||||
>
|
||||
ollama.ai
|
||||
</a>
|
||||
{" "}then run: <code className="bg-surface px-2 py-1 rounded text-primary">ollama pull llama3.2</code>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* OpenAI Settings */}
|
||||
{selectedProvider === "openai" && (
|
||||
<Card variant="elevated" className="mb-8 animate-fade-in-up">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-primary/20">
|
||||
<Zap className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-white">OpenAI Settings</CardTitle>
|
||||
<CardDescription>
|
||||
Configure your OpenAI API for GPT-4 Vision translations
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
{openaiTestStatus !== "idle" && openaiTestStatus !== "testing" && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={
|
||||
openaiTestStatus === "success"
|
||||
? "border-success/50 text-success bg-success/10"
|
||||
: "border-destructive/50 text-destructive bg-destructive/10"
|
||||
}
|
||||
>
|
||||
{openaiTestStatus === "success" && <CheckCircle className="h-3 w-3 mr-1" />}
|
||||
{openaiTestStatus === "error" && <XCircle className="h-3 w-3 mr-1" />}
|
||||
{openaiTestStatus === "success" ? "Connected" : "Error"}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="space-y-3">
|
||||
<Label htmlFor="openai-key" className="text-text-secondary font-medium">
|
||||
API Key
|
||||
</Label>
|
||||
<div className="flex gap-3">
|
||||
<Input
|
||||
id="openai-key"
|
||||
type="password"
|
||||
value={openaiApiKey}
|
||||
onChange={(e) => setOpenaiApiKey(e.target.value)}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
placeholder="sk-..."
|
||||
className="bg-surface border-border-subtle text-white placeholder:text-text-tertiary focus:border-primary focus:ring-primary/20"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleTestOpenAI}
|
||||
disabled={openaiTestStatus === "testing"}
|
||||
className="border-border-subtle text-text-secondary hover:bg-surface-hover hover:text-primary group"
|
||||
>
|
||||
{openaiTestStatus === "testing" ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Wifi className="h-4 w-4 transition-transform duration-200 group-hover:scale-110" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
{openaiTestMessage && (
|
||||
<div className={cn(
|
||||
"flex items-center gap-2 p-3 rounded-lg",
|
||||
openaiTestStatus === "success"
|
||||
? "bg-success/10 text-success border border-success/30"
|
||||
: "bg-destructive/10 text-destructive border border-destructive/30"
|
||||
)}>
|
||||
{openaiTestStatus === "success" ? (
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
) : (
|
||||
<XCircle className="h-4 w-4" />
|
||||
)}
|
||||
<span className="text-sm">{openaiTestMessage}</span>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-sm text-text-tertiary">
|
||||
Get your API key from{" "}
|
||||
<a
|
||||
href="https://platform.openai.com/api-keys"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:text-primary/80 underline"
|
||||
>
|
||||
platform.openai.com
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<Label htmlFor="openai-model" className="text-text-secondary font-medium">
|
||||
Model
|
||||
</Label>
|
||||
<Select
|
||||
value={openaiModel}
|
||||
onValueChange={setOpenaiModel}
|
||||
>
|
||||
<SelectTrigger className="bg-surface border-border-subtle text-white focus:border-primary focus:ring-primary/20">
|
||||
<SelectValue placeholder="Select a model" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-surface-elevated border-border-subtle">
|
||||
{openaiModels.map((model) => (
|
||||
<SelectItem
|
||||
key={model.id}
|
||||
value={model.id}
|
||||
className="text-white hover:bg-surface-hover focus:bg-primary/20 focus:text-primary"
|
||||
>
|
||||
<span className="flex items-center justify-between gap-4">
|
||||
<span>{model.name}</span>
|
||||
{model.vision && (
|
||||
<Badge variant="outline" className="border-primary/50 text-primary bg-primary/10 text-xs">
|
||||
Vision
|
||||
</Badge>
|
||||
)}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="p-4 rounded-lg bg-accent/10 border border-accent/30">
|
||||
<p className="text-sm text-accent">
|
||||
<strong>💡 Vision Models:</strong> Models with Vision can translate text in images
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* OpenRouter Settings */}
|
||||
{selectedProvider === "openrouter" && (
|
||||
<Card variant="gradient" className="mb-8 animate-fade-in-up">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-accent/20">
|
||||
<Zap className="h-5 w-5 text-accent" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-white">OpenRouter Settings</CardTitle>
|
||||
<CardDescription>
|
||||
Access DeepSeek, Mistral, Llama & more - Best value for translation
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
{openrouterTestStatus !== "idle" && openrouterTestStatus !== "testing" && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={
|
||||
openrouterTestStatus === "success"
|
||||
? "border-success/50 text-success bg-success/10"
|
||||
: "border-destructive/50 text-destructive bg-destructive/10"
|
||||
}
|
||||
>
|
||||
{openrouterTestStatus === "success" && <CheckCircle className="h-3 w-3 mr-1" />}
|
||||
{openrouterTestStatus === "error" && <XCircle className="h-3 w-3 mr-1" />}
|
||||
{openrouterTestStatus === "success" ? "Connected" : "Error"}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="space-y-3">
|
||||
<Label htmlFor="openrouter-key" className="text-text-secondary font-medium">
|
||||
API Key
|
||||
</Label>
|
||||
<div className="flex gap-3">
|
||||
<Input
|
||||
id="openrouter-key"
|
||||
type="password"
|
||||
value={openrouterApiKey}
|
||||
onChange={(e) => setOpenrouterApiKey(e.target.value)}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
placeholder="sk-or-..."
|
||||
className="bg-surface border-border-subtle text-white placeholder:text-text-tertiary focus:border-primary focus:ring-primary/20"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={testOpenRouterConnection}
|
||||
disabled={openrouterTestStatus === "testing"}
|
||||
className="border-border-subtle text-text-secondary hover:bg-surface-hover hover:text-accent group"
|
||||
>
|
||||
{openrouterTestStatus === "testing" ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Wifi className="h-4 w-4 transition-transform duration-200 group-hover:scale-110" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
{openrouterTestMessage && (
|
||||
<div className={cn(
|
||||
"flex items-center gap-2 p-3 rounded-lg",
|
||||
openrouterTestStatus === "success"
|
||||
? "bg-success/10 text-success border border-success/30"
|
||||
: "bg-destructive/10 text-destructive border border-destructive/30"
|
||||
)}>
|
||||
{openrouterTestStatus === "success" ? (
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
) : (
|
||||
<XCircle className="h-4 w-4" />
|
||||
)}
|
||||
<span className="text-sm">{openrouterTestMessage}</span>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-sm text-text-tertiary">
|
||||
Get your free API key from{" "}
|
||||
<a
|
||||
href="https://openrouter.ai/keys"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-accent hover:text-accent/80 underline"
|
||||
>
|
||||
openrouter.ai/keys
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<Label htmlFor="openrouter-model" className="text-text-secondary font-medium">
|
||||
Model
|
||||
</Label>
|
||||
<Select
|
||||
value={openrouterModel}
|
||||
onValueChange={setOpenrouterModel}
|
||||
>
|
||||
<SelectTrigger className="bg-surface border-border-subtle text-white focus:border-accent focus:ring-accent/20">
|
||||
<SelectValue placeholder="Select a model" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-surface-elevated border-border-subtle">
|
||||
{openrouterModels.map((model) => (
|
||||
<SelectItem
|
||||
key={model.id}
|
||||
value={model.id}
|
||||
className="text-white hover:bg-surface-hover focus:bg-accent/20 focus:text-accent"
|
||||
>
|
||||
<span className="flex items-center justify-between gap-4">
|
||||
<span>{model.name}</span>
|
||||
<Badge variant="outline" className="border-accent/50 text-accent bg-accent/10 text-xs">
|
||||
{model.description.split(' - ')[1]}
|
||||
</Badge>
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="p-4 rounded-lg bg-gradient-to-r from-accent/20 to-primary/20 border border-accent/30">
|
||||
<p className="text-sm text-white">
|
||||
<strong>💡 Recommended:</strong> DeepSeek Chat at $0.14/M tokens translates 200 pages for ~$0.50
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* WebLLM Settings */}
|
||||
{selectedProvider === "webllm" && (
|
||||
<Card variant="elevated" className="mb-8 animate-fade-in-up">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-success/20">
|
||||
<Cpu className="h-5 w-5 text-success" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-white">WebLLM Settings</CardTitle>
|
||||
<CardDescription>
|
||||
Run AI models directly in your browser using WebGPU - no server required!
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* WebGPU Support Check */}
|
||||
{!webllm.isWebGPUSupported() && (
|
||||
<div className="p-4 rounded-lg bg-destructive/10 border border-destructive/30">
|
||||
<div className="flex items-center gap-3">
|
||||
<AlertCircle className="h-5 w-5 text-destructive" />
|
||||
<p className="text-destructive text-sm">
|
||||
WebGPU is not supported in this browser. Please use Chrome 113+, Edge 113+, or another WebGPU-compatible browser.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label htmlFor="webllm-model" className="text-text-secondary font-medium">
|
||||
Model
|
||||
</Label>
|
||||
<Select value={webllmModel} onValueChange={setWebllmModel}>
|
||||
<SelectTrigger className="bg-surface border-border-subtle text-white focus:border-success focus:ring-success/20">
|
||||
<SelectValue placeholder="Select a model" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-surface-elevated border-border-subtle">
|
||||
{webllmModels.map((model) => (
|
||||
<SelectItem
|
||||
key={model.id}
|
||||
value={model.id}
|
||||
className="text-white hover:bg-surface-hover focus:bg-success/20 focus:text-success"
|
||||
>
|
||||
<span className="flex items-center justify-between gap-4">
|
||||
<span>{model.name}</span>
|
||||
<Badge variant="outline" className="border-success/50 text-success bg-success/10 text-xs">
|
||||
{model.size}
|
||||
</Badge>
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Model Loading Status */}
|
||||
{webllm.isLoading && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-text-secondary">{webllm.loadStatus}</span>
|
||||
<span className="text-success">{webllm.loadProgress}%</span>
|
||||
</div>
|
||||
<Progress value={webllm.loadProgress} className="h-2" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{webllm.isLoaded && (
|
||||
<div className="p-4 rounded-lg bg-success/10 border border-success/30">
|
||||
<div className="flex items-center gap-3">
|
||||
<CheckCircle className="h-5 w-5 text-success" />
|
||||
<p className="text-success text-sm">
|
||||
Model loaded: {webllm.currentModel}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{webllm.error && (
|
||||
<div className="p-4 rounded-lg bg-destructive/10 border border-destructive/30">
|
||||
<div className="flex items-center gap-3">
|
||||
<XCircle className="h-5 w-5 text-destructive" />
|
||||
<p className="text-destructive text-sm">{webllm.error}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
onClick={() => webllm.loadModel(webllmModel)}
|
||||
disabled={webllm.isLoading || !webllm.isWebGPUSupported()}
|
||||
className="bg-success hover:bg-success/90 text-white flex-1 group"
|
||||
>
|
||||
{webllm.isLoading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Loading...
|
||||
</>
|
||||
) : webllm.isLoaded && webllm.currentModel === webllmModel ? (
|
||||
<>
|
||||
<CheckCircle className="mr-2 h-4 w-4" />
|
||||
Loaded
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download className="mr-2 h-4 w-4 transition-transform duration-200 group-hover:scale-110" />
|
||||
Load Model
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => webllm.clearCache()}
|
||||
variant="outline"
|
||||
className="border-destructive/50 text-destructive hover:bg-destructive/10 group"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4 transition-transform duration-200 group-hover:scale-110" />
|
||||
Clear Cache
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="p-4 rounded-lg bg-success/10 border border-success/30">
|
||||
<p className="text-sm text-success">
|
||||
<strong>💡 Tip:</strong> Models are downloaded once and cached in your browser (~1-5GB depending on model). Loading may take a minute on first use.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Image Translation - Only for Ollama and OpenAI */}
|
||||
{(selectedProvider === "ollama" || selectedProvider === "openai") && (
|
||||
<Card variant="elevated" className="mb-8 animate-fade-in-up">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-accent/20">
|
||||
<Shield className="h-5 w-5 text-accent" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-white">Advanced Options</CardTitle>
|
||||
<CardDescription>
|
||||
Additional translation features
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between rounded-lg border border-border-subtle p-6 bg-surface/50">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<Label className="text-text-secondary font-medium">Translate Images by Default</Label>
|
||||
<Badge variant="outline" className="border-primary/50 text-primary bg-primary/10 text-xs">
|
||||
Vision Models
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-text-tertiary">
|
||||
Extract and translate text from embedded images using vision models
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={translateImages}
|
||||
onCheckedChange={setTranslateImages}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Save Button */}
|
||||
<div className="flex justify-end animate-fade-in-up">
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
size="lg"
|
||||
className="bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90 text-white group"
|
||||
>
|
||||
{isSaving ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="mr-2 h-4 w-4 transition-transform duration-200 group-hover:scale-110" />
|
||||
Save Settings
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
649
frontend/src/components/file-uploader.tsx
Normal file
649
frontend/src/components/file-uploader.tsx
Normal file
@@ -0,0 +1,649 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useEffect, useRef } from "react";
|
||||
import { useDropzone } from "react-dropzone";
|
||||
import {
|
||||
Upload,
|
||||
FileText,
|
||||
FileSpreadsheet,
|
||||
Presentation,
|
||||
X,
|
||||
Download,
|
||||
Loader2,
|
||||
Cpu,
|
||||
AlertTriangle,
|
||||
Brain,
|
||||
CheckCircle,
|
||||
File,
|
||||
Zap,
|
||||
Shield,
|
||||
Eye,
|
||||
Trash2,
|
||||
Copy,
|
||||
ExternalLink
|
||||
} from "lucide-react";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useTranslationStore, openaiModels, openrouterModels } from "@/lib/store";
|
||||
import { translateDocument, languages, providers, extractTextsFromDocument, reconstructDocument, TranslatedText } from "@/lib/api";
|
||||
import { useWebLLM } from "@/lib/webllm";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const fileIcons: Record<string, React.ElementType> = {
|
||||
xlsx: FileSpreadsheet,
|
||||
xls: FileSpreadsheet,
|
||||
docx: FileText,
|
||||
doc: FileText,
|
||||
pptx: Presentation,
|
||||
ppt: Presentation,
|
||||
};
|
||||
|
||||
type ProviderType = "google" | "ollama" | "deepl" | "libre" | "webllm" | "openai" | "openrouter";
|
||||
|
||||
interface FilePreviewProps {
|
||||
file: File;
|
||||
onRemove: () => void;
|
||||
}
|
||||
|
||||
const FilePreview = ({ file, onRemove }: FilePreviewProps) => {
|
||||
const [preview, setPreview] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const generatePreview = async () => {
|
||||
if (!file) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
if (file.type.startsWith('image/')) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
setPreview(e.target?.result as string);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
} else if (file.type === 'application/pdf') {
|
||||
setPreview('/pdf-preview.png'); // Placeholder
|
||||
} else {
|
||||
// Generate text preview for documents
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const text = e.target?.result as string;
|
||||
setPreview(text.substring(0, 200) + (text.length > 200 ? '...' : ''));
|
||||
};
|
||||
reader.readAsText(file);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Preview generation failed:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
generatePreview();
|
||||
}, [file]);
|
||||
|
||||
const getFileExtension = (filename: string) => {
|
||||
return filename.split(".").pop()?.toLowerCase() || "";
|
||||
};
|
||||
|
||||
const formatFileSize = (bytes: number) => {
|
||||
if (bytes < 1024) return bytes + " B";
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB";
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + " MB";
|
||||
};
|
||||
|
||||
const FileIcon = fileIcons[getFileExtension(file.name)] || FileText;
|
||||
|
||||
return (
|
||||
<Card variant="elevated" className="overflow-hidden group">
|
||||
<CardContent className="p-0">
|
||||
{/* File Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-border-subtle bg-surface/50">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||
<FileIcon className="w-6 h-6 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-foreground truncate max-w-xs">
|
||||
{file.name}
|
||||
</p>
|
||||
<p className="text-sm text-text-tertiary">
|
||||
{formatFileSize(file.size)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" size="sm">
|
||||
{getFileExtension(file.name).toUpperCase()}
|
||||
</Badge>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={onRemove}
|
||||
className="text-text-tertiary hover:text-destructive"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* File Preview */}
|
||||
<div className="relative h-48 bg-surface/30">
|
||||
{loading ? (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
</div>
|
||||
) : preview ? (
|
||||
<div className="p-4 h-full overflow-hidden">
|
||||
{file.type.startsWith('image/') ? (
|
||||
<img
|
||||
src={preview}
|
||||
alt="Preview"
|
||||
className="w-full h-full object-contain rounded"
|
||||
/>
|
||||
) : (
|
||||
<div className="text-sm text-text-secondary font-mono whitespace-pre-wrap break-all">
|
||||
{preview}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<File className="h-12 w-12 text-border" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* File Actions */}
|
||||
<div className="flex items-center justify-between p-4 border-t border-border-subtle">
|
||||
<div className="flex items-center gap-2 text-sm text-text-tertiary">
|
||||
<Eye className="h-4 w-4" />
|
||||
Preview
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" size="icon-sm">
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon-sm">
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export function FileUploader() {
|
||||
const { settings, isTranslating, progress, setTranslating, setProgress } = useTranslationStore();
|
||||
const webllm = useWebLLM();
|
||||
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [targetLanguage, setTargetLanguage] = useState(settings.defaultTargetLanguage);
|
||||
const [provider, setProvider] = useState<ProviderType>(settings.defaultProvider);
|
||||
const [translateImages, setTranslateImages] = useState(settings.translateImages);
|
||||
const [downloadUrl, setDownloadUrl] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [translationStatus, setTranslationStatus] = useState<string>("");
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Sync with store settings when they change
|
||||
useEffect(() => {
|
||||
setTargetLanguage(settings.defaultTargetLanguage);
|
||||
setProvider(settings.defaultProvider);
|
||||
setTranslateImages(settings.translateImages);
|
||||
}, [settings.defaultTargetLanguage, settings.defaultProvider, settings.translateImages]);
|
||||
|
||||
const onDrop = useCallback((acceptedFiles: File[]) => {
|
||||
if (acceptedFiles.length > 0) {
|
||||
setFile(acceptedFiles[0]);
|
||||
setDownloadUrl(null);
|
||||
setError(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
onDrop,
|
||||
accept: {
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": [".xlsx"],
|
||||
"application/vnd.ms-excel": [".xls"],
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": [".docx"],
|
||||
"application/msword": [".doc"],
|
||||
"application/vnd.openxmlformats-officedocument.presentationml.presentation": [".pptx"],
|
||||
"application/vnd.ms-powerpoint": [".ppt"],
|
||||
},
|
||||
multiple: false,
|
||||
});
|
||||
|
||||
const handleTranslate = async () => {
|
||||
if (!file) return;
|
||||
|
||||
// Validate provider-specific requirements
|
||||
if (provider === "openai" && !settings.openaiApiKey) {
|
||||
setError("OpenAI API key not configured. Go to Settings > Translation Services to add your API key.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (provider === "deepl" && !settings.deeplApiKey) {
|
||||
setError("DeepL API key not configured. Go to Settings > Translation Services to add your API key.");
|
||||
return;
|
||||
}
|
||||
|
||||
// WebLLM specific validation
|
||||
if (provider === "webllm") {
|
||||
if (!webllm.isWebGPUSupported()) {
|
||||
setError("WebGPU is not supported in this browser. Please use Chrome 113+ or Edge 113+.");
|
||||
return;
|
||||
}
|
||||
if (!webllm.isLoaded) {
|
||||
setError("WebLLM model not loaded. Go to Settings > Translation Services to load a model first.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setTranslating(true);
|
||||
setProgress(0);
|
||||
setError(null);
|
||||
setDownloadUrl(null);
|
||||
setTranslationStatus("");
|
||||
|
||||
try {
|
||||
// For WebLLM, use client-side translation
|
||||
if (provider === "webllm") {
|
||||
await handleWebLLMTranslation();
|
||||
} else {
|
||||
await handleServerTranslation();
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Translation failed");
|
||||
} finally {
|
||||
setTranslating(false);
|
||||
setTranslationStatus("");
|
||||
}
|
||||
};
|
||||
|
||||
// Get language name from code
|
||||
const getLanguageName = (code: string): string => {
|
||||
const lang = languages.find(l => l.code === code);
|
||||
return lang ? lang.name : code;
|
||||
};
|
||||
|
||||
// WebLLM client-side translation
|
||||
const handleWebLLMTranslation = async () => {
|
||||
if (!file) return;
|
||||
|
||||
try {
|
||||
// Step 1: Extract texts from document
|
||||
setTranslationStatus("Extracting texts from document...");
|
||||
setProgress(5);
|
||||
const extractResult = await extractTextsFromDocument(file);
|
||||
|
||||
if (extractResult.texts.length === 0) {
|
||||
throw new Error("No translatable text found in document");
|
||||
}
|
||||
|
||||
setTranslationStatus(`Found ${extractResult.texts.length} texts to translate`);
|
||||
setProgress(10);
|
||||
|
||||
// Step 2: Translate each text using WebLLM
|
||||
const translations: TranslatedText[] = [];
|
||||
const totalTexts = extractResult.texts.length;
|
||||
const langName = getLanguageName(targetLanguage);
|
||||
|
||||
for (let i = 0; i < totalTexts; i++) {
|
||||
const item = extractResult.texts[i];
|
||||
setTranslationStatus(`Translating ${i + 1}/${totalTexts}: "${item.text.substring(0, 30)}..."`);
|
||||
|
||||
const translatedText = await webllm.translate(
|
||||
item.text,
|
||||
langName,
|
||||
settings.systemPrompt || undefined,
|
||||
settings.glossary || undefined
|
||||
);
|
||||
|
||||
translations.push({
|
||||
id: item.id,
|
||||
translated_text: translatedText,
|
||||
});
|
||||
|
||||
// Update progress (10% for extraction, 80% for translation, 10% for reconstruction)
|
||||
const translationProgress = 10 + (80 * (i + 1)) / totalTexts;
|
||||
setProgress(translationProgress);
|
||||
}
|
||||
|
||||
// Step 3: Reconstruct document with translations
|
||||
setTranslationStatus("Reconstructing document...");
|
||||
setProgress(92);
|
||||
const blob = await reconstructDocument(
|
||||
extractResult.session_id,
|
||||
translations,
|
||||
targetLanguage
|
||||
);
|
||||
|
||||
setProgress(100);
|
||||
setTranslationStatus("Translation complete!");
|
||||
|
||||
const url = URL.createObjectURL(blob);
|
||||
setDownloadUrl(url);
|
||||
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
// Server-side translation (existing logic)
|
||||
const handleServerTranslation = async () => {
|
||||
if (!file) return;
|
||||
|
||||
// Simulate progress for UX
|
||||
let currentProgress = 0;
|
||||
const progressInterval = setInterval(() => {
|
||||
currentProgress = Math.min(currentProgress + Math.random() * 10, 90);
|
||||
setProgress(currentProgress);
|
||||
}, 500);
|
||||
|
||||
try {
|
||||
const blob = await translateDocument({
|
||||
file,
|
||||
targetLanguage,
|
||||
provider,
|
||||
ollamaModel: settings.ollamaModel,
|
||||
translateImages: translateImages || settings.translateImages,
|
||||
systemPrompt: settings.systemPrompt,
|
||||
glossary: settings.glossary,
|
||||
libreUrl: settings.libreTranslateUrl,
|
||||
openaiApiKey: settings.openaiApiKey,
|
||||
openaiModel: settings.openaiModel,
|
||||
openrouterApiKey: settings.openrouterApiKey,
|
||||
openrouterModel: settings.openrouterModel,
|
||||
});
|
||||
|
||||
clearInterval(progressInterval);
|
||||
setProgress(100);
|
||||
|
||||
const url = URL.createObjectURL(blob);
|
||||
setDownloadUrl(url);
|
||||
} catch (err) {
|
||||
clearInterval(progressInterval);
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = () => {
|
||||
if (!downloadUrl || !file) return;
|
||||
|
||||
const a = document.createElement("a");
|
||||
a.href = downloadUrl;
|
||||
const ext = getFileExtension(file.name);
|
||||
const baseName = file.name.replace(`.${ext}`, "");
|
||||
a.download = `${baseName}_translated.${ext}`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
};
|
||||
|
||||
const getFileExtension = (filename: string) => {
|
||||
return filename.split(".").pop()?.toLowerCase() || "";
|
||||
};
|
||||
|
||||
const removeFile = () => {
|
||||
setFile(null);
|
||||
setDownloadUrl(null);
|
||||
setError(null);
|
||||
setProgress(0);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const FileIcon = file ? fileIcons[getFileExtension(file.name)] : FileText;
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Enhanced File Drop Zone */}
|
||||
<Card variant="elevated" className="overflow-hidden">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-3">
|
||||
<Upload className="h-5 w-5 text-primary" />
|
||||
Upload Document
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Drag and drop or click to select a file (Excel, Word, PowerPoint)
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="p-6">
|
||||
{!file ? (
|
||||
<div
|
||||
{...getRootProps()}
|
||||
className={cn(
|
||||
"relative border-2 border-dashed rounded-xl p-12 text-center cursor-pointer transition-all duration-300",
|
||||
isDragActive
|
||||
? "border-primary bg-primary/5 scale-[1.02]"
|
||||
: "border-border-subtle hover:border-border hover:bg-surface/50"
|
||||
)}
|
||||
>
|
||||
<input {...getInputProps()} ref={fileInputRef} className="hidden" />
|
||||
|
||||
{/* Upload Icon with animation */}
|
||||
<div className={cn(
|
||||
"w-16 h-16 mx-auto mb-4 rounded-2xl bg-primary/10 flex items-center justify-center transition-all duration-300",
|
||||
isDragActive ? "scale-110 bg-primary/20" : ""
|
||||
)}>
|
||||
<Upload className={cn(
|
||||
"w-8 h-8 text-primary transition-transform duration-300",
|
||||
isDragActive ? "scale-110" : ""
|
||||
)} />
|
||||
</div>
|
||||
|
||||
<p className="text-lg font-medium text-foreground mb-2">
|
||||
{isDragActive
|
||||
? "Drop your file here..."
|
||||
: "Drag & drop your document here"}
|
||||
</p>
|
||||
<p className="text-sm text-text-tertiary mb-6">
|
||||
or click to browse
|
||||
</p>
|
||||
|
||||
{/* Supported formats */}
|
||||
<div className="flex flex-wrap justify-center gap-3">
|
||||
{[
|
||||
{ ext: "xlsx", name: "Excel", icon: FileSpreadsheet, color: "text-green-400" },
|
||||
{ ext: "docx", name: "Word", icon: FileText, color: "text-blue-400" },
|
||||
{ ext: "pptx", name: "PowerPoint", icon: Presentation, color: "text-orange-400" },
|
||||
].map((format) => (
|
||||
<div key={format.ext} className="flex items-center gap-2 px-3 py-2 rounded-lg bg-surface border border-border-subtle">
|
||||
<format.icon className={cn("w-4 h-4", format.color)} />
|
||||
<span className="text-sm text-text-secondary">{format.name}</span>
|
||||
<span className="text-xs text-text-tertiary">.{format.ext}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<FilePreview file={file} onRemove={removeFile} />
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Enhanced Translation Options */}
|
||||
{file && (
|
||||
<Card variant="elevated">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-3">
|
||||
<Brain className="h-5 w-5 text-primary" />
|
||||
Translation Options
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Configure your translation settings
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* Target Language */}
|
||||
<div className="space-y-3">
|
||||
<Label htmlFor="language" className="text-text-secondary font-medium">Target Language</Label>
|
||||
<Select value={targetLanguage} onValueChange={setTargetLanguage}>
|
||||
<SelectTrigger id="language" className="bg-surface border-border-subtle">
|
||||
<SelectValue placeholder="Select language" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-surface-elevated border-border max-h-80">
|
||||
{languages.map((lang) => (
|
||||
<SelectItem
|
||||
key={lang.code}
|
||||
value={lang.code}
|
||||
className="text-foreground hover:bg-surface hover:text-primary"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<span>{lang.flag}</span>
|
||||
<span>{lang.name}</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Provider Selection */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-text-secondary font-medium">Translation Provider</Label>
|
||||
<Select value={provider} onValueChange={(value: ProviderType) => setProvider(value)}>
|
||||
<SelectTrigger className="bg-surface border-border-subtle">
|
||||
<SelectValue placeholder="Select provider" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-surface-elevated border-border">
|
||||
{providers.map((p) => (
|
||||
<SelectItem
|
||||
key={p.id}
|
||||
value={p.id}
|
||||
className="text-foreground hover:bg-surface hover:text-primary"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<span>{p.icon}</span>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{p.name}</span>
|
||||
<span className="text-xs text-text-tertiary">{p.description}</span>
|
||||
</div>
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Advanced Options Toggle */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||
className="w-full justify-between text-primary hover:text-primary/80"
|
||||
>
|
||||
<span>Advanced Options</span>
|
||||
<ChevronRight className={cn(
|
||||
"h-4 w-4 transition-transform duration-200",
|
||||
showAdvanced && "rotate-90"
|
||||
)} />
|
||||
</Button>
|
||||
|
||||
{/* Advanced Options */}
|
||||
{showAdvanced && (
|
||||
<div className="space-y-4 p-4 rounded-lg bg-surface/50 border border-border-subtle animate-slide-up">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="translate-images" className="text-text-secondary">Translate Images</Label>
|
||||
<Switch
|
||||
id="translate-images"
|
||||
checked={translateImages}
|
||||
onCheckedChange={setTranslateImages}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Translate Button */}
|
||||
<Button
|
||||
onClick={handleTranslate}
|
||||
disabled={isTranslating}
|
||||
variant="premium"
|
||||
size="lg"
|
||||
className="w-full h-12 text-lg group"
|
||||
>
|
||||
{isTranslating ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
|
||||
Translating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Zap className="mr-2 h-5 w-5 transition-transform group-hover:scale-110" />
|
||||
Translate Document
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* Progress Bar */}
|
||||
{isTranslating && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-text-secondary">
|
||||
{translationStatus || "Processing..."}
|
||||
</span>
|
||||
<span className="text-primary font-medium">{Math.round(progress)}%</span>
|
||||
</div>
|
||||
<Progress value={progress} className="h-2" />
|
||||
{provider === "webllm" && (
|
||||
<div className="flex items-center gap-2 text-xs text-text-tertiary p-3 rounded-lg bg-primary/5">
|
||||
<Cpu className="h-3 w-3" />
|
||||
Translating locally with WebLLM...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error Display */}
|
||||
{error && (
|
||||
<div className="rounded-lg bg-destructive/10 border border-destructive/30 p-4 animate-slide-up">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="h-5 w-5 text-destructive flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-destructive mb-1">Translation Error</p>
|
||||
<p className="text-sm text-destructive/80">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Enhanced Download Section */}
|
||||
{downloadUrl && (
|
||||
<Card variant="gradient" className="overflow-hidden animate-slide-up">
|
||||
<CardContent className="p-8 text-center">
|
||||
<div className="w-16 h-16 mx-auto mb-4 rounded-2xl bg-white/20 flex items-center justify-center animate-pulse">
|
||||
<CheckCircle className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<CardTitle className="text-2xl mb-2">Translation Complete!</CardTitle>
|
||||
<CardDescription className="mb-6">
|
||||
Your document has been translated successfully while preserving all formatting.
|
||||
</CardDescription>
|
||||
<Button
|
||||
onClick={handleDownload}
|
||||
variant="glass"
|
||||
size="lg"
|
||||
className="group px-8"
|
||||
>
|
||||
<Download className="mr-2 h-5 w-5 transition-transform group-hover:scale-110" />
|
||||
Download Translated Document
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
594
frontend/src/components/landing-sections.tsx
Normal file
594
frontend/src/components/landing-sections.tsx
Normal file
@@ -0,0 +1,594 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
ArrowRight,
|
||||
Check,
|
||||
FileText,
|
||||
Globe2,
|
||||
Zap,
|
||||
Shield,
|
||||
Server,
|
||||
Sparkles,
|
||||
FileSpreadsheet,
|
||||
Presentation,
|
||||
Star,
|
||||
TrendingUp,
|
||||
Users,
|
||||
Clock,
|
||||
Award,
|
||||
ChevronRight,
|
||||
Play,
|
||||
BarChart3,
|
||||
Brain,
|
||||
Lock,
|
||||
Zap as ZapIcon
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardFeature } from "@/components/ui/card";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface User {
|
||||
name: string;
|
||||
plan: string;
|
||||
}
|
||||
|
||||
export function LandingHero() {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [isLoaded, setIsLoaded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const storedUser = localStorage.getItem("user");
|
||||
if (storedUser) {
|
||||
try {
|
||||
setUser(JSON.parse(storedUser));
|
||||
} catch {
|
||||
setUser(null);
|
||||
}
|
||||
}
|
||||
// Trigger animation after mount
|
||||
setTimeout(() => setIsLoaded(true), 100);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="relative overflow-hidden">
|
||||
{/* Enhanced Background with animated gradient */}
|
||||
<div className="absolute inset-0">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-primary/5 via-transparent to-accent/5" />
|
||||
<div className="absolute inset-0 bg-[url('/grid.svg')] opacity-5" />
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-background via-background/90 to-background/50" />
|
||||
</div>
|
||||
|
||||
{/* Animated floating elements */}
|
||||
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
||||
<div className={cn(
|
||||
"absolute top-20 left-10 w-20 h-20 bg-primary/10 rounded-full blur-xl animate-pulse",
|
||||
isLoaded && "animate-float"
|
||||
)} />
|
||||
<div className={cn(
|
||||
"absolute top-40 right-20 w-32 h-32 bg-accent/10 rounded-full blur-2xl animate-pulse animation-delay-2000",
|
||||
isLoaded && "animate-float-delayed"
|
||||
)} />
|
||||
<div className={cn(
|
||||
"absolute bottom-20 left-1/4 w-16 h-16 bg-success/10 rounded-full blur-lg animate-pulse animation-delay-4000",
|
||||
isLoaded && "animate-float-slow"
|
||||
)} />
|
||||
</div>
|
||||
|
||||
{/* Hero content */}
|
||||
<div className={cn(
|
||||
"relative px-4 py-24 sm:py-32 transition-all duration-1000 ease-out",
|
||||
isLoaded ? "opacity-100 translate-y-0" : "opacity-0 translate-y-4"
|
||||
)}>
|
||||
<div className="text-center max-w-5xl mx-auto">
|
||||
{/* Premium Badge */}
|
||||
<Badge
|
||||
variant="premium"
|
||||
size="lg"
|
||||
className="mb-8 animate-slide-up animation-delay-200"
|
||||
>
|
||||
<Sparkles className="w-4 h-4 mr-2" />
|
||||
AI-Powered Document Translation
|
||||
</Badge>
|
||||
|
||||
{/* Enhanced Headline */}
|
||||
<h1 className="text-display text-4xl sm:text-6xl lg:text-7xl font-bold text-white mb-6 leading-tight">
|
||||
<span className={cn(
|
||||
"block bg-gradient-to-r from-primary via-accent to-primary bg-clip-text text-transparent bg-size-200 animate-gradient",
|
||||
isLoaded && "animate-gradient-shift"
|
||||
)}>
|
||||
Translate Documents
|
||||
</span>
|
||||
<span className="block text-3xl sm:text-4xl lg:text-5xl mt-2">
|
||||
<span className="relative">
|
||||
Instantly
|
||||
<span className="absolute -bottom-2 left-0 right-0 h-1 bg-gradient-to-r from-primary to-accent rounded-full animate-underline-expand" />
|
||||
</span>
|
||||
</span>
|
||||
</h1>
|
||||
|
||||
{/* Enhanced Description */}
|
||||
<p className={cn(
|
||||
"text-xl text-text-secondary mb-12 max-w-3xl mx-auto leading-relaxed",
|
||||
isLoaded && "animate-slide-up animation-delay-400"
|
||||
)}>
|
||||
Upload Word, Excel, and PowerPoint files. Get perfect translations while preserving
|
||||
all formatting, styles, and layouts. Powered by advanced AI technology.
|
||||
</p>
|
||||
|
||||
{/* Enhanced CTA Buttons */}
|
||||
<div className={cn(
|
||||
"flex flex-col sm:flex-row gap-6 justify-center mb-16",
|
||||
isLoaded && "animate-slide-up animation-delay-600"
|
||||
)}>
|
||||
{user ? (
|
||||
<Link href="#upload">
|
||||
<Button size="lg" variant="premium" className="group px-8 py-4 text-lg">
|
||||
Start Translating
|
||||
<ArrowRight className="ml-2 h-5 w-5 transition-transform group-hover:translate-x-1" />
|
||||
</Button>
|
||||
</Link>
|
||||
) : (
|
||||
<>
|
||||
<Link href="/auth/register">
|
||||
<Button size="lg" variant="premium" className="group px-8 py-4 text-lg">
|
||||
Get Started Free
|
||||
<ArrowRight className="ml-2 h-5 w-5 transition-transform group-hover:translate-x-1" />
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/pricing">
|
||||
<Button
|
||||
size="lg"
|
||||
variant="glass"
|
||||
className="px-8 py-4 text-lg border-2 hover:border-primary/50"
|
||||
>
|
||||
View Pricing
|
||||
</Button>
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Enhanced Supported formats */}
|
||||
<div className={cn(
|
||||
"flex flex-wrap justify-center gap-4 mb-16",
|
||||
isLoaded && "animate-slide-up animation-delay-800"
|
||||
)}>
|
||||
{[
|
||||
{ icon: FileText, name: "Word", ext: ".docx", color: "text-blue-400" },
|
||||
{ icon: FileSpreadsheet, name: "Excel", ext: ".xlsx", color: "text-green-400" },
|
||||
{ icon: Presentation, name: "PowerPoint", ext: ".pptx", color: "text-orange-400" },
|
||||
].map((format, idx) => (
|
||||
<Card
|
||||
key={format.name}
|
||||
variant="glass"
|
||||
className="group px-6 py-4 hover:scale-105 transition-all duration-300"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={cn(
|
||||
"w-12 h-12 rounded-xl flex items-center justify-center transition-all duration-300",
|
||||
format.color
|
||||
)}>
|
||||
<format.icon className="w-6 h-6" />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<div className="font-semibold text-white">{format.name}</div>
|
||||
<div className="text-sm text-text-tertiary">{format.ext}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Trust Indicators */}
|
||||
<div className={cn(
|
||||
"flex flex-wrap justify-center gap-8 text-sm text-text-tertiary",
|
||||
isLoaded && "animate-slide-up animation-delay-1000"
|
||||
)}>
|
||||
{[
|
||||
{ icon: Users, text: "10,000+ Users" },
|
||||
{ icon: Star, text: "4.9/5 Rating" },
|
||||
{ icon: Shield, text: "Bank-level Security" },
|
||||
{ icon: ZapIcon, text: "Lightning Fast" },
|
||||
].map((indicator, idx) => (
|
||||
<div key={indicator.text} className="flex items-center gap-2">
|
||||
<indicator.icon className="w-4 h-4 text-primary" />
|
||||
<span>{indicator.text}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FeaturesSection() {
|
||||
const [ref, setRef] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting) {
|
||||
setRef(true);
|
||||
}
|
||||
},
|
||||
{ threshold: 0.1 }
|
||||
);
|
||||
|
||||
const element = document.getElementById('features-section');
|
||||
if (element) {
|
||||
observer.observe(element);
|
||||
}
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
const features = [
|
||||
{
|
||||
icon: Globe2,
|
||||
title: "100+ Languages",
|
||||
description: "Translate between any language pair with high accuracy using advanced AI models",
|
||||
color: "text-blue-400",
|
||||
stats: "100+",
|
||||
},
|
||||
{
|
||||
icon: FileText,
|
||||
title: "Preserve Formatting",
|
||||
description: "All styles, fonts, colors, tables, and charts remain intact",
|
||||
color: "text-green-400",
|
||||
stats: "100%",
|
||||
},
|
||||
{
|
||||
icon: Zap,
|
||||
title: "Lightning Fast",
|
||||
description: "Batch processing translates entire documents in seconds",
|
||||
color: "text-amber-400",
|
||||
stats: "2s",
|
||||
},
|
||||
{
|
||||
icon: Shield,
|
||||
title: "Secure & Private",
|
||||
description: "Your documents are encrypted and never stored permanently",
|
||||
color: "text-purple-400",
|
||||
stats: "AES-256",
|
||||
},
|
||||
{
|
||||
icon: Brain,
|
||||
title: "AI-Powered",
|
||||
description: "Advanced neural translation for natural, context-aware results",
|
||||
color: "text-teal-400",
|
||||
stats: "GPT-4",
|
||||
},
|
||||
{
|
||||
icon: Server,
|
||||
title: "Enterprise Ready",
|
||||
description: "API access, team management, and dedicated support for businesses",
|
||||
color: "text-orange-400",
|
||||
stats: "99.9%",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div id="features-section" className="py-24 px-4 relative">
|
||||
{/* Background decoration */}
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-transparent to-surface/50 pointer-events-none" />
|
||||
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="text-center mb-16">
|
||||
<Badge variant="glass" className="mb-4">
|
||||
Features
|
||||
</Badge>
|
||||
<h2 className="text-3xl font-bold text-white mb-4">
|
||||
Everything You Need for Document Translation
|
||||
</h2>
|
||||
<p className="text-xl text-text-secondary max-w-3xl mx-auto">
|
||||
Professional-grade translation with enterprise features, available to everyone.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
{features.map((feature, idx) => {
|
||||
const Icon = feature.icon;
|
||||
return (
|
||||
<CardFeature
|
||||
key={feature.title}
|
||||
icon={<Icon className="w-6 h-6" />}
|
||||
title={feature.title}
|
||||
description={feature.description}
|
||||
color="primary"
|
||||
className={cn(
|
||||
"group",
|
||||
ref && "animate-fade-in-up",
|
||||
`animation-delay-${idx * 100}`
|
||||
)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Enhanced Stats Row */}
|
||||
<div className="mt-20 grid grid-cols-2 md:grid-cols-4 gap-8">
|
||||
{[
|
||||
{ value: "10M+", label: "Documents Translated", icon: FileText },
|
||||
{ value: "150+", label: "Countries", icon: Globe2 },
|
||||
{ value: "99.9%", label: "Uptime", icon: Shield },
|
||||
{ value: "24/7", label: "Support", icon: Clock },
|
||||
].map((stat, idx) => (
|
||||
<div
|
||||
key={stat.label}
|
||||
className={cn(
|
||||
"text-center p-6 rounded-xl surface-elevated border border-border-subtle",
|
||||
ref && "animate-fade-in-up",
|
||||
`animation-delay-${idx * 100 + 600}`
|
||||
)}
|
||||
>
|
||||
<stat.icon className="w-8 h-8 mx-auto mb-3 text-primary" />
|
||||
<div className="text-2xl font-bold text-white mb-1">{stat.value}</div>
|
||||
<div className="text-sm text-text-secondary">{stat.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PricingPreview() {
|
||||
const [ref, setRef] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting) {
|
||||
setRef(true);
|
||||
}
|
||||
},
|
||||
{ threshold: 0.1 }
|
||||
);
|
||||
|
||||
const element = document.getElementById('pricing-preview');
|
||||
if (element) {
|
||||
observer.observe(element);
|
||||
}
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
const plans = [
|
||||
{
|
||||
name: "Free",
|
||||
price: "$0",
|
||||
description: "Perfect for trying out",
|
||||
features: ["5 documents/day", "10 pages/doc", "Basic support"],
|
||||
cta: "Get Started",
|
||||
href: "/auth/register",
|
||||
popular: false,
|
||||
},
|
||||
{
|
||||
name: "Pro",
|
||||
price: "$29",
|
||||
period: "/month",
|
||||
description: "For professionals",
|
||||
features: ["200 documents/month", "Unlimited pages", "Priority support", "API access"],
|
||||
cta: "Start Free Trial",
|
||||
href: "/pricing",
|
||||
popular: true,
|
||||
},
|
||||
{
|
||||
name: "Business",
|
||||
price: "$79",
|
||||
period: "/month",
|
||||
description: "For teams",
|
||||
features: ["1000 documents/month", "Team management", "Dedicated support", "SLA"],
|
||||
cta: "Contact Sales",
|
||||
href: "/pricing",
|
||||
popular: false,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div id="pricing-preview" className="py-24 px-4 relative">
|
||||
{/* Background decoration */}
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-surface/50 to-transparent pointer-events-none" />
|
||||
|
||||
<div className="max-w-5xl mx-auto">
|
||||
<div className="text-center mb-16">
|
||||
<Badge variant="glass" className="mb-4">
|
||||
Pricing
|
||||
</Badge>
|
||||
<h2 className="text-3xl font-bold text-white mb-4">
|
||||
Simple, Transparent Pricing
|
||||
</h2>
|
||||
<p className="text-xl text-text-secondary max-w-3xl mx-auto">
|
||||
Start free, upgrade when you need more.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
{plans.map((plan, idx) => (
|
||||
<Card
|
||||
key={plan.name}
|
||||
variant={plan.popular ? "gradient" : "elevated"}
|
||||
className={cn(
|
||||
"relative overflow-hidden group",
|
||||
ref && "animate-fade-in-up",
|
||||
`animation-delay-${idx * 100}`
|
||||
)}
|
||||
>
|
||||
{plan.popular && (
|
||||
<div className="absolute -top-4 left-1/2 transform -translate-x-1/2">
|
||||
<Badge variant="premium" className="animate-pulse">
|
||||
Most Popular
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CardHeader className="text-center pb-4">
|
||||
<CardTitle className="text-xl mb-2">{plan.name}</CardTitle>
|
||||
<CardDescription>{plan.description}</CardDescription>
|
||||
|
||||
<div className="my-6">
|
||||
<span className="text-4xl font-bold text-white">
|
||||
{plan.price}
|
||||
</span>
|
||||
{plan.period && (
|
||||
<span className="text-lg text-text-secondary ml-1">
|
||||
{plan.period}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="pt-0">
|
||||
<ul className="space-y-3 mb-6">
|
||||
{plan.features.map((feature) => (
|
||||
<li key={feature} className="flex items-center gap-2 text-sm text-text-secondary">
|
||||
<Check className="h-4 w-4 text-success flex-shrink-0" />
|
||||
<span>{feature}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<Link href={plan.href}>
|
||||
<Button
|
||||
variant={plan.popular ? "default" : "outline"}
|
||||
className="w-full group"
|
||||
size="lg"
|
||||
>
|
||||
{plan.cta}
|
||||
<ChevronRight className="ml-2 h-4 w-4 transition-transform group-hover:translate-x-1" />
|
||||
</Button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
|
||||
{/* Hover effect for popular plan */}
|
||||
{plan.popular && (
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-primary/20 to-accent/20 opacity-0 group-hover:opacity-100 transition-opacity duration-300 pointer-events-none" />
|
||||
)}
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="text-center mt-12">
|
||||
<Link href="/pricing" className="group">
|
||||
<Button variant="ghost" className="text-primary hover:text-primary/80">
|
||||
View all plans and features
|
||||
<ArrowRight className="ml-2 h-4 w-4 transition-transform group-hover:translate-x-1" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SelfHostCTA() {
|
||||
return null; // Removed for commercial version
|
||||
}
|
||||
|
||||
// Custom animations
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
@keyframes float {
|
||||
0%, 100% { transform: translateY(0px) rotate(0deg); }
|
||||
33% { transform: translateY(-20px) rotate(120deg); }
|
||||
66% { transform: translateY(-10px) rotate(240deg); }
|
||||
}
|
||||
|
||||
@keyframes float-delayed {
|
||||
0%, 100% { transform: translateY(0px) rotate(0deg); }
|
||||
33% { transform: translateY(-30px) rotate(90deg); }
|
||||
66% { transform: translateY(-15px) rotate(180deg); }
|
||||
}
|
||||
|
||||
@keyframes float-slow {
|
||||
0%, 100% { transform: translateY(0px) translateX(0px); }
|
||||
25% { transform: translateY(-15px) translateX(10px); }
|
||||
50% { transform: translateY(-25px) translateX(-10px); }
|
||||
75% { transform: translateY(-10px) translateX(5px); }
|
||||
}
|
||||
|
||||
@keyframes gradient-shift {
|
||||
0%, 100% { background-position: 0% 50%; }
|
||||
50% { background-position: 100% 50%; }
|
||||
}
|
||||
|
||||
@keyframes underline-expand {
|
||||
0% { width: 0%; left: 50%; }
|
||||
100% { width: 100%; left: 0%; }
|
||||
}
|
||||
|
||||
@keyframes fade-in-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slide-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-float {
|
||||
animation: float 6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.animate-float-delayed {
|
||||
animation: float-delayed 8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.animate-float-slow {
|
||||
animation: float-slow 10s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.animate-gradient {
|
||||
background-size: 200% 200%;
|
||||
animation: gradient-shift 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.animate-gradient-shift {
|
||||
animation: gradient-shift 4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.animate-underline-expand {
|
||||
animation: underline-expand 1s ease-out forwards;
|
||||
}
|
||||
|
||||
.animate-fade-in-up {
|
||||
animation: fade-in-up 0.6s ease-out forwards;
|
||||
}
|
||||
|
||||
.animate-slide-up {
|
||||
animation: slide-up 0.6s ease-out forwards;
|
||||
}
|
||||
|
||||
.animation-delay-200 { animation-delay: 200ms; }
|
||||
.animation-delay-400 { animation-delay: 400ms; }
|
||||
.animation-delay-600 { animation-delay: 600ms; }
|
||||
.animation-delay-800 { animation-delay: 800ms; }
|
||||
.animation-delay-1000 { animation-delay: 1000ms; }
|
||||
.animation-delay-2000 { animation-delay: 2000ms; }
|
||||
.animation-delay-4000 { animation-delay: 4000ms; }
|
||||
|
||||
.bg-size-200 {
|
||||
background-size: 200% 200%;
|
||||
}
|
||||
`;
|
||||
|
||||
if (typeof document !== 'undefined') {
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
261
frontend/src/components/sidebar.tsx
Normal file
261
frontend/src/components/sidebar.tsx
Normal file
@@ -0,0 +1,261 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useState, useEffect, useCallback, memo } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Upload,
|
||||
LayoutDashboard,
|
||||
LogIn,
|
||||
Crown,
|
||||
LogOut,
|
||||
BookOpen,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
plan: string;
|
||||
}
|
||||
|
||||
const navigation = [
|
||||
{
|
||||
name: "Translate",
|
||||
href: "/",
|
||||
icon: Upload,
|
||||
description: "Translate documents",
|
||||
},
|
||||
{
|
||||
name: "Context",
|
||||
href: "/settings/context",
|
||||
icon: BookOpen,
|
||||
description: "Configure AI instructions & glossary",
|
||||
},
|
||||
];
|
||||
|
||||
const planColors: Record<string, string> = {
|
||||
free: "bg-zinc-600",
|
||||
starter: "bg-blue-500",
|
||||
pro: "bg-teal-500",
|
||||
business: "bg-purple-500",
|
||||
enterprise: "bg-amber-500",
|
||||
};
|
||||
|
||||
// Memoized NavItem for performance
|
||||
const NavItem = memo(function NavItem({
|
||||
item,
|
||||
isActive
|
||||
}: {
|
||||
item: typeof navigation[0];
|
||||
isActive: boolean;
|
||||
}) {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Link
|
||||
href={item.href}
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors",
|
||||
isActive
|
||||
? "bg-teal-500/10 text-teal-400"
|
||||
: "text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100"
|
||||
)}
|
||||
>
|
||||
<Icon className="h-5 w-5" />
|
||||
<span>{item.name}</span>
|
||||
</Link>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>{item.description}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
});
|
||||
|
||||
export function Sidebar() {
|
||||
const pathname = usePathname();
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
const storedUser = localStorage.getItem("user");
|
||||
if (storedUser) {
|
||||
try {
|
||||
setUser(JSON.parse(storedUser));
|
||||
} catch {
|
||||
setUser(null);
|
||||
}
|
||||
}
|
||||
// Listen for storage changes
|
||||
const handleStorage = (e: StorageEvent) => {
|
||||
if (e.key === "user") {
|
||||
setUser(e.newValue ? JSON.parse(e.newValue) : null);
|
||||
}
|
||||
};
|
||||
window.addEventListener("storage", handleStorage);
|
||||
return () => window.removeEventListener("storage", handleStorage);
|
||||
}, []);
|
||||
|
||||
const handleLogout = useCallback(() => {
|
||||
localStorage.removeItem("token");
|
||||
localStorage.removeItem("refresh_token");
|
||||
localStorage.removeItem("user");
|
||||
setUser(null);
|
||||
window.location.href = "/";
|
||||
}, []);
|
||||
|
||||
// Prevent hydration mismatch
|
||||
if (!mounted) {
|
||||
return (
|
||||
<aside className="fixed left-0 top-0 z-40 h-screen w-64 border-r border-zinc-800 bg-[#1a1a1a]">
|
||||
<div className="flex h-16 items-center gap-3 border-b border-zinc-800 px-6">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-teal-500 text-white font-bold">文A</div>
|
||||
<span className="text-lg font-semibold text-white">Translate Co.</span>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<aside className="fixed left-0 top-0 z-40 h-screen w-64 border-r border-zinc-800 bg-[#1a1a1a]">
|
||||
{/* Logo */}
|
||||
<div className="flex h-16 items-center gap-3 border-b border-zinc-800 px-6">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-teal-500 text-white font-bold">
|
||||
文A
|
||||
</div>
|
||||
<span className="text-lg font-semibold text-white">Translate Co.</span>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex flex-col gap-1 p-4">
|
||||
{navigation.map((item) => {
|
||||
const isActive = pathname === item.href;
|
||||
const Icon = item.icon;
|
||||
|
||||
return (
|
||||
<Tooltip key={item.name}>
|
||||
<TooltipTrigger asChild>
|
||||
<Link
|
||||
href={item.href}
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors",
|
||||
isActive
|
||||
? "bg-teal-500/10 text-teal-400"
|
||||
: "text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100"
|
||||
)}
|
||||
>
|
||||
<Icon className="h-5 w-5" />
|
||||
<span>{item.name}</span>
|
||||
</Link>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>{item.description}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* User Section */}
|
||||
{user && (
|
||||
<div className="mt-4 pt-4 border-t border-zinc-800">
|
||||
<p className="px-3 mb-2 text-xs font-medium text-zinc-600 uppercase tracking-wider">Account</p>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors",
|
||||
pathname === "/dashboard"
|
||||
? "bg-teal-500/10 text-teal-400"
|
||||
: "text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100"
|
||||
)}
|
||||
>
|
||||
<LayoutDashboard className="h-5 w-5" />
|
||||
<span>Dashboard</span>
|
||||
</Link>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>View your usage and settings</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{user.plan === "free" && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Link
|
||||
href="/pricing"
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors",
|
||||
pathname === "/pricing"
|
||||
? "bg-amber-500/10 text-amber-400"
|
||||
: "text-amber-400/70 hover:bg-zinc-800 hover:text-amber-400"
|
||||
)}
|
||||
>
|
||||
<Crown className="h-5 w-5" />
|
||||
<span>Upgrade Plan</span>
|
||||
</Link>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>Get more translations and features</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
{/* User section at bottom */}
|
||||
<div className="absolute bottom-0 left-0 right-0 border-t border-zinc-800 p-4">
|
||||
{user ? (
|
||||
<div className="flex items-center justify-between">
|
||||
<Link href="/dashboard" className="flex items-center gap-3 flex-1 min-w-0">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-gradient-to-br from-teal-500 to-teal-600 text-white text-sm font-medium shrink-0">
|
||||
{user.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="text-sm font-medium text-white truncate">{user.name}</span>
|
||||
<Badge className={cn("text-xs mt-0.5 w-fit", planColors[user.plan] || "bg-zinc-600")}>
|
||||
{user.plan.charAt(0).toUpperCase() + user.plan.slice(1)}
|
||||
</Badge>
|
||||
</div>
|
||||
</Link>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="p-2 rounded-lg text-zinc-400 hover:text-white hover:bg-zinc-800"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<Link href="/auth/login" className="block">
|
||||
<button className="w-full flex items-center justify-center gap-2 px-4 py-2 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-white text-sm font-medium transition-colors">
|
||||
<LogIn className="h-4 w-4" />
|
||||
Sign In
|
||||
</button>
|
||||
</Link>
|
||||
<Link href="/auth/register" className="block">
|
||||
<button className="w-full flex items-center justify-center gap-2 px-4 py-2 rounded-lg bg-teal-500 hover:bg-teal-600 text-white text-sm font-medium transition-colors">
|
||||
Get Started Free
|
||||
</button>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
66
frontend/src/components/ui/alert.tsx
Normal file
66
frontend/src/components/ui/alert.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const alertVariants = cva(
|
||||
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-card text-card-foreground",
|
||||
destructive:
|
||||
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Alert({
|
||||
className,
|
||||
variant,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert"
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-title"
|
||||
className={cn(
|
||||
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-description"
|
||||
className={cn(
|
||||
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription }
|
||||
295
frontend/src/components/ui/badge.tsx
Normal file
295
frontend/src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,295 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-all duration-200 ease-out focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground shadow-sm hover:shadow-md hover:bg-primary/80",
|
||||
secondary:
|
||||
"border-transparent bg-surface text-secondary-foreground hover:bg-surface-hover",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/80",
|
||||
outline: "text-foreground border-border hover:bg-surface hover:border-border-strong",
|
||||
success:
|
||||
"border-transparent bg-success text-success-foreground shadow-sm hover:bg-success/80",
|
||||
warning:
|
||||
"border-transparent bg-warning text-warning-foreground shadow-sm hover:bg-warning/80",
|
||||
info:
|
||||
"border-transparent bg-primary/20 text-primary border-primary/30 hover:bg-primary/30",
|
||||
accent:
|
||||
"border-transparent bg-accent text-accent-foreground shadow-sm hover:bg-accent/80",
|
||||
glass:
|
||||
"glass text-foreground border-border/20 hover:bg-surface/50 hover:border-border/40",
|
||||
gradient:
|
||||
"border-transparent bg-gradient-to-r from-primary to-accent text-white shadow-lg hover:shadow-xl hover:shadow-primary/25",
|
||||
neon:
|
||||
"border-transparent bg-primary/10 text-primary border-primary/20 shadow-lg shadow-primary/20 hover:bg-primary/20 hover:border-primary/30 hover:shadow-primary/30",
|
||||
pulse:
|
||||
"border-transparent bg-primary text-primary-foreground shadow-lg shadow-primary/25 animate-pulse hover:animate-none",
|
||||
dot:
|
||||
"border-transparent bg-primary text-primary-foreground w-2 h-2 p-0 rounded-full",
|
||||
premium:
|
||||
"border-transparent bg-gradient-to-r from-primary via-accent to-primary text-white shadow-lg hover:shadow-xl hover:shadow-primary/25 relative overflow-hidden",
|
||||
},
|
||||
size: {
|
||||
default: "px-2.5 py-0.5 text-xs",
|
||||
sm: "px-2 py-0.5 text-xs",
|
||||
lg: "px-3 py-1 text-sm",
|
||||
xl: "px-4 py-1.5 text-base",
|
||||
icon: "w-6 h-6 p-0 rounded-lg flex items-center justify-center",
|
||||
dot: "w-2 h-2 p-0 rounded-full",
|
||||
},
|
||||
interactive: {
|
||||
true: "cursor-pointer hover:scale-105 active:scale-95",
|
||||
false: "",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
interactive: false,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {
|
||||
icon?: React.ReactNode
|
||||
removable?: boolean
|
||||
onRemove?: () => void
|
||||
pulse?: boolean
|
||||
count?: number
|
||||
maxCount?: number
|
||||
}
|
||||
|
||||
const Badge = React.forwardRef<HTMLDivElement, BadgeProps>(
|
||||
({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
interactive = false,
|
||||
icon,
|
||||
removable = false,
|
||||
onRemove,
|
||||
pulse = false,
|
||||
count,
|
||||
maxCount = 99,
|
||||
children,
|
||||
...props
|
||||
}, ref) => {
|
||||
const [visible, setVisible] = React.useState(true)
|
||||
const [removing, setRemoving] = React.useState(false)
|
||||
|
||||
const handleRemove = () => {
|
||||
setRemoving(true)
|
||||
setTimeout(() => {
|
||||
setVisible(false)
|
||||
onRemove?.()
|
||||
}, 200)
|
||||
}
|
||||
|
||||
const displayCount = count && count > maxCount ? `${maxCount}+` : count
|
||||
|
||||
if (!visible) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
badgeVariants({ variant, size, interactive }),
|
||||
pulse && "animate-pulse",
|
||||
removing && "scale-0 opacity-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{/* Gradient Shine Effect */}
|
||||
{(variant === "premium" || variant === "gradient") && (
|
||||
<span className="absolute inset-0 bg-gradient-to-r from-transparent via-white/20 to-transparent -skew-x-12 -translate-x-full group-hover:translate-x-full transition-transform duration-1000 ease-out pointer-events-none" />
|
||||
)}
|
||||
|
||||
{/* Icon */}
|
||||
{icon && (
|
||||
<span className="mr-1 flex-shrink-0">
|
||||
{icon}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<span className="flex items-center gap-1">
|
||||
{children}
|
||||
{displayCount && (
|
||||
<span className="ml-1 bg-white/20 px-1.5 py-0.5 rounded text-xs font-bold">
|
||||
{displayCount}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
|
||||
{/* Remove Button */}
|
||||
{removable && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRemove}
|
||||
className="ml-1 flex-shrink-0 rounded-full bg-white/20 hover:bg-white/30 transition-colors p-0.5"
|
||||
aria-label="Remove badge"
|
||||
>
|
||||
<svg
|
||||
className="h-3 w-3"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Pulse Ring for Neon Variant */}
|
||||
{variant === "neon" && (
|
||||
<>
|
||||
<span className="absolute inset-0 rounded-full bg-primary/20 animate-ping" />
|
||||
<span className="absolute inset-0 rounded-full bg-primary/10 animate-ping animation-delay-200" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
Badge.displayName = "Badge"
|
||||
|
||||
// Status Badge Component
|
||||
export const StatusBadge = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
Omit<BadgeProps, 'variant'> & {
|
||||
status: "online" | "offline" | "busy" | "away" | "success" | "error" | "warning"
|
||||
showLabel?: boolean
|
||||
}
|
||||
>(({ className, status, showLabel = true, children, ...props }, ref) => {
|
||||
const statusConfig = {
|
||||
online: { variant: "success" as const, label: "Online", icon: "●" },
|
||||
offline: { variant: "secondary" as const, label: "Offline", icon: "○" },
|
||||
busy: { variant: "destructive" as const, label: "Busy", icon: "◐" },
|
||||
away: { variant: "warning" as const, label: "Away", icon: "◐" },
|
||||
success: { variant: "success" as const, label: "Success", icon: "✓" },
|
||||
error: { variant: "destructive" as const, label: "Error", icon: "✕" },
|
||||
warning: { variant: "warning" as const, label: "Warning", icon: "!" },
|
||||
}
|
||||
|
||||
const config = statusConfig[status]
|
||||
|
||||
return (
|
||||
<Badge
|
||||
ref={ref}
|
||||
variant={config.variant}
|
||||
className={cn("gap-1.5", className)}
|
||||
{...props}
|
||||
>
|
||||
<span className="relative">
|
||||
<span className="text-xs">{config.icon}</span>
|
||||
{status === "online" && (
|
||||
<span className="absolute inset-0 rounded-full bg-success animate-ping" />
|
||||
)}
|
||||
</span>
|
||||
{showLabel && (
|
||||
<span>{children || config.label}</span>
|
||||
)}
|
||||
</Badge>
|
||||
)
|
||||
})
|
||||
StatusBadge.displayName = "StatusBadge"
|
||||
|
||||
// Counter Badge Component
|
||||
export const CounterBadge = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
Omit<BadgeProps, 'children'> & {
|
||||
value: number
|
||||
showZero?: boolean
|
||||
position?: "top-right" | "top-left" | "bottom-right" | "bottom-left"
|
||||
}
|
||||
>(({
|
||||
className,
|
||||
value,
|
||||
showZero = false,
|
||||
position = "top-right",
|
||||
size = "icon",
|
||||
...props
|
||||
}, ref) => {
|
||||
if (value === 0 && !showZero) return null
|
||||
|
||||
const positionClasses = {
|
||||
"top-right": "-top-2 -right-2",
|
||||
"top-left": "-top-2 -left-2",
|
||||
"bottom-right": "-bottom-2 -right-2",
|
||||
"bottom-left": "-bottom-2 -left-2",
|
||||
}
|
||||
|
||||
return (
|
||||
<Badge
|
||||
ref={ref}
|
||||
variant="destructive"
|
||||
size={size}
|
||||
className={cn(
|
||||
"absolute min-w-[20px] h-5 flex items-center justify-center text-xs font-bold",
|
||||
positionClasses[position],
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{value > 99 ? "99+" : value}
|
||||
</Badge>
|
||||
)
|
||||
})
|
||||
CounterBadge.displayName = "CounterBadge"
|
||||
|
||||
// Progress Badge Component
|
||||
export const ProgressBadge = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
Omit<BadgeProps, 'children'> & {
|
||||
value: number
|
||||
max?: number
|
||||
showPercentage?: boolean
|
||||
}
|
||||
>(({
|
||||
className,
|
||||
value,
|
||||
max = 100,
|
||||
showPercentage = true,
|
||||
...props
|
||||
}, ref) => {
|
||||
const percentage = Math.min(100, Math.max(0, (value / max) * 100))
|
||||
const displayValue = showPercentage ? `${Math.round(percentage)}%` : `${value}/${max}`
|
||||
|
||||
return (
|
||||
<Badge
|
||||
ref={ref}
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"relative overflow-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{/* Progress Background */}
|
||||
<span
|
||||
className="absolute inset-0 bg-primary/20"
|
||||
style={{ width: `${percentage}%` }}
|
||||
/>
|
||||
|
||||
{/* Text */}
|
||||
<span className="relative z-10">{displayValue}</span>
|
||||
</Badge>
|
||||
)
|
||||
})
|
||||
ProgressBadge.displayName = "ProgressBadge"
|
||||
|
||||
export { Badge, badgeVariants, StatusBadge, CounterBadge, ProgressBadge }
|
||||
143
frontend/src/components/ui/button.tsx
Normal file
143
frontend/src/components/ui/button.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg text-sm font-medium transition-all duration-200 ease-out disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive relative overflow-hidden group",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90 shadow-lg hover:shadow-xl hover:shadow-primary/25 transform hover:-translate-y-0.5 active:translate-y-0",
|
||||
destructive:
|
||||
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60 shadow-lg hover:shadow-xl hover:shadow-destructive/25 transform hover:-translate-y-0.5 active:translate-y-0",
|
||||
outline:
|
||||
"border border-border bg-transparent shadow-sm hover:bg-surface hover:text-accent-foreground hover:border-accent hover:shadow-md transform hover:-translate-y-0.5 active:translate-y-0",
|
||||
secondary:
|
||||
"bg-surface text-secondary-foreground hover:bg-surface-hover hover:text-secondary-foreground/80 border border-border-subtle shadow-sm hover:shadow-md transform hover:-translate-y-0.5 active:translate-y-0",
|
||||
ghost:
|
||||
"hover:bg-surface-hover hover:text-accent-foreground dark:hover:bg-surface/50 transform hover:-translate-y-0.5 active:translate-y-0",
|
||||
link: "text-primary underline-offset-4 hover:underline decoration-2 decoration-primary/60 hover:decoration-primary transition-all duration-200",
|
||||
premium: "bg-gradient-to-r from-primary to-accent text-white hover:from-primary/90 hover:to-accent/90 shadow-lg hover:shadow-xl hover:shadow-primary/25 transform hover:-translate-y-0.5 active:translate-y-0 relative overflow-hidden",
|
||||
glass: "glass text-foreground hover:bg-surface/50 border border-border-subtle hover:border-border shadow-md hover:shadow-lg transform hover:-translate-y-0.5 active:translate-y-0",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2 has-[>svg]:px-3",
|
||||
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5 text-xs",
|
||||
lg: "h-12 rounded-xl px-6 has-[>svg]:px-4 text-base",
|
||||
icon: "size-10 rounded-lg",
|
||||
"icon-sm": "size-8 rounded-md",
|
||||
"icon-lg": "size-12 rounded-xl",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean
|
||||
loading?: boolean
|
||||
ripple?: boolean
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, loading = false, ripple = true, children, disabled, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
const [ripples, setRipples] = React.useState<Array<{ id: number; x: number; y: number; size: number }>>([])
|
||||
|
||||
const createRipple = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
if (!ripple || disabled || loading) return
|
||||
|
||||
const button = event.currentTarget
|
||||
const rect = button.getBoundingClientRect()
|
||||
const size = Math.max(rect.width, rect.height)
|
||||
const x = event.clientX - rect.left - size / 2
|
||||
const y = event.clientY - rect.top - size / 2
|
||||
|
||||
const newRipple = {
|
||||
id: Date.now(),
|
||||
x,
|
||||
y,
|
||||
size,
|
||||
}
|
||||
|
||||
setRipples(prev => [...prev, newRipple])
|
||||
|
||||
// Remove ripple after animation
|
||||
setTimeout(() => {
|
||||
setRipples(prev => prev.filter(r => r.id !== newRipple.id))
|
||||
}, 600)
|
||||
}
|
||||
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
disabled={disabled || loading}
|
||||
onClick={createRipple}
|
||||
{...props}
|
||||
>
|
||||
{/* Ripple Effect */}
|
||||
{ripple && (
|
||||
<span className="absolute inset-0 overflow-hidden rounded-[inherit]">
|
||||
{ripples.map(ripple => (
|
||||
<span
|
||||
key={ripple.id}
|
||||
className="absolute bg-white/20 rounded-full animate-ping"
|
||||
style={{
|
||||
left: ripple.x,
|
||||
top: ripple.y,
|
||||
width: ripple.size,
|
||||
height: ripple.size,
|
||||
animation: 'ripple 0.6s ease-out',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Loading State */}
|
||||
{loading && (
|
||||
<svg
|
||||
className="animate-spin -ml-1 mr-2 h-4 w-4"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
></circle>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
)}
|
||||
|
||||
{/* Button Content */}
|
||||
<span className={cn("flex items-center gap-2", loading && "opacity-70")}>
|
||||
{children}
|
||||
</span>
|
||||
|
||||
{/* Shine Effect for Premium Variant */}
|
||||
{variant === 'premium' && (
|
||||
<span className="absolute inset-0 bg-gradient-to-r from-transparent via-white/10 to-transparent -skew-x-12 -translate-x-full group-hover:translate-x-full transition-transform duration-1000 ease-out pointer-events-none" />
|
||||
)}
|
||||
</Comp>
|
||||
)
|
||||
}
|
||||
)
|
||||
Button.displayName = "Button"
|
||||
|
||||
export { Button, buttonVariants }
|
||||
312
frontend/src/components/ui/card.tsx
Normal file
312
frontend/src/components/ui/card.tsx
Normal file
@@ -0,0 +1,312 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface CardProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
variant?: "default" | "elevated" | "glass" | "gradient"
|
||||
hover?: boolean
|
||||
interactive?: boolean
|
||||
}
|
||||
|
||||
const Card = React.forwardRef<HTMLDivElement, CardProps>(
|
||||
({ className, variant = "default", hover = true, interactive = false, ...props }, ref) => {
|
||||
const variants = {
|
||||
default: "bg-card text-card-foreground border border-border shadow-sm",
|
||||
elevated: "bg-surface-elevated text-card-foreground border border-border shadow-lg",
|
||||
glass: "glass text-card-foreground border border-border/20 shadow-lg backdrop-blur-xl",
|
||||
gradient: "bg-gradient-to-br from-surface to-surface-elevated text-card-foreground border border-border/50 shadow-xl"
|
||||
}
|
||||
|
||||
const hoverClasses = hover ? `
|
||||
transition-all duration-300 ease-out
|
||||
${interactive ? 'cursor-pointer' : ''}
|
||||
${hover ? 'hover:shadow-xl hover:shadow-primary/10 hover:-translate-y-1' : ''}
|
||||
${interactive ? 'active:translate-y-0 active:shadow-lg' : ''}
|
||||
` : ''
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"flex flex-col gap-6 rounded-xl p-6 relative overflow-hidden",
|
||||
variants[variant],
|
||||
hoverClasses,
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Card.displayName = "Card"
|
||||
|
||||
const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & {
|
||||
title?: string
|
||||
description?: string
|
||||
action?: React.ReactNode
|
||||
}
|
||||
>(({ className, title, description, action, children, ...props }, ref) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"grid auto-rows-min grid-rows-[auto_auto] items-start gap-3 relative",
|
||||
action && "grid-cols-[1fr_auto]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children || (
|
||||
<>
|
||||
{title && (
|
||||
<div className="flex items-center gap-3">
|
||||
<h3 className="text-lg font-semibold leading-none text-card-foreground">
|
||||
{title}
|
||||
</h3>
|
||||
</div>
|
||||
)}
|
||||
{description && (
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
{action && (
|
||||
<div className="col-start-2 row-span-2 row-start-1 self-start justify-self-end">
|
||||
{action}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
CardHeader.displayName = "CardHeader"
|
||||
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & {
|
||||
as?: "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "div"
|
||||
icon?: React.ReactNode
|
||||
badge?: React.ReactNode
|
||||
}
|
||||
>(({ className, as: Component = "h3", icon, badge, children, ...props }, ref) => {
|
||||
const Comp = Component
|
||||
|
||||
return (
|
||||
<Comp
|
||||
ref={ref}
|
||||
data-slot="card-title"
|
||||
className={cn(
|
||||
"leading-none font-semibold text-xl flex items-center gap-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{icon && (
|
||||
<span className="flex-shrink-0 w-8 h-8 rounded-lg bg-primary/10 flex items-center justify-center text-primary">
|
||||
{icon}
|
||||
</span>
|
||||
)}
|
||||
<span className="flex-1">{children}</span>
|
||||
{badge && (
|
||||
<span className="flex-shrink-0">
|
||||
{badge}
|
||||
</span>
|
||||
)}
|
||||
</Comp>
|
||||
)
|
||||
})
|
||||
CardTitle.displayName = "CardTitle"
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<p
|
||||
ref={ref}
|
||||
data-slot="card-description"
|
||||
className={cn("text-sm text-muted-foreground leading-relaxed", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardDescription.displayName = "CardDescription"
|
||||
|
||||
const CardAction = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardAction.displayName = "CardAction"
|
||||
|
||||
const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & {
|
||||
noPadding?: boolean
|
||||
}
|
||||
>(({ className, noPadding = false, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
data-slot="card-content"
|
||||
className={cn(
|
||||
noPadding ? "" : "px-6",
|
||||
"relative",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardContent.displayName = "CardContent"
|
||||
|
||||
const CardFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & {
|
||||
sticky?: boolean
|
||||
}
|
||||
>(({ className, sticky = false, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
data-slot="card-footer"
|
||||
className={cn(
|
||||
"flex items-center px-6 py-4",
|
||||
sticky && "sticky bottom-0 bg-card/95 backdrop-blur-sm border-t border-border/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardFooter.displayName = "CardFooter"
|
||||
|
||||
// Enhanced Card Components
|
||||
const CardStats = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & {
|
||||
title: string
|
||||
value: string | number
|
||||
change?: {
|
||||
value: number
|
||||
type: "increase" | "decrease"
|
||||
period?: string
|
||||
}
|
||||
icon?: React.ReactNode
|
||||
trend?: "up" | "down" | "stable"
|
||||
}
|
||||
>(({ className, title, value, change, icon, trend, ...props }, ref) => {
|
||||
const trendColors = {
|
||||
up: "text-success",
|
||||
down: "text-destructive",
|
||||
stable: "text-muted-foreground"
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("p-6 space-y-4", className)}
|
||||
{...props}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-muted-foreground">{title}</span>
|
||||
{icon && (
|
||||
<span className="w-8 h-8 rounded-lg bg-primary/10 flex items-center justify-center text-primary">
|
||||
{icon}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="text-2xl font-bold text-card-foreground">
|
||||
{value}
|
||||
</div>
|
||||
|
||||
{change && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className={cn(
|
||||
"flex items-center gap-1",
|
||||
change.type === "increase" ? "text-success" : "text-destructive"
|
||||
)}>
|
||||
{change.type === "increase" ? "↑" : "↓"} {Math.abs(change.value)}%
|
||||
</span>
|
||||
{change.period && (
|
||||
<span className="text-muted-foreground">{change.period}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{trend && (
|
||||
<div className={cn("text-sm", trendColors[trend])}>
|
||||
{trend === "up" && "↗ Trending up"}
|
||||
{trend === "down" && "↘ Trending down"}
|
||||
{trend === "stable" && "→ Stable"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
CardStats.displayName = "CardStats"
|
||||
|
||||
const CardFeature = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & {
|
||||
icon: React.ReactNode
|
||||
title: string
|
||||
description: string
|
||||
color?: "primary" | "accent" | "success" | "warning" | "destructive"
|
||||
}
|
||||
>(({ className, icon, title, description, color = "primary", ...props }, ref) => {
|
||||
const colorClasses = {
|
||||
primary: "bg-primary/10 text-primary",
|
||||
accent: "bg-accent/10 text-accent",
|
||||
success: "bg-success/10 text-success",
|
||||
warning: "bg-warning/10 text-warning",
|
||||
destructive: "bg-destructive/10 text-destructive"
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("p-6 space-y-4 text-center group", className)}
|
||||
{...props}
|
||||
>
|
||||
<div className={cn(
|
||||
"w-16 h-16 rounded-2xl flex items-center justify-center mx-auto transition-all duration-300 group-hover:scale-110 group-hover:shadow-lg",
|
||||
colorClasses[color]
|
||||
)}>
|
||||
<span className="text-2xl">{icon}</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-lg font-semibold text-card-foreground">
|
||||
{title}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
CardFeature.displayName = "CardFeature"
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardAction,
|
||||
CardContent,
|
||||
CardStats,
|
||||
CardFeature,
|
||||
}
|
||||
32
frontend/src/components/ui/checkbox.tsx
Normal file
32
frontend/src/components/ui/checkbox.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
||||
import { CheckIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Checkbox({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
||||
return (
|
||||
<CheckboxPrimitive.Root
|
||||
data-slot="checkbox"
|
||||
className={cn(
|
||||
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
data-slot="checkbox-indicator"
|
||||
className="grid place-content-center text-current transition-none"
|
||||
>
|
||||
<CheckIcon className="size-3.5" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Checkbox }
|
||||
143
frontend/src/components/ui/dialog.tsx
Normal file
143
frontend/src/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { XIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Dialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||
}
|
||||
|
||||
function DialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||
}
|
||||
|
||||
function DialogClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn("text-lg leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
}
|
||||
257
frontend/src/components/ui/dropdown-menu.tsx
Normal file
257
frontend/src/components/ui/dropdown-menu.tsx
Normal file
@@ -0,0 +1,257 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function DropdownMenu({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Trigger
|
||||
data-slot="dropdown-menu-trigger"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuContent({
|
||||
className,
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
data-slot="dropdown-menu-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
variant?: "default" | "destructive"
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item
|
||||
data-slot="dropdown-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioGroup
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CircleIcon className="size-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Label
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn(
|
||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto size-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
}
|
||||
354
frontend/src/components/ui/input.tsx
Normal file
354
frontend/src/components/ui/input.tsx
Normal file
@@ -0,0 +1,354 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export interface InputProps
|
||||
extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
label?: string
|
||||
error?: string
|
||||
helper?: string
|
||||
leftIcon?: React.ReactNode
|
||||
rightIcon?: React.ReactNode
|
||||
loading?: boolean
|
||||
variant?: "default" | "filled" | "outlined" | "ghost"
|
||||
}
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({
|
||||
className,
|
||||
type,
|
||||
label,
|
||||
error,
|
||||
helper,
|
||||
leftIcon,
|
||||
rightIcon,
|
||||
loading = false,
|
||||
variant = "default",
|
||||
...props
|
||||
}, ref) => {
|
||||
const [focused, setFocused] = React.useState(false)
|
||||
const [filled, setFilled] = React.useState(false)
|
||||
|
||||
const handleFocus = (e: React.FocusEvent<HTMLInputElement>) => {
|
||||
setFocused(true)
|
||||
props.onFocus?.(e)
|
||||
}
|
||||
|
||||
const handleBlur = (e: React.FocusEvent<HTMLInputElement>) => {
|
||||
setFocused(false)
|
||||
setFilled(e.target.value.length > 0)
|
||||
props.onBlur?.(e)
|
||||
}
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setFilled(e.target.value.length > 0)
|
||||
props.onChange?.(e)
|
||||
}
|
||||
|
||||
const variants = {
|
||||
default: `
|
||||
bg-surface border border-border-subtle
|
||||
focus:border-primary focus:ring-2 focus:ring-primary/20
|
||||
hover:border-border
|
||||
${error ? 'border-destructive focus:border-destructive focus:ring-destructive/20' : ''}
|
||||
`,
|
||||
filled: `
|
||||
bg-surface-elevated border-0 border-b-2 border-border-subtle
|
||||
focus:border-primary focus:ring-0
|
||||
hover:border-border
|
||||
${error ? 'border-b-destructive focus:border-destructive' : ''}
|
||||
`,
|
||||
outlined: `
|
||||
bg-transparent border-2 border-border-subtle
|
||||
focus:border-primary focus:ring-2 focus:ring-primary/20
|
||||
hover:border-border
|
||||
${error ? 'border-destructive focus:border-destructive focus:ring-destructive/20' : ''}
|
||||
`,
|
||||
ghost: `
|
||||
bg-transparent border-0 border-b border-border-subtle
|
||||
focus:border-primary focus:ring-0
|
||||
hover:border-border
|
||||
${error ? 'border-b-destructive focus:border-destructive' : ''}
|
||||
`
|
||||
}
|
||||
|
||||
const inputClasses = cn(
|
||||
"flex h-10 w-full rounded-lg px-3 py-2 text-sm transition-all duration-200 ease-out",
|
||||
"file:text-foreground placeholder:text-muted-foreground",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"outline-none focus:outline-none",
|
||||
"read-only:cursor-default read-only:bg-surface/50",
|
||||
variants[variant],
|
||||
leftIcon && "pl-10",
|
||||
rightIcon && "pr-10",
|
||||
(leftIcon && rightIcon) && "px-10",
|
||||
error && "text-destructive",
|
||||
className
|
||||
)
|
||||
|
||||
const containerClasses = cn(
|
||||
"relative w-full",
|
||||
label && "space-y-2"
|
||||
)
|
||||
|
||||
const labelClasses = cn(
|
||||
"text-sm font-medium transition-all duration-200",
|
||||
error ? "text-destructive" : "text-foreground",
|
||||
focused && "text-primary",
|
||||
filled && "text-muted-foreground"
|
||||
)
|
||||
|
||||
return (
|
||||
<div className={containerClasses}>
|
||||
{label && (
|
||||
<label className={labelClasses}>
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
|
||||
<div className="relative">
|
||||
{/* Left Icon */}
|
||||
{leftIcon && (
|
||||
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground pointer-events-none">
|
||||
{leftIcon}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Input Field */}
|
||||
<input
|
||||
type={type}
|
||||
className={inputClasses}
|
||||
ref={ref}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
onChange={handleChange}
|
||||
disabled={loading}
|
||||
aria-invalid={!!error}
|
||||
aria-describedby={error ? `${props.id}-error` : helper ? `${props.id}-helper` : undefined}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
{/* Right Icon or Loading */}
|
||||
{(rightIcon || loading) && (
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground pointer-events-none">
|
||||
{loading ? (
|
||||
<svg
|
||||
className="animate-spin h-4 w-4"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
></circle>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
) : (
|
||||
rightIcon
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Focus Ring */}
|
||||
{focused && (
|
||||
<div className="absolute inset-0 rounded-lg pointer-events-none">
|
||||
<div className="absolute inset-0 rounded-lg border-2 border-primary/20 animate-pulse" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Helper Text */}
|
||||
{helper && !error && (
|
||||
<p className="text-xs text-muted-foreground mt-1" id={`${props.id}-helper`}>
|
||||
{helper}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<p className="text-xs text-destructive mt-1 flex items-center gap-1" id={`${props.id}-error`}>
|
||||
<svg
|
||||
className="h-3 w-3 flex-shrink-0"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
Input.displayName = "Input"
|
||||
|
||||
export { Input }
|
||||
|
||||
// Enhanced Search Input Component
|
||||
export const SearchInput = React.forwardRef<
|
||||
HTMLInputElement,
|
||||
Omit<InputProps, 'leftIcon'> & {
|
||||
onClear?: () => void
|
||||
showClear?: boolean
|
||||
}
|
||||
>(({ className, showClear = true, onClear, value, ...props }, ref) => {
|
||||
const [focused, setFocused] = React.useState(false)
|
||||
|
||||
const handleClear = () => {
|
||||
onClear?.()
|
||||
// Trigger change event
|
||||
const event = new Event('input', { bubbles: true })
|
||||
const input = ref as React.RefObject<HTMLInputElement>
|
||||
if (input?.current) {
|
||||
input.current.value = ''
|
||||
input.current.dispatchEvent(event)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<Input
|
||||
ref={ref}
|
||||
type="search"
|
||||
leftIcon={
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
/>
|
||||
</svg>
|
||||
}
|
||||
rightIcon={
|
||||
showClear && value && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClear}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
className={cn(
|
||||
"pl-10 pr-10",
|
||||
focused && "shadow-lg shadow-primary/10",
|
||||
className
|
||||
)}
|
||||
onFocus={(e) => {
|
||||
setFocused(true)
|
||||
props.onFocus?.(e)
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
setFocused(false)
|
||||
props.onBlur?.(e)
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
SearchInput.displayName = "SearchInput"
|
||||
|
||||
// File Input Component
|
||||
export const FileInput = React.forwardRef<
|
||||
HTMLInputElement,
|
||||
Omit<InputProps, 'type'> & {
|
||||
accept?: string
|
||||
multiple?: boolean
|
||||
onFileSelect?: (files: FileList | null) => void
|
||||
}
|
||||
>(({ className, onFileSelect, accept, multiple = false, ...props }, ref) => {
|
||||
const [dragActive, setDragActive] = React.useState(false)
|
||||
const [fileName, setFileName] = React.useState<string>("")
|
||||
|
||||
const handleDrag = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (e.type === "dragenter" || e.type === "dragover") {
|
||||
setDragActive(true)
|
||||
} else if (e.type === "dragleave") {
|
||||
setDragActive(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setDragActive(false)
|
||||
|
||||
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
|
||||
setFileName(e.dataTransfer.files[0].name)
|
||||
onFileSelect?.(e.dataTransfer.files)
|
||||
}
|
||||
}
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files && e.target.files[0]) {
|
||||
setFileName(e.target.files[0].name)
|
||||
onFileSelect?.(e.target.files)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<Input
|
||||
ref={ref}
|
||||
type="file"
|
||||
accept={accept}
|
||||
multiple={multiple}
|
||||
className={cn(
|
||||
"file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-medium file:bg-primary file:text-primary-foreground hover:file:bg-primary/90 cursor-pointer",
|
||||
dragActive && "border-primary bg-primary/5",
|
||||
className
|
||||
)}
|
||||
onDragEnter={handleDrag}
|
||||
onDragLeave={handleDrag}
|
||||
onDragOver={handleDrag}
|
||||
onDrop={handleDrop}
|
||||
onChange={handleChange}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
{fileName && (
|
||||
<div className="absolute inset-y-0 right-0 flex items-center pr-3">
|
||||
<span className="text-xs text-muted-foreground truncate max-w-32">
|
||||
{fileName}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
FileInput.displayName = "FileInput"
|
||||
24
frontend/src/components/ui/label.tsx
Normal file
24
frontend/src/components/ui/label.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Label({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
return (
|
||||
<LabelPrimitive.Root
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Label }
|
||||
325
frontend/src/components/ui/notification.tsx
Normal file
325
frontend/src/components/ui/notification.tsx
Normal file
@@ -0,0 +1,325 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { X, CheckCircle, AlertCircle, AlertTriangle, Info, Loader2 } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const notificationVariants = cva(
|
||||
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-lg border p-4 pr-8 shadow-lg transition-all duration-300 ease-out",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border-border bg-card text-foreground",
|
||||
destructive: "border-destructive bg-destructive text-destructive-foreground",
|
||||
success: "border-success bg-success text-success-foreground",
|
||||
warning: "border-warning bg-warning text-warning-foreground",
|
||||
info: "border-primary bg-primary text-primary-foreground",
|
||||
glass: "glass text-foreground border-border/20",
|
||||
},
|
||||
size: {
|
||||
default: "max-w-md",
|
||||
sm: "max-w-sm",
|
||||
lg: "max-w-lg",
|
||||
xl: "max-w-xl",
|
||||
full: "max-w-full",
|
||||
},
|
||||
position: {
|
||||
"top-right": "fixed top-4 right-4 z-50",
|
||||
"top-left": "fixed top-4 left-4 z-50",
|
||||
"bottom-right": "fixed bottom-4 right-4 z-50",
|
||||
"bottom-left": "fixed bottom-4 left-4 z-50",
|
||||
"top-center": "fixed top-4 left-1/2 transform -translate-x-1/2 z-50",
|
||||
"bottom-center": "fixed bottom-4 left-1/2 transform -translate-x-1/2 z-50",
|
||||
"center": "fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 z-50",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
position: "top-right",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface NotificationProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof notificationVariants> {
|
||||
title?: string
|
||||
description?: string
|
||||
action?: React.ReactNode
|
||||
icon?: React.ReactNode
|
||||
loading?: boolean
|
||||
closable?: boolean
|
||||
autoClose?: boolean
|
||||
duration?: number
|
||||
onClose?: () => void
|
||||
}
|
||||
|
||||
const Notification = React.forwardRef<HTMLDivElement, NotificationProps>(
|
||||
({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
position,
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
icon,
|
||||
loading = false,
|
||||
closable = true,
|
||||
autoClose = true,
|
||||
duration = 5000,
|
||||
onClose,
|
||||
children,
|
||||
...props
|
||||
}, ref) => {
|
||||
const [visible, setVisible] = React.useState(true)
|
||||
const [progress, setProgress] = React.useState(100)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (autoClose && !loading) {
|
||||
const startTime = Date.now()
|
||||
const interval = setInterval(() => {
|
||||
const elapsed = Date.now() - startTime
|
||||
const remaining = Math.max(0, 100 - (elapsed / duration) * 100)
|
||||
setProgress(remaining)
|
||||
|
||||
if (remaining === 0) {
|
||||
clearInterval(interval)
|
||||
setVisible(false)
|
||||
onClose?.()
|
||||
}
|
||||
}, 50)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}
|
||||
}, [autoClose, loading, duration, onClose])
|
||||
|
||||
const handleClose = () => {
|
||||
setVisible(false)
|
||||
onClose?.()
|
||||
}
|
||||
|
||||
const defaultIcons = {
|
||||
default: <Info className="h-5 w-5" />,
|
||||
destructive: <AlertCircle className="h-5 w-5" />,
|
||||
success: <CheckCircle className="h-5 w-5" />,
|
||||
warning: <AlertTriangle className="h-5 w-5" />,
|
||||
info: <Info className="h-5 w-5" />,
|
||||
glass: <Info className="h-5 w-5" />,
|
||||
}
|
||||
|
||||
const displayIcon = icon || defaultIcons[variant as keyof typeof defaultIcons] || defaultIcons.default
|
||||
|
||||
if (!visible) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(notificationVariants({ variant, size, position }), className)}
|
||||
{...props}
|
||||
>
|
||||
{/* Progress Bar for Auto-close */}
|
||||
{autoClose && !loading && (
|
||||
<div className="absolute top-0 left-0 h-1 bg-white/20">
|
||||
<div
|
||||
className="h-full bg-white/40 transition-all duration-100 ease-linear"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-3">
|
||||
<div className="flex items-start gap-3">
|
||||
{/* Icon */}
|
||||
<div className={cn(
|
||||
"flex-shrink-0 w-10 h-10 rounded-lg flex items-center justify-center",
|
||||
variant === "success" && "bg-success/20 text-success",
|
||||
variant === "destructive" && "bg-destructive/20 text-destructive",
|
||||
variant === "warning" && "bg-warning/20 text-warning",
|
||||
variant === "info" && "bg-primary/20 text-primary",
|
||||
variant === "default" && "bg-muted text-muted-foreground",
|
||||
variant === "glass" && "bg-surface/50 text-foreground"
|
||||
)}>
|
||||
{loading ? (
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
) : (
|
||||
displayIcon
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="grid gap-1 flex-1 min-w-0">
|
||||
{title && (
|
||||
<div className="text-sm font-semibold leading-none">
|
||||
{title}
|
||||
</div>
|
||||
)}
|
||||
{description && (
|
||||
<div className="text-sm opacity-90 leading-relaxed">
|
||||
{description}
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action */}
|
||||
{action && (
|
||||
<div className="flex-shrink-0">
|
||||
{action}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Close Button */}
|
||||
{closable && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
className="absolute right-2 top-2 flex-shrink-0 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
||||
aria-label="Close notification"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
Notification.displayName = "Notification"
|
||||
|
||||
// Notification Context
|
||||
interface NotificationContextType {
|
||||
notifications: Array<{
|
||||
id: string
|
||||
title?: string
|
||||
description?: string
|
||||
variant?: VariantProps<typeof notificationVariants>["variant"]
|
||||
duration?: number
|
||||
action?: React.ReactNode
|
||||
icon?: React.ReactNode
|
||||
closable?: boolean
|
||||
autoClose?: boolean
|
||||
}>
|
||||
notify: (notification: Omit<NotificationContextType["notifications"][0], "id">) => void
|
||||
success: (notification: Omit<NotificationContextType["notifications"][0], "variant">) => void
|
||||
error: (notification: Omit<NotificationContextType["notifications"][0], "variant">) => void
|
||||
warning: (notification: Omit<NotificationContextType["notifications"][0], "variant">) => void
|
||||
info: (notification: Omit<NotificationContextType["notifications"][0], "variant">) => void
|
||||
dismiss: (id: string) => void
|
||||
dismissAll: () => void
|
||||
}
|
||||
|
||||
const NotificationContext = React.createContext<NotificationContextType | undefined>(undefined)
|
||||
|
||||
export function NotificationProvider({ children }: { children: React.ReactNode }) {
|
||||
const [notifications, setNotifications] = React.useState<NotificationContextType["notifications"]>([])
|
||||
|
||||
const notify = React.useCallback(
|
||||
(notification: Omit<NotificationContextType["notifications"][0], "id">) => {
|
||||
const id = Math.random().toString(36).substr(2, 9)
|
||||
setNotifications(prev => [...prev, { ...notification, id }])
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const success = React.useCallback(
|
||||
(notification: Omit<NotificationContextType["notifications"][0], "variant">) =>
|
||||
notify({ ...notification, variant: "success" }),
|
||||
[notify]
|
||||
)
|
||||
|
||||
const error = React.useCallback(
|
||||
(notification: Omit<NotificationContextType["notifications"][0], "variant">) =>
|
||||
notify({ ...notification, variant: "destructive" }),
|
||||
[notify]
|
||||
)
|
||||
|
||||
const warning = React.useCallback(
|
||||
(notification: Omit<NotificationContextType["notifications"][0], "variant">) =>
|
||||
notify({ ...notification, variant: "warning" }),
|
||||
[notify]
|
||||
)
|
||||
|
||||
const info = React.useCallback(
|
||||
(notification: Omit<NotificationContextType["notifications"][0], "variant">) =>
|
||||
notify({ ...notification, variant: "info" }),
|
||||
[notify]
|
||||
)
|
||||
|
||||
const dismiss = React.useCallback((id: string) => {
|
||||
setNotifications(prev => prev.filter(n => n.id !== id))
|
||||
}, [])
|
||||
|
||||
const dismissAll = React.useCallback(() => {
|
||||
setNotifications([])
|
||||
}, [])
|
||||
|
||||
const value = React.useMemo(() => ({
|
||||
notifications,
|
||||
notify,
|
||||
success,
|
||||
error,
|
||||
warning,
|
||||
info,
|
||||
dismiss,
|
||||
dismissAll,
|
||||
}), [notifications, notify, success, error, warning, info, dismiss, dismissAll])
|
||||
|
||||
return (
|
||||
<NotificationContext.Provider value={value}>
|
||||
{children}
|
||||
<NotificationContainer />
|
||||
</NotificationContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useNotification() {
|
||||
const context = React.useContext(NotificationContext)
|
||||
if (context === undefined) {
|
||||
throw new Error("useNotification must be used within a NotificationProvider")
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
// Notification Container
|
||||
function NotificationContainer() {
|
||||
const { notifications, dismiss } = useNotification()
|
||||
|
||||
return (
|
||||
<div className="fixed top-0 right-0 z-50 flex flex-col-reverse p-4 space-y-2 pointer-events-none">
|
||||
{notifications.map((notification) => (
|
||||
<Notification
|
||||
key={notification.id}
|
||||
position="top-right"
|
||||
variant={notification.variant}
|
||||
title={notification.title}
|
||||
description={notification.description}
|
||||
action={notification.action}
|
||||
icon={notification.icon}
|
||||
closable={notification.closable}
|
||||
autoClose={notification.autoClose}
|
||||
duration={notification.duration}
|
||||
onClose={() => dismiss(notification.id)}
|
||||
className="pointer-events-auto animate-in slide-in-from-top-2"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Skeleton Notification for Loading States
|
||||
export const NotificationSkeleton = () => (
|
||||
<div className="w-full max-w-md rounded-lg border border-border bg-card p-4 shadow-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-border skeleton" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="h-4 w-3/4 rounded bg-border skeleton" />
|
||||
<div className="h-3 w-1/2 rounded bg-border skeleton" />
|
||||
</div>
|
||||
<div className="w-6 h-6 rounded bg-border skeleton" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
export { Notification, notificationVariants }
|
||||
31
frontend/src/components/ui/progress.tsx
Normal file
31
frontend/src/components/ui/progress.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as ProgressPrimitive from "@radix-ui/react-progress"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Progress({
|
||||
className,
|
||||
value,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
|
||||
return (
|
||||
<ProgressPrimitive.Root
|
||||
data-slot="progress"
|
||||
className={cn(
|
||||
"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ProgressPrimitive.Indicator
|
||||
data-slot="progress-indicator"
|
||||
className="bg-primary h-full w-full flex-1 transition-all"
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Progress }
|
||||
58
frontend/src/components/ui/scroll-area.tsx
Normal file
58
frontend/src/components/ui/scroll-area.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function ScrollArea({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.Root
|
||||
data-slot="scroll-area"
|
||||
className={cn("relative", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport
|
||||
data-slot="scroll-area-viewport"
|
||||
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
|
||||
>
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
function ScrollBar({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
data-slot="scroll-area-scrollbar"
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none p-px transition-colors select-none",
|
||||
orientation === "vertical" &&
|
||||
"h-full w-2.5 border-l border-l-transparent",
|
||||
orientation === "horizontal" &&
|
||||
"h-2.5 flex-col border-t border-t-transparent",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb
|
||||
data-slot="scroll-area-thumb"
|
||||
className="bg-border relative flex-1 rounded-full"
|
||||
/>
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
)
|
||||
}
|
||||
|
||||
export { ScrollArea, ScrollBar }
|
||||
187
frontend/src/components/ui/select.tsx
Normal file
187
frontend/src/components/ui/select.tsx
Normal file
@@ -0,0 +1,187 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Select({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||
return <SelectPrimitive.Root data-slot="select" {...props} />
|
||||
}
|
||||
|
||||
function SelectGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||
return <SelectPrimitive.Group data-slot="select-group" {...props} />
|
||||
}
|
||||
|
||||
function SelectValue({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
size = "default",
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||
size?: "sm" | "default"
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDownIcon className="size-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
position = "popper",
|
||||
align = "center",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
align={align}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||
return (
|
||||
<SelectPrimitive.Label
|
||||
data-slot="select-label"
|
||||
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute right-2 flex size-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
}
|
||||
28
frontend/src/components/ui/separator.tsx
Normal file
28
frontend/src/components/ui/separator.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
decorative = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||
return (
|
||||
<SeparatorPrimitive.Root
|
||||
data-slot="separator"
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Separator }
|
||||
31
frontend/src/components/ui/switch.tsx
Normal file
31
frontend/src/components/ui/switch.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SwitchPrimitive from "@radix-ui/react-switch"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Switch({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
|
||||
return (
|
||||
<SwitchPrimitive.Root
|
||||
data-slot="switch"
|
||||
className={cn(
|
||||
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SwitchPrimitive.Thumb
|
||||
data-slot="switch-thumb"
|
||||
className={cn(
|
||||
"bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0"
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Switch }
|
||||
66
frontend/src/components/ui/tabs.tsx
Normal file
66
frontend/src/components/ui/tabs.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Tabs({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
||||
return (
|
||||
<TabsPrimitive.Root
|
||||
data-slot="tabs"
|
||||
className={cn("flex flex-col gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsList({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.List>) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
data-slot="tabs-list"
|
||||
className={cn(
|
||||
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsTrigger({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
||||
return (
|
||||
<TabsPrimitive.Content
|
||||
data-slot="tabs-content"
|
||||
className={cn("flex-1 outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||
18
frontend/src/components/ui/textarea.tsx
Normal file
18
frontend/src/components/ui/textarea.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Textarea }
|
||||
313
frontend/src/components/ui/toast.tsx
Normal file
313
frontend/src/components/ui/toast.tsx
Normal file
@@ -0,0 +1,313 @@
|
||||
import * as React from "react"
|
||||
import * as ToastPrimitives from "@radix-ui/react-toast"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { X, CheckCircle, AlertCircle, AlertTriangle, Info } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const toastVariants = cva(
|
||||
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-lg border p-6 pr-8 shadow-lg transition-all data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=cancel]:translate-x-0 data-[swipe=end]:animate-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:animate-out",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border-border bg-card text-foreground",
|
||||
destructive: "border-destructive bg-destructive text-destructive-foreground",
|
||||
success: "border-success bg-success text-success-foreground",
|
||||
warning: "border-warning bg-warning text-warning-foreground",
|
||||
info: "border-primary bg-primary text-primary-foreground",
|
||||
glass: "glass text-foreground border-border/20",
|
||||
},
|
||||
size: {
|
||||
default: "max-w-md",
|
||||
sm: "max-w-sm",
|
||||
lg: "max-w-lg",
|
||||
xl: "max-w-xl",
|
||||
full: "max-w-full",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const ToastProvider = ToastPrimitives.Provider
|
||||
|
||||
const ToastViewport = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Viewport>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Viewport
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
|
||||
|
||||
const Toast = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
|
||||
VariantProps<typeof toastVariants> & {
|
||||
icon?: React.ReactNode
|
||||
title?: string
|
||||
description?: string
|
||||
action?: React.ReactNode
|
||||
duration?: number
|
||||
}
|
||||
>(({ className, variant, size, icon, title, description, action, duration = 5000, ...props }, ref) => {
|
||||
const [open, setOpen] = React.useState(true)
|
||||
|
||||
const defaultIcons = {
|
||||
default: <Info className="h-5 w-5" />,
|
||||
destructive: <AlertCircle className="h-5 w-5" />,
|
||||
success: <CheckCircle className="h-5 w-5" />,
|
||||
warning: <AlertTriangle className="h-5 w-5" />,
|
||||
info: <Info className="h-5 w-5" />,
|
||||
glass: <Info className="h-5 w-5" />,
|
||||
}
|
||||
|
||||
const displayIcon = icon || defaultIcons[variant as keyof typeof defaultIcons] || defaultIcons.default
|
||||
|
||||
return (
|
||||
<ToastPrimitives.Root
|
||||
ref={ref}
|
||||
className={cn(toastVariants({ variant, size }), className)}
|
||||
duration={duration}
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
{...props}
|
||||
>
|
||||
<div className="grid gap-1">
|
||||
<div className="flex items-center gap-3">
|
||||
{displayIcon && (
|
||||
<div className={cn(
|
||||
"flex-shrink-0 w-10 h-10 rounded-lg flex items-center justify-center",
|
||||
variant === "success" && "bg-success/20 text-success",
|
||||
variant === "destructive" && "bg-destructive/20 text-destructive",
|
||||
variant === "warning" && "bg-warning/20 text-warning",
|
||||
variant === "info" && "bg-primary/20 text-primary",
|
||||
variant === "default" && "bg-muted text-muted-foreground",
|
||||
variant === "glass" && "bg-surface/50 text-foreground"
|
||||
)}>
|
||||
{displayIcon}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-1 flex-1">
|
||||
{title && (
|
||||
<ToastPrimitives.Title className="text-sm font-semibold leading-none">
|
||||
{title}
|
||||
</ToastPrimitives.Title>
|
||||
)}
|
||||
{description && (
|
||||
<ToastPrimitives.Description className="text-sm opacity-90 leading-relaxed">
|
||||
{description}
|
||||
</ToastPrimitives.Description>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{action && (
|
||||
<ToastPrimitives.Action
|
||||
className={cn(
|
||||
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive"
|
||||
)}
|
||||
alt={typeof action === 'string' ? action : undefined}
|
||||
>
|
||||
{action}
|
||||
</ToastPrimitives.Action>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ToastPrimitives.Close className="absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600">
|
||||
<X className="h-4 w-4" />
|
||||
</ToastPrimitives.Close>
|
||||
</ToastPrimitives.Root>
|
||||
)
|
||||
})
|
||||
Toast.displayName = ToastPrimitives.Root.displayName
|
||||
|
||||
const ToastAction = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Action
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastAction.displayName = ToastPrimitives.Action.displayName
|
||||
|
||||
const ToastTitle = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Title
|
||||
ref={ref}
|
||||
className={cn("text-sm font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastTitle.displayName = ToastPrimitives.Title.displayName
|
||||
|
||||
const ToastDescription = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm opacity-90", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastDescription.displayName = ToastPrimitives.Description.displayName
|
||||
|
||||
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
|
||||
|
||||
type ToastActionElement = React.ReactElement<{
|
||||
altText: string
|
||||
onClick: () => void
|
||||
}>
|
||||
|
||||
// Enhanced Toast Hook
|
||||
export function useToast() {
|
||||
const [toasts, setToasts] = React.useState<Array<{
|
||||
id: string
|
||||
title?: string
|
||||
description?: string
|
||||
variant?: VariantProps<typeof toastVariants>["variant"]
|
||||
duration?: number
|
||||
action?: ToastActionElement
|
||||
icon?: React.ReactNode
|
||||
}>>([])
|
||||
|
||||
const toast = React.useCallback(
|
||||
({ title, description, variant = "default", duration = 5000, action, icon }: Omit<ToastProps, "id">) => {
|
||||
const id = Math.random().toString(36).substr(2, 9)
|
||||
|
||||
setToasts(prev => [...prev, {
|
||||
id,
|
||||
title,
|
||||
description,
|
||||
variant,
|
||||
duration,
|
||||
action,
|
||||
icon,
|
||||
}])
|
||||
|
||||
// Auto remove after duration
|
||||
setTimeout(() => {
|
||||
setToasts(prev => prev.filter(toast => toast.id !== id))
|
||||
}, duration)
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const success = React.useCallback(
|
||||
(props: Omit<ToastProps, "variant">) =>
|
||||
toast({ ...props, variant: "success" }),
|
||||
[toast]
|
||||
)
|
||||
|
||||
const error = React.useCallback(
|
||||
(props: Omit<ToastProps, "variant">) =>
|
||||
toast({ ...props, variant: "destructive" }),
|
||||
[toast]
|
||||
)
|
||||
|
||||
const warning = React.useCallback(
|
||||
(props: Omit<ToastProps, "variant">) =>
|
||||
toast({ ...props, variant: "warning" }),
|
||||
[toast]
|
||||
)
|
||||
|
||||
const info = React.useCallback(
|
||||
(props: Omit<ToastProps, "variant">) =>
|
||||
toast({ ...props, variant: "info" }),
|
||||
[toast]
|
||||
)
|
||||
|
||||
const dismiss = React.useCallback((id: string) => {
|
||||
setToasts(prev => prev.filter(toast => toast.id !== id))
|
||||
}, [])
|
||||
|
||||
const dismissAll = React.useCallback(() => {
|
||||
setToasts([])
|
||||
}, [])
|
||||
|
||||
return {
|
||||
toast,
|
||||
success,
|
||||
error,
|
||||
warning,
|
||||
info,
|
||||
dismiss,
|
||||
dismissAll,
|
||||
toasts,
|
||||
}
|
||||
}
|
||||
|
||||
// Toast Container Component
|
||||
export const ToastContainer = ({ children }: { children: React.ReactNode }) => {
|
||||
return (
|
||||
<ToastProvider>
|
||||
{children}
|
||||
<ToastViewport />
|
||||
</ToastProvider>
|
||||
)
|
||||
}
|
||||
|
||||
// Individual Toast Component for use in ToastContainer
|
||||
export const ToastItem = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
{
|
||||
toast: {
|
||||
id: string
|
||||
title?: string
|
||||
description?: string
|
||||
variant?: VariantProps<typeof toastVariants>["variant"]
|
||||
duration?: number
|
||||
action?: ToastActionElement
|
||||
icon?: React.ReactNode
|
||||
}
|
||||
onDismiss: (id: string) => void
|
||||
}
|
||||
>(({ toast, onDismiss, ...props }, ref) => {
|
||||
return (
|
||||
<Toast
|
||||
ref={ref}
|
||||
variant={toast.variant}
|
||||
title={toast.title}
|
||||
description={toast.description}
|
||||
duration={toast.duration}
|
||||
icon={toast.icon}
|
||||
action={toast.action}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
onDismiss(toast.id)
|
||||
}
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
ToastItem.displayName = "ToastItem"
|
||||
|
||||
export {
|
||||
type ToastProps,
|
||||
ToastProvider,
|
||||
ToastViewport,
|
||||
Toast,
|
||||
ToastTitle,
|
||||
ToastDescription,
|
||||
ToastClose,
|
||||
ToastAction,
|
||||
}
|
||||
61
frontend/src/components/ui/tooltip.tsx
Normal file
61
frontend/src/components/ui/tooltip.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function TooltipProvider({
|
||||
delayDuration = 0,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
||||
return (
|
||||
<TooltipPrimitive.Provider
|
||||
data-slot="tooltip-provider"
|
||||
delayDuration={delayDuration}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function Tooltip({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function TooltipTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
||||
}
|
||||
|
||||
function TooltipContent({
|
||||
className,
|
||||
sideOffset = 0,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
data-slot="tooltip-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||
134
frontend/tailwind.config.js
Normal file
134
frontend/tailwind.config.js
Normal file
@@ -0,0 +1,134 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: [
|
||||
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
// Enhanced Dark Theme Colors
|
||||
background: '#0a0a0a',
|
||||
surface: '#141414',
|
||||
'surface-elevated': '#1a1a1a',
|
||||
'surface-hover': '#1f1f1f',
|
||||
foreground: '#fafafa',
|
||||
'text-primary': '#fafafa',
|
||||
'text-secondary': '#a1a1aa',
|
||||
'text-tertiary': '#71717a',
|
||||
'text-inverse': '#0a0a0a',
|
||||
'text-muted': '#71717a',
|
||||
|
||||
card: '#141414',
|
||||
'card-foreground': '#fafafa',
|
||||
popover: '#141414',
|
||||
'popover-foreground': '#fafafa',
|
||||
|
||||
primary: '#3b82f6',
|
||||
'primary-hover': '#2563eb',
|
||||
'primary-light': '#60a5fa',
|
||||
'primary-foreground': '#ffffff',
|
||||
|
||||
accent: '#8b5cf6',
|
||||
'accent-hover': '#7c3aed',
|
||||
'accent-foreground': '#ffffff',
|
||||
|
||||
secondary: '#272727',
|
||||
'secondary-foreground': '#fafafa',
|
||||
|
||||
muted: '#1f1f1f',
|
||||
'muted-foreground': '#71717a',
|
||||
|
||||
border: '#272727',
|
||||
'border-subtle': '#1f1f1f',
|
||||
'border-strong': '#2f2f2f',
|
||||
input: '#272727',
|
||||
|
||||
destructive: '#ef4444',
|
||||
'destructive-foreground': '#ffffff',
|
||||
success: '#10b981',
|
||||
'success-foreground': '#ffffff',
|
||||
warning: '#f59e0b',
|
||||
'warning-foreground': '#000000',
|
||||
|
||||
ring: '#3b82f6',
|
||||
'ring-offset': '#0a0a0a',
|
||||
|
||||
sidebar: '#0f0f0f',
|
||||
'sidebar-foreground': '#fafafa',
|
||||
'sidebar-primary': '#3b82f6',
|
||||
'sidebar-primary-foreground': '#ffffff',
|
||||
'sidebar-accent': '#272727',
|
||||
'sidebar-accent-foreground': '#fafafa',
|
||||
'sidebar-border': '#1f1f1f',
|
||||
'sidebar-ring': '#3b82f6',
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'system-ui', '-apple-system', 'sans-serif'],
|
||||
mono: ['JetBrains Mono', 'SF Mono', 'Monaco', 'Cascadia Code', 'monospace'],
|
||||
},
|
||||
fontSize: {
|
||||
xs: '0.75rem', // 12px
|
||||
sm: '0.875rem', // 14px
|
||||
base: '1rem', // 16px
|
||||
lg: '1.125rem', // 18px
|
||||
xl: '1.25rem', // 20px
|
||||
'2xl': '1.5rem', // 24px
|
||||
'3xl': '1.875rem', // 30px
|
||||
'4xl': '2.25rem', // 36px
|
||||
'5xl': '3rem', // 48px
|
||||
'6xl': '3.75rem', // 60px
|
||||
},
|
||||
lineHeight: {
|
||||
tight: '1.25',
|
||||
normal: '1.5',
|
||||
relaxed: '1.625',
|
||||
},
|
||||
spacing: {
|
||||
xs: '0.25rem', // 4px
|
||||
sm: '0.5rem', // 8px
|
||||
md: '0.75rem', // 12px
|
||||
lg: '1rem', // 16px
|
||||
xl: '1.5rem', // 24px
|
||||
'2xl': '2rem', // 32px
|
||||
'3xl': '3rem', // 48px
|
||||
'4xl': '4rem', // 64px
|
||||
},
|
||||
borderRadius: {
|
||||
sm: 'calc(var(--radius) - 4px)',
|
||||
md: 'calc(var(--radius) - 2px)',
|
||||
lg: 'var(--radius)',
|
||||
xl: 'calc(var(--radius) + 4px)',
|
||||
},
|
||||
boxShadow: {
|
||||
sm: '0 1px 2px 0 rgb(0 0 0 / 0.05)',
|
||||
md: '0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)',
|
||||
lg: '0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)',
|
||||
xl: '0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1)',
|
||||
glow: '0 0 20px rgb(59 130 246 / 0.15)',
|
||||
},
|
||||
animationDuration: {
|
||||
fast: '150ms',
|
||||
normal: '200ms',
|
||||
slow: '300ms',
|
||||
slower: '500ms',
|
||||
},
|
||||
animationTimingFunction: {
|
||||
easeOut: 'cubic-bezier(0.25, 0.46, 0.45, 0.94)',
|
||||
easeIn: 'cubic-bezier(0.55, 0.055, 0.675, 0.19)',
|
||||
easeInOut: 'cubic-bezier(0.645, 0.045, 0.355, 1)',
|
||||
},
|
||||
screens: {
|
||||
xs: '640px',
|
||||
sm: '768px',
|
||||
md: '1024px',
|
||||
lg: '1280px',
|
||||
xl: '1440px',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
require('@tailwindcss/postcss'),
|
||||
],
|
||||
}
|
||||
34
frontend/tsconfig.json
Normal file
34
frontend/tsconfig.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts",
|
||||
"**/*.mts"
|
||||
],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
484
k8s/deployment.yaml
Normal file
484
k8s/deployment.yaml
Normal file
@@ -0,0 +1,484 @@
|
||||
# ============================================
|
||||
# Document Translation API - Kubernetes Deployment
|
||||
# ============================================
|
||||
# Apply with: kubectl apply -f k8s/
|
||||
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: translate-api
|
||||
labels:
|
||||
app: translate-api
|
||||
|
||||
---
|
||||
# ConfigMap for application settings
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: translate-config
|
||||
namespace: translate-api
|
||||
data:
|
||||
TRANSLATION_SERVICE: "ollama"
|
||||
OLLAMA_BASE_URL: "http://ollama-service:11434"
|
||||
OLLAMA_MODEL: "llama3"
|
||||
MAX_FILE_SIZE_MB: "50"
|
||||
RATE_LIMIT_REQUESTS_PER_MINUTE: "60"
|
||||
RATE_LIMIT_TRANSLATIONS_PER_MINUTE: "10"
|
||||
LOG_LEVEL: "INFO"
|
||||
# Database and Redis URLs are in secrets for security
|
||||
|
||||
---
|
||||
# Secret for sensitive data
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: translate-secrets
|
||||
namespace: translate-api
|
||||
type: Opaque
|
||||
stringData:
|
||||
# CHANGE ALL THESE IN PRODUCTION!
|
||||
ADMIN_USERNAME: "admin"
|
||||
ADMIN_PASSWORD_HASH: "" # Use: echo -n 'yourpassword' | sha256sum
|
||||
JWT_SECRET: "" # Generate with: openssl rand -hex 32
|
||||
DATABASE_URL: "postgresql://translate:translate_secret@postgres-service:5432/translate_db"
|
||||
REDIS_URL: "redis://redis-service:6379/0"
|
||||
DEEPL_API_KEY: ""
|
||||
OPENAI_API_KEY: ""
|
||||
OPENROUTER_API_KEY: ""
|
||||
STRIPE_SECRET_KEY: ""
|
||||
STRIPE_WEBHOOK_SECRET: ""
|
||||
|
||||
---
|
||||
# Backend Deployment
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: backend
|
||||
namespace: translate-api
|
||||
labels:
|
||||
app: backend
|
||||
spec:
|
||||
replicas: 2
|
||||
selector:
|
||||
matchLabels:
|
||||
app: backend
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: backend
|
||||
spec:
|
||||
containers:
|
||||
- name: backend
|
||||
image: translate-api-backend:latest
|
||||
imagePullPolicy: IfNotPresent
|
||||
ports:
|
||||
- containerPort: 8000
|
||||
envFrom:
|
||||
- configMapRef:
|
||||
name: translate-config
|
||||
- secretRef:
|
||||
name: translate-secrets
|
||||
resources:
|
||||
requests:
|
||||
memory: "512Mi"
|
||||
cpu: "250m"
|
||||
limits:
|
||||
memory: "2Gi"
|
||||
cpu: "1000m"
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /ready
|
||||
port: 8000
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 10
|
||||
failureThreshold: 3
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: 8000
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 30
|
||||
failureThreshold: 3
|
||||
volumeMounts:
|
||||
- name: uploads
|
||||
mountPath: /app/uploads
|
||||
- name: outputs
|
||||
mountPath: /app/outputs
|
||||
volumes:
|
||||
- name: uploads
|
||||
persistentVolumeClaim:
|
||||
claimName: uploads-pvc
|
||||
- name: outputs
|
||||
persistentVolumeClaim:
|
||||
claimName: outputs-pvc
|
||||
|
||||
---
|
||||
# Backend Service
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: backend-service
|
||||
namespace: translate-api
|
||||
spec:
|
||||
selector:
|
||||
app: backend
|
||||
ports:
|
||||
- port: 8000
|
||||
targetPort: 8000
|
||||
type: ClusterIP
|
||||
|
||||
---
|
||||
# Frontend Deployment
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: frontend
|
||||
namespace: translate-api
|
||||
labels:
|
||||
app: frontend
|
||||
spec:
|
||||
replicas: 2
|
||||
selector:
|
||||
matchLabels:
|
||||
app: frontend
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: frontend
|
||||
spec:
|
||||
containers:
|
||||
- name: frontend
|
||||
image: translate-api-frontend:latest
|
||||
imagePullPolicy: IfNotPresent
|
||||
ports:
|
||||
- containerPort: 3000
|
||||
env:
|
||||
- name: NEXT_PUBLIC_API_URL
|
||||
value: "http://backend-service:8000"
|
||||
resources:
|
||||
requests:
|
||||
memory: "128Mi"
|
||||
cpu: "100m"
|
||||
limits:
|
||||
memory: "512Mi"
|
||||
cpu: "500m"
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /
|
||||
port: 3000
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 10
|
||||
|
||||
---
|
||||
# Frontend Service
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: frontend-service
|
||||
namespace: translate-api
|
||||
spec:
|
||||
selector:
|
||||
app: frontend
|
||||
ports:
|
||||
- port: 3000
|
||||
targetPort: 3000
|
||||
type: ClusterIP
|
||||
|
||||
---
|
||||
# Ingress for external access
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: translate-ingress
|
||||
namespace: translate-api
|
||||
annotations:
|
||||
kubernetes.io/ingress.class: nginx
|
||||
nginx.ingress.kubernetes.io/proxy-body-size: "100m"
|
||||
nginx.ingress.kubernetes.io/proxy-read-timeout: "600"
|
||||
nginx.ingress.kubernetes.io/proxy-send-timeout: "600"
|
||||
cert-manager.io/cluster-issuer: "letsencrypt-prod"
|
||||
spec:
|
||||
tls:
|
||||
- hosts:
|
||||
- translate.yourdomain.com
|
||||
secretName: translate-tls
|
||||
rules:
|
||||
- host: translate.yourdomain.com
|
||||
http:
|
||||
paths:
|
||||
- path: /api
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: backend-service
|
||||
port:
|
||||
number: 8000
|
||||
- path: /translate
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: backend-service
|
||||
port:
|
||||
number: 8000
|
||||
- path: /health
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: backend-service
|
||||
port:
|
||||
number: 8000
|
||||
- path: /admin
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: backend-service
|
||||
port:
|
||||
number: 8000
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: frontend-service
|
||||
port:
|
||||
number: 3000
|
||||
|
||||
---
|
||||
# Persistent Volume Claims
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: uploads-pvc
|
||||
namespace: translate-api
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteMany
|
||||
resources:
|
||||
requests:
|
||||
storage: 10Gi
|
||||
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: outputs-pvc
|
||||
namespace: translate-api
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteMany
|
||||
resources:
|
||||
requests:
|
||||
storage: 20Gi
|
||||
|
||||
---
|
||||
# Horizontal Pod Autoscaler for Backend
|
||||
apiVersion: autoscaling/v2
|
||||
kind: HorizontalPodAutoscaler
|
||||
metadata:
|
||||
name: backend-hpa
|
||||
namespace: translate-api
|
||||
spec:
|
||||
scaleTargetRef:
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
name: backend
|
||||
minReplicas: 2
|
||||
maxReplicas: 10
|
||||
metrics:
|
||||
- type: Resource
|
||||
resource:
|
||||
name: cpu
|
||||
target:
|
||||
type: Utilization
|
||||
averageUtilization: 70
|
||||
- type: Resource
|
||||
resource:
|
||||
name: memory
|
||||
target:
|
||||
type: Utilization
|
||||
averageUtilization: 80
|
||||
|
||||
---
|
||||
# ============================================
|
||||
# PostgreSQL Database
|
||||
# ============================================
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: postgres-pvc
|
||||
namespace: translate-api
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
resources:
|
||||
requests:
|
||||
storage: 10Gi
|
||||
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: postgres
|
||||
namespace: translate-api
|
||||
labels:
|
||||
app: postgres
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: postgres
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: postgres
|
||||
spec:
|
||||
containers:
|
||||
- name: postgres
|
||||
image: postgres:16-alpine
|
||||
ports:
|
||||
- containerPort: 5432
|
||||
env:
|
||||
- name: POSTGRES_USER
|
||||
value: "translate"
|
||||
- name: POSTGRES_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: postgres-secrets
|
||||
key: password
|
||||
- name: POSTGRES_DB
|
||||
value: "translate_db"
|
||||
- name: PGDATA
|
||||
value: "/var/lib/postgresql/data/pgdata"
|
||||
volumeMounts:
|
||||
- name: postgres-data
|
||||
mountPath: /var/lib/postgresql/data
|
||||
resources:
|
||||
requests:
|
||||
memory: "256Mi"
|
||||
cpu: "100m"
|
||||
limits:
|
||||
memory: "512Mi"
|
||||
cpu: "500m"
|
||||
readinessProbe:
|
||||
exec:
|
||||
command:
|
||||
- pg_isready
|
||||
- -U
|
||||
- translate
|
||||
- -d
|
||||
- translate_db
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 10
|
||||
livenessProbe:
|
||||
exec:
|
||||
command:
|
||||
- pg_isready
|
||||
- -U
|
||||
- translate
|
||||
- -d
|
||||
- translate_db
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 30
|
||||
volumes:
|
||||
- name: postgres-data
|
||||
persistentVolumeClaim:
|
||||
claimName: postgres-pvc
|
||||
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: postgres-secrets
|
||||
namespace: translate-api
|
||||
type: Opaque
|
||||
stringData:
|
||||
password: "translate_secret_change_me" # CHANGE IN PRODUCTION!
|
||||
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: postgres-service
|
||||
namespace: translate-api
|
||||
spec:
|
||||
selector:
|
||||
app: postgres
|
||||
ports:
|
||||
- port: 5432
|
||||
targetPort: 5432
|
||||
type: ClusterIP
|
||||
|
||||
---
|
||||
# ============================================
|
||||
# Redis Cache
|
||||
# ============================================
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: redis
|
||||
namespace: translate-api
|
||||
labels:
|
||||
app: redis
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: redis
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: redis
|
||||
spec:
|
||||
containers:
|
||||
- name: redis
|
||||
image: redis:7-alpine
|
||||
command:
|
||||
- redis-server
|
||||
- --appendonly
|
||||
- "yes"
|
||||
- --maxmemory
|
||||
- "256mb"
|
||||
- --maxmemory-policy
|
||||
- "allkeys-lru"
|
||||
ports:
|
||||
- containerPort: 6379
|
||||
resources:
|
||||
requests:
|
||||
memory: "128Mi"
|
||||
cpu: "50m"
|
||||
limits:
|
||||
memory: "256Mi"
|
||||
cpu: "200m"
|
||||
readinessProbe:
|
||||
exec:
|
||||
command:
|
||||
- redis-cli
|
||||
- ping
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 10
|
||||
livenessProbe:
|
||||
exec:
|
||||
command:
|
||||
- redis-cli
|
||||
- ping
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 30
|
||||
volumeMounts:
|
||||
- name: redis-data
|
||||
mountPath: /data
|
||||
volumes:
|
||||
- name: redis-data
|
||||
emptyDir: {}
|
||||
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: redis-service
|
||||
namespace: translate-api
|
||||
spec:
|
||||
selector:
|
||||
app: redis
|
||||
ports:
|
||||
- port: 6379
|
||||
targetPort: 6379
|
||||
type: ClusterIP
|
||||
157
mcp.json
Normal file
157
mcp.json
Normal file
@@ -0,0 +1,157 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/mcp-config.json",
|
||||
"name": "document-translator",
|
||||
"version": "1.0.0",
|
||||
"description": "Document Translation API - Translate Excel, Word, PowerPoint files with format preservation",
|
||||
"author": "Sepehr",
|
||||
"repository": "https://gitea.parsanet.org/sepehr/office_translator.git",
|
||||
"license": "MIT",
|
||||
|
||||
"runtime": {
|
||||
"type": "python",
|
||||
"command": "python",
|
||||
"args": ["mcp_server.py"],
|
||||
"cwd": "${workspaceFolder}"
|
||||
},
|
||||
|
||||
"requirements": {
|
||||
"python": ">=3.8",
|
||||
"dependencies": [
|
||||
"requests>=2.28.0"
|
||||
]
|
||||
},
|
||||
|
||||
"tools": [
|
||||
{
|
||||
"name": "translate_document",
|
||||
"description": "Translate a document (Excel, Word, PowerPoint) to another language while preserving all formatting, styles, formulas, and layouts",
|
||||
"parameters": {
|
||||
"file_path": {
|
||||
"type": "string",
|
||||
"description": "Absolute path to the document file (.xlsx, .docx, .pptx)",
|
||||
"required": true
|
||||
},
|
||||
"target_language": {
|
||||
"type": "string",
|
||||
"description": "Target language code (en, fr, es, fa, de, it, pt, ru, zh, ja, ko, ar)",
|
||||
"required": true
|
||||
},
|
||||
"provider": {
|
||||
"type": "string",
|
||||
"enum": ["google", "ollama", "deepl", "libre"],
|
||||
"default": "google",
|
||||
"description": "Translation provider to use"
|
||||
},
|
||||
"ollama_model": {
|
||||
"type": "string",
|
||||
"description": "Ollama model name (e.g., llama3.2, gemma3:12b, qwen3-vl)"
|
||||
},
|
||||
"translate_images": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Use vision model to extract and translate text from embedded images"
|
||||
},
|
||||
"system_prompt": {
|
||||
"type": "string",
|
||||
"description": "Custom instructions and context for LLM translation (glossary, domain context, style guidelines)"
|
||||
},
|
||||
"output_path": {
|
||||
"type": "string",
|
||||
"description": "Path where to save the translated document"
|
||||
}
|
||||
},
|
||||
"examples": [
|
||||
{
|
||||
"description": "Translate Excel file to French using Google",
|
||||
"arguments": {
|
||||
"file_path": "C:/Documents/data.xlsx",
|
||||
"target_language": "fr",
|
||||
"provider": "google"
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "Translate Word document to Persian with Ollama and custom glossary",
|
||||
"arguments": {
|
||||
"file_path": "C:/Documents/report.docx",
|
||||
"target_language": "fa",
|
||||
"provider": "ollama",
|
||||
"ollama_model": "gemma3:12b",
|
||||
"system_prompt": "You are translating HVAC technical documentation. Glossary: batterie=کویل, ventilateur=فن, condenseur=کندانسور"
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "Translate PowerPoint with image text extraction",
|
||||
"arguments": {
|
||||
"file_path": "C:/Presentations/slides.pptx",
|
||||
"target_language": "de",
|
||||
"provider": "ollama",
|
||||
"ollama_model": "gemma3:12b",
|
||||
"translate_images": true
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "list_ollama_models",
|
||||
"description": "List all available Ollama models for translation",
|
||||
"parameters": {
|
||||
"base_url": {
|
||||
"type": "string",
|
||||
"default": "http://localhost:11434",
|
||||
"description": "Ollama server URL"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "get_supported_languages",
|
||||
"description": "Get the full list of supported language codes and names",
|
||||
"parameters": {}
|
||||
},
|
||||
{
|
||||
"name": "check_api_health",
|
||||
"description": "Check if the translation API server is running and healthy",
|
||||
"parameters": {}
|
||||
}
|
||||
],
|
||||
|
||||
"features": [
|
||||
"Format-preserving translation for Excel, Word, PowerPoint",
|
||||
"Multiple translation providers (Google, Ollama, DeepL, LibreTranslate)",
|
||||
"Image text extraction using vision models (Gemma3, Qwen3-VL)",
|
||||
"Custom system prompts and glossaries for technical translation",
|
||||
"Domain-specific presets (HVAC, IT, Legal, Medical)",
|
||||
"Browser-based WebLLM support for offline translation"
|
||||
],
|
||||
|
||||
"usage": {
|
||||
"start_server": "python main.py",
|
||||
"api_endpoint": "http://localhost:8000",
|
||||
"web_interface": "http://localhost:8000"
|
||||
},
|
||||
|
||||
"providers": {
|
||||
"google": {
|
||||
"description": "Google Translate (free, no API key required)",
|
||||
"supports_system_prompt": false
|
||||
},
|
||||
"ollama": {
|
||||
"description": "Local Ollama LLM server",
|
||||
"supports_system_prompt": true,
|
||||
"supports_vision": true,
|
||||
"recommended_models": [
|
||||
"llama3.2",
|
||||
"gemma3:12b",
|
||||
"qwen3-vl",
|
||||
"mistral"
|
||||
]
|
||||
},
|
||||
"deepl": {
|
||||
"description": "DeepL API (requires API key)",
|
||||
"supports_system_prompt": false
|
||||
},
|
||||
"libre": {
|
||||
"description": "LibreTranslate (self-hosted)",
|
||||
"supports_system_prompt": false
|
||||
}
|
||||
}
|
||||
}
|
||||
391
mcp_server.py
Normal file
391
mcp_server.py
Normal file
@@ -0,0 +1,391 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
MCP Server for Document Translation API
|
||||
Model Context Protocol server for AI assistant integration
|
||||
"""
|
||||
|
||||
import sys
|
||||
import json
|
||||
import asyncio
|
||||
import base64
|
||||
import requests
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
# MCP Protocol Constants
|
||||
JSONRPC_VERSION = "2.0"
|
||||
|
||||
class MCPServer:
|
||||
"""MCP Server for Document Translation"""
|
||||
|
||||
def __init__(self):
|
||||
self.api_base = "http://localhost:8000"
|
||||
self.capabilities = {
|
||||
"tools": {}
|
||||
}
|
||||
|
||||
def get_tools(self) -> list:
|
||||
"""Return list of available tools"""
|
||||
return [
|
||||
{
|
||||
"name": "translate_document",
|
||||
"description": "Translate a document (Excel, Word, PowerPoint) to another language while preserving formatting",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"file_path": {
|
||||
"type": "string",
|
||||
"description": "Path to the document file (.xlsx, .docx, .pptx)"
|
||||
},
|
||||
"target_language": {
|
||||
"type": "string",
|
||||
"description": "Target language code (e.g., 'en', 'fr', 'es', 'fa', 'de')"
|
||||
},
|
||||
"provider": {
|
||||
"type": "string",
|
||||
"enum": ["google", "ollama", "deepl", "libre"],
|
||||
"description": "Translation provider (default: google)"
|
||||
},
|
||||
"ollama_model": {
|
||||
"type": "string",
|
||||
"description": "Ollama model to use (e.g., 'llama3.2', 'gemma3:12b')"
|
||||
},
|
||||
"translate_images": {
|
||||
"type": "boolean",
|
||||
"description": "Extract and translate text from images using vision model"
|
||||
},
|
||||
"system_prompt": {
|
||||
"type": "string",
|
||||
"description": "Custom system prompt with context, glossary, or instructions for LLM translation"
|
||||
},
|
||||
"output_path": {
|
||||
"type": "string",
|
||||
"description": "Path where to save the translated document (optional)"
|
||||
}
|
||||
},
|
||||
"required": ["file_path", "target_language"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "list_ollama_models",
|
||||
"description": "List available Ollama models for translation",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"base_url": {
|
||||
"type": "string",
|
||||
"description": "Ollama server URL (default: http://localhost:11434)"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "get_supported_languages",
|
||||
"description": "Get list of supported language codes for translation",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "configure_translation",
|
||||
"description": "Configure translation settings",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"provider": {
|
||||
"type": "string",
|
||||
"enum": ["google", "ollama", "deepl", "libre"],
|
||||
"description": "Default translation provider"
|
||||
},
|
||||
"ollama_url": {
|
||||
"type": "string",
|
||||
"description": "Ollama server URL"
|
||||
},
|
||||
"ollama_model": {
|
||||
"type": "string",
|
||||
"description": "Default Ollama model"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "check_api_health",
|
||||
"description": "Check if the translation API is running and healthy",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
async def handle_tool_call(self, name: str, arguments: dict) -> dict:
|
||||
"""Handle tool calls"""
|
||||
try:
|
||||
if name == "translate_document":
|
||||
return await self.translate_document(arguments)
|
||||
elif name == "list_ollama_models":
|
||||
return await self.list_ollama_models(arguments)
|
||||
elif name == "get_supported_languages":
|
||||
return await self.get_supported_languages()
|
||||
elif name == "configure_translation":
|
||||
return await self.configure_translation(arguments)
|
||||
elif name == "check_api_health":
|
||||
return await self.check_api_health()
|
||||
else:
|
||||
return {"error": f"Unknown tool: {name}"}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
async def translate_document(self, args: dict) -> dict:
|
||||
"""Translate a document file"""
|
||||
file_path = Path(args["file_path"])
|
||||
|
||||
if not file_path.exists():
|
||||
return {"error": f"File not found: {file_path}"}
|
||||
|
||||
# Prepare form data
|
||||
with open(file_path, 'rb') as f:
|
||||
files = {'file': (file_path.name, f)}
|
||||
data = {
|
||||
'target_language': args['target_language'],
|
||||
'provider': args.get('provider', 'google'),
|
||||
'translate_images': str(args.get('translate_images', False)).lower(),
|
||||
}
|
||||
|
||||
if args.get('ollama_model'):
|
||||
data['ollama_model'] = args['ollama_model']
|
||||
|
||||
if args.get('system_prompt'):
|
||||
data['system_prompt'] = args['system_prompt']
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
f"{self.api_base}/translate",
|
||||
files=files,
|
||||
data=data,
|
||||
timeout=300 # 5 minutes timeout
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
# Save translated file
|
||||
output_path = args.get('output_path')
|
||||
if not output_path:
|
||||
output_path = file_path.parent / f"translated_{file_path.name}"
|
||||
|
||||
output_path = Path(output_path)
|
||||
with open(output_path, 'wb') as out:
|
||||
out.write(response.content)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Document translated successfully",
|
||||
"output_path": str(output_path),
|
||||
"source_file": str(file_path),
|
||||
"target_language": args['target_language'],
|
||||
"provider": args.get('provider', 'google')
|
||||
}
|
||||
else:
|
||||
error_detail = response.json() if response.headers.get('content-type') == 'application/json' else response.text
|
||||
return {"error": f"Translation failed: {error_detail}"}
|
||||
|
||||
except requests.exceptions.ConnectionError:
|
||||
return {"error": "Cannot connect to translation API. Make sure the server is running on http://localhost:8000"}
|
||||
except requests.exceptions.Timeout:
|
||||
return {"error": "Translation request timed out"}
|
||||
|
||||
async def list_ollama_models(self, args: dict) -> dict:
|
||||
"""List available Ollama models"""
|
||||
base_url = args.get('base_url', 'http://localhost:11434')
|
||||
|
||||
try:
|
||||
response = requests.get(
|
||||
f"{self.api_base}/ollama/models",
|
||||
params={'base_url': base_url},
|
||||
timeout=10
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
return {
|
||||
"models": data.get('models', []),
|
||||
"count": data.get('count', 0),
|
||||
"ollama_url": base_url
|
||||
}
|
||||
else:
|
||||
return {"error": "Failed to list models", "models": []}
|
||||
|
||||
except requests.exceptions.ConnectionError:
|
||||
return {"error": "Cannot connect to API server", "models": []}
|
||||
|
||||
async def get_supported_languages(self) -> dict:
|
||||
"""Get supported language codes"""
|
||||
return {
|
||||
"languages": [
|
||||
{"code": "en", "name": "English"},
|
||||
{"code": "fa", "name": "Persian/Farsi"},
|
||||
{"code": "fr", "name": "French"},
|
||||
{"code": "es", "name": "Spanish"},
|
||||
{"code": "de", "name": "German"},
|
||||
{"code": "it", "name": "Italian"},
|
||||
{"code": "pt", "name": "Portuguese"},
|
||||
{"code": "ru", "name": "Russian"},
|
||||
{"code": "zh", "name": "Chinese"},
|
||||
{"code": "ja", "name": "Japanese"},
|
||||
{"code": "ko", "name": "Korean"},
|
||||
{"code": "ar", "name": "Arabic"},
|
||||
{"code": "nl", "name": "Dutch"},
|
||||
{"code": "pl", "name": "Polish"},
|
||||
{"code": "tr", "name": "Turkish"},
|
||||
{"code": "vi", "name": "Vietnamese"},
|
||||
{"code": "th", "name": "Thai"},
|
||||
{"code": "hi", "name": "Hindi"},
|
||||
{"code": "he", "name": "Hebrew"},
|
||||
{"code": "sv", "name": "Swedish"}
|
||||
]
|
||||
}
|
||||
|
||||
async def configure_translation(self, args: dict) -> dict:
|
||||
"""Configure translation settings"""
|
||||
config = {}
|
||||
|
||||
if args.get('ollama_url') and args.get('ollama_model'):
|
||||
try:
|
||||
response = requests.post(
|
||||
f"{self.api_base}/ollama/configure",
|
||||
data={
|
||||
'base_url': args['ollama_url'],
|
||||
'model': args['ollama_model']
|
||||
},
|
||||
timeout=10
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
config['ollama'] = response.json()
|
||||
|
||||
except Exception as e:
|
||||
config['ollama_error'] = str(e)
|
||||
|
||||
config['provider'] = args.get('provider', 'google')
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"configuration": config
|
||||
}
|
||||
|
||||
async def check_api_health(self) -> dict:
|
||||
"""Check API health status"""
|
||||
try:
|
||||
response = requests.get(f"{self.api_base}/health", timeout=5)
|
||||
|
||||
if response.status_code == 200:
|
||||
return {
|
||||
"status": "healthy",
|
||||
"api_url": self.api_base,
|
||||
"details": response.json()
|
||||
}
|
||||
else:
|
||||
return {"status": "unhealthy", "error": "API returned non-200 status"}
|
||||
|
||||
except requests.exceptions.ConnectionError:
|
||||
return {
|
||||
"status": "unavailable",
|
||||
"error": "Cannot connect to API. Start the server with: python main.py"
|
||||
}
|
||||
|
||||
def create_response(self, id: Any, result: Any) -> dict:
|
||||
"""Create JSON-RPC response"""
|
||||
return {
|
||||
"jsonrpc": JSONRPC_VERSION,
|
||||
"id": id,
|
||||
"result": result
|
||||
}
|
||||
|
||||
def create_error(self, id: Any, code: int, message: str) -> dict:
|
||||
"""Create JSON-RPC error response"""
|
||||
return {
|
||||
"jsonrpc": JSONRPC_VERSION,
|
||||
"id": id,
|
||||
"error": {
|
||||
"code": code,
|
||||
"message": message
|
||||
}
|
||||
}
|
||||
|
||||
async def handle_message(self, message: dict) -> Optional[dict]:
|
||||
"""Handle incoming JSON-RPC message"""
|
||||
msg_id = message.get("id")
|
||||
method = message.get("method")
|
||||
params = message.get("params", {})
|
||||
|
||||
if method == "initialize":
|
||||
return self.create_response(msg_id, {
|
||||
"protocolVersion": "2024-11-05",
|
||||
"capabilities": self.capabilities,
|
||||
"serverInfo": {
|
||||
"name": "document-translator",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
})
|
||||
|
||||
elif method == "notifications/initialized":
|
||||
return None # No response needed for notifications
|
||||
|
||||
elif method == "tools/list":
|
||||
return self.create_response(msg_id, {
|
||||
"tools": self.get_tools()
|
||||
})
|
||||
|
||||
elif method == "tools/call":
|
||||
tool_name = params.get("name")
|
||||
tool_args = params.get("arguments", {})
|
||||
result = await self.handle_tool_call(tool_name, tool_args)
|
||||
|
||||
return self.create_response(msg_id, {
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": json.dumps(result, indent=2, ensure_ascii=False)
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
elif method == "ping":
|
||||
return self.create_response(msg_id, {})
|
||||
|
||||
else:
|
||||
return self.create_error(msg_id, -32601, f"Method not found: {method}")
|
||||
|
||||
async def run(self):
|
||||
"""Run the MCP server using stdio"""
|
||||
while True:
|
||||
try:
|
||||
line = sys.stdin.readline()
|
||||
if not line:
|
||||
break
|
||||
|
||||
message = json.loads(line)
|
||||
response = await self.handle_message(message)
|
||||
|
||||
if response:
|
||||
sys.stdout.write(json.dumps(response) + "\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
error = self.create_error(None, -32700, f"Parse error: {e}")
|
||||
sys.stdout.write(json.dumps(error) + "\n")
|
||||
sys.stdout.flush()
|
||||
except Exception as e:
|
||||
error = self.create_error(None, -32603, f"Internal error: {e}")
|
||||
sys.stdout.write(json.dumps(error) + "\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point"""
|
||||
server = MCPServer()
|
||||
asyncio.run(server.run())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
62
middleware/__init__.py
Normal file
62
middleware/__init__.py
Normal file
@@ -0,0 +1,62 @@
|
||||
"""
|
||||
Middleware package for SaaS robustness
|
||||
|
||||
This package provides:
|
||||
- Rate limiting: Protect against abuse and ensure fair usage
|
||||
- Validation: Validate all inputs before processing
|
||||
- Security: Security headers, request logging, error handling
|
||||
- Cleanup: Automatic file cleanup and resource management
|
||||
"""
|
||||
|
||||
from .rate_limiting import (
|
||||
RateLimitConfig,
|
||||
RateLimitManager,
|
||||
RateLimitMiddleware,
|
||||
ClientRateLimiter,
|
||||
)
|
||||
|
||||
from .validation import (
|
||||
ValidationError,
|
||||
ValidationResult,
|
||||
FileValidator,
|
||||
LanguageValidator,
|
||||
ProviderValidator,
|
||||
InputSanitizer,
|
||||
)
|
||||
|
||||
from .security import (
|
||||
SecurityHeadersMiddleware,
|
||||
RequestLoggingMiddleware,
|
||||
ErrorHandlingMiddleware,
|
||||
)
|
||||
|
||||
from .cleanup import (
|
||||
FileCleanupManager,
|
||||
MemoryMonitor,
|
||||
HealthChecker,
|
||||
create_cleanup_manager,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# Rate limiting
|
||||
"RateLimitConfig",
|
||||
"RateLimitManager",
|
||||
"RateLimitMiddleware",
|
||||
"ClientRateLimiter",
|
||||
# Validation
|
||||
"ValidationError",
|
||||
"ValidationResult",
|
||||
"FileValidator",
|
||||
"LanguageValidator",
|
||||
"ProviderValidator",
|
||||
"InputSanitizer",
|
||||
# Security
|
||||
"SecurityHeadersMiddleware",
|
||||
"RequestLoggingMiddleware",
|
||||
"ErrorHandlingMiddleware",
|
||||
# Cleanup
|
||||
"FileCleanupManager",
|
||||
"MemoryMonitor",
|
||||
"HealthChecker",
|
||||
"create_cleanup_manager",
|
||||
]
|
||||
400
middleware/cleanup.py
Normal file
400
middleware/cleanup.py
Normal file
@@ -0,0 +1,400 @@
|
||||
"""
|
||||
Cleanup and Resource Management for SaaS robustness
|
||||
Automatic cleanup of temporary files and resources
|
||||
"""
|
||||
import os
|
||||
import time
|
||||
import asyncio
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, Set
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FileCleanupManager:
|
||||
"""Manages automatic cleanup of temporary and output files"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
upload_dir: Path,
|
||||
output_dir: Path,
|
||||
temp_dir: Path,
|
||||
max_file_age_hours: int = 1,
|
||||
cleanup_interval_minutes: int = 10,
|
||||
max_total_size_gb: float = 10.0
|
||||
):
|
||||
self.upload_dir = Path(upload_dir)
|
||||
self.output_dir = Path(output_dir)
|
||||
self.temp_dir = Path(temp_dir)
|
||||
self.max_file_age_seconds = max_file_age_hours * 3600
|
||||
self.cleanup_interval = cleanup_interval_minutes * 60
|
||||
self.max_total_size_bytes = int(max_total_size_gb * 1024 * 1024 * 1024)
|
||||
|
||||
self._running = False
|
||||
self._task: Optional[asyncio.Task] = None
|
||||
self._protected_files: Set[str] = set()
|
||||
self._tracked_files: dict = {} # filepath -> {created, ttl_minutes}
|
||||
self._lock = threading.Lock()
|
||||
self._stats = {
|
||||
"files_cleaned": 0,
|
||||
"bytes_freed": 0,
|
||||
"cleanup_runs": 0
|
||||
}
|
||||
|
||||
async def track_file(self, filepath: Path, ttl_minutes: int = 60):
|
||||
"""Track a file for automatic cleanup after TTL expires"""
|
||||
with self._lock:
|
||||
self._tracked_files[str(filepath)] = {
|
||||
"created": time.time(),
|
||||
"ttl_minutes": ttl_minutes,
|
||||
"expires_at": time.time() + (ttl_minutes * 60)
|
||||
}
|
||||
|
||||
def get_tracked_files(self) -> list:
|
||||
"""Get list of currently tracked files with their status"""
|
||||
now = time.time()
|
||||
result = []
|
||||
|
||||
with self._lock:
|
||||
for filepath, info in self._tracked_files.items():
|
||||
remaining = info["expires_at"] - now
|
||||
result.append({
|
||||
"path": filepath,
|
||||
"exists": Path(filepath).exists(),
|
||||
"expires_in_seconds": max(0, int(remaining)),
|
||||
"ttl_minutes": info["ttl_minutes"]
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
async def cleanup_expired(self) -> int:
|
||||
"""Cleanup expired tracked files"""
|
||||
now = time.time()
|
||||
cleaned = 0
|
||||
to_remove = []
|
||||
|
||||
with self._lock:
|
||||
for filepath, info in list(self._tracked_files.items()):
|
||||
if now > info["expires_at"]:
|
||||
to_remove.append(filepath)
|
||||
|
||||
for filepath in to_remove:
|
||||
try:
|
||||
path = Path(filepath)
|
||||
if path.exists() and not self.is_protected(path):
|
||||
size = path.stat().st_size
|
||||
path.unlink()
|
||||
cleaned += 1
|
||||
self._stats["files_cleaned"] += 1
|
||||
self._stats["bytes_freed"] += size
|
||||
logger.info(f"Cleaned expired file: {filepath}")
|
||||
|
||||
with self._lock:
|
||||
self._tracked_files.pop(filepath, None)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to clean expired file {filepath}: {e}")
|
||||
|
||||
return cleaned
|
||||
|
||||
def get_stats(self) -> dict:
|
||||
"""Get cleanup statistics"""
|
||||
disk_usage = self.get_disk_usage()
|
||||
|
||||
with self._lock:
|
||||
tracked_count = len(self._tracked_files)
|
||||
|
||||
return {
|
||||
"files_cleaned_total": self._stats["files_cleaned"],
|
||||
"bytes_freed_total_mb": round(self._stats["bytes_freed"] / (1024 * 1024), 2),
|
||||
"cleanup_runs": self._stats["cleanup_runs"],
|
||||
"tracked_files": tracked_count,
|
||||
"disk_usage": disk_usage,
|
||||
"is_running": self._running
|
||||
}
|
||||
|
||||
def protect_file(self, filepath: Path):
|
||||
"""Mark a file as protected (being processed)"""
|
||||
with self._lock:
|
||||
self._protected_files.add(str(filepath))
|
||||
|
||||
def unprotect_file(self, filepath: Path):
|
||||
"""Remove protection from a file"""
|
||||
with self._lock:
|
||||
self._protected_files.discard(str(filepath))
|
||||
|
||||
def is_protected(self, filepath: Path) -> bool:
|
||||
"""Check if a file is protected"""
|
||||
with self._lock:
|
||||
return str(filepath) in self._protected_files
|
||||
|
||||
async def start(self):
|
||||
"""Start the cleanup background task"""
|
||||
if self._running:
|
||||
return
|
||||
|
||||
self._running = True
|
||||
self._task = asyncio.create_task(self._cleanup_loop())
|
||||
logger.info("File cleanup manager started")
|
||||
|
||||
async def stop(self):
|
||||
"""Stop the cleanup background task"""
|
||||
self._running = False
|
||||
if self._task:
|
||||
self._task.cancel()
|
||||
try:
|
||||
await self._task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
logger.info("File cleanup manager stopped")
|
||||
|
||||
async def _cleanup_loop(self):
|
||||
"""Background loop for periodic cleanup"""
|
||||
while self._running:
|
||||
try:
|
||||
await self.cleanup()
|
||||
await self.cleanup_expired()
|
||||
self._stats["cleanup_runs"] += 1
|
||||
except Exception as e:
|
||||
logger.error(f"Cleanup error: {e}")
|
||||
|
||||
await asyncio.sleep(self.cleanup_interval)
|
||||
|
||||
async def cleanup(self) -> dict:
|
||||
"""Perform cleanup of old files"""
|
||||
stats = {
|
||||
"files_deleted": 0,
|
||||
"bytes_freed": 0,
|
||||
"errors": []
|
||||
}
|
||||
|
||||
now = time.time()
|
||||
|
||||
# Cleanup each directory
|
||||
for directory in [self.upload_dir, self.output_dir, self.temp_dir]:
|
||||
if not directory.exists():
|
||||
continue
|
||||
|
||||
for filepath in directory.iterdir():
|
||||
if not filepath.is_file():
|
||||
continue
|
||||
|
||||
# Skip protected files
|
||||
if self.is_protected(filepath):
|
||||
continue
|
||||
|
||||
try:
|
||||
# Check file age
|
||||
file_age = now - filepath.stat().st_mtime
|
||||
|
||||
if file_age > self.max_file_age_seconds:
|
||||
file_size = filepath.stat().st_size
|
||||
filepath.unlink()
|
||||
stats["files_deleted"] += 1
|
||||
stats["bytes_freed"] += file_size
|
||||
logger.debug(f"Deleted old file: {filepath}")
|
||||
|
||||
except Exception as e:
|
||||
stats["errors"].append(str(e))
|
||||
logger.warning(f"Failed to delete {filepath}: {e}")
|
||||
|
||||
# Force cleanup if total size exceeds limit
|
||||
await self._enforce_size_limit(stats)
|
||||
|
||||
if stats["files_deleted"] > 0:
|
||||
mb_freed = stats["bytes_freed"] / (1024 * 1024)
|
||||
logger.info(f"Cleanup: deleted {stats['files_deleted']} files, freed {mb_freed:.2f}MB")
|
||||
|
||||
return stats
|
||||
|
||||
async def _enforce_size_limit(self, stats: dict):
|
||||
"""Delete oldest files if total size exceeds limit"""
|
||||
files_with_mtime = []
|
||||
total_size = 0
|
||||
|
||||
for directory in [self.upload_dir, self.output_dir, self.temp_dir]:
|
||||
if not directory.exists():
|
||||
continue
|
||||
|
||||
for filepath in directory.iterdir():
|
||||
if not filepath.is_file() or self.is_protected(filepath):
|
||||
continue
|
||||
|
||||
try:
|
||||
stat = filepath.stat()
|
||||
files_with_mtime.append((filepath, stat.st_mtime, stat.st_size))
|
||||
total_size += stat.st_size
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# If under limit, nothing to do
|
||||
if total_size <= self.max_total_size_bytes:
|
||||
return
|
||||
|
||||
# Sort by modification time (oldest first)
|
||||
files_with_mtime.sort(key=lambda x: x[1])
|
||||
|
||||
# Delete oldest files until under limit
|
||||
for filepath, _, size in files_with_mtime:
|
||||
if total_size <= self.max_total_size_bytes:
|
||||
break
|
||||
|
||||
try:
|
||||
filepath.unlink()
|
||||
total_size -= size
|
||||
stats["files_deleted"] += 1
|
||||
stats["bytes_freed"] += size
|
||||
logger.info(f"Deleted file to free space: {filepath}")
|
||||
except Exception as e:
|
||||
stats["errors"].append(str(e))
|
||||
|
||||
def get_disk_usage(self) -> dict:
|
||||
"""Get current disk usage statistics"""
|
||||
total_files = 0
|
||||
total_size = 0
|
||||
|
||||
for directory in [self.upload_dir, self.output_dir, self.temp_dir]:
|
||||
if not directory.exists():
|
||||
continue
|
||||
|
||||
for filepath in directory.iterdir():
|
||||
if filepath.is_file():
|
||||
total_files += 1
|
||||
try:
|
||||
total_size += filepath.stat().st_size
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {
|
||||
"total_files": total_files,
|
||||
"total_size_mb": round(total_size / (1024 * 1024), 2),
|
||||
"max_size_gb": self.max_total_size_bytes / (1024 * 1024 * 1024),
|
||||
"usage_percent": round((total_size / self.max_total_size_bytes) * 100, 1) if self.max_total_size_bytes > 0 else 0,
|
||||
"directories": {
|
||||
"uploads": str(self.upload_dir),
|
||||
"outputs": str(self.output_dir),
|
||||
"temp": str(self.temp_dir)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class MemoryMonitor:
|
||||
"""Monitors memory usage and triggers cleanup if needed"""
|
||||
|
||||
def __init__(self, max_memory_percent: float = 80.0):
|
||||
self.max_memory_percent = max_memory_percent
|
||||
self._high_memory_callbacks = []
|
||||
|
||||
def get_memory_usage(self) -> dict:
|
||||
"""Get current memory usage"""
|
||||
try:
|
||||
import psutil
|
||||
process = psutil.Process()
|
||||
memory_info = process.memory_info()
|
||||
system_memory = psutil.virtual_memory()
|
||||
|
||||
return {
|
||||
"process_rss_mb": round(memory_info.rss / (1024 * 1024), 2),
|
||||
"process_vms_mb": round(memory_info.vms / (1024 * 1024), 2),
|
||||
"system_total_gb": round(system_memory.total / (1024 * 1024 * 1024), 2),
|
||||
"system_available_gb": round(system_memory.available / (1024 * 1024 * 1024), 2),
|
||||
"system_percent": system_memory.percent
|
||||
}
|
||||
except ImportError:
|
||||
return {"error": "psutil not installed"}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
def check_memory(self) -> bool:
|
||||
"""Check if memory usage is within limits"""
|
||||
usage = self.get_memory_usage()
|
||||
if "error" in usage:
|
||||
return True # Can't check, assume OK
|
||||
|
||||
return usage.get("system_percent", 0) < self.max_memory_percent
|
||||
|
||||
def on_high_memory(self, callback):
|
||||
"""Register callback for high memory situations"""
|
||||
self._high_memory_callbacks.append(callback)
|
||||
|
||||
|
||||
class HealthChecker:
|
||||
"""Comprehensive health checking for the application"""
|
||||
|
||||
def __init__(self, cleanup_manager: FileCleanupManager, memory_monitor: MemoryMonitor):
|
||||
self.cleanup_manager = cleanup_manager
|
||||
self.memory_monitor = memory_monitor
|
||||
self.start_time = datetime.now()
|
||||
self._translation_count = 0
|
||||
self._error_count = 0
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def record_translation(self, success: bool = True):
|
||||
"""Record a translation attempt"""
|
||||
with self._lock:
|
||||
self._translation_count += 1
|
||||
if not success:
|
||||
self._error_count += 1
|
||||
|
||||
async def check_health(self) -> dict:
|
||||
"""Get comprehensive health status (async version)"""
|
||||
return self.get_health()
|
||||
|
||||
def get_health(self) -> dict:
|
||||
"""Get comprehensive health status"""
|
||||
memory = self.memory_monitor.get_memory_usage()
|
||||
disk = self.cleanup_manager.get_disk_usage()
|
||||
|
||||
# Determine overall status
|
||||
status = "healthy"
|
||||
issues = []
|
||||
|
||||
if "error" not in memory:
|
||||
if memory.get("system_percent", 0) > 90:
|
||||
status = "degraded"
|
||||
issues.append("High memory usage")
|
||||
elif memory.get("system_percent", 0) > 80:
|
||||
issues.append("Memory usage elevated")
|
||||
|
||||
if disk.get("usage_percent", 0) > 90:
|
||||
status = "degraded"
|
||||
issues.append("High disk usage")
|
||||
elif disk.get("usage_percent", 0) > 80:
|
||||
issues.append("Disk usage elevated")
|
||||
|
||||
uptime = datetime.now() - self.start_time
|
||||
|
||||
return {
|
||||
"status": status,
|
||||
"issues": issues,
|
||||
"uptime_seconds": int(uptime.total_seconds()),
|
||||
"uptime_human": str(uptime).split('.')[0],
|
||||
"translations": {
|
||||
"total": self._translation_count,
|
||||
"errors": self._error_count,
|
||||
"success_rate": round(
|
||||
((self._translation_count - self._error_count) / self._translation_count * 100)
|
||||
if self._translation_count > 0 else 100, 1
|
||||
)
|
||||
},
|
||||
"memory": memory,
|
||||
"disk": disk,
|
||||
"cleanup_service": self.cleanup_manager.get_stats(),
|
||||
"timestamp": datetime.now().isoformat()
|
||||
}
|
||||
|
||||
|
||||
# Create default instances
|
||||
def create_cleanup_manager(config) -> FileCleanupManager:
|
||||
"""Create cleanup manager with config"""
|
||||
return FileCleanupManager(
|
||||
upload_dir=config.UPLOAD_DIR,
|
||||
output_dir=config.OUTPUT_DIR,
|
||||
temp_dir=config.TEMP_DIR,
|
||||
max_file_age_hours=getattr(config, 'MAX_FILE_AGE_HOURS', 1),
|
||||
cleanup_interval_minutes=getattr(config, 'CLEANUP_INTERVAL_MINUTES', 10),
|
||||
max_total_size_gb=getattr(config, 'MAX_TOTAL_SIZE_GB', 10.0)
|
||||
)
|
||||
328
middleware/rate_limiting.py
Normal file
328
middleware/rate_limiting.py
Normal file
@@ -0,0 +1,328 @@
|
||||
"""
|
||||
Rate Limiting Middleware for SaaS robustness
|
||||
Protects against abuse and ensures fair usage
|
||||
"""
|
||||
import time
|
||||
import asyncio
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Dict, Optional
|
||||
from fastapi import Request, HTTPException
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.responses import JSONResponse
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class RateLimitConfig:
|
||||
"""Configuration for rate limiting"""
|
||||
# Requests per window
|
||||
requests_per_minute: int = 30
|
||||
requests_per_hour: int = 200
|
||||
requests_per_day: int = 1000
|
||||
|
||||
# Translation-specific limits
|
||||
translations_per_minute: int = 10
|
||||
translations_per_hour: int = 50
|
||||
max_concurrent_translations: int = 5
|
||||
|
||||
# File size limits (MB)
|
||||
max_file_size_mb: int = 50
|
||||
max_total_size_per_hour_mb: int = 500
|
||||
|
||||
# Burst protection
|
||||
burst_limit: int = 10 # Max requests in 1 second
|
||||
|
||||
# Whitelist IPs (no rate limiting)
|
||||
whitelist_ips: list = field(default_factory=lambda: ["127.0.0.1", "::1"])
|
||||
|
||||
|
||||
class TokenBucket:
|
||||
"""Token bucket algorithm for rate limiting"""
|
||||
|
||||
def __init__(self, capacity: int, refill_rate: float):
|
||||
self.capacity = capacity
|
||||
self.refill_rate = refill_rate # tokens per second
|
||||
self.tokens = capacity
|
||||
self.last_refill = time.time()
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def consume(self, tokens: int = 1) -> bool:
|
||||
"""Try to consume tokens, return True if successful"""
|
||||
async with self._lock:
|
||||
self._refill()
|
||||
if self.tokens >= tokens:
|
||||
self.tokens -= tokens
|
||||
return True
|
||||
return False
|
||||
|
||||
def _refill(self):
|
||||
"""Refill tokens based on time elapsed"""
|
||||
now = time.time()
|
||||
elapsed = now - self.last_refill
|
||||
self.tokens = min(self.capacity, self.tokens + elapsed * self.refill_rate)
|
||||
self.last_refill = now
|
||||
|
||||
|
||||
class SlidingWindowCounter:
|
||||
"""Sliding window counter for accurate rate limiting"""
|
||||
|
||||
def __init__(self, window_seconds: int, max_requests: int):
|
||||
self.window_seconds = window_seconds
|
||||
self.max_requests = max_requests
|
||||
self.requests: list = []
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def is_allowed(self) -> bool:
|
||||
"""Check if a new request is allowed"""
|
||||
async with self._lock:
|
||||
now = time.time()
|
||||
# Remove old requests outside the window
|
||||
self.requests = [ts for ts in self.requests if now - ts < self.window_seconds]
|
||||
|
||||
if len(self.requests) < self.max_requests:
|
||||
self.requests.append(now)
|
||||
return True
|
||||
return False
|
||||
|
||||
@property
|
||||
def current_count(self) -> int:
|
||||
"""Get current request count in window"""
|
||||
now = time.time()
|
||||
return len([ts for ts in self.requests if now - ts < self.window_seconds])
|
||||
|
||||
|
||||
class ClientRateLimiter:
|
||||
"""Per-client rate limiter with multiple windows"""
|
||||
|
||||
def __init__(self, config: RateLimitConfig):
|
||||
self.config = config
|
||||
self.minute_counter = SlidingWindowCounter(60, config.requests_per_minute)
|
||||
self.hour_counter = SlidingWindowCounter(3600, config.requests_per_hour)
|
||||
self.day_counter = SlidingWindowCounter(86400, config.requests_per_day)
|
||||
self.burst_bucket = TokenBucket(config.burst_limit, config.burst_limit)
|
||||
self.translation_minute = SlidingWindowCounter(60, config.translations_per_minute)
|
||||
self.translation_hour = SlidingWindowCounter(3600, config.translations_per_hour)
|
||||
self.concurrent_translations = 0
|
||||
self.total_size_hour: list = [] # List of (timestamp, size_mb)
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def check_request(self) -> tuple[bool, str]:
|
||||
"""Check if request is allowed, return (allowed, reason)"""
|
||||
# Check burst limit
|
||||
if not await self.burst_bucket.consume():
|
||||
return False, "Too many requests. Please slow down."
|
||||
|
||||
# Check minute limit
|
||||
if not await self.minute_counter.is_allowed():
|
||||
return False, f"Rate limit exceeded. Max {self.config.requests_per_minute} requests per minute."
|
||||
|
||||
# Check hour limit
|
||||
if not await self.hour_counter.is_allowed():
|
||||
return False, f"Hourly limit exceeded. Max {self.config.requests_per_hour} requests per hour."
|
||||
|
||||
# Check day limit
|
||||
if not await self.day_counter.is_allowed():
|
||||
return False, f"Daily limit exceeded. Max {self.config.requests_per_day} requests per day."
|
||||
|
||||
return True, ""
|
||||
|
||||
async def check_translation(self, file_size_mb: float = 0) -> tuple[bool, str]:
|
||||
"""Check if translation request is allowed"""
|
||||
async with self._lock:
|
||||
# Check concurrent limit
|
||||
if self.concurrent_translations >= self.config.max_concurrent_translations:
|
||||
return False, f"Too many concurrent translations. Max {self.config.max_concurrent_translations} at a time."
|
||||
|
||||
# Check translation per minute
|
||||
if not await self.translation_minute.is_allowed():
|
||||
return False, f"Translation rate limit exceeded. Max {self.config.translations_per_minute} translations per minute."
|
||||
|
||||
# Check translation per hour
|
||||
if not await self.translation_hour.is_allowed():
|
||||
return False, f"Hourly translation limit exceeded. Max {self.config.translations_per_hour} translations per hour."
|
||||
|
||||
# Check total size per hour
|
||||
async with self._lock:
|
||||
now = time.time()
|
||||
self.total_size_hour = [(ts, size) for ts, size in self.total_size_hour if now - ts < 3600]
|
||||
total_size = sum(size for _, size in self.total_size_hour)
|
||||
|
||||
if total_size + file_size_mb > self.config.max_total_size_per_hour_mb:
|
||||
return False, f"Hourly data limit exceeded. Max {self.config.max_total_size_per_hour_mb}MB per hour."
|
||||
|
||||
self.total_size_hour.append((now, file_size_mb))
|
||||
|
||||
return True, ""
|
||||
|
||||
async def start_translation(self):
|
||||
"""Mark start of translation"""
|
||||
async with self._lock:
|
||||
self.concurrent_translations += 1
|
||||
|
||||
async def end_translation(self):
|
||||
"""Mark end of translation"""
|
||||
async with self._lock:
|
||||
self.concurrent_translations = max(0, self.concurrent_translations - 1)
|
||||
|
||||
def get_stats(self) -> dict:
|
||||
"""Get current rate limit stats"""
|
||||
return {
|
||||
"requests_minute": self.minute_counter.current_count,
|
||||
"requests_hour": self.hour_counter.current_count,
|
||||
"requests_day": self.day_counter.current_count,
|
||||
"translations_minute": self.translation_minute.current_count,
|
||||
"translations_hour": self.translation_hour.current_count,
|
||||
"concurrent_translations": self.concurrent_translations,
|
||||
}
|
||||
|
||||
|
||||
class RateLimitManager:
|
||||
"""Manages rate limiters for all clients"""
|
||||
|
||||
def __init__(self, config: Optional[RateLimitConfig] = None):
|
||||
self.config = config or RateLimitConfig()
|
||||
self.clients: Dict[str, ClientRateLimiter] = defaultdict(lambda: ClientRateLimiter(self.config))
|
||||
self._cleanup_interval = 3600 # Cleanup old clients every hour
|
||||
self._last_cleanup = time.time()
|
||||
self._total_requests = 0
|
||||
self._total_translations = 0
|
||||
|
||||
def get_client_id(self, request: Request) -> str:
|
||||
"""Extract client identifier from request"""
|
||||
# Try to get real IP from headers (for proxied requests)
|
||||
forwarded = request.headers.get("X-Forwarded-For")
|
||||
if forwarded:
|
||||
return forwarded.split(",")[0].strip()
|
||||
|
||||
real_ip = request.headers.get("X-Real-IP")
|
||||
if real_ip:
|
||||
return real_ip
|
||||
|
||||
# Fall back to direct client IP
|
||||
if request.client:
|
||||
return request.client.host
|
||||
|
||||
return "unknown"
|
||||
|
||||
def is_whitelisted(self, client_id: str) -> bool:
|
||||
"""Check if client is whitelisted"""
|
||||
return client_id in self.config.whitelist_ips
|
||||
|
||||
async def check_request(self, request: Request) -> tuple[bool, str, str]:
|
||||
"""Check if request is allowed, return (allowed, reason, client_id)"""
|
||||
client_id = self.get_client_id(request)
|
||||
self._total_requests += 1
|
||||
|
||||
if self.is_whitelisted(client_id):
|
||||
return True, "", client_id
|
||||
|
||||
client = self.clients[client_id]
|
||||
allowed, reason = await client.check_request()
|
||||
|
||||
return allowed, reason, client_id
|
||||
|
||||
async def check_translation(self, request: Request, file_size_mb: float = 0) -> tuple[bool, str]:
|
||||
"""Check if translation is allowed"""
|
||||
client_id = self.get_client_id(request)
|
||||
self._total_translations += 1
|
||||
|
||||
if self.is_whitelisted(client_id):
|
||||
return True, ""
|
||||
|
||||
client = self.clients[client_id]
|
||||
return await client.check_translation(file_size_mb)
|
||||
|
||||
async def check_translation_limit(self, client_id: str, file_size_mb: float = 0) -> bool:
|
||||
"""Check if translation is allowed for a specific client ID"""
|
||||
if self.is_whitelisted(client_id):
|
||||
return True
|
||||
|
||||
client = self.clients[client_id]
|
||||
allowed, _ = await client.check_translation(file_size_mb)
|
||||
return allowed
|
||||
|
||||
def get_client_stats(self, request: Request) -> dict:
|
||||
"""Get rate limit stats for a client"""
|
||||
client_id = self.get_client_id(request)
|
||||
client = self.clients[client_id]
|
||||
return {
|
||||
"client_id": client_id,
|
||||
"is_whitelisted": self.is_whitelisted(client_id),
|
||||
**client.get_stats()
|
||||
}
|
||||
|
||||
async def get_client_status(self, client_id: str) -> dict:
|
||||
"""Get current usage status for a client"""
|
||||
if client_id not in self.clients:
|
||||
return {"status": "no_activity", "requests": 0}
|
||||
|
||||
client = self.clients[client_id]
|
||||
stats = client.get_stats()
|
||||
|
||||
return {
|
||||
"requests_used_minute": stats["requests_minute"],
|
||||
"requests_used_hour": stats["requests_hour"],
|
||||
"translations_used_minute": stats["translations_minute"],
|
||||
"translations_used_hour": stats["translations_hour"],
|
||||
"concurrent_translations": stats["concurrent_translations"],
|
||||
"is_whitelisted": self.is_whitelisted(client_id)
|
||||
}
|
||||
|
||||
def get_stats(self) -> dict:
|
||||
"""Get global rate limiting statistics"""
|
||||
return {
|
||||
"total_requests": self._total_requests,
|
||||
"total_translations": self._total_translations,
|
||||
"active_clients": len(self.clients),
|
||||
"config": {
|
||||
"requests_per_minute": self.config.requests_per_minute,
|
||||
"requests_per_hour": self.config.requests_per_hour,
|
||||
"translations_per_minute": self.config.translations_per_minute,
|
||||
"translations_per_hour": self.config.translations_per_hour,
|
||||
"max_concurrent_translations": self.config.max_concurrent_translations
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class RateLimitMiddleware(BaseHTTPMiddleware):
|
||||
"""FastAPI middleware for rate limiting"""
|
||||
|
||||
def __init__(self, app, rate_limit_manager: RateLimitManager):
|
||||
super().__init__(app)
|
||||
self.manager = rate_limit_manager
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
# Skip rate limiting for health checks and static files
|
||||
if request.url.path in ["/health", "/", "/docs", "/openapi.json", "/redoc"]:
|
||||
return await call_next(request)
|
||||
|
||||
if request.url.path.startswith("/static"):
|
||||
return await call_next(request)
|
||||
|
||||
# Check rate limit
|
||||
allowed, reason, client_id = await self.manager.check_request(request)
|
||||
|
||||
if not allowed:
|
||||
logger.warning(f"Rate limit exceeded for {client_id}: {reason}")
|
||||
return JSONResponse(
|
||||
status_code=429,
|
||||
content={
|
||||
"error": "rate_limit_exceeded",
|
||||
"message": reason,
|
||||
"retry_after": 60
|
||||
},
|
||||
headers={"Retry-After": "60"}
|
||||
)
|
||||
|
||||
# Add client info to request state for use in endpoints
|
||||
request.state.client_id = client_id
|
||||
request.state.rate_limiter = self.manager.clients[client_id]
|
||||
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
# Global rate limit manager
|
||||
rate_limit_manager = RateLimitManager()
|
||||
142
middleware/security.py
Normal file
142
middleware/security.py
Normal file
@@ -0,0 +1,142 @@
|
||||
"""
|
||||
Security Headers Middleware for SaaS robustness
|
||||
Adds security headers to all responses
|
||||
"""
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
||||
"""Add security headers to all responses"""
|
||||
|
||||
def __init__(self, app, config: dict = None):
|
||||
super().__init__(app)
|
||||
self.config = config or {}
|
||||
|
||||
async def dispatch(self, request: Request, call_next) -> Response:
|
||||
response = await call_next(request)
|
||||
|
||||
# Prevent clickjacking
|
||||
response.headers["X-Frame-Options"] = "DENY"
|
||||
|
||||
# Prevent MIME type sniffing
|
||||
response.headers["X-Content-Type-Options"] = "nosniff"
|
||||
|
||||
# Enable XSS filter
|
||||
response.headers["X-XSS-Protection"] = "1; mode=block"
|
||||
|
||||
# Referrer policy
|
||||
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
|
||||
|
||||
# Permissions policy
|
||||
response.headers["Permissions-Policy"] = "geolocation=(), microphone=(), camera=()"
|
||||
|
||||
# Content Security Policy (adjust for your frontend)
|
||||
if not request.url.path.startswith("/docs") and not request.url.path.startswith("/redoc"):
|
||||
response.headers["Content-Security-Policy"] = (
|
||||
"default-src 'self'; "
|
||||
"script-src 'self' 'unsafe-inline' 'unsafe-eval' blob:; "
|
||||
"style-src 'self' 'unsafe-inline'; "
|
||||
"img-src 'self' data: blob:; "
|
||||
"font-src 'self' data:; "
|
||||
"connect-src 'self' http://localhost:* https://localhost:* ws://localhost:*; "
|
||||
"worker-src 'self' blob:; "
|
||||
)
|
||||
|
||||
# HSTS (only in production with HTTPS)
|
||||
if self.config.get("enable_hsts", False):
|
||||
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
|
||||
|
||||
return response
|
||||
|
||||
|
||||
class RequestLoggingMiddleware(BaseHTTPMiddleware):
|
||||
"""Log all requests for monitoring and debugging"""
|
||||
|
||||
def __init__(self, app, log_body: bool = False):
|
||||
super().__init__(app)
|
||||
self.log_body = log_body
|
||||
|
||||
async def dispatch(self, request: Request, call_next) -> Response:
|
||||
import time
|
||||
import uuid
|
||||
|
||||
# Generate request ID
|
||||
request_id = str(uuid.uuid4())[:8]
|
||||
request.state.request_id = request_id
|
||||
|
||||
# Get client info
|
||||
client_ip = self._get_client_ip(request)
|
||||
|
||||
# Log request start
|
||||
start_time = time.time()
|
||||
logger.info(
|
||||
f"[{request_id}] {request.method} {request.url.path} "
|
||||
f"from {client_ip} - Started"
|
||||
)
|
||||
|
||||
try:
|
||||
response = await call_next(request)
|
||||
|
||||
# Log request completion
|
||||
duration = time.time() - start_time
|
||||
logger.info(
|
||||
f"[{request_id}] {request.method} {request.url.path} "
|
||||
f"- {response.status_code} in {duration:.3f}s"
|
||||
)
|
||||
|
||||
# Add request ID to response headers
|
||||
response.headers["X-Request-ID"] = request_id
|
||||
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
duration = time.time() - start_time
|
||||
logger.error(
|
||||
f"[{request_id}] {request.method} {request.url.path} "
|
||||
f"- ERROR in {duration:.3f}s: {str(e)}"
|
||||
)
|
||||
raise
|
||||
|
||||
def _get_client_ip(self, request: Request) -> str:
|
||||
"""Get real client IP from headers or connection"""
|
||||
forwarded = request.headers.get("X-Forwarded-For")
|
||||
if forwarded:
|
||||
return forwarded.split(",")[0].strip()
|
||||
|
||||
real_ip = request.headers.get("X-Real-IP")
|
||||
if real_ip:
|
||||
return real_ip
|
||||
|
||||
if request.client:
|
||||
return request.client.host
|
||||
|
||||
return "unknown"
|
||||
|
||||
|
||||
class ErrorHandlingMiddleware(BaseHTTPMiddleware):
|
||||
"""Catch all unhandled exceptions and return proper error responses"""
|
||||
|
||||
async def dispatch(self, request: Request, call_next) -> Response:
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
try:
|
||||
return await call_next(request)
|
||||
|
||||
except Exception as e:
|
||||
request_id = getattr(request.state, 'request_id', 'unknown')
|
||||
logger.exception(f"[{request_id}] Unhandled exception: {str(e)}")
|
||||
|
||||
# Don't expose internal errors in production
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={
|
||||
"error": "internal_server_error",
|
||||
"message": "An unexpected error occurred. Please try again later.",
|
||||
"request_id": request_id
|
||||
}
|
||||
)
|
||||
440
middleware/validation.py
Normal file
440
middleware/validation.py
Normal file
@@ -0,0 +1,440 @@
|
||||
"""
|
||||
Input Validation Module for SaaS robustness
|
||||
Validates all user inputs before processing
|
||||
"""
|
||||
import re
|
||||
import magic
|
||||
from pathlib import Path
|
||||
from typing import Optional, List, Set
|
||||
from fastapi import UploadFile, HTTPException
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ValidationError(Exception):
|
||||
"""Custom validation error with user-friendly messages"""
|
||||
def __init__(self, message: str, code: str = "validation_error", details: Optional[dict] = None):
|
||||
self.message = message
|
||||
self.code = code
|
||||
self.details = details or {}
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class ValidationResult:
|
||||
"""Result of a validation check"""
|
||||
def __init__(self, is_valid: bool = True, errors: List[str] = None, warnings: List[str] = None, data: dict = None):
|
||||
self.is_valid = is_valid
|
||||
self.errors = errors or []
|
||||
self.warnings = warnings or []
|
||||
self.data = data or {}
|
||||
|
||||
|
||||
class FileValidator:
|
||||
"""Validates uploaded files for security and compatibility"""
|
||||
|
||||
# Allowed MIME types mapped to extensions
|
||||
ALLOWED_MIME_TYPES = {
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": ".xlsx",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": ".docx",
|
||||
"application/vnd.openxmlformats-officedocument.presentationml.presentation": ".pptx",
|
||||
}
|
||||
|
||||
# Magic bytes for Office Open XML files (ZIP format)
|
||||
OFFICE_MAGIC_BYTES = b"PK\x03\x04"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
max_size_mb: int = 50,
|
||||
allowed_extensions: Set[str] = None,
|
||||
scan_content: bool = True
|
||||
):
|
||||
self.max_size_bytes = max_size_mb * 1024 * 1024
|
||||
self.max_size_mb = max_size_mb
|
||||
self.allowed_extensions = allowed_extensions or {".xlsx", ".docx", ".pptx"}
|
||||
self.scan_content = scan_content
|
||||
|
||||
async def validate_async(self, file: UploadFile) -> ValidationResult:
|
||||
"""
|
||||
Validate an uploaded file asynchronously
|
||||
Returns ValidationResult with is_valid, errors, warnings
|
||||
"""
|
||||
errors = []
|
||||
warnings = []
|
||||
data = {}
|
||||
|
||||
try:
|
||||
# Validate filename
|
||||
if not file.filename:
|
||||
errors.append("Filename is required")
|
||||
return ValidationResult(is_valid=False, errors=errors)
|
||||
|
||||
# Sanitize filename
|
||||
try:
|
||||
safe_filename = self._sanitize_filename(file.filename)
|
||||
data["safe_filename"] = safe_filename
|
||||
except ValidationError as e:
|
||||
errors.append(str(e.message))
|
||||
return ValidationResult(is_valid=False, errors=errors)
|
||||
|
||||
# Validate extension
|
||||
try:
|
||||
extension = self._validate_extension(safe_filename)
|
||||
data["extension"] = extension
|
||||
except ValidationError as e:
|
||||
errors.append(str(e.message))
|
||||
return ValidationResult(is_valid=False, errors=errors)
|
||||
|
||||
# Read file content for validation
|
||||
content = await file.read()
|
||||
await file.seek(0) # Reset for later processing
|
||||
|
||||
# Validate file size
|
||||
file_size = len(content)
|
||||
data["size_bytes"] = file_size
|
||||
data["size_mb"] = round(file_size / (1024*1024), 2)
|
||||
|
||||
if file_size > self.max_size_bytes:
|
||||
errors.append(f"File too large. Maximum size is {self.max_size_mb}MB, got {file_size / (1024*1024):.1f}MB")
|
||||
return ValidationResult(is_valid=False, errors=errors, data=data)
|
||||
|
||||
if file_size == 0:
|
||||
errors.append("File is empty")
|
||||
return ValidationResult(is_valid=False, errors=errors, data=data)
|
||||
|
||||
# Warn about large files
|
||||
if file_size > self.max_size_bytes * 0.8:
|
||||
warnings.append(f"File is {data['size_mb']}MB, approaching the {self.max_size_mb}MB limit")
|
||||
|
||||
# Validate magic bytes
|
||||
if self.scan_content:
|
||||
try:
|
||||
self._validate_magic_bytes(content, extension)
|
||||
except ValidationError as e:
|
||||
errors.append(str(e.message))
|
||||
return ValidationResult(is_valid=False, errors=errors, data=data)
|
||||
|
||||
# Validate MIME type
|
||||
try:
|
||||
mime_type = self._detect_mime_type(content)
|
||||
data["mime_type"] = mime_type
|
||||
self._validate_mime_type(mime_type, extension)
|
||||
except ValidationError as e:
|
||||
warnings.append(f"MIME type warning: {e.message}")
|
||||
except Exception:
|
||||
warnings.append("Could not verify MIME type")
|
||||
|
||||
data["original_filename"] = file.filename
|
||||
|
||||
return ValidationResult(is_valid=True, errors=errors, warnings=warnings, data=data)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Validation error: {str(e)}")
|
||||
errors.append(f"Validation failed: {str(e)}")
|
||||
return ValidationResult(is_valid=False, errors=errors, warnings=warnings, data=data)
|
||||
|
||||
async def validate(self, file: UploadFile) -> dict:
|
||||
"""
|
||||
Validate an uploaded file
|
||||
Returns validation info dict or raises ValidationError
|
||||
"""
|
||||
# Validate filename
|
||||
if not file.filename:
|
||||
raise ValidationError(
|
||||
"Filename is required",
|
||||
code="missing_filename"
|
||||
)
|
||||
|
||||
# Sanitize filename
|
||||
safe_filename = self._sanitize_filename(file.filename)
|
||||
|
||||
# Validate extension
|
||||
extension = self._validate_extension(safe_filename)
|
||||
|
||||
# Read file content for validation
|
||||
content = await file.read()
|
||||
await file.seek(0) # Reset for later processing
|
||||
|
||||
# Validate file size
|
||||
file_size = len(content)
|
||||
if file_size > self.max_size_bytes:
|
||||
raise ValidationError(
|
||||
f"File too large. Maximum size is {self.max_size_mb}MB, got {file_size / (1024*1024):.1f}MB",
|
||||
code="file_too_large",
|
||||
details={"max_mb": self.max_size_mb, "actual_mb": round(file_size / (1024*1024), 2)}
|
||||
)
|
||||
|
||||
if file_size == 0:
|
||||
raise ValidationError(
|
||||
"File is empty",
|
||||
code="empty_file"
|
||||
)
|
||||
|
||||
# Validate magic bytes (file signature)
|
||||
if self.scan_content:
|
||||
self._validate_magic_bytes(content, extension)
|
||||
|
||||
# Validate MIME type
|
||||
mime_type = self._detect_mime_type(content)
|
||||
self._validate_mime_type(mime_type, extension)
|
||||
|
||||
return {
|
||||
"original_filename": file.filename,
|
||||
"safe_filename": safe_filename,
|
||||
"extension": extension,
|
||||
"size_bytes": file_size,
|
||||
"size_mb": round(file_size / (1024*1024), 2),
|
||||
"mime_type": mime_type
|
||||
}
|
||||
|
||||
def _sanitize_filename(self, filename: str) -> str:
|
||||
"""Sanitize filename to prevent path traversal and other attacks"""
|
||||
# Remove path components
|
||||
filename = Path(filename).name
|
||||
|
||||
# Remove null bytes and control characters
|
||||
filename = re.sub(r'[\x00-\x1f\x7f-\x9f]', '', filename)
|
||||
|
||||
# Remove potentially dangerous characters
|
||||
filename = re.sub(r'[<>:"/\\|?*]', '_', filename)
|
||||
|
||||
# Limit length
|
||||
if len(filename) > 255:
|
||||
name, ext = filename.rsplit('.', 1) if '.' in filename else (filename, '')
|
||||
filename = name[:250] + ('.' + ext if ext else '')
|
||||
|
||||
# Ensure not empty after sanitization
|
||||
if not filename or filename.strip() == '':
|
||||
raise ValidationError(
|
||||
"Invalid filename",
|
||||
code="invalid_filename"
|
||||
)
|
||||
|
||||
return filename
|
||||
|
||||
def _validate_extension(self, filename: str) -> str:
|
||||
"""Validate and return the file extension"""
|
||||
if '.' not in filename:
|
||||
raise ValidationError(
|
||||
f"File must have an extension. Supported: {', '.join(self.allowed_extensions)}",
|
||||
code="missing_extension",
|
||||
details={"allowed_extensions": list(self.allowed_extensions)}
|
||||
)
|
||||
|
||||
extension = '.' + filename.rsplit('.', 1)[1].lower()
|
||||
|
||||
if extension not in self.allowed_extensions:
|
||||
raise ValidationError(
|
||||
f"File type '{extension}' not supported. Supported types: {', '.join(self.allowed_extensions)}",
|
||||
code="unsupported_file_type",
|
||||
details={"extension": extension, "allowed_extensions": list(self.allowed_extensions)}
|
||||
)
|
||||
|
||||
return extension
|
||||
|
||||
def _validate_magic_bytes(self, content: bytes, extension: str):
|
||||
"""Validate file magic bytes match expected format"""
|
||||
# All supported formats are Office Open XML (ZIP-based)
|
||||
if not content.startswith(self.OFFICE_MAGIC_BYTES):
|
||||
raise ValidationError(
|
||||
"File content does not match expected format. The file may be corrupted or not a valid Office document.",
|
||||
code="invalid_file_content"
|
||||
)
|
||||
|
||||
def _detect_mime_type(self, content: bytes) -> str:
|
||||
"""Detect MIME type from file content"""
|
||||
try:
|
||||
mime = magic.Magic(mime=True)
|
||||
return mime.from_buffer(content)
|
||||
except Exception:
|
||||
# Fallback to basic detection
|
||||
if content.startswith(self.OFFICE_MAGIC_BYTES):
|
||||
return "application/zip"
|
||||
return "application/octet-stream"
|
||||
|
||||
def _validate_mime_type(self, mime_type: str, extension: str):
|
||||
"""Validate MIME type matches extension"""
|
||||
# Office Open XML files may be detected as ZIP
|
||||
allowed_mimes = list(self.ALLOWED_MIME_TYPES.keys()) + ["application/zip", "application/octet-stream"]
|
||||
|
||||
if mime_type not in allowed_mimes:
|
||||
raise ValidationError(
|
||||
f"Invalid file type detected. Expected Office document, got: {mime_type}",
|
||||
code="invalid_mime_type",
|
||||
details={"detected_mime": mime_type}
|
||||
)
|
||||
|
||||
|
||||
class LanguageValidator:
|
||||
"""Validates language codes"""
|
||||
|
||||
SUPPORTED_LANGUAGES = {
|
||||
# ISO 639-1 codes
|
||||
"af", "sq", "am", "ar", "hy", "az", "eu", "be", "bn", "bs",
|
||||
"bg", "ca", "ceb", "zh", "zh-CN", "zh-TW", "co", "hr", "cs",
|
||||
"da", "nl", "en", "eo", "et", "fi", "fr", "fy", "gl", "ka",
|
||||
"de", "el", "gu", "ht", "ha", "haw", "he", "hi", "hmn", "hu",
|
||||
"is", "ig", "id", "ga", "it", "ja", "jv", "kn", "kk", "km",
|
||||
"rw", "ko", "ku", "ky", "lo", "la", "lv", "lt", "lb", "mk",
|
||||
"mg", "ms", "ml", "mt", "mi", "mr", "mn", "my", "ne", "no",
|
||||
"ny", "or", "ps", "fa", "pl", "pt", "pa", "ro", "ru", "sm",
|
||||
"gd", "sr", "st", "sn", "sd", "si", "sk", "sl", "so", "es",
|
||||
"su", "sw", "sv", "tl", "tg", "ta", "tt", "te", "th", "tr",
|
||||
"tk", "uk", "ur", "ug", "uz", "vi", "cy", "xh", "yi", "yo",
|
||||
"zu", "auto"
|
||||
}
|
||||
|
||||
LANGUAGE_NAMES = {
|
||||
"en": "English", "es": "Spanish", "fr": "French", "de": "German",
|
||||
"it": "Italian", "pt": "Portuguese", "ru": "Russian", "zh": "Chinese",
|
||||
"zh-CN": "Chinese (Simplified)", "zh-TW": "Chinese (Traditional)",
|
||||
"ja": "Japanese", "ko": "Korean", "ar": "Arabic", "hi": "Hindi",
|
||||
"nl": "Dutch", "pl": "Polish", "tr": "Turkish", "sv": "Swedish",
|
||||
"da": "Danish", "no": "Norwegian", "fi": "Finnish", "cs": "Czech",
|
||||
"el": "Greek", "th": "Thai", "vi": "Vietnamese", "id": "Indonesian",
|
||||
"uk": "Ukrainian", "ro": "Romanian", "hu": "Hungarian", "auto": "Auto-detect"
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def validate(cls, language_code: str, field_name: str = "language") -> str:
|
||||
"""Validate and normalize language code"""
|
||||
if not language_code:
|
||||
raise ValidationError(
|
||||
f"{field_name} is required",
|
||||
code="missing_language"
|
||||
)
|
||||
|
||||
# Normalize
|
||||
normalized = language_code.strip().lower()
|
||||
|
||||
# Handle common variations
|
||||
if normalized in ["chinese", "cn"]:
|
||||
normalized = "zh-CN"
|
||||
elif normalized in ["chinese-traditional", "tw"]:
|
||||
normalized = "zh-TW"
|
||||
|
||||
if normalized not in cls.SUPPORTED_LANGUAGES:
|
||||
raise ValidationError(
|
||||
f"Unsupported language code: '{language_code}'. See /languages for supported codes.",
|
||||
code="unsupported_language",
|
||||
details={"language": language_code}
|
||||
)
|
||||
|
||||
return normalized
|
||||
|
||||
@classmethod
|
||||
def get_language_name(cls, code: str) -> str:
|
||||
"""Get human-readable language name"""
|
||||
return cls.LANGUAGE_NAMES.get(code, code.upper())
|
||||
|
||||
|
||||
class ProviderValidator:
|
||||
"""Validates translation provider configuration"""
|
||||
|
||||
SUPPORTED_PROVIDERS = {"google", "ollama", "deepl", "libre", "openai", "webllm", "openrouter"}
|
||||
|
||||
@classmethod
|
||||
def validate(cls, provider: str, **kwargs) -> dict:
|
||||
"""Validate provider and its required configuration"""
|
||||
if not provider:
|
||||
raise ValidationError(
|
||||
"Translation provider is required",
|
||||
code="missing_provider"
|
||||
)
|
||||
|
||||
normalized = provider.strip().lower()
|
||||
|
||||
if normalized not in cls.SUPPORTED_PROVIDERS:
|
||||
raise ValidationError(
|
||||
f"Unsupported provider: '{provider}'. Supported: {', '.join(cls.SUPPORTED_PROVIDERS)}",
|
||||
code="unsupported_provider",
|
||||
details={"provider": provider, "supported": list(cls.SUPPORTED_PROVIDERS)}
|
||||
)
|
||||
|
||||
# Provider-specific validation
|
||||
if normalized == "deepl":
|
||||
if not kwargs.get("deepl_api_key"):
|
||||
raise ValidationError(
|
||||
"DeepL API key is required when using DeepL provider",
|
||||
code="missing_deepl_key"
|
||||
)
|
||||
|
||||
elif normalized == "openai":
|
||||
if not kwargs.get("openai_api_key"):
|
||||
raise ValidationError(
|
||||
"OpenAI API key is required when using OpenAI provider",
|
||||
code="missing_openai_key"
|
||||
)
|
||||
|
||||
elif normalized == "ollama":
|
||||
# Ollama doesn't require API key but may need model
|
||||
model = kwargs.get("ollama_model", "")
|
||||
if not model:
|
||||
logger.warning("No Ollama model specified, will use default")
|
||||
|
||||
return {"provider": normalized, "validated": True}
|
||||
|
||||
|
||||
class InputSanitizer:
|
||||
"""Sanitizes user inputs to prevent injection attacks"""
|
||||
|
||||
@staticmethod
|
||||
def sanitize_text(text: str, max_length: int = 10000) -> str:
|
||||
"""Sanitize text input"""
|
||||
if not text:
|
||||
return ""
|
||||
|
||||
# Remove null bytes
|
||||
text = text.replace('\x00', '')
|
||||
|
||||
# Limit length
|
||||
if len(text) > max_length:
|
||||
text = text[:max_length]
|
||||
|
||||
return text.strip()
|
||||
|
||||
@staticmethod
|
||||
def sanitize_language_code(code: str) -> str:
|
||||
"""Sanitize and normalize language code"""
|
||||
if not code:
|
||||
return "auto"
|
||||
|
||||
# Remove dangerous characters, keep only alphanumeric and hyphen
|
||||
code = re.sub(r'[^a-zA-Z0-9\-]', '', code.strip())
|
||||
|
||||
# Limit length
|
||||
if len(code) > 10:
|
||||
code = code[:10]
|
||||
|
||||
return code.lower() if code else "auto"
|
||||
|
||||
@staticmethod
|
||||
def sanitize_url(url: str) -> str:
|
||||
"""Sanitize URL input"""
|
||||
if not url:
|
||||
return ""
|
||||
|
||||
url = url.strip()
|
||||
|
||||
# Basic URL validation
|
||||
if not re.match(r'^https?://', url, re.IGNORECASE):
|
||||
raise ValidationError(
|
||||
"Invalid URL format. Must start with http:// or https://",
|
||||
code="invalid_url"
|
||||
)
|
||||
|
||||
# Remove trailing slashes
|
||||
url = url.rstrip('/')
|
||||
|
||||
return url
|
||||
|
||||
@staticmethod
|
||||
def sanitize_api_key(key: str) -> str:
|
||||
"""Sanitize API key (just trim, no logging)"""
|
||||
if not key:
|
||||
return ""
|
||||
return key.strip()
|
||||
|
||||
|
||||
# Default validators
|
||||
file_validator = FileValidator()
|
||||
1
models/__init__.py
Normal file
1
models/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Models package
|
||||
253
models/subscription.py
Normal file
253
models/subscription.py
Normal file
@@ -0,0 +1,253 @@
|
||||
"""
|
||||
Subscription and User models for the monetization system
|
||||
"""
|
||||
from pydantic import BaseModel, EmailStr, Field
|
||||
from typing import Optional, List, Dict, Any
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class PlanType(str, Enum):
|
||||
FREE = "free"
|
||||
STARTER = "starter"
|
||||
PRO = "pro"
|
||||
BUSINESS = "business"
|
||||
ENTERPRISE = "enterprise"
|
||||
|
||||
|
||||
class SubscriptionStatus(str, Enum):
|
||||
ACTIVE = "active"
|
||||
CANCELED = "canceled"
|
||||
PAST_DUE = "past_due"
|
||||
TRIALING = "trialing"
|
||||
PAUSED = "paused"
|
||||
|
||||
import os
|
||||
|
||||
# Plan definitions with limits
|
||||
# NOTE: Stripe price IDs should be set via environment variables in production
|
||||
# Create products and prices in Stripe Dashboard: https://dashboard.stripe.com/products
|
||||
PLANS = {
|
||||
PlanType.FREE: {
|
||||
"name": "Free",
|
||||
"price_monthly": 0,
|
||||
"price_yearly": 0,
|
||||
"docs_per_month": 3,
|
||||
"max_pages_per_doc": 10,
|
||||
"max_file_size_mb": 5,
|
||||
"providers": ["ollama"], # Only self-hosted
|
||||
"features": [
|
||||
"3 documents per day",
|
||||
"Up to 10 pages per document",
|
||||
"Ollama (self-hosted) only",
|
||||
"Basic support via community",
|
||||
],
|
||||
"api_access": False,
|
||||
"priority_processing": False,
|
||||
"stripe_price_id_monthly": None,
|
||||
"stripe_price_id_yearly": None,
|
||||
},
|
||||
PlanType.STARTER: {
|
||||
"name": "Starter",
|
||||
"price_monthly": 12, # Updated pricing
|
||||
"price_yearly": 120, # 2 months free
|
||||
"docs_per_month": 50,
|
||||
"max_pages_per_doc": 50,
|
||||
"max_file_size_mb": 25,
|
||||
"providers": ["ollama", "google", "libre"],
|
||||
"features": [
|
||||
"50 documents per month",
|
||||
"Up to 50 pages per document",
|
||||
"Google Translate included",
|
||||
"LibreTranslate included",
|
||||
"Email support",
|
||||
],
|
||||
"api_access": False,
|
||||
"priority_processing": False,
|
||||
"stripe_price_id_monthly": os.getenv("STRIPE_PRICE_STARTER_MONTHLY", ""),
|
||||
"stripe_price_id_yearly": os.getenv("STRIPE_PRICE_STARTER_YEARLY", ""),
|
||||
},
|
||||
PlanType.PRO: {
|
||||
"name": "Pro",
|
||||
"price_monthly": 39, # Updated pricing
|
||||
"price_yearly": 390, # 2 months free
|
||||
"docs_per_month": 200,
|
||||
"max_pages_per_doc": 200,
|
||||
"max_file_size_mb": 100,
|
||||
"providers": ["ollama", "google", "deepl", "openai", "libre", "openrouter"],
|
||||
"features": [
|
||||
"200 documents per month",
|
||||
"Up to 200 pages per document",
|
||||
"All translation providers",
|
||||
"DeepL & OpenAI included",
|
||||
"API access (1000 calls/month)",
|
||||
"Priority email support",
|
||||
],
|
||||
"api_access": True,
|
||||
"api_calls_per_month": 1000,
|
||||
"priority_processing": True,
|
||||
"stripe_price_id_monthly": os.getenv("STRIPE_PRICE_PRO_MONTHLY", ""),
|
||||
"stripe_price_id_yearly": os.getenv("STRIPE_PRICE_PRO_YEARLY", ""),
|
||||
},
|
||||
PlanType.BUSINESS: {
|
||||
"name": "Business",
|
||||
"price_monthly": 99, # Updated pricing
|
||||
"price_yearly": 990, # 2 months free
|
||||
"docs_per_month": 1000,
|
||||
"max_pages_per_doc": 500,
|
||||
"max_file_size_mb": 250,
|
||||
"providers": ["ollama", "google", "deepl", "openai", "libre", "openrouter", "azure"],
|
||||
"features": [
|
||||
"1000 documents per month",
|
||||
"Up to 500 pages per document",
|
||||
"All translation providers",
|
||||
"Azure Translator included",
|
||||
"Unlimited API access",
|
||||
"Priority processing queue",
|
||||
"Dedicated support",
|
||||
"Team management (up to 5 users)",
|
||||
],
|
||||
"api_access": True,
|
||||
"api_calls_per_month": -1, # Unlimited
|
||||
"priority_processing": True,
|
||||
"team_seats": 5,
|
||||
"stripe_price_id_monthly": os.getenv("STRIPE_PRICE_BUSINESS_MONTHLY", ""),
|
||||
"stripe_price_id_yearly": os.getenv("STRIPE_PRICE_BUSINESS_YEARLY", ""),
|
||||
},
|
||||
PlanType.ENTERPRISE: {
|
||||
"name": "Enterprise",
|
||||
"price_monthly": -1, # Custom
|
||||
"price_yearly": -1,
|
||||
"docs_per_month": -1, # Unlimited
|
||||
"max_pages_per_doc": -1,
|
||||
"max_file_size_mb": -1,
|
||||
"providers": ["ollama", "google", "deepl", "openai", "libre", "openrouter", "azure", "custom"],
|
||||
"features": [
|
||||
"Unlimited documents",
|
||||
"Unlimited pages",
|
||||
"Custom integrations",
|
||||
"On-premise deployment",
|
||||
"SLA guarantee",
|
||||
"24/7 dedicated support",
|
||||
"Custom AI models",
|
||||
"White-label option",
|
||||
],
|
||||
"api_access": True,
|
||||
"api_calls_per_month": -1,
|
||||
"priority_processing": True,
|
||||
"team_seats": -1, # Unlimited
|
||||
"stripe_price_id_monthly": None, # Contact sales
|
||||
"stripe_price_id_yearly": None,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class User(BaseModel):
|
||||
id: str
|
||||
email: EmailStr
|
||||
name: str
|
||||
password_hash: str
|
||||
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
updated_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
email_verified: bool = False
|
||||
avatar_url: Optional[str] = None
|
||||
|
||||
# Subscription info
|
||||
plan: PlanType = PlanType.FREE
|
||||
subscription_status: SubscriptionStatus = SubscriptionStatus.ACTIVE
|
||||
stripe_customer_id: Optional[str] = None
|
||||
stripe_subscription_id: Optional[str] = None
|
||||
subscription_ends_at: Optional[datetime] = None
|
||||
|
||||
# Usage tracking
|
||||
docs_translated_this_month: int = 0
|
||||
pages_translated_this_month: int = 0
|
||||
api_calls_this_month: int = 0
|
||||
usage_reset_date: datetime = Field(default_factory=datetime.utcnow)
|
||||
|
||||
# Extra credits (purchased separately)
|
||||
extra_credits: int = 0 # Each credit = 1 page
|
||||
|
||||
# Settings
|
||||
default_source_lang: str = "auto"
|
||||
default_target_lang: str = "en"
|
||||
default_provider: str = "google"
|
||||
|
||||
# Ollama self-hosted config
|
||||
ollama_endpoint: Optional[str] = None
|
||||
ollama_model: Optional[str] = None
|
||||
|
||||
|
||||
class UserCreate(BaseModel):
|
||||
email: EmailStr
|
||||
name: str
|
||||
password: str
|
||||
|
||||
|
||||
class UserLogin(BaseModel):
|
||||
email: EmailStr
|
||||
password: str
|
||||
|
||||
|
||||
class UserResponse(BaseModel):
|
||||
id: str
|
||||
email: EmailStr
|
||||
name: str
|
||||
avatar_url: Optional[str] = None
|
||||
plan: PlanType
|
||||
subscription_status: SubscriptionStatus
|
||||
docs_translated_this_month: int
|
||||
pages_translated_this_month: int
|
||||
api_calls_this_month: int
|
||||
extra_credits: int
|
||||
created_at: datetime
|
||||
|
||||
# Plan limits for display
|
||||
plan_limits: Dict[str, Any] = {}
|
||||
|
||||
|
||||
class Subscription(BaseModel):
|
||||
id: str
|
||||
user_id: str
|
||||
plan: PlanType
|
||||
status: SubscriptionStatus
|
||||
stripe_subscription_id: Optional[str] = None
|
||||
stripe_customer_id: Optional[str] = None
|
||||
current_period_start: datetime
|
||||
current_period_end: datetime
|
||||
cancel_at_period_end: bool = False
|
||||
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
|
||||
|
||||
class UsageRecord(BaseModel):
|
||||
id: str
|
||||
user_id: str
|
||||
document_name: str
|
||||
document_type: str # excel, word, pptx
|
||||
pages_count: int
|
||||
source_lang: str
|
||||
target_lang: str
|
||||
provider: str
|
||||
processing_time_seconds: float
|
||||
credits_used: int
|
||||
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
|
||||
|
||||
class CreditPurchase(BaseModel):
|
||||
"""For buying extra credits (pay-per-use)"""
|
||||
id: str
|
||||
user_id: str
|
||||
credits_amount: int
|
||||
price_paid: float # in cents
|
||||
stripe_payment_id: str
|
||||
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
|
||||
|
||||
# Credit packages for purchase
|
||||
CREDIT_PACKAGES = [
|
||||
{"credits": 50, "price": 5.00, "price_per_credit": 0.10, "stripe_price_id": "price_credits_50"},
|
||||
{"credits": 100, "price": 9.00, "price_per_credit": 0.09, "stripe_price_id": "price_credits_100", "popular": True},
|
||||
{"credits": 250, "price": 20.00, "price_per_credit": 0.08, "stripe_price_id": "price_credits_250"},
|
||||
{"credits": 500, "price": 35.00, "price_per_credit": 0.07, "stripe_price_id": "price_credits_500"},
|
||||
{"credits": 1000, "price": 60.00, "price_per_credit": 0.06, "stripe_price_id": "price_credits_1000"},
|
||||
]
|
||||
@@ -1,5 +0,0 @@
|
||||
# Testing requirements
|
||||
requests==2.31.0
|
||||
pytest==7.4.3
|
||||
pytest-asyncio==0.23.2
|
||||
httpx==0.26.0
|
||||
@@ -7,9 +7,28 @@ python-pptx==0.6.23
|
||||
deep-translator==1.11.4
|
||||
python-dotenv==1.0.0
|
||||
pydantic==2.5.3
|
||||
pydantic[email]==2.5.3
|
||||
aiofiles==23.2.1
|
||||
Pillow==10.2.0
|
||||
matplotlib==3.8.2
|
||||
pandas==2.1.4
|
||||
requests==2.31.0
|
||||
ipykernel==6.27.1
|
||||
openai>=1.0.0
|
||||
|
||||
# SaaS robustness dependencies
|
||||
psutil==5.9.8
|
||||
python-magic-bin==0.4.14 # For Windows, use python-magic on Linux
|
||||
|
||||
# Authentication & Payments
|
||||
PyJWT==2.8.0
|
||||
passlib[bcrypt]==1.7.4
|
||||
stripe==7.0.0
|
||||
|
||||
# Session storage & caching (optional but recommended for production)
|
||||
redis==5.0.1
|
||||
|
||||
# Database (recommended for production)
|
||||
sqlalchemy==2.0.25
|
||||
psycopg2-binary==2.9.9 # PostgreSQL driver
|
||||
alembic==1.13.1 # Database migrations
|
||||
|
||||
1
routes/__init__.py
Normal file
1
routes/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Routes package
|
||||
281
routes/auth_routes.py
Normal file
281
routes/auth_routes.py
Normal file
@@ -0,0 +1,281 @@
|
||||
"""
|
||||
Authentication and User API routes
|
||||
"""
|
||||
from fastapi import APIRouter, HTTPException, Depends, Header, Request
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from pydantic import BaseModel, EmailStr
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
from models.subscription import UserCreate, UserLogin, UserResponse, PlanType, PLANS, CREDIT_PACKAGES
|
||||
from services.auth_service import (
|
||||
create_user, authenticate_user, get_user_by_id,
|
||||
create_access_token, create_refresh_token, verify_token,
|
||||
check_usage_limits, update_user
|
||||
)
|
||||
from services.payment_service import (
|
||||
create_checkout_session, create_credits_checkout,
|
||||
cancel_subscription, get_billing_portal_url,
|
||||
handle_webhook, is_stripe_configured
|
||||
)
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api/auth", tags=["Authentication"])
|
||||
security = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
# Request/Response models
|
||||
class TokenResponse(BaseModel):
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
token_type: str = "bearer"
|
||||
user: UserResponse
|
||||
|
||||
|
||||
class RefreshRequest(BaseModel):
|
||||
refresh_token: str
|
||||
|
||||
|
||||
class CheckoutRequest(BaseModel):
|
||||
plan: PlanType
|
||||
billing_period: str = "monthly"
|
||||
|
||||
|
||||
class CreditsCheckoutRequest(BaseModel):
|
||||
package_index: int
|
||||
|
||||
|
||||
# Dependency to get current user
|
||||
async def get_current_user(credentials: Optional[HTTPAuthorizationCredentials] = Depends(security)):
|
||||
if not credentials:
|
||||
return None
|
||||
|
||||
payload = verify_token(credentials.credentials)
|
||||
if not payload:
|
||||
return None
|
||||
|
||||
user = get_user_by_id(payload.get("sub"))
|
||||
return user
|
||||
|
||||
|
||||
async def require_user(credentials: HTTPAuthorizationCredentials = Depends(security)):
|
||||
if not credentials:
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
|
||||
payload = verify_token(credentials.credentials)
|
||||
if not payload:
|
||||
raise HTTPException(status_code=401, detail="Invalid or expired token")
|
||||
|
||||
user = get_user_by_id(payload.get("sub"))
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="User not found")
|
||||
|
||||
return user
|
||||
|
||||
|
||||
def user_to_response(user) -> UserResponse:
|
||||
"""Convert User to UserResponse with plan limits"""
|
||||
plan_limits = PLANS[user.plan]
|
||||
return UserResponse(
|
||||
id=user.id,
|
||||
email=user.email,
|
||||
name=user.name,
|
||||
avatar_url=user.avatar_url,
|
||||
plan=user.plan,
|
||||
subscription_status=user.subscription_status,
|
||||
docs_translated_this_month=user.docs_translated_this_month,
|
||||
pages_translated_this_month=user.pages_translated_this_month,
|
||||
api_calls_this_month=user.api_calls_this_month,
|
||||
extra_credits=user.extra_credits,
|
||||
created_at=user.created_at,
|
||||
plan_limits={
|
||||
"docs_per_month": plan_limits["docs_per_month"],
|
||||
"max_pages_per_doc": plan_limits["max_pages_per_doc"],
|
||||
"max_file_size_mb": plan_limits["max_file_size_mb"],
|
||||
"providers": plan_limits["providers"],
|
||||
"features": plan_limits["features"],
|
||||
"api_access": plan_limits.get("api_access", False),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# Auth endpoints
|
||||
@router.post("/register", response_model=TokenResponse)
|
||||
async def register(user_create: UserCreate):
|
||||
"""Register a new user"""
|
||||
try:
|
||||
user = create_user(user_create)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
access_token = create_access_token(user.id)
|
||||
refresh_token = create_refresh_token(user.id)
|
||||
|
||||
return TokenResponse(
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token,
|
||||
user=user_to_response(user)
|
||||
)
|
||||
|
||||
|
||||
@router.post("/login", response_model=TokenResponse)
|
||||
async def login(credentials: UserLogin):
|
||||
"""Login with email and password"""
|
||||
user = authenticate_user(credentials.email, credentials.password)
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="Invalid email or password")
|
||||
|
||||
access_token = create_access_token(user.id)
|
||||
refresh_token = create_refresh_token(user.id)
|
||||
|
||||
return TokenResponse(
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token,
|
||||
user=user_to_response(user)
|
||||
)
|
||||
|
||||
|
||||
@router.post("/refresh", response_model=TokenResponse)
|
||||
async def refresh_tokens(request: RefreshRequest):
|
||||
"""Refresh access token"""
|
||||
payload = verify_token(request.refresh_token)
|
||||
if not payload or payload.get("type") != "refresh":
|
||||
raise HTTPException(status_code=401, detail="Invalid refresh token")
|
||||
|
||||
user = get_user_by_id(payload.get("sub"))
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="User not found")
|
||||
|
||||
access_token = create_access_token(user.id)
|
||||
refresh_token = create_refresh_token(user.id)
|
||||
|
||||
return TokenResponse(
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token,
|
||||
user=user_to_response(user)
|
||||
)
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserResponse)
|
||||
async def get_me(user=Depends(require_user)):
|
||||
"""Get current user info"""
|
||||
return user_to_response(user)
|
||||
|
||||
|
||||
@router.get("/usage")
|
||||
async def get_usage(user=Depends(require_user)):
|
||||
"""Get current usage and limits"""
|
||||
return check_usage_limits(user)
|
||||
|
||||
|
||||
@router.put("/settings")
|
||||
async def update_settings(settings: Dict[str, Any], user=Depends(require_user)):
|
||||
"""Update user settings"""
|
||||
allowed_fields = [
|
||||
"default_source_lang", "default_target_lang", "default_provider",
|
||||
"ollama_endpoint", "ollama_model", "name"
|
||||
]
|
||||
|
||||
updates = {k: v for k, v in settings.items() if k in allowed_fields}
|
||||
updated_user = update_user(user.id, updates)
|
||||
|
||||
if not updated_user:
|
||||
raise HTTPException(status_code=400, detail="Failed to update settings")
|
||||
|
||||
return user_to_response(updated_user)
|
||||
|
||||
|
||||
# Plans endpoint (public)
|
||||
@router.get("/plans")
|
||||
async def get_plans():
|
||||
"""Get all available plans"""
|
||||
plans = []
|
||||
for plan_type, config in PLANS.items():
|
||||
plans.append({
|
||||
"id": plan_type.value,
|
||||
"name": config["name"],
|
||||
"price_monthly": config["price_monthly"],
|
||||
"price_yearly": config["price_yearly"],
|
||||
"features": config["features"],
|
||||
"docs_per_month": config["docs_per_month"],
|
||||
"max_pages_per_doc": config["max_pages_per_doc"],
|
||||
"max_file_size_mb": config["max_file_size_mb"],
|
||||
"providers": config["providers"],
|
||||
"api_access": config.get("api_access", False),
|
||||
"popular": plan_type == PlanType.PRO,
|
||||
})
|
||||
return {"plans": plans, "credit_packages": CREDIT_PACKAGES}
|
||||
|
||||
|
||||
# Payment endpoints
|
||||
@router.post("/checkout/subscription")
|
||||
async def checkout_subscription(request: CheckoutRequest, user=Depends(require_user)):
|
||||
"""Create Stripe checkout session for subscription"""
|
||||
if not is_stripe_configured():
|
||||
# Demo mode - just upgrade the user
|
||||
update_user(user.id, {"plan": request.plan.value})
|
||||
return {"demo_mode": True, "message": "Upgraded in demo mode", "plan": request.plan.value}
|
||||
|
||||
result = await create_checkout_session(
|
||||
user.id,
|
||||
request.plan,
|
||||
request.billing_period
|
||||
)
|
||||
|
||||
if "error" in result:
|
||||
raise HTTPException(status_code=400, detail=result["error"])
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/checkout/credits")
|
||||
async def checkout_credits(request: CreditsCheckoutRequest, user=Depends(require_user)):
|
||||
"""Create Stripe checkout session for credits"""
|
||||
if not is_stripe_configured():
|
||||
# Demo mode - add credits directly
|
||||
from services.auth_service import add_credits
|
||||
credits = CREDIT_PACKAGES[request.package_index]["credits"]
|
||||
add_credits(user.id, credits)
|
||||
return {"demo_mode": True, "message": f"Added {credits} credits in demo mode"}
|
||||
|
||||
result = await create_credits_checkout(user.id, request.package_index)
|
||||
|
||||
if "error" in result:
|
||||
raise HTTPException(status_code=400, detail=result["error"])
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/subscription/cancel")
|
||||
async def cancel_user_subscription(user=Depends(require_user)):
|
||||
"""Cancel subscription"""
|
||||
result = await cancel_subscription(user.id)
|
||||
|
||||
if "error" in result:
|
||||
raise HTTPException(status_code=400, detail=result["error"])
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/billing-portal")
|
||||
async def get_billing_portal(user=Depends(require_user)):
|
||||
"""Get Stripe billing portal URL"""
|
||||
url = await get_billing_portal_url(user.id)
|
||||
|
||||
if not url:
|
||||
raise HTTPException(status_code=400, detail="Billing portal not available")
|
||||
|
||||
return {"url": url}
|
||||
|
||||
|
||||
# Stripe webhook
|
||||
@router.post("/webhook/stripe")
|
||||
async def stripe_webhook(request: Request, stripe_signature: str = Header(None)):
|
||||
"""Handle Stripe webhooks"""
|
||||
payload = await request.body()
|
||||
|
||||
result = await handle_webhook(payload, stripe_signature or "")
|
||||
|
||||
if "error" in result:
|
||||
raise HTTPException(status_code=400, detail=result["error"])
|
||||
|
||||
return result
|
||||
@@ -1 +0,0 @@
|
||||
,ramez,simorgh,30.11.2025 09:24,C:/Users/ramez/AppData/Local/onlyoffice;
|
||||
119
sample_files/webllm.html
Normal file
119
sample_files/webllm.html
Normal file
@@ -0,0 +1,119 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Test LLM Local - WebGPU</title>
|
||||
<style>
|
||||
body { font-family: system-ui, sans-serif; max-width: 800px; margin: 2rem auto; padding: 0 1rem; line-height: 1.5; }
|
||||
#chat-box { border: 1px solid #ccc; padding: 1rem; height: 400px; overflow-y: auto; border-radius: 8px; background: #f9f9f9; margin-bottom: 1rem; }
|
||||
.message { margin-bottom: 1rem; padding: 0.5rem 1rem; border-radius: 8px; }
|
||||
.user { background: #e3f2fd; align-self: flex-end; text-align: right; }
|
||||
.bot { background: #fff; border: 1px solid #eee; }
|
||||
#controls { display: flex; gap: 10px; }
|
||||
input { flex-grow: 1; padding: 10px; border-radius: 4px; border: 1px solid #ddd; }
|
||||
button { padding: 10px 20px; background: #007bff; color: white; border: none; border-radius: 4px; cursor: pointer; }
|
||||
button:disabled { background: #ccc; }
|
||||
#status { font-size: 0.9rem; color: #666; margin-bottom: 1rem; font-style: italic; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<h2>🤖 Mon LLM Local (via Chrome WebGPU)</h2>
|
||||
|
||||
<div id="status">Initialisation... cliquez sur "Charger le modèle" pour commencer.</div>
|
||||
|
||||
<div id="chat-box"></div>
|
||||
|
||||
<div id="controls">
|
||||
<input type="text" id="user-input" placeholder="Écrivez votre message ici..." disabled>
|
||||
<button id="send-btn" disabled>Envoyer</button>
|
||||
</div>
|
||||
|
||||
<button id="load-btn" style="margin-top: 10px; background-color: #28a745;">Charger le Modèle (Llama 3.2)</button>
|
||||
|
||||
<script type="module">
|
||||
// Importation de WebLLM directement depuis le CDN
|
||||
import { CreateMLCEngine } from "https://esm.run/@mlc-ai/web-llm";
|
||||
|
||||
// Configuration du modèle (ici Llama 3.2 1B, léger et rapide)
|
||||
const selectedModel = "Llama-3.2-1B-Instruct-q4f16_1-MLC";
|
||||
|
||||
let engine;
|
||||
const statusLabel = document.getElementById('status');
|
||||
const chatBox = document.getElementById('chat-box');
|
||||
const userInput = document.getElementById('user-input');
|
||||
const sendBtn = document.getElementById('send-btn');
|
||||
const loadBtn = document.getElementById('load-btn');
|
||||
|
||||
// Fonction pour mettre à jour l'état de chargement
|
||||
const initProgressCallback = (report) => {
|
||||
statusLabel.innerText = report.text;
|
||||
};
|
||||
|
||||
// 1. Chargement du moteur (Engine)
|
||||
loadBtn.addEventListener('click', async () => {
|
||||
loadBtn.disabled = true;
|
||||
statusLabel.innerText = "Démarrage du téléchargement du modèle (peut prendre quelques minutes)...";
|
||||
|
||||
try {
|
||||
// Création du moteur WebLLM
|
||||
engine = await CreateMLCEngine(
|
||||
selectedModel,
|
||||
{ initProgressCallback: initProgressCallback }
|
||||
);
|
||||
|
||||
statusLabel.innerText = "✅ Modèle chargé et prêt ! (GPU Actif)";
|
||||
userInput.disabled = false;
|
||||
sendBtn.disabled = false;
|
||||
loadBtn.style.display = 'none';
|
||||
userInput.focus();
|
||||
} catch (err) {
|
||||
statusLabel.innerText = "❌ Erreur : " + err.message;
|
||||
loadBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
// 2. Fonction d'envoi de message
|
||||
sendBtn.addEventListener('click', sendMessage);
|
||||
userInput.addEventListener('keypress', (e) => { if(e.key === 'Enter') sendMessage() });
|
||||
|
||||
async function sendMessage() {
|
||||
const text = userInput.value.trim();
|
||||
if (!text) return;
|
||||
|
||||
// Affichage message utilisateur
|
||||
appendMessage(text, 'user');
|
||||
userInput.value = '';
|
||||
|
||||
// Création placeholder pour la réponse
|
||||
const botMessageDiv = appendMessage("...", 'bot');
|
||||
let fullResponse = "";
|
||||
|
||||
// Inférence (Génération)
|
||||
const chunks = await engine.chat.completions.create({
|
||||
messages: [{ role: "user", content: text }],
|
||||
stream: true, // Important pour voir le texte s'écrire en temps réel
|
||||
});
|
||||
|
||||
// Lecture du flux (Streaming)
|
||||
for await (const chunk of chunks) {
|
||||
const content = chunk.choices[0]?.delta?.content || "";
|
||||
fullResponse += content;
|
||||
botMessageDiv.innerText = fullResponse;
|
||||
// Auto-scroll vers le bas
|
||||
chatBox.scrollTop = chatBox.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
function appendMessage(text, sender) {
|
||||
const div = document.createElement('div');
|
||||
div.classList.add('message', sender);
|
||||
div.innerText = text;
|
||||
chatBox.appendChild(div);
|
||||
chatBox.scrollTop = chatBox.scrollHeight;
|
||||
return div;
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
67
scripts/backup.sh
Normal file
67
scripts/backup.sh
Normal file
@@ -0,0 +1,67 @@
|
||||
#!/bin/bash
|
||||
# ============================================
|
||||
# Document Translation API - Backup Script
|
||||
# ============================================
|
||||
# Usage: ./scripts/backup.sh [backup_dir]
|
||||
|
||||
set -e
|
||||
|
||||
# Configuration
|
||||
BACKUP_DIR="${1:-./backups}"
|
||||
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
|
||||
BACKUP_NAME="translate_backup_$TIMESTAMP"
|
||||
|
||||
# Colors
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m'
|
||||
|
||||
echo -e "${YELLOW}Starting backup: $BACKUP_NAME${NC}"
|
||||
|
||||
# Create backup directory
|
||||
mkdir -p "$BACKUP_DIR/$BACKUP_NAME"
|
||||
|
||||
# Backup uploaded files
|
||||
if [ -d "./uploads" ]; then
|
||||
echo "Backing up uploads..."
|
||||
cp -r ./uploads "$BACKUP_DIR/$BACKUP_NAME/"
|
||||
fi
|
||||
|
||||
# Backup output files
|
||||
if [ -d "./outputs" ]; then
|
||||
echo "Backing up outputs..."
|
||||
cp -r ./outputs "$BACKUP_DIR/$BACKUP_NAME/"
|
||||
fi
|
||||
|
||||
# Backup configuration
|
||||
echo "Backing up configuration..."
|
||||
cp .env* "$BACKUP_DIR/$BACKUP_NAME/" 2>/dev/null || true
|
||||
cp docker-compose*.yml "$BACKUP_DIR/$BACKUP_NAME/" 2>/dev/null || true
|
||||
|
||||
# Backup Docker volumes (if using Docker)
|
||||
if command -v docker &> /dev/null; then
|
||||
echo "Backing up Docker volumes..."
|
||||
|
||||
# Get volume names
|
||||
VOLUMES=$(docker volume ls --format "{{.Name}}" | grep translate || true)
|
||||
|
||||
for vol in $VOLUMES; do
|
||||
echo " Backing up volume: $vol"
|
||||
docker run --rm \
|
||||
-v "$vol:/data:ro" \
|
||||
-v "$(pwd)/$BACKUP_DIR/$BACKUP_NAME:/backup" \
|
||||
alpine tar czf "/backup/${vol}.tar.gz" -C /data . 2>/dev/null || true
|
||||
done
|
||||
fi
|
||||
|
||||
# Compress backup
|
||||
echo "Compressing backup..."
|
||||
cd "$BACKUP_DIR"
|
||||
tar czf "${BACKUP_NAME}.tar.gz" "$BACKUP_NAME"
|
||||
rm -rf "$BACKUP_NAME"
|
||||
|
||||
# Cleanup old backups (keep last 7)
|
||||
echo "Cleaning old backups..."
|
||||
ls -t translate_backup_*.tar.gz 2>/dev/null | tail -n +8 | xargs rm -f 2>/dev/null || true
|
||||
|
||||
echo -e "${GREEN}Backup complete: $BACKUP_DIR/${BACKUP_NAME}.tar.gz${NC}"
|
||||
168
scripts/deploy.sh
Normal file
168
scripts/deploy.sh
Normal file
@@ -0,0 +1,168 @@
|
||||
#!/bin/bash
|
||||
# ============================================
|
||||
# Document Translation API - Deployment Script
|
||||
# ============================================
|
||||
# Usage: ./scripts/deploy.sh [environment] [options]
|
||||
# Example: ./scripts/deploy.sh production --with-ollama
|
||||
|
||||
set -e
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Configuration
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
|
||||
ENVIRONMENT="${1:-production}"
|
||||
COMPOSE_FILE="docker-compose.yml"
|
||||
|
||||
# Parse options
|
||||
PROFILES=""
|
||||
while [[ $# -gt 1 ]]; do
|
||||
case $2 in
|
||||
--with-ollama)
|
||||
PROFILES="$PROFILES --profile with-ollama"
|
||||
shift
|
||||
;;
|
||||
--with-cache)
|
||||
PROFILES="$PROFILES --profile with-cache"
|
||||
shift
|
||||
;;
|
||||
--with-monitoring)
|
||||
PROFILES="$PROFILES --profile with-monitoring"
|
||||
shift
|
||||
;;
|
||||
--full)
|
||||
PROFILES="--profile with-ollama --profile with-cache --profile with-monitoring"
|
||||
shift
|
||||
;;
|
||||
*)
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
echo -e "${BLUE}========================================${NC}"
|
||||
echo -e "${BLUE} Document Translation API Deployment${NC}"
|
||||
echo -e "${BLUE}========================================${NC}"
|
||||
echo ""
|
||||
|
||||
# Check prerequisites
|
||||
echo -e "${YELLOW}Checking prerequisites...${NC}"
|
||||
|
||||
if ! command -v docker &> /dev/null; then
|
||||
echo -e "${RED}Error: Docker is not installed${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v docker-compose &> /dev/null && ! docker compose version &> /dev/null; then
|
||||
echo -e "${RED}Error: Docker Compose is not installed${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo -e "${GREEN}✓ Docker and Docker Compose are installed${NC}"
|
||||
|
||||
# Check environment file
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
if [ "$ENVIRONMENT" == "production" ]; then
|
||||
ENV_FILE=".env.production"
|
||||
else
|
||||
ENV_FILE=".env"
|
||||
fi
|
||||
|
||||
if [ ! -f "$ENV_FILE" ]; then
|
||||
echo -e "${YELLOW}Creating $ENV_FILE from template...${NC}"
|
||||
if [ -f ".env.example" ]; then
|
||||
cp .env.example "$ENV_FILE"
|
||||
echo -e "${YELLOW}Please edit $ENV_FILE with your configuration${NC}"
|
||||
else
|
||||
echo -e "${RED}Error: No environment file found${NC}"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
echo -e "${GREEN}✓ Environment file: $ENV_FILE${NC}"
|
||||
|
||||
# Load environment
|
||||
set -a
|
||||
source "$ENV_FILE"
|
||||
set +a
|
||||
|
||||
# Create SSL directory if needed
|
||||
if [ ! -d "docker/nginx/ssl" ]; then
|
||||
mkdir -p docker/nginx/ssl
|
||||
echo -e "${YELLOW}Created SSL directory. Add your certificates:${NC}"
|
||||
echo " - docker/nginx/ssl/fullchain.pem"
|
||||
echo " - docker/nginx/ssl/privkey.pem"
|
||||
echo " - docker/nginx/ssl/chain.pem"
|
||||
|
||||
# Generate self-signed cert for testing
|
||||
if [ "$ENVIRONMENT" != "production" ]; then
|
||||
echo -e "${YELLOW}Generating self-signed certificate for development...${NC}"
|
||||
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
|
||||
-keyout docker/nginx/ssl/privkey.pem \
|
||||
-out docker/nginx/ssl/fullchain.pem \
|
||||
-subj "/C=US/ST=State/L=City/O=Organization/CN=localhost" 2>/dev/null
|
||||
cp docker/nginx/ssl/fullchain.pem docker/nginx/ssl/chain.pem
|
||||
fi
|
||||
fi
|
||||
|
||||
# Build and deploy
|
||||
echo ""
|
||||
echo -e "${YELLOW}Building containers...${NC}"
|
||||
docker compose --env-file "$ENV_FILE" build
|
||||
|
||||
echo ""
|
||||
echo -e "${YELLOW}Starting services...${NC}"
|
||||
docker compose --env-file "$ENV_FILE" $PROFILES up -d
|
||||
|
||||
# Wait for services to be healthy
|
||||
echo ""
|
||||
echo -e "${YELLOW}Waiting for services to be ready...${NC}"
|
||||
sleep 10
|
||||
|
||||
# Health check
|
||||
echo ""
|
||||
echo -e "${YELLOW}Running health checks...${NC}"
|
||||
|
||||
BACKEND_HEALTH=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:8000/health 2>/dev/null || echo "000")
|
||||
if [ "$BACKEND_HEALTH" == "200" ]; then
|
||||
echo -e "${GREEN}✓ Backend is healthy${NC}"
|
||||
else
|
||||
echo -e "${RED}✗ Backend health check failed (HTTP $BACKEND_HEALTH)${NC}"
|
||||
fi
|
||||
|
||||
FRONTEND_HEALTH=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:3000 2>/dev/null || echo "000")
|
||||
if [ "$FRONTEND_HEALTH" == "200" ]; then
|
||||
echo -e "${GREEN}✓ Frontend is healthy${NC}"
|
||||
else
|
||||
echo -e "${RED}✗ Frontend health check failed (HTTP $FRONTEND_HEALTH)${NC}"
|
||||
fi
|
||||
|
||||
# Show status
|
||||
echo ""
|
||||
echo -e "${BLUE}========================================${NC}"
|
||||
echo -e "${BLUE} Deployment Complete!${NC}"
|
||||
echo -e "${BLUE}========================================${NC}"
|
||||
echo ""
|
||||
echo -e "Services running:"
|
||||
docker compose ps --format "table {{.Name}}\t{{.Status}}\t{{.Ports}}"
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}Access your application:${NC}"
|
||||
echo -e " Frontend: http://localhost (or https://localhost)"
|
||||
echo -e " API: http://localhost/api"
|
||||
echo -e " Admin: http://localhost/admin"
|
||||
echo -e " Health: http://localhost/health"
|
||||
|
||||
echo ""
|
||||
echo -e "${YELLOW}Useful commands:${NC}"
|
||||
echo " View logs: docker compose logs -f"
|
||||
echo " Stop: docker compose down"
|
||||
echo " Restart: docker compose restart"
|
||||
echo " Update: ./scripts/deploy.sh $ENVIRONMENT"
|
||||
87
scripts/health-check.sh
Normal file
87
scripts/health-check.sh
Normal file
@@ -0,0 +1,87 @@
|
||||
#!/bin/bash
|
||||
# ============================================
|
||||
# Document Translation API - Health Check Script
|
||||
# ============================================
|
||||
# Usage: ./scripts/health-check.sh [--verbose]
|
||||
|
||||
set -e
|
||||
|
||||
VERBOSE=false
|
||||
if [ "$1" == "--verbose" ]; then
|
||||
VERBOSE=true
|
||||
fi
|
||||
|
||||
# Colors
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m'
|
||||
|
||||
# Configuration
|
||||
BACKEND_URL="${BACKEND_URL:-http://localhost:8000}"
|
||||
FRONTEND_URL="${FRONTEND_URL:-http://localhost:3000}"
|
||||
NGINX_URL="${NGINX_URL:-http://localhost}"
|
||||
|
||||
EXIT_CODE=0
|
||||
|
||||
check_service() {
|
||||
local name=$1
|
||||
local url=$2
|
||||
local expected=${3:-200}
|
||||
|
||||
response=$(curl -s -o /dev/null -w "%{http_code}" "$url" 2>/dev/null || echo "000")
|
||||
|
||||
if [ "$response" == "$expected" ]; then
|
||||
echo -e "${GREEN}✓ $name: OK (HTTP $response)${NC}"
|
||||
else
|
||||
echo -e "${RED}✗ $name: FAILED (HTTP $response, expected $expected)${NC}"
|
||||
EXIT_CODE=1
|
||||
fi
|
||||
|
||||
if [ "$VERBOSE" == "true" ] && [ "$response" == "200" ]; then
|
||||
echo " Response:"
|
||||
curl -s "$url" 2>/dev/null | head -c 500
|
||||
echo ""
|
||||
fi
|
||||
}
|
||||
|
||||
echo "========================================="
|
||||
echo " Health Check - Document Translation API"
|
||||
echo "========================================="
|
||||
echo ""
|
||||
|
||||
# Check backend
|
||||
echo "Backend Services:"
|
||||
check_service "API Health" "$BACKEND_URL/health"
|
||||
check_service "API Root" "$BACKEND_URL/"
|
||||
|
||||
echo ""
|
||||
|
||||
# Check frontend
|
||||
echo "Frontend Services:"
|
||||
check_service "Frontend" "$FRONTEND_URL"
|
||||
|
||||
echo ""
|
||||
|
||||
# Check nginx (if running)
|
||||
echo "Proxy Services:"
|
||||
check_service "Nginx" "$NGINX_URL/health"
|
||||
|
||||
echo ""
|
||||
|
||||
# Docker health
|
||||
if command -v docker &> /dev/null; then
|
||||
echo "Docker Container Status:"
|
||||
docker compose ps --format "table {{.Name}}\t{{.Status}}" 2>/dev/null || echo " Docker Compose not running"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "========================================="
|
||||
|
||||
if [ $EXIT_CODE -eq 0 ]; then
|
||||
echo -e "${GREEN}All health checks passed!${NC}"
|
||||
else
|
||||
echo -e "${RED}Some health checks failed!${NC}"
|
||||
fi
|
||||
|
||||
exit $EXIT_CODE
|
||||
160
scripts/migrate_to_db.py
Normal file
160
scripts/migrate_to_db.py
Normal file
@@ -0,0 +1,160 @@
|
||||
"""
|
||||
Migration script to move data from JSON files to database
|
||||
Run this once to migrate existing users to the new database system
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
|
||||
# Add parent directory to path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from database.connection import init_db, get_db_session
|
||||
from database.repositories import UserRepository
|
||||
from database.models import PlanType, SubscriptionStatus
|
||||
|
||||
|
||||
def migrate_users_from_json():
|
||||
"""Migrate users from JSON file to database"""
|
||||
json_path = Path("data/users.json")
|
||||
|
||||
if not json_path.exists():
|
||||
print("No users.json found, nothing to migrate")
|
||||
return 0
|
||||
|
||||
# Initialize database
|
||||
print("Initializing database tables...")
|
||||
init_db()
|
||||
|
||||
# Load JSON data
|
||||
with open(json_path, 'r') as f:
|
||||
users_data = json.load(f)
|
||||
|
||||
print(f"Found {len(users_data)} users to migrate")
|
||||
|
||||
migrated = 0
|
||||
skipped = 0
|
||||
errors = 0
|
||||
|
||||
with get_db_session() as db:
|
||||
repo = UserRepository(db)
|
||||
|
||||
for user_id, user_data in users_data.items():
|
||||
try:
|
||||
# Check if user already exists
|
||||
existing = repo.get_by_email(user_data.get('email', ''))
|
||||
if existing:
|
||||
print(f" Skipping {user_data.get('email')} - already exists")
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
# Map plan string to enum
|
||||
plan_str = user_data.get('plan', 'free')
|
||||
try:
|
||||
plan = PlanType(plan_str)
|
||||
except ValueError:
|
||||
plan = PlanType.FREE
|
||||
|
||||
# Map subscription status
|
||||
status_str = user_data.get('subscription_status', 'active')
|
||||
try:
|
||||
status = SubscriptionStatus(status_str)
|
||||
except ValueError:
|
||||
status = SubscriptionStatus.ACTIVE
|
||||
|
||||
# Create user with original ID
|
||||
from database.models import User
|
||||
user = User(
|
||||
id=user_id,
|
||||
email=user_data.get('email', '').lower(),
|
||||
name=user_data.get('name', ''),
|
||||
password_hash=user_data.get('password_hash', ''),
|
||||
email_verified=user_data.get('email_verified', False),
|
||||
avatar_url=user_data.get('avatar_url'),
|
||||
plan=plan,
|
||||
subscription_status=status,
|
||||
stripe_customer_id=user_data.get('stripe_customer_id'),
|
||||
stripe_subscription_id=user_data.get('stripe_subscription_id'),
|
||||
docs_translated_this_month=user_data.get('docs_translated_this_month', 0),
|
||||
pages_translated_this_month=user_data.get('pages_translated_this_month', 0),
|
||||
api_calls_this_month=user_data.get('api_calls_this_month', 0),
|
||||
extra_credits=user_data.get('extra_credits', 0),
|
||||
)
|
||||
|
||||
# Parse dates
|
||||
if user_data.get('created_at'):
|
||||
try:
|
||||
user.created_at = datetime.fromisoformat(user_data['created_at'].replace('Z', '+00:00'))
|
||||
except:
|
||||
pass
|
||||
|
||||
if user_data.get('updated_at'):
|
||||
try:
|
||||
user.updated_at = datetime.fromisoformat(user_data['updated_at'].replace('Z', '+00:00'))
|
||||
except:
|
||||
pass
|
||||
|
||||
db.add(user)
|
||||
db.commit()
|
||||
|
||||
print(f" Migrated: {user.email}")
|
||||
migrated += 1
|
||||
|
||||
except Exception as e:
|
||||
print(f" Error migrating {user_data.get('email', user_id)}: {e}")
|
||||
errors += 1
|
||||
db.rollback()
|
||||
|
||||
print(f"\nMigration complete:")
|
||||
print(f" Migrated: {migrated}")
|
||||
print(f" Skipped: {skipped}")
|
||||
print(f" Errors: {errors}")
|
||||
|
||||
# Backup original file
|
||||
if migrated > 0:
|
||||
backup_path = json_path.with_suffix('.json.bak')
|
||||
os.rename(json_path, backup_path)
|
||||
print(f"\nOriginal file backed up to: {backup_path}")
|
||||
|
||||
return migrated
|
||||
|
||||
|
||||
def verify_migration():
|
||||
"""Verify the migration was successful"""
|
||||
from database.connection import get_db_session
|
||||
from database.repositories import UserRepository
|
||||
|
||||
with get_db_session() as db:
|
||||
repo = UserRepository(db)
|
||||
count = repo.count_users()
|
||||
print(f"\nDatabase now contains {count} users")
|
||||
|
||||
# List first 5 users
|
||||
users = repo.get_all_users(limit=5)
|
||||
if users:
|
||||
print("\nSample users:")
|
||||
for user in users:
|
||||
print(f" - {user.email} ({user.plan.value})")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("=" * 50)
|
||||
print("JSON to Database Migration Script")
|
||||
print("=" * 50)
|
||||
|
||||
# Check environment
|
||||
db_url = os.getenv("DATABASE_URL", "")
|
||||
if db_url:
|
||||
print(f"Database: PostgreSQL")
|
||||
else:
|
||||
print(f"Database: SQLite (development)")
|
||||
|
||||
print()
|
||||
|
||||
# Run migration
|
||||
migrate_users_from_json()
|
||||
|
||||
# Verify
|
||||
verify_migration()
|
||||
79
scripts/setup-ssl.sh
Normal file
79
scripts/setup-ssl.sh
Normal file
@@ -0,0 +1,79 @@
|
||||
#!/bin/bash
|
||||
# ============================================
|
||||
# Document Translation API - SSL Setup Script
|
||||
# ============================================
|
||||
# Usage: ./scripts/setup-ssl.sh <domain> <email>
|
||||
# Example: ./scripts/setup-ssl.sh translate.example.com admin@example.com
|
||||
|
||||
set -e
|
||||
|
||||
DOMAIN="${1:-}"
|
||||
EMAIL="${2:-}"
|
||||
|
||||
if [ -z "$DOMAIN" ] || [ -z "$EMAIL" ]; then
|
||||
echo "Usage: ./scripts/setup-ssl.sh <domain> <email>"
|
||||
echo "Example: ./scripts/setup-ssl.sh translate.example.com admin@example.com"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Colors
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m'
|
||||
|
||||
echo -e "${YELLOW}Setting up SSL for $DOMAIN${NC}"
|
||||
|
||||
# Create directory for certbot
|
||||
mkdir -p ./docker/certbot/www
|
||||
mkdir -p ./docker/certbot/conf
|
||||
|
||||
# Create initial nginx config for ACME challenge
|
||||
cat > ./docker/nginx/conf.d/certbot.conf << EOF
|
||||
server {
|
||||
listen 80;
|
||||
server_name $DOMAIN;
|
||||
|
||||
location /.well-known/acme-challenge/ {
|
||||
root /var/www/certbot;
|
||||
}
|
||||
|
||||
location / {
|
||||
return 301 https://\$host\$request_uri;
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
# Start nginx with HTTP only
|
||||
echo "Starting nginx for certificate request..."
|
||||
docker compose up -d nginx
|
||||
|
||||
# Request certificate
|
||||
echo "Requesting Let's Encrypt certificate..."
|
||||
docker run --rm \
|
||||
-v "$(pwd)/docker/certbot/www:/var/www/certbot" \
|
||||
-v "$(pwd)/docker/certbot/conf:/etc/letsencrypt" \
|
||||
certbot/certbot certonly \
|
||||
--webroot \
|
||||
--webroot-path=/var/www/certbot \
|
||||
--email "$EMAIL" \
|
||||
--agree-tos \
|
||||
--no-eff-email \
|
||||
-d "$DOMAIN"
|
||||
|
||||
# Copy certificates
|
||||
echo "Installing certificates..."
|
||||
cp ./docker/certbot/conf/live/$DOMAIN/fullchain.pem ./docker/nginx/ssl/
|
||||
cp ./docker/certbot/conf/live/$DOMAIN/privkey.pem ./docker/nginx/ssl/
|
||||
cp ./docker/certbot/conf/live/$DOMAIN/chain.pem ./docker/nginx/ssl/
|
||||
|
||||
# Remove temporary config
|
||||
rm ./docker/nginx/conf.d/certbot.conf
|
||||
|
||||
# Restart nginx with SSL
|
||||
echo "Restarting nginx with SSL..."
|
||||
docker compose restart nginx
|
||||
|
||||
echo -e "${GREEN}SSL setup complete for $DOMAIN${NC}"
|
||||
echo ""
|
||||
echo "To auto-renew certificates, add this to crontab:"
|
||||
echo "0 0 1 * * cd $(pwd) && ./scripts/renew-ssl.sh"
|
||||
364
services/auth_service.py
Normal file
364
services/auth_service.py
Normal file
@@ -0,0 +1,364 @@
|
||||
"""
|
||||
Authentication service with JWT tokens and password hashing
|
||||
|
||||
This service provides user authentication with automatic backend selection:
|
||||
- If DATABASE_URL is configured: Uses PostgreSQL database
|
||||
- Otherwise: Falls back to JSON file storage (development mode)
|
||||
"""
|
||||
import os
|
||||
import secrets
|
||||
import hashlib
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, Dict, Any
|
||||
import json
|
||||
from pathlib import Path
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Try to import optional dependencies
|
||||
try:
|
||||
import jwt
|
||||
JWT_AVAILABLE = True
|
||||
except ImportError:
|
||||
JWT_AVAILABLE = False
|
||||
logger.warning("PyJWT not installed. Using fallback token encoding.")
|
||||
|
||||
try:
|
||||
from passlib.context import CryptContext
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
PASSLIB_AVAILABLE = True
|
||||
except ImportError:
|
||||
PASSLIB_AVAILABLE = False
|
||||
logger.warning("passlib not installed. Using SHA256 fallback for password hashing.")
|
||||
|
||||
# Check if database is configured
|
||||
DATABASE_URL = os.getenv("DATABASE_URL", "")
|
||||
USE_DATABASE = bool(DATABASE_URL and DATABASE_URL.startswith("postgresql"))
|
||||
|
||||
if USE_DATABASE:
|
||||
try:
|
||||
from database.repositories import UserRepository
|
||||
from database.connection import get_sync_session, init_db as _init_db
|
||||
from database import models as db_models
|
||||
DATABASE_AVAILABLE = True
|
||||
logger.info("Database backend enabled for authentication")
|
||||
except ImportError as e:
|
||||
DATABASE_AVAILABLE = False
|
||||
USE_DATABASE = False
|
||||
logger.warning(f"Database modules not available: {e}. Using JSON storage.")
|
||||
else:
|
||||
DATABASE_AVAILABLE = False
|
||||
logger.info("Using JSON file storage for authentication (DATABASE_URL not configured)")
|
||||
|
||||
from models.subscription import User, UserCreate, PlanType, SubscriptionStatus, PLANS
|
||||
|
||||
|
||||
# Configuration
|
||||
SECRET_KEY = os.getenv("JWT_SECRET", os.getenv("JWT_SECRET_KEY", secrets.token_urlsafe(32)))
|
||||
ALGORITHM = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_HOURS = 24
|
||||
REFRESH_TOKEN_EXPIRE_DAYS = 30
|
||||
|
||||
# Simple file-based storage (used when database is not configured)
|
||||
USERS_FILE = Path("data/users.json")
|
||||
USERS_FILE.parent.mkdir(exist_ok=True)
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
"""Hash a password using bcrypt or fallback to SHA256"""
|
||||
if PASSLIB_AVAILABLE:
|
||||
return pwd_context.hash(password)
|
||||
else:
|
||||
# Fallback to SHA256 with salt
|
||||
salt = secrets.token_hex(16)
|
||||
hashed = hashlib.sha256(f"{salt}{password}".encode()).hexdigest()
|
||||
return f"sha256${salt}${hashed}"
|
||||
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
"""Verify a password against its hash"""
|
||||
if PASSLIB_AVAILABLE and not hashed_password.startswith("sha256$"):
|
||||
return pwd_context.verify(plain_password, hashed_password)
|
||||
else:
|
||||
# Fallback SHA256 verification
|
||||
parts = hashed_password.split("$")
|
||||
if len(parts) == 3 and parts[0] == "sha256":
|
||||
salt = parts[1]
|
||||
expected_hash = parts[2]
|
||||
actual_hash = hashlib.sha256(f"{salt}{plain_password}".encode()).hexdigest()
|
||||
return secrets.compare_digest(actual_hash, expected_hash)
|
||||
return False
|
||||
|
||||
|
||||
def create_access_token(user_id: str, expires_delta: Optional[timedelta] = None) -> str:
|
||||
"""Create a JWT access token"""
|
||||
if not JWT_AVAILABLE:
|
||||
# Fallback to simple token
|
||||
token_data = {
|
||||
"user_id": user_id,
|
||||
"exp": (datetime.utcnow() + (expires_delta or timedelta(hours=ACCESS_TOKEN_EXPIRE_HOURS))).isoformat()
|
||||
}
|
||||
import base64
|
||||
return base64.urlsafe_b64encode(json.dumps(token_data).encode()).decode()
|
||||
|
||||
expire = datetime.utcnow() + (expires_delta or timedelta(hours=ACCESS_TOKEN_EXPIRE_HOURS))
|
||||
to_encode = {"sub": user_id, "exp": expire, "type": "access"}
|
||||
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
||||
|
||||
|
||||
def create_refresh_token(user_id: str) -> str:
|
||||
"""Create a JWT refresh token"""
|
||||
if not JWT_AVAILABLE:
|
||||
token_data = {
|
||||
"user_id": user_id,
|
||||
"exp": (datetime.utcnow() + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)).isoformat()
|
||||
}
|
||||
import base64
|
||||
return base64.urlsafe_b64encode(json.dumps(token_data).encode()).decode()
|
||||
|
||||
expire = datetime.utcnow() + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
|
||||
to_encode = {"sub": user_id, "exp": expire, "type": "refresh"}
|
||||
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
||||
|
||||
|
||||
def verify_token(token: str) -> Optional[Dict[str, Any]]:
|
||||
"""Verify a JWT token and return payload"""
|
||||
if not JWT_AVAILABLE:
|
||||
try:
|
||||
import base64
|
||||
data = json.loads(base64.urlsafe_b64decode(token.encode()).decode())
|
||||
exp = datetime.fromisoformat(data["exp"])
|
||||
if exp < datetime.utcnow():
|
||||
return None
|
||||
return {"sub": data["user_id"]}
|
||||
except:
|
||||
return None
|
||||
|
||||
try:
|
||||
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
||||
return payload
|
||||
except jwt.ExpiredSignatureError:
|
||||
return None
|
||||
except jwt.JWTError:
|
||||
return None
|
||||
|
||||
|
||||
def load_users() -> Dict[str, Dict]:
|
||||
"""Load users from file storage (JSON backend only)"""
|
||||
if USERS_FILE.exists():
|
||||
try:
|
||||
with open(USERS_FILE, 'r') as f:
|
||||
return json.load(f)
|
||||
except:
|
||||
return {}
|
||||
return {}
|
||||
|
||||
|
||||
def save_users(users: Dict[str, Dict]):
|
||||
"""Save users to file storage (JSON backend only)"""
|
||||
with open(USERS_FILE, 'w') as f:
|
||||
json.dump(users, f, indent=2, default=str)
|
||||
|
||||
|
||||
def _db_user_to_model(db_user) -> User:
|
||||
"""Convert database user model to Pydantic User model"""
|
||||
return User(
|
||||
id=str(db_user.id),
|
||||
email=db_user.email,
|
||||
name=db_user.name or "",
|
||||
password_hash=db_user.password_hash,
|
||||
avatar_url=db_user.avatar_url,
|
||||
plan=PlanType(db_user.plan) if db_user.plan else PlanType.FREE,
|
||||
subscription_status=SubscriptionStatus(db_user.subscription_status) if db_user.subscription_status else SubscriptionStatus.ACTIVE,
|
||||
stripe_customer_id=db_user.stripe_customer_id,
|
||||
stripe_subscription_id=db_user.stripe_subscription_id,
|
||||
docs_translated_this_month=db_user.docs_translated_this_month or 0,
|
||||
pages_translated_this_month=db_user.pages_translated_this_month or 0,
|
||||
api_calls_this_month=db_user.api_calls_this_month or 0,
|
||||
extra_credits=db_user.extra_credits or 0,
|
||||
usage_reset_date=db_user.usage_reset_date or datetime.utcnow(),
|
||||
default_source_lang=db_user.default_source_lang or "en",
|
||||
default_target_lang=db_user.default_target_lang or "es",
|
||||
default_provider=db_user.default_provider or "google",
|
||||
created_at=db_user.created_at or datetime.utcnow(),
|
||||
updated_at=db_user.updated_at,
|
||||
)
|
||||
|
||||
|
||||
def get_user_by_email(email: str) -> Optional[User]:
|
||||
"""Get a user by email"""
|
||||
if USE_DATABASE and DATABASE_AVAILABLE:
|
||||
with get_sync_session() as session:
|
||||
repo = UserRepository(session)
|
||||
db_user = repo.get_by_email(email)
|
||||
if db_user:
|
||||
return _db_user_to_model(db_user)
|
||||
return None
|
||||
else:
|
||||
users = load_users()
|
||||
for user_data in users.values():
|
||||
if user_data.get("email", "").lower() == email.lower():
|
||||
return User(**user_data)
|
||||
return None
|
||||
|
||||
|
||||
def get_user_by_id(user_id: str) -> Optional[User]:
|
||||
"""Get a user by ID"""
|
||||
if USE_DATABASE and DATABASE_AVAILABLE:
|
||||
with get_sync_session() as session:
|
||||
repo = UserRepository(session)
|
||||
db_user = repo.get_by_id(user_id)
|
||||
if db_user:
|
||||
return _db_user_to_model(db_user)
|
||||
return None
|
||||
else:
|
||||
users = load_users()
|
||||
if user_id in users:
|
||||
return User(**users[user_id])
|
||||
return None
|
||||
|
||||
|
||||
def create_user(user_create: UserCreate) -> User:
|
||||
"""Create a new user"""
|
||||
# Check if email exists
|
||||
if get_user_by_email(user_create.email):
|
||||
raise ValueError("Email already registered")
|
||||
|
||||
if USE_DATABASE and DATABASE_AVAILABLE:
|
||||
with get_sync_session() as session:
|
||||
repo = UserRepository(session)
|
||||
db_user = repo.create(
|
||||
email=user_create.email,
|
||||
name=user_create.name,
|
||||
password_hash=hash_password(user_create.password),
|
||||
plan=PlanType.FREE.value,
|
||||
subscription_status=SubscriptionStatus.ACTIVE.value
|
||||
)
|
||||
session.commit()
|
||||
session.refresh(db_user)
|
||||
return _db_user_to_model(db_user)
|
||||
else:
|
||||
users = load_users()
|
||||
|
||||
# Generate user ID
|
||||
user_id = secrets.token_urlsafe(16)
|
||||
|
||||
# Create user
|
||||
user = User(
|
||||
id=user_id,
|
||||
email=user_create.email,
|
||||
name=user_create.name,
|
||||
password_hash=hash_password(user_create.password),
|
||||
plan=PlanType.FREE,
|
||||
subscription_status=SubscriptionStatus.ACTIVE,
|
||||
)
|
||||
|
||||
# Save to storage
|
||||
users[user_id] = user.model_dump()
|
||||
save_users(users)
|
||||
|
||||
return user
|
||||
|
||||
|
||||
def authenticate_user(email: str, password: str) -> Optional[User]:
|
||||
"""Authenticate a user with email and password"""
|
||||
user = get_user_by_email(email)
|
||||
if not user:
|
||||
return None
|
||||
if not verify_password(password, user.password_hash):
|
||||
return None
|
||||
return user
|
||||
|
||||
|
||||
def update_user(user_id: str, updates: Dict[str, Any]) -> Optional[User]:
|
||||
"""Update a user's data"""
|
||||
if USE_DATABASE and DATABASE_AVAILABLE:
|
||||
with get_sync_session() as session:
|
||||
repo = UserRepository(session)
|
||||
db_user = repo.update(user_id, updates)
|
||||
if db_user:
|
||||
session.commit()
|
||||
session.refresh(db_user)
|
||||
return _db_user_to_model(db_user)
|
||||
return None
|
||||
else:
|
||||
users = load_users()
|
||||
if user_id not in users:
|
||||
return None
|
||||
|
||||
users[user_id].update(updates)
|
||||
users[user_id]["updated_at"] = datetime.utcnow().isoformat()
|
||||
save_users(users)
|
||||
|
||||
return User(**users[user_id])
|
||||
|
||||
|
||||
def check_usage_limits(user: User) -> Dict[str, Any]:
|
||||
"""Check if user has exceeded their plan limits"""
|
||||
plan = PLANS[user.plan]
|
||||
|
||||
# Reset usage if it's a new month
|
||||
now = datetime.utcnow()
|
||||
if user.usage_reset_date.month != now.month or user.usage_reset_date.year != now.year:
|
||||
update_user(user.id, {
|
||||
"docs_translated_this_month": 0,
|
||||
"pages_translated_this_month": 0,
|
||||
"api_calls_this_month": 0,
|
||||
"usage_reset_date": now.isoformat() if not USE_DATABASE else now
|
||||
})
|
||||
user.docs_translated_this_month = 0
|
||||
user.pages_translated_this_month = 0
|
||||
user.api_calls_this_month = 0
|
||||
|
||||
docs_limit = plan["docs_per_month"]
|
||||
docs_remaining = max(0, docs_limit - user.docs_translated_this_month) if docs_limit > 0 else -1
|
||||
|
||||
return {
|
||||
"can_translate": docs_remaining != 0 or user.extra_credits > 0,
|
||||
"docs_used": user.docs_translated_this_month,
|
||||
"docs_limit": docs_limit,
|
||||
"docs_remaining": docs_remaining,
|
||||
"pages_used": user.pages_translated_this_month,
|
||||
"extra_credits": user.extra_credits,
|
||||
"max_pages_per_doc": plan["max_pages_per_doc"],
|
||||
"max_file_size_mb": plan["max_file_size_mb"],
|
||||
"allowed_providers": plan["providers"],
|
||||
}
|
||||
|
||||
|
||||
def record_usage(user_id: str, pages_count: int, use_credits: bool = False) -> bool:
|
||||
"""Record document translation usage"""
|
||||
user = get_user_by_id(user_id)
|
||||
if not user:
|
||||
return False
|
||||
|
||||
updates = {
|
||||
"docs_translated_this_month": user.docs_translated_this_month + 1,
|
||||
"pages_translated_this_month": user.pages_translated_this_month + pages_count,
|
||||
}
|
||||
|
||||
if use_credits:
|
||||
updates["extra_credits"] = max(0, user.extra_credits - pages_count)
|
||||
|
||||
result = update_user(user_id, updates)
|
||||
return result is not None
|
||||
|
||||
|
||||
def add_credits(user_id: str, credits: int) -> bool:
|
||||
"""Add credits to a user's account"""
|
||||
user = get_user_by_id(user_id)
|
||||
if not user:
|
||||
return False
|
||||
|
||||
result = update_user(user_id, {"extra_credits": user.extra_credits + credits})
|
||||
return result is not None
|
||||
|
||||
|
||||
def init_database():
|
||||
"""Initialize the database (call on application startup)"""
|
||||
if USE_DATABASE and DATABASE_AVAILABLE:
|
||||
_init_db()
|
||||
logger.info("Database initialized successfully")
|
||||
else:
|
||||
logger.info("Using JSON file storage")
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user