Compare commits

..

22 Commits

Author SHA1 Message Date
3d37ce4582 feat: Update Docker and Kubernetes for database infrastructure
- Update backend Dockerfile with PostgreSQL deps and entrypoint
- Add entrypoint.sh with db/redis wait and auto-migration
- Add /ready endpoint for Kubernetes readiness probe
- Enhance /health endpoint with database and Redis status
- Update k8s deployment with PostgreSQL and Redis services
- Add proper secrets management for database credentials
- Update k8s readiness probe to use /ready endpoint
2025-12-31 10:58:41 +01:00
550f3516db feat: Add PostgreSQL database infrastructure
- Add SQLAlchemy models for User, Translation, ApiKey, UsageLog, PaymentHistory
- Add database connection management with PostgreSQL/SQLite support
- Add repository layer for CRUD operations
- Add Alembic migration setup with initial migration
- Update auth_service to automatically use database when DATABASE_URL is set
- Update docker-compose.yml with PostgreSQL service and Redis (non-optional)
- Add database migration script (scripts/migrate_to_db.py)
- Update .env.example with database configuration
2025-12-31 10:56:19 +01:00
c4d6cae735 Production-ready improvements: security hardening, Redis sessions, retry logic, updated pricing
Changes:
- Removed hardcoded admin credentials (now requires env vars)
- Added Redis session storage with in-memory fallback
- Improved CORS configuration with warnings for development mode
- Added retry_with_backoff decorator for translation API calls
- Updated pricing: Starter=, Pro=, Business=
- Stripe price IDs now loaded from environment variables
- Added redis to requirements.txt
- Updated .env.example with all new configuration options
- Created COMPREHENSIVE_REVIEW_AND_PLAN.md with deployment roadmap
- Frontend: Updated pricing page, new UI components
2025-12-31 10:43:31 +01:00
721b18dbbd Restore provider selection, model selection, and context/glossary in file uploader 2025-11-30 22:57:21 +01:00
dfd45d9f07 Fix admin login endpoint to accept JSON instead of form data 2025-11-30 22:49:39 +01:00
80318a8d43 Complete admin dashboard with user management, config and settings tabs 2025-11-30 22:44:10 +01:00
d31a132808 Commercial frontend cleanup: fix admin TypeError, simplify UI for end users, add Suspense boundaries 2025-11-30 22:28:59 +01:00
3346817a8a Add OpenRouter provider with DeepSeek support - best value for translation (.14/M tokens) 2025-11-30 22:10:34 +01:00
b65e683d32 Add translation cache for faster repeated translations (5000 entry LRU cache with hit rate tracking) 2025-11-30 21:37:11 +01:00
d2b820c6f1 Hide admin section in sidebar, optimize translation service with parallel processing, improve UX 2025-11-30 21:33:44 +01:00
fcabe882cd feat: Add complete monetization system
Backend:
- User authentication with JWT tokens (auth_service.py)
- Subscription plans: Free, Starter (), Pro (), Business (), Enterprise
- Stripe integration for payments (payment_service.py)
- Usage tracking and quotas
- Credit packages for pay-per-use
- Plan-based provider restrictions

Frontend:
- Landing page with hero, features, pricing preview (landing-sections.tsx)
- Pricing page with all plans and credit packages (/pricing)
- User dashboard with usage stats (/dashboard)
- Login/Register pages with validation (/auth/login, /auth/register)
- Ollama self-hosting setup guide (/ollama-setup)
- Updated sidebar with user section and plan badge

Monetization strategy:
- Freemium: 3 docs/day, Ollama only
- Starter: 50 docs/month, Google Translate
- Pro: 200 docs/month, all providers, API access
- Business: 1000 docs/month, team management
- Enterprise: Custom pricing, SLA

Self-hosted option:
- Free unlimited usage with own Ollama server
- Complete privacy (data never leaves machine)
- Step-by-step setup guide included
2025-11-30 21:11:51 +01:00
29178a75a5 feat: Add complete production deployment infrastructure
- Docker configuration:
  - Multi-stage Dockerfiles for backend (Python 3.11) and frontend (Node 20)
  - Production docker-compose.yml with all services
  - Development docker-compose.dev.yml with hot-reload

- Nginx reverse proxy:
  - SSL/TLS termination with modern cipher suites
  - Rate limiting and security headers
  - Caching and compression
  - Load balancing ready

- Kubernetes manifests:
  - Deployment, Service, Ingress configurations
  - ConfigMap and Secrets
  - HPA for auto-scaling
  - PersistentVolumeClaims

- Deployment scripts:
  - deploy.sh: Automated deployment with health checks
  - backup.sh: Automated backup with retention
  - health-check.sh: Service health monitoring
  - setup-ssl.sh: Let's Encrypt SSL automation

- Monitoring:
  - Prometheus configuration
  - Grafana dashboards (optional)
  - Structured logging

- Documentation:
  - DEPLOYMENT_GUIDE.md: Complete deployment instructions
  - Environment templates (.env.production)

Ready for commercial deployment!
2025-11-30 20:56:15 +01:00
8f9ca669cf Performance optimization: batch translation for 5-10x speed improvement
- GoogleTranslationProvider: Added batch translation with separator method
- DeepLTranslationProvider: Added translator caching and batch support
- LibreTranslationProvider: Added translator caching and batch support
- WordTranslator: Collect all texts -> batch translate -> apply pattern
- ExcelTranslator: Collect all texts -> batch translate -> apply pattern
- PowerPointTranslator: Collect all texts -> batch translate -> apply pattern
- Enhanced Ollama/OpenAI prompts with stricter translation-only rules
- Added rule: return original text if uncertain about translation
2025-11-30 20:41:20 +01:00
54d85f0b34 feat: Add admin dashboard with authentication - Admin login/logout with Bearer token authentication - Secure admin dashboard page in frontend - Real-time system monitoring (memory, disk, translations) - Rate limits and cleanup service monitoring - Protected admin endpoints - Updated README with full SaaS documentation 2025-11-30 19:33:59 +01:00
500502440c feat: Add SaaS robustness middleware - Rate limiting with token bucket and sliding window algorithms - Input validation (file, language, provider) - Security headers middleware (CSP, XSS protection, etc.) - Automatic file cleanup with TTL tracking - Memory and disk monitoring - Enhanced health check and metrics endpoints - Request logging with unique IDs 2025-11-30 19:25:09 +01:00
8c7716bf4d Add Next.js frontend with WebLLM, OpenAI support - Add complete Next.js frontend with Tailwind CSS and shadcn/ui - Integrate WebLLM for client-side browser-based translations - Add OpenAI provider support with gpt-4o-mini default - Add Context & Glossary page for LLM customization - Reorganize settings: Translation Services includes all providers - Add system prompt and glossary support for all LLMs - Remove test files and requirements-test.txt 2025-11-30 19:02:41 +01:00
a4ecd3e0ec Add MCP server and configuration for AI assistant integration 2025-11-30 16:53:53 +01:00
e48ea07e44 Add system prompt, glossary, presets for Ollama/WebLLM, image translation support 2025-11-30 16:45:41 +01:00
465cab8a61 Add WebLLM model selection and cache management 2025-11-30 11:57:58 +01:00
9410b07512 Add WebLLM support, fix progress bar blocking at 90%, add timeout protection 2025-11-30 11:54:33 +01:00
1d2784602b Add Ollama vision image translation with checkbox option 2025-11-30 11:48:29 +01:00
abe77e3b29 Add Ollama support, progress bar, and professional UI redesign 2025-11-30 11:27:13 +01:00
108 changed files with 30896 additions and 622 deletions

View File

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

@@ -40,9 +40,11 @@ outputs/
temp/
translated_files/
translated_test.*
data/
# Logs
*.log
logs/
# UV / UV lock
.venv/

View 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
View 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
View 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
View File

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

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

View File

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

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

View 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"]

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

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

View 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
View 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
View 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
View 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": {}
}

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

File diff suppressed because it is too large Load Diff

50
frontend/package.json Normal file
View 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"
}
}

View File

@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

1
frontend/public/file.svg Normal file
View 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

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

View 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

View 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

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

1853
frontend/src/app/globals.css Normal file

File diff suppressed because it is too large Load Diff

View 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>
);
}

View 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
View 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>
);
}

View 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>
);
}

View 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&#10;récupérateur=heat recovery unit&#10;ventilo-connecteur=fan coil unit&#10;gaine=duct&#10;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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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);
}

View 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>
);
}

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

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

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

View 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,
}

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

View 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,
}

View 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,
}

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

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

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

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

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

View 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,
}

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

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

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

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

View 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,
}

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

992
main.py

File diff suppressed because it is too large Load Diff

157
mcp.json Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1 @@
# Models package

253
models/subscription.py Normal file
View 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"},
]

View File

@@ -1,5 +0,0 @@
# Testing requirements
requests==2.31.0
pytest==7.4.3
pytest-asyncio==0.23.2
httpx==0.26.0

View File

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

@@ -0,0 +1 @@
# Routes package

281
routes/auth_routes.py Normal file
View 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

View File

@@ -1 +0,0 @@
,ramez,simorgh,30.11.2025 09:24,C:/Users/ramez/AppData/Local/onlyoffice;

119
sample_files/webllm.html Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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