Compare commits
4 Commits
master
...
b65e683d32
| Author | SHA1 | Date | |
|---|---|---|---|
| b65e683d32 | |||
| d2b820c6f1 | |||
| fcabe882cd | |||
| 29178a75a5 |
13
.env.example
13
.env.example
@@ -73,6 +73,19 @@ ADMIN_PASSWORD=changeme123
|
|||||||
# Token secret for session management (auto-generated if not set)
|
# Token secret for session management (auto-generated if not set)
|
||||||
# ADMIN_TOKEN_SECRET=
|
# ADMIN_TOKEN_SECRET=
|
||||||
|
|
||||||
|
# ============== User Authentication ==============
|
||||||
|
# JWT secret key (auto-generated if not set)
|
||||||
|
# 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_...
|
||||||
|
|
||||||
# ============== Monitoring ==============
|
# ============== Monitoring ==============
|
||||||
# Log level: DEBUG, INFO, WARNING, ERROR
|
# Log level: DEBUG, INFO, WARNING, ERROR
|
||||||
LOG_LEVEL=INFO
|
LOG_LEVEL=INFO
|
||||||
|
|||||||
75
.env.production
Normal file
75
.env.production
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
# ============================================
|
||||||
|
# Document Translation API - Production Environment
|
||||||
|
# ============================================
|
||||||
|
# IMPORTANT: Review and update all values before deployment
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# Application Settings
|
||||||
|
# ===========================================
|
||||||
|
APP_NAME=Document Translation API
|
||||||
|
APP_ENV=production
|
||||||
|
DEBUG=false
|
||||||
|
LOG_LEVEL=INFO
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# Server Configuration
|
||||||
|
# ===========================================
|
||||||
|
HTTP_PORT=80
|
||||||
|
HTTPS_PORT=443
|
||||||
|
BACKEND_PORT=8000
|
||||||
|
FRONTEND_PORT=3000
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# Domain Configuration
|
||||||
|
# ===========================================
|
||||||
|
DOMAIN=translate.yourdomain.com
|
||||||
|
NEXT_PUBLIC_API_URL=https://translate.yourdomain.com
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# Translation Service Configuration
|
||||||
|
# ===========================================
|
||||||
|
TRANSLATION_SERVICE=ollama
|
||||||
|
OLLAMA_BASE_URL=http://ollama:11434
|
||||||
|
OLLAMA_MODEL=llama3
|
||||||
|
|
||||||
|
# DeepL API (optional)
|
||||||
|
DEEPL_API_KEY=
|
||||||
|
|
||||||
|
# OpenAI API (optional)
|
||||||
|
OPENAI_API_KEY=
|
||||||
|
OPENAI_MODEL=gpt-4o-mini
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# File Upload Settings
|
||||||
|
# ===========================================
|
||||||
|
MAX_FILE_SIZE_MB=50
|
||||||
|
ALLOWED_EXTENSIONS=.docx,.xlsx,.pptx
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# Rate Limiting
|
||||||
|
# ===========================================
|
||||||
|
RATE_LIMIT_ENABLED=true
|
||||||
|
RATE_LIMIT_REQUESTS_PER_MINUTE=60
|
||||||
|
RATE_LIMIT_TRANSLATIONS_PER_MINUTE=10
|
||||||
|
RATE_LIMIT_TRANSLATIONS_PER_HOUR=100
|
||||||
|
RATE_LIMIT_TRANSLATIONS_PER_DAY=500
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# Security (CHANGE THESE!)
|
||||||
|
# ===========================================
|
||||||
|
ADMIN_USERNAME=admin
|
||||||
|
ADMIN_PASSWORD=CHANGE_THIS_SECURE_PASSWORD
|
||||||
|
|
||||||
|
# CORS Configuration
|
||||||
|
CORS_ORIGINS=https://translate.yourdomain.com
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# Monitoring (Optional)
|
||||||
|
# ===========================================
|
||||||
|
GRAFANA_USER=admin
|
||||||
|
GRAFANA_PASSWORD=CHANGE_THIS_TOO
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# SSL Configuration
|
||||||
|
# ===========================================
|
||||||
|
LETSENCRYPT_EMAIL=admin@yourdomain.com
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -40,9 +40,11 @@ outputs/
|
|||||||
temp/
|
temp/
|
||||||
translated_files/
|
translated_files/
|
||||||
translated_test.*
|
translated_test.*
|
||||||
|
data/
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
*.log
|
*.log
|
||||||
|
logs/
|
||||||
|
|
||||||
# UV / UV lock
|
# UV / UV lock
|
||||||
.venv/
|
.venv/
|
||||||
|
|||||||
455
DEPLOYMENT_GUIDE.md
Normal file
455
DEPLOYMENT_GUIDE.md
Normal file
@@ -0,0 +1,455 @@
|
|||||||
|
# 🚀 Document Translation API - Production Deployment Guide
|
||||||
|
|
||||||
|
Complete guide for deploying the Document Translation API in production environments.
|
||||||
|
|
||||||
|
## 📋 Table of Contents
|
||||||
|
|
||||||
|
1. [Quick Start](#quick-start)
|
||||||
|
2. [Architecture Overview](#architecture-overview)
|
||||||
|
3. [Docker Deployment](#docker-deployment)
|
||||||
|
4. [Kubernetes Deployment](#kubernetes-deployment)
|
||||||
|
5. [SSL/TLS Configuration](#ssltls-configuration)
|
||||||
|
6. [Environment Configuration](#environment-configuration)
|
||||||
|
7. [Monitoring & Logging](#monitoring--logging)
|
||||||
|
8. [Scaling & Performance](#scaling--performance)
|
||||||
|
9. [Backup & Recovery](#backup--recovery)
|
||||||
|
10. [Troubleshooting](#troubleshooting)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Quick Start
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Docker & Docker Compose v2.0+
|
||||||
|
- Domain name (for production)
|
||||||
|
- SSL certificate (or use Let's Encrypt)
|
||||||
|
|
||||||
|
### One-Command Deployment
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clone and deploy
|
||||||
|
git clone https://github.com/your-repo/translate-api.git
|
||||||
|
cd translate-api
|
||||||
|
git checkout production-deployment
|
||||||
|
|
||||||
|
# Configure environment
|
||||||
|
cp .env.example .env.production
|
||||||
|
# Edit .env.production with your settings
|
||||||
|
|
||||||
|
# Deploy!
|
||||||
|
./scripts/deploy.sh production
|
||||||
|
```
|
||||||
|
|
||||||
|
Your application will be available at `https://your-domain.com`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🏗️ Architecture Overview
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────┐
|
||||||
|
│ Internet │
|
||||||
|
└────────┬────────┘
|
||||||
|
│
|
||||||
|
┌────────▼────────┐
|
||||||
|
│ Nginx │
|
||||||
|
│ (Reverse Proxy)│
|
||||||
|
│ - SSL/TLS │
|
||||||
|
│ - Rate Limit │
|
||||||
|
│ - Caching │
|
||||||
|
└────────┬────────┘
|
||||||
|
┌──────────────┼──────────────┐
|
||||||
|
│ │ │
|
||||||
|
┌────────▼────┐ ┌──────▼─────┐ ┌─────▼─────┐
|
||||||
|
│ Frontend │ │ Backend │ │ Admin │
|
||||||
|
│ (Next.js) │ │ (FastAPI) │ │ Dashboard│
|
||||||
|
│ Port 3000 │ │ Port 8000 │ │ │
|
||||||
|
└─────────────┘ └──────┬─────┘ └───────────┘
|
||||||
|
│
|
||||||
|
┌────────────────────────┼────────────────────────┐
|
||||||
|
│ │ │
|
||||||
|
┌────────▼────────┐ ┌──────────▼──────────┐ ┌───────▼───────┐
|
||||||
|
│ Ollama │ │ Google/DeepL/ │ │ Redis │
|
||||||
|
│ (Local LLM) │ │ OpenAI APIs │ │ (Cache) │
|
||||||
|
└─────────────────┘ └─────────────────────┘ └───────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🐳 Docker Deployment
|
||||||
|
|
||||||
|
### Directory Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
translate-api/
|
||||||
|
├── docker/
|
||||||
|
│ ├── backend/
|
||||||
|
│ │ └── Dockerfile
|
||||||
|
│ ├── frontend/
|
||||||
|
│ │ └── Dockerfile
|
||||||
|
│ ├── nginx/
|
||||||
|
│ │ ├── nginx.conf
|
||||||
|
│ │ ├── conf.d/
|
||||||
|
│ │ │ └── default.conf
|
||||||
|
│ │ └── ssl/
|
||||||
|
│ │ ├── fullchain.pem
|
||||||
|
│ │ └── privkey.pem
|
||||||
|
│ └── prometheus/
|
||||||
|
│ └── prometheus.yml
|
||||||
|
├── scripts/
|
||||||
|
│ ├── deploy.sh
|
||||||
|
│ ├── backup.sh
|
||||||
|
│ ├── health-check.sh
|
||||||
|
│ └── setup-ssl.sh
|
||||||
|
├── docker-compose.yml
|
||||||
|
├── docker-compose.dev.yml
|
||||||
|
├── .env.production
|
||||||
|
└── .env.example
|
||||||
|
```
|
||||||
|
|
||||||
|
### Basic Deployment
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Production (with nginx, SSL)
|
||||||
|
docker compose --env-file .env.production up -d
|
||||||
|
|
||||||
|
# View logs
|
||||||
|
docker compose logs -f
|
||||||
|
|
||||||
|
# Check status
|
||||||
|
docker compose ps
|
||||||
|
```
|
||||||
|
|
||||||
|
### With Optional Services
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# With local Ollama LLM
|
||||||
|
docker compose --profile with-ollama up -d
|
||||||
|
|
||||||
|
# With Redis caching
|
||||||
|
docker compose --profile with-cache up -d
|
||||||
|
|
||||||
|
# With monitoring (Prometheus + Grafana)
|
||||||
|
docker compose --profile with-monitoring up -d
|
||||||
|
|
||||||
|
# All services
|
||||||
|
docker compose --profile with-ollama --profile with-cache --profile with-monitoring up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### Service Endpoints
|
||||||
|
|
||||||
|
| Service | Internal Port | External Access |
|
||||||
|
|---------|--------------|-----------------|
|
||||||
|
| Frontend | 3000 | https://domain.com/ |
|
||||||
|
| Backend API | 8000 | https://domain.com/api/ |
|
||||||
|
| Admin Dashboard | 8000 | https://domain.com/admin |
|
||||||
|
| Health Check | 8000 | https://domain.com/health |
|
||||||
|
| Prometheus | 9090 | Internal only |
|
||||||
|
| Grafana | 3001 | https://domain.com:3001 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ☸️ Kubernetes Deployment
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Kubernetes cluster (1.25+)
|
||||||
|
- kubectl configured
|
||||||
|
- Ingress controller (nginx-ingress)
|
||||||
|
- cert-manager (for SSL)
|
||||||
|
|
||||||
|
### Deploy to Kubernetes
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create namespace and deploy
|
||||||
|
kubectl apply -f k8s/deployment.yaml
|
||||||
|
|
||||||
|
# Check status
|
||||||
|
kubectl get pods -n translate-api
|
||||||
|
kubectl get services -n translate-api
|
||||||
|
kubectl get ingress -n translate-api
|
||||||
|
|
||||||
|
# View logs
|
||||||
|
kubectl logs -f deployment/backend -n translate-api
|
||||||
|
```
|
||||||
|
|
||||||
|
### Scaling
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Manual scaling
|
||||||
|
kubectl scale deployment/backend --replicas=5 -n translate-api
|
||||||
|
|
||||||
|
# Auto-scaling is configured via HPA
|
||||||
|
kubectl get hpa -n translate-api
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔒 SSL/TLS Configuration
|
||||||
|
|
||||||
|
### Option 1: Let's Encrypt (Recommended)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Automated setup
|
||||||
|
./scripts/setup-ssl.sh translate.yourdomain.com admin@yourdomain.com
|
||||||
|
```
|
||||||
|
|
||||||
|
### Option 2: Custom Certificate
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Place your certificates in:
|
||||||
|
docker/nginx/ssl/fullchain.pem # Full certificate chain
|
||||||
|
docker/nginx/ssl/privkey.pem # Private key
|
||||||
|
docker/nginx/ssl/chain.pem # CA chain (optional)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Option 3: Self-Signed (Development Only)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
|
||||||
|
-keyout docker/nginx/ssl/privkey.pem \
|
||||||
|
-out docker/nginx/ssl/fullchain.pem \
|
||||||
|
-subj "/CN=localhost"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚙️ Environment Configuration
|
||||||
|
|
||||||
|
### Required Variables
|
||||||
|
|
||||||
|
| Variable | Description | Default |
|
||||||
|
|----------|-------------|---------|
|
||||||
|
| `DOMAIN` | Your domain name | - |
|
||||||
|
| `ADMIN_USERNAME` | Admin login | admin |
|
||||||
|
| `ADMIN_PASSWORD` | Admin password | changeme123 |
|
||||||
|
| `TRANSLATION_SERVICE` | Default provider | ollama |
|
||||||
|
|
||||||
|
### Translation Providers
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Ollama (Local LLM)
|
||||||
|
TRANSLATION_SERVICE=ollama
|
||||||
|
OLLAMA_BASE_URL=http://ollama:11434
|
||||||
|
OLLAMA_MODEL=llama3
|
||||||
|
|
||||||
|
# Google Translate (Free)
|
||||||
|
TRANSLATION_SERVICE=google
|
||||||
|
|
||||||
|
# DeepL (Requires API key)
|
||||||
|
TRANSLATION_SERVICE=deepl
|
||||||
|
DEEPL_API_KEY=your-api-key
|
||||||
|
|
||||||
|
# OpenAI (Requires API key)
|
||||||
|
TRANSLATION_SERVICE=openai
|
||||||
|
OPENAI_API_KEY=your-api-key
|
||||||
|
OPENAI_MODEL=gpt-4o-mini
|
||||||
|
```
|
||||||
|
|
||||||
|
### Rate Limiting
|
||||||
|
|
||||||
|
```bash
|
||||||
|
RATE_LIMIT_REQUESTS_PER_MINUTE=60
|
||||||
|
RATE_LIMIT_TRANSLATIONS_PER_MINUTE=10
|
||||||
|
RATE_LIMIT_TRANSLATIONS_PER_HOUR=100
|
||||||
|
RATE_LIMIT_TRANSLATIONS_PER_DAY=500
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 Monitoring & Logging
|
||||||
|
|
||||||
|
### Enable Monitoring
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose --profile with-monitoring up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### Access Dashboards
|
||||||
|
|
||||||
|
- **Prometheus**: http://localhost:9090
|
||||||
|
- **Grafana**: http://localhost:3001 (admin/admin)
|
||||||
|
|
||||||
|
### Log Locations
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Docker logs
|
||||||
|
docker compose logs backend
|
||||||
|
docker compose logs frontend
|
||||||
|
docker compose logs nginx
|
||||||
|
|
||||||
|
# Application logs (inside container)
|
||||||
|
docker exec translate-backend cat /app/logs/app.log
|
||||||
|
```
|
||||||
|
|
||||||
|
### Health Checks
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Manual check
|
||||||
|
./scripts/health-check.sh --verbose
|
||||||
|
|
||||||
|
# API health endpoint
|
||||||
|
curl https://your-domain.com/health
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📈 Scaling & Performance
|
||||||
|
|
||||||
|
### Horizontal Scaling
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# docker-compose.yml
|
||||||
|
services:
|
||||||
|
backend:
|
||||||
|
deploy:
|
||||||
|
replicas: 4
|
||||||
|
```
|
||||||
|
|
||||||
|
### Performance Tuning
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Backend workers (in Dockerfile CMD)
|
||||||
|
CMD ["uvicorn", "main:app", "--workers", "4"]
|
||||||
|
|
||||||
|
# Nginx connections
|
||||||
|
worker_connections 2048;
|
||||||
|
|
||||||
|
# File upload limits
|
||||||
|
client_max_body_size 100M;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Resource Limits
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# docker-compose.yml
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 2G
|
||||||
|
cpus: '1'
|
||||||
|
reservations:
|
||||||
|
memory: 512M
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💾 Backup & Recovery
|
||||||
|
|
||||||
|
### Automated Backup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run backup
|
||||||
|
./scripts/backup.sh /path/to/backups
|
||||||
|
|
||||||
|
# Add to crontab (daily at 2 AM)
|
||||||
|
0 2 * * * /path/to/scripts/backup.sh /backups
|
||||||
|
```
|
||||||
|
|
||||||
|
### Restore from Backup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Extract backup
|
||||||
|
tar xzf translate_backup_20241130.tar.gz
|
||||||
|
|
||||||
|
# Restore files
|
||||||
|
docker cp translate_backup/uploads translate-backend:/app/
|
||||||
|
docker cp translate_backup/outputs translate-backend:/app/
|
||||||
|
|
||||||
|
# Restart services
|
||||||
|
docker compose restart
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 Troubleshooting
|
||||||
|
|
||||||
|
### Common Issues
|
||||||
|
|
||||||
|
#### Port Already in Use
|
||||||
|
```bash
|
||||||
|
# Find process
|
||||||
|
netstat -tulpn | grep :8000
|
||||||
|
# Kill process
|
||||||
|
kill -9 <PID>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Container Won't Start
|
||||||
|
```bash
|
||||||
|
# Check logs
|
||||||
|
docker compose logs backend --tail 100
|
||||||
|
|
||||||
|
# Check resources
|
||||||
|
docker stats
|
||||||
|
```
|
||||||
|
|
||||||
|
#### SSL Certificate Issues
|
||||||
|
```bash
|
||||||
|
# Test certificate
|
||||||
|
openssl s_client -connect your-domain.com:443
|
||||||
|
|
||||||
|
# Renew Let's Encrypt
|
||||||
|
./scripts/renew-ssl.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Memory Issues
|
||||||
|
```bash
|
||||||
|
# Increase limits in docker-compose.yml
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 4G
|
||||||
|
```
|
||||||
|
|
||||||
|
### Getting Help
|
||||||
|
|
||||||
|
1. Check logs: `docker compose logs -f`
|
||||||
|
2. Run health check: `./scripts/health-check.sh`
|
||||||
|
3. Review configuration: `.env.production`
|
||||||
|
4. Check container status: `docker compose ps`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 Maintenance Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Update application
|
||||||
|
git pull origin production-deployment
|
||||||
|
docker compose build
|
||||||
|
docker compose up -d
|
||||||
|
|
||||||
|
# Prune unused resources
|
||||||
|
docker system prune -a
|
||||||
|
|
||||||
|
# View resource usage
|
||||||
|
docker stats
|
||||||
|
|
||||||
|
# Enter container shell
|
||||||
|
docker exec -it translate-backend /bin/bash
|
||||||
|
|
||||||
|
# Database backup (if using)
|
||||||
|
docker exec translate-db pg_dump -U user database > backup.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔐 Security Checklist
|
||||||
|
|
||||||
|
- [ ] Change default admin password
|
||||||
|
- [ ] Configure SSL/TLS
|
||||||
|
- [ ] Set up firewall rules
|
||||||
|
- [ ] Enable rate limiting
|
||||||
|
- [ ] Configure CORS properly
|
||||||
|
- [ ] Regular security updates
|
||||||
|
- [ ] Backup encryption
|
||||||
|
- [ ] Monitor access logs
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📞 Support
|
||||||
|
|
||||||
|
For issues and feature requests, please open a GitHub issue or contact support.
|
||||||
|
|
||||||
|
**Version**: 2.0.0
|
||||||
|
**Last Updated**: November 2025
|
||||||
40
docker-compose.dev.yml
Normal file
40
docker-compose.dev.yml
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
# Document Translation API - Development Docker Compose
|
||||||
|
# Usage: docker-compose -f docker-compose.yml -f docker-compose.dev.yml up
|
||||||
|
|
||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: docker/backend/Dockerfile
|
||||||
|
target: builder # Use builder stage for dev
|
||||||
|
volumes:
|
||||||
|
- .:/app
|
||||||
|
- /app/venv # Don't override venv
|
||||||
|
environment:
|
||||||
|
- DEBUG=true
|
||||||
|
- LOG_LEVEL=DEBUG
|
||||||
|
command: uvicorn main:app --host 0.0.0.0 --port 8000 --reload
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: docker/frontend/Dockerfile
|
||||||
|
target: builder
|
||||||
|
volumes:
|
||||||
|
- ./frontend:/app
|
||||||
|
- /app/node_modules
|
||||||
|
- /app/.next
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=development
|
||||||
|
command: npm run dev
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
|
||||||
|
# No nginx in dev - direct access to services
|
||||||
|
nginx:
|
||||||
|
profiles:
|
||||||
|
- disabled
|
||||||
208
docker-compose.yml
Normal file
208
docker-compose.yml
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
# Document Translation API - Production Docker Compose
|
||||||
|
# Usage: docker-compose -f docker-compose.yml -f docker-compose.prod.yml up -d
|
||||||
|
|
||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
# ===========================================
|
||||||
|
# Backend API Service
|
||||||
|
# ===========================================
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: docker/backend/Dockerfile
|
||||||
|
container_name: translate-backend
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
- 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:-}
|
||||||
|
- MAX_FILE_SIZE_MB=${MAX_FILE_SIZE_MB:-50}
|
||||||
|
- RATE_LIMIT_REQUESTS_PER_MINUTE=${RATE_LIMIT_REQUESTS_PER_MINUTE:-60}
|
||||||
|
- RATE_LIMIT_TRANSLATIONS_PER_MINUTE=${RATE_LIMIT_TRANSLATIONS_PER_MINUTE:-10}
|
||||||
|
- ADMIN_USERNAME=${ADMIN_USERNAME:-admin}
|
||||||
|
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-changeme123}
|
||||||
|
- CORS_ORIGINS=${CORS_ORIGINS:-*}
|
||||||
|
volumes:
|
||||||
|
- uploads_data:/app/uploads
|
||||||
|
- outputs_data:/app/outputs
|
||||||
|
- logs_data:/app/logs
|
||||||
|
networks:
|
||||||
|
- translate-network
|
||||||
|
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 (Optional - For 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: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
profiles:
|
||||||
|
- with-cache
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# 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:
|
||||||
|
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
|
||||||
65
docker/backend/Dockerfile
Normal file
65
docker/backend/Dockerfile
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
# 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 \
|
||||||
|
&& 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 \
|
||||||
|
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 . .
|
||||||
|
|
||||||
|
# Switch to non-root user
|
||||||
|
USER translator
|
||||||
|
|
||||||
|
# Environment variables
|
||||||
|
ENV PYTHONUNBUFFERED=1 \
|
||||||
|
PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PORT=8000
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||||
|
CMD curl -f http://localhost:${PORT}/health || exit 1
|
||||||
|
|
||||||
|
# Expose port
|
||||||
|
EXPOSE ${PORT}
|
||||||
|
|
||||||
|
# Run with uvicorn
|
||||||
|
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "4"]
|
||||||
51
docker/frontend/Dockerfile
Normal file
51
docker/frontend/Dockerfile
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
# Document Translation Frontend - Dockerfile
|
||||||
|
# Multi-stage build for optimized production
|
||||||
|
|
||||||
|
# Build stage
|
||||||
|
FROM node:20-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy package files
|
||||||
|
COPY frontend/package*.json ./
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
RUN npm ci --only=production=false
|
||||||
|
|
||||||
|
# Copy source code
|
||||||
|
COPY frontend/ .
|
||||||
|
|
||||||
|
# Build arguments for environment
|
||||||
|
ARG NEXT_PUBLIC_API_URL=http://localhost:8000
|
||||||
|
ENV NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL}
|
||||||
|
|
||||||
|
# Build the application
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Production stage
|
||||||
|
FROM node:20-alpine AS production
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Create non-root user
|
||||||
|
RUN addgroup -g 1001 -S nodejs && \
|
||||||
|
adduser -S nextjs -u 1001
|
||||||
|
|
||||||
|
# Copy built assets from builder
|
||||||
|
COPY --from=builder /app/public ./public
|
||||||
|
COPY --from=builder /app/.next/standalone ./
|
||||||
|
COPY --from=builder /app/.next/static ./.next/static
|
||||||
|
|
||||||
|
# Set correct permissions
|
||||||
|
RUN chown -R nextjs:nodejs /app
|
||||||
|
|
||||||
|
USER nextjs
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
ENV NODE_ENV=production \
|
||||||
|
PORT=3000 \
|
||||||
|
HOSTNAME="0.0.0.0"
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
CMD ["node", "server.js"]
|
||||||
150
docker/nginx/conf.d/default.conf
Normal file
150
docker/nginx/conf.d/default.conf
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
# Document Translation API - Main Server Block
|
||||||
|
# HTTP to HTTPS redirect and main application routing
|
||||||
|
|
||||||
|
# HTTP server - redirect to HTTPS
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
listen [::]:80;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
# Allow health checks on HTTP
|
||||||
|
location /health {
|
||||||
|
proxy_pass http://backend/health;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Connection "";
|
||||||
|
}
|
||||||
|
|
||||||
|
# ACME challenge for Let's Encrypt
|
||||||
|
location /.well-known/acme-challenge/ {
|
||||||
|
root /var/www/certbot;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Redirect all other traffic to HTTPS
|
||||||
|
location / {
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# HTTPS server - main application
|
||||||
|
server {
|
||||||
|
listen 443 ssl http2;
|
||||||
|
listen [::]:443 ssl http2;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
# SSL certificates (replace with your paths)
|
||||||
|
ssl_certificate /etc/nginx/ssl/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/nginx/ssl/privkey.pem;
|
||||||
|
ssl_trusted_certificate /etc/nginx/ssl/chain.pem;
|
||||||
|
|
||||||
|
# SSL configuration
|
||||||
|
ssl_session_timeout 1d;
|
||||||
|
ssl_session_cache shared:SSL:50m;
|
||||||
|
ssl_session_tickets off;
|
||||||
|
|
||||||
|
# Modern SSL configuration
|
||||||
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
|
||||||
|
ssl_prefer_server_ciphers off;
|
||||||
|
|
||||||
|
# OCSP Stapling
|
||||||
|
ssl_stapling on;
|
||||||
|
ssl_stapling_verify on;
|
||||||
|
resolver 8.8.8.8 8.8.4.4 valid=300s;
|
||||||
|
resolver_timeout 5s;
|
||||||
|
|
||||||
|
# Security headers
|
||||||
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header X-XSS-Protection "1; mode=block" always;
|
||||||
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
|
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||||
|
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' ws: wss:;" always;
|
||||||
|
|
||||||
|
# API routes - proxy to backend
|
||||||
|
location /api/ {
|
||||||
|
rewrite ^/api/(.*)$ /$1 break;
|
||||||
|
|
||||||
|
limit_req zone=api_limit burst=20 nodelay;
|
||||||
|
limit_conn conn_limit 10;
|
||||||
|
|
||||||
|
proxy_pass http://backend;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header Connection "";
|
||||||
|
|
||||||
|
# CORS headers for API
|
||||||
|
add_header Access-Control-Allow-Origin $http_origin always;
|
||||||
|
add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" always;
|
||||||
|
add_header Access-Control-Allow-Headers "Authorization, Content-Type, X-Requested-With" always;
|
||||||
|
add_header Access-Control-Allow-Credentials "true" always;
|
||||||
|
|
||||||
|
if ($request_method = 'OPTIONS') {
|
||||||
|
return 204;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# File upload endpoint - special handling
|
||||||
|
location /translate {
|
||||||
|
limit_req zone=upload_limit burst=5 nodelay;
|
||||||
|
limit_conn conn_limit 5;
|
||||||
|
|
||||||
|
proxy_pass http://backend/translate;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|
||||||
|
# Increased timeouts for file processing
|
||||||
|
proxy_connect_timeout 60s;
|
||||||
|
proxy_send_timeout 600s;
|
||||||
|
proxy_read_timeout 600s;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Health check endpoint
|
||||||
|
location /health {
|
||||||
|
proxy_pass http://backend/health;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Connection "";
|
||||||
|
}
|
||||||
|
|
||||||
|
# Admin endpoints
|
||||||
|
location /admin {
|
||||||
|
limit_req zone=api_limit burst=10 nodelay;
|
||||||
|
|
||||||
|
proxy_pass http://backend/admin;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Frontend - Next.js application
|
||||||
|
location / {
|
||||||
|
proxy_pass http://frontend;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
}
|
||||||
|
|
||||||
|
# Static files caching
|
||||||
|
location /_next/static/ {
|
||||||
|
proxy_pass http://frontend;
|
||||||
|
proxy_cache_valid 200 365d;
|
||||||
|
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||||
|
}
|
||||||
|
|
||||||
|
# Error pages
|
||||||
|
error_page 500 502 503 504 /50x.html;
|
||||||
|
location = /50x.html {
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
}
|
||||||
|
}
|
||||||
74
docker/nginx/nginx.conf
Normal file
74
docker/nginx/nginx.conf
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
# Nginx Configuration for Document Translation API
|
||||||
|
# Production-ready with SSL, caching, and security headers
|
||||||
|
|
||||||
|
user nginx;
|
||||||
|
worker_processes auto;
|
||||||
|
error_log /var/log/nginx/error.log warn;
|
||||||
|
pid /var/run/nginx.pid;
|
||||||
|
|
||||||
|
events {
|
||||||
|
worker_connections 1024;
|
||||||
|
use epoll;
|
||||||
|
multi_accept on;
|
||||||
|
}
|
||||||
|
|
||||||
|
http {
|
||||||
|
include /etc/nginx/mime.types;
|
||||||
|
default_type application/octet-stream;
|
||||||
|
|
||||||
|
# Logging format
|
||||||
|
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
|
||||||
|
'$status $body_bytes_sent "$http_referer" '
|
||||||
|
'"$http_user_agent" "$http_x_forwarded_for" '
|
||||||
|
'rt=$request_time uct="$upstream_connect_time" '
|
||||||
|
'uht="$upstream_header_time" urt="$upstream_response_time"';
|
||||||
|
|
||||||
|
access_log /var/log/nginx/access.log main;
|
||||||
|
|
||||||
|
# Performance optimizations
|
||||||
|
sendfile on;
|
||||||
|
tcp_nopush on;
|
||||||
|
tcp_nodelay on;
|
||||||
|
keepalive_timeout 65;
|
||||||
|
types_hash_max_size 2048;
|
||||||
|
|
||||||
|
# Gzip compression
|
||||||
|
gzip on;
|
||||||
|
gzip_vary on;
|
||||||
|
gzip_proxied any;
|
||||||
|
gzip_comp_level 6;
|
||||||
|
gzip_types text/plain text/css text/xml application/json application/javascript
|
||||||
|
application/xml application/rss+xml application/atom+xml image/svg+xml;
|
||||||
|
|
||||||
|
# File upload size (for document translation)
|
||||||
|
client_max_body_size 100M;
|
||||||
|
client_body_timeout 300s;
|
||||||
|
client_header_timeout 60s;
|
||||||
|
|
||||||
|
# Proxy settings
|
||||||
|
proxy_connect_timeout 60s;
|
||||||
|
proxy_send_timeout 300s;
|
||||||
|
proxy_read_timeout 300s;
|
||||||
|
proxy_buffer_size 128k;
|
||||||
|
proxy_buffers 4 256k;
|
||||||
|
proxy_busy_buffers_size 256k;
|
||||||
|
|
||||||
|
# Rate limiting zones
|
||||||
|
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s;
|
||||||
|
limit_req_zone $binary_remote_addr zone=upload_limit:10m rate=2r/s;
|
||||||
|
limit_conn_zone $binary_remote_addr zone=conn_limit:10m;
|
||||||
|
|
||||||
|
# Upstream definitions
|
||||||
|
upstream backend {
|
||||||
|
server backend:8000;
|
||||||
|
keepalive 32;
|
||||||
|
}
|
||||||
|
|
||||||
|
upstream frontend {
|
||||||
|
server frontend:3000;
|
||||||
|
keepalive 32;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Include additional configs
|
||||||
|
include /etc/nginx/conf.d/*.conf;
|
||||||
|
}
|
||||||
8
docker/nginx/ssl/.gitkeep
Normal file
8
docker/nginx/ssl/.gitkeep
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
# Self-signed SSL certificate placeholder
|
||||||
|
# Replace with real certificates in production!
|
||||||
|
|
||||||
|
# Generate self-signed certificate:
|
||||||
|
# openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
|
||||||
|
# -keyout privkey.pem \
|
||||||
|
# -out fullchain.pem \
|
||||||
|
# -subj "/CN=localhost"
|
||||||
37
docker/prometheus/prometheus.yml
Normal file
37
docker/prometheus/prometheus.yml
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
# Prometheus Configuration for Document Translation API
|
||||||
|
|
||||||
|
global:
|
||||||
|
scrape_interval: 15s
|
||||||
|
evaluation_interval: 15s
|
||||||
|
external_labels:
|
||||||
|
monitor: 'translate-api'
|
||||||
|
|
||||||
|
alerting:
|
||||||
|
alertmanagers:
|
||||||
|
- static_configs:
|
||||||
|
- targets: []
|
||||||
|
|
||||||
|
rule_files: []
|
||||||
|
|
||||||
|
scrape_configs:
|
||||||
|
# Backend API metrics
|
||||||
|
- job_name: 'translate-backend'
|
||||||
|
static_configs:
|
||||||
|
- targets: ['backend:8000']
|
||||||
|
metrics_path: /metrics
|
||||||
|
scrape_interval: 10s
|
||||||
|
|
||||||
|
# Nginx metrics (requires nginx-prometheus-exporter)
|
||||||
|
- job_name: 'nginx'
|
||||||
|
static_configs:
|
||||||
|
- targets: ['nginx-exporter:9113']
|
||||||
|
|
||||||
|
# Node exporter for system metrics
|
||||||
|
- job_name: 'node'
|
||||||
|
static_configs:
|
||||||
|
- targets: ['node-exporter:9100']
|
||||||
|
|
||||||
|
# Docker metrics
|
||||||
|
- job_name: 'docker'
|
||||||
|
static_configs:
|
||||||
|
- targets: ['cadvisor:8080']
|
||||||
169
frontend/src/app/auth/login/page.tsx
Normal file
169
frontend/src/app/auth/login/page.tsx
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Eye, EyeOff, Mail, Lock, ArrowRight, Loader2 } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
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 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));
|
||||||
|
|
||||||
|
// Redirect
|
||||||
|
router.push(redirect);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || "Login failed");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-b from-[#1a1a1a] to-[#262626] flex items-center justify-center p-4">
|
||||||
|
<div className="w-full max-w-md">
|
||||||
|
{/* Logo */}
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<Link href="/" className="inline-flex items-center gap-3">
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-teal-500 text-white font-bold text-xl">
|
||||||
|
文A
|
||||||
|
</div>
|
||||||
|
<span className="text-2xl font-semibold text-white">Translate Co.</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Card */}
|
||||||
|
<div className="rounded-2xl border border-zinc-800 bg-zinc-900/50 p-8">
|
||||||
|
<div className="text-center mb-6">
|
||||||
|
<h1 className="text-2xl font-bold text-white mb-2">Welcome back</h1>
|
||||||
|
<p className="text-zinc-400">Sign in to continue translating</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 p-3 rounded-lg bg-red-500/10 border border-red-500/20 text-red-400 text-sm">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="email" className="text-zinc-300">Email</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-zinc-500" />
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
placeholder="you@example.com"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
className="pl-10 bg-zinc-800 border-zinc-700 text-white placeholder:text-zinc-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label htmlFor="password" className="text-zinc-300">Password</Label>
|
||||||
|
<Link href="/auth/forgot-password" className="text-sm text-teal-400 hover:text-teal-300">
|
||||||
|
Forgot password?
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="relative">
|
||||||
|
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-zinc-500" />
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type={showPassword ? "text" : "password"}
|
||||||
|
placeholder="••••••••"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
className="pl-10 pr-10 bg-zinc-800 border-zinc-700 text-white placeholder:text-zinc-500"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-zinc-500 hover:text-zinc-300"
|
||||||
|
>
|
||||||
|
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full bg-teal-500 hover:bg-teal-600 text-white"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
Sign In
|
||||||
|
<ArrowRight className="ml-2 h-4 w-4" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="mt-6 text-center text-sm text-zinc-400">
|
||||||
|
Don't have an account?{" "}
|
||||||
|
<Link
|
||||||
|
href={`/auth/register${redirect !== "/" ? `?redirect=${redirect}` : ""}`}
|
||||||
|
className="text-teal-400 hover:text-teal-300"
|
||||||
|
>
|
||||||
|
Sign up for free
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Features reminder */}
|
||||||
|
<div className="mt-8 text-center">
|
||||||
|
<p className="text-sm text-zinc-500 mb-3">Start with our free plan:</p>
|
||||||
|
<div className="flex flex-wrap justify-center gap-2">
|
||||||
|
{["3 docs/day", "10 pages/doc", "Ollama support"].map((feature) => (
|
||||||
|
<span
|
||||||
|
key={feature}
|
||||||
|
className="px-3 py-1 rounded-full bg-zinc-800 text-zinc-400 text-xs"
|
||||||
|
>
|
||||||
|
{feature}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
206
frontend/src/app/auth/register/page.tsx
Normal file
206
frontend/src/app/auth/register/page.tsx
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Eye, EyeOff, Mail, Lock, User, ArrowRight, Loader2 } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
|
||||||
|
export default function RegisterPage() {
|
||||||
|
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 [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError("");
|
||||||
|
|
||||||
|
if (password !== confirmPassword) {
|
||||||
|
setError("Passwords do not match");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password.length < 8) {
|
||||||
|
setError("Password must be at least 8 characters");
|
||||||
|
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));
|
||||||
|
|
||||||
|
// Redirect
|
||||||
|
router.push(redirect);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || "Registration failed");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-b from-[#1a1a1a] to-[#262626] flex items-center justify-center p-4">
|
||||||
|
<div className="w-full max-w-md">
|
||||||
|
{/* Logo */}
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<Link href="/" className="inline-flex items-center gap-3">
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-teal-500 text-white font-bold text-xl">
|
||||||
|
文A
|
||||||
|
</div>
|
||||||
|
<span className="text-2xl font-semibold text-white">Translate Co.</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Card */}
|
||||||
|
<div className="rounded-2xl border border-zinc-800 bg-zinc-900/50 p-8">
|
||||||
|
<div className="text-center mb-6">
|
||||||
|
<h1 className="text-2xl font-bold text-white mb-2">Create an account</h1>
|
||||||
|
<p className="text-zinc-400">Start translating documents for free</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 p-3 rounded-lg bg-red-500/10 border border-red-500/20 text-red-400 text-sm">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="name" className="text-zinc-300">Full Name</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<User className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-zinc-500" />
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
type="text"
|
||||||
|
placeholder="John Doe"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
required
|
||||||
|
className="pl-10 bg-zinc-800 border-zinc-700 text-white placeholder:text-zinc-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="email" className="text-zinc-300">Email</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-zinc-500" />
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
placeholder="you@example.com"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
className="pl-10 bg-zinc-800 border-zinc-700 text-white placeholder:text-zinc-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="password" className="text-zinc-300">Password</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-zinc-500" />
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type={showPassword ? "text" : "password"}
|
||||||
|
placeholder="••••••••"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
className="pl-10 pr-10 bg-zinc-800 border-zinc-700 text-white placeholder:text-zinc-500"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-zinc-500 hover:text-zinc-300"
|
||||||
|
>
|
||||||
|
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="confirmPassword" className="text-zinc-300">Confirm Password</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-zinc-500" />
|
||||||
|
<Input
|
||||||
|
id="confirmPassword"
|
||||||
|
type={showPassword ? "text" : "password"}
|
||||||
|
placeholder="••••••••"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
className="pl-10 bg-zinc-800 border-zinc-700 text-white placeholder:text-zinc-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full bg-teal-500 hover:bg-teal-600 text-white"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
Create Account
|
||||||
|
<ArrowRight className="ml-2 h-4 w-4" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="mt-6 text-center text-sm text-zinc-400">
|
||||||
|
Already have an account?{" "}
|
||||||
|
<Link
|
||||||
|
href={`/auth/login${redirect !== "/" ? `?redirect=${redirect}` : ""}`}
|
||||||
|
className="text-teal-400 hover:text-teal-300"
|
||||||
|
>
|
||||||
|
Sign in
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 text-center text-xs text-zinc-500">
|
||||||
|
By creating an account, you agree to our{" "}
|
||||||
|
<Link href="/terms" className="text-zinc-400 hover:text-zinc-300">
|
||||||
|
Terms of Service
|
||||||
|
</Link>{" "}
|
||||||
|
and{" "}
|
||||||
|
<Link href="/privacy" className="text-zinc-400 hover:text-zinc-300">
|
||||||
|
Privacy Policy
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
363
frontend/src/app/dashboard/page.tsx
Normal file
363
frontend/src/app/dashboard/page.tsx
Normal file
@@ -0,0 +1,363 @@
|
|||||||
|
"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,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Progress } from "@/components/ui/progress";
|
||||||
|
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[];
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const token = localStorage.getItem("token");
|
||||||
|
if (!token) {
|
||||||
|
router.push("/auth/login?redirect=/dashboard");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchUserData(token);
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
|
const fetchUserData = async (token: string) => {
|
||||||
|
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);
|
||||||
|
} catch (error) {
|
||||||
|
localStorage.removeItem("token");
|
||||||
|
localStorage.removeItem("user");
|
||||||
|
router.push("/auth/login?redirect=/dashboard");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-[#262626] flex items-center justify-center">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-t-2 border-teal-500" />
|
||||||
|
</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-500",
|
||||||
|
starter: "bg-blue-500",
|
||||||
|
pro: "bg-teal-500",
|
||||||
|
business: "bg-purple-500",
|
||||||
|
enterprise: "bg-amber-500",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-b from-[#1a1a1a] to-[#262626]">
|
||||||
|
{/* Header */}
|
||||||
|
<header className="border-b border-zinc-800 bg-[#1a1a1a]/80 backdrop-blur-sm sticky top-0 z-50">
|
||||||
|
<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">
|
||||||
|
<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>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Link href="/">
|
||||||
|
<Button variant="outline" size="sm" className="border-zinc-700 text-zinc-300 hover:bg-zinc-800">
|
||||||
|
<FileText className="h-4 w-4 mr-2" />
|
||||||
|
Translate
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="h-8 w-8 rounded-full bg-teal-600 flex items-center justify-center text-white text-sm font-medium">
|
||||||
|
{user.name.charAt(0).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<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>
|
||||||
|
</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-3xl font-bold text-white">Welcome back, {user.name.split(" ")[0]}!</h1>
|
||||||
|
<p className="text-zinc-400 mt-1">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 */}
|
||||||
|
<div className="rounded-xl border border-zinc-800 bg-zinc-900/50 p-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<span className="text-sm text-zinc-400">Current Plan</span>
|
||||||
|
<Badge className={cn("text-white", planColors[user.plan])}>
|
||||||
|
{user.plan.charAt(0).toUpperCase() + user.plan.slice(1)}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Crown className="h-5 w-5 text-amber-400" />
|
||||||
|
<span className="text-2xl font-bold text-white capitalize">{user.plan}</span>
|
||||||
|
</div>
|
||||||
|
{user.plan !== "enterprise" && (
|
||||||
|
<Button
|
||||||
|
onClick={handleUpgrade}
|
||||||
|
size="sm"
|
||||||
|
className="mt-4 w-full bg-teal-500 hover:bg-teal-600 text-white"
|
||||||
|
>
|
||||||
|
Upgrade Plan
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Documents Used */}
|
||||||
|
<div className="rounded-xl border border-zinc-800 bg-zinc-900/50 p-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<span className="text-sm text-zinc-400">Documents This Month</span>
|
||||||
|
<FileText className="h-4 w-4 text-zinc-500" />
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-bold text-white mb-2">
|
||||||
|
{usage.docs_used} / {usage.docs_limit === -1 ? "∞" : usage.docs_limit}
|
||||||
|
</div>
|
||||||
|
<Progress value={docsPercentage} className="h-2 bg-zinc-800" />
|
||||||
|
<p className="text-xs text-zinc-500 mt-2">
|
||||||
|
{usage.docs_remaining === -1
|
||||||
|
? "Unlimited"
|
||||||
|
: `${usage.docs_remaining} remaining`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pages Translated */}
|
||||||
|
<div className="rounded-xl border border-zinc-800 bg-zinc-900/50 p-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<span className="text-sm text-zinc-400">Pages Translated</span>
|
||||||
|
<TrendingUp className="h-4 w-4 text-teal-400" />
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-bold text-white">
|
||||||
|
{usage.pages_used}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-zinc-500 mt-2">
|
||||||
|
Max {usage.max_pages_per_doc === -1 ? "unlimited" : usage.max_pages_per_doc} pages/doc
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Extra Credits */}
|
||||||
|
<div className="rounded-xl border border-zinc-800 bg-zinc-900/50 p-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<span className="text-sm text-zinc-400">Extra Credits</span>
|
||||||
|
<Zap className="h-4 w-4 text-amber-400" />
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-bold text-white">
|
||||||
|
{usage.extra_credits}
|
||||||
|
</div>
|
||||||
|
<Link href="/pricing#credits">
|
||||||
|
<Button variant="outline" size="sm" className="mt-4 w-full border-zinc-700 text-zinc-300 hover:bg-zinc-800">
|
||||||
|
Buy Credits
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Features & Actions */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
{/* Available Features */}
|
||||||
|
<div className="rounded-xl border border-zinc-800 bg-zinc-900/50 p-6">
|
||||||
|
<h2 className="text-lg font-semibold text-white mb-4">Your Plan Features</h2>
|
||||||
|
<ul className="space-y-3">
|
||||||
|
{user.plan_limits.features.map((feature, idx) => (
|
||||||
|
<li key={idx} className="flex items-start gap-2">
|
||||||
|
<Check className="h-5 w-5 text-teal-400 shrink-0 mt-0.5" />
|
||||||
|
<span className="text-zinc-300">{feature}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick Actions */}
|
||||||
|
<div className="rounded-xl border border-zinc-800 bg-zinc-900/50 p-6">
|
||||||
|
<h2 className="text-lg font-semibold text-white mb-4">Quick Actions</h2>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Link href="/">
|
||||||
|
<button className="w-full flex items-center justify-between p-3 rounded-lg bg-zinc-800 hover:bg-zinc-700 transition-colors">
|
||||||
|
<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-zinc-500" />
|
||||||
|
</button>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<Link href="/settings/services">
|
||||||
|
<button className="w-full flex items-center justify-between p-3 rounded-lg bg-zinc-800 hover:bg-zinc-700 transition-colors">
|
||||||
|
<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-zinc-500" />
|
||||||
|
</button>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{user.plan !== "free" && (
|
||||||
|
<button
|
||||||
|
onClick={handleManageBilling}
|
||||||
|
className="w-full flex items-center justify-between p-3 rounded-lg bg-zinc-800 hover:bg-zinc-700 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<CreditCard className="h-5 w-5 text-purple-400" />
|
||||||
|
<span className="text-white">Manage Billing</span>
|
||||||
|
</div>
|
||||||
|
<ExternalLink className="h-4 w-4 text-zinc-500" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Link href="/pricing">
|
||||||
|
<button className="w-full flex items-center justify-between p-3 rounded-lg bg-zinc-800 hover:bg-zinc-700 transition-colors">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Crown className="h-5 w-5 text-amber-400" />
|
||||||
|
<span className="text-white">View Plans & Pricing</span>
|
||||||
|
</div>
|
||||||
|
<ChevronRight className="h-4 w-4 text-zinc-500" />
|
||||||
|
</button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Available Providers */}
|
||||||
|
<div className="mt-6 rounded-xl border border-zinc-800 bg-zinc-900/50 p-6">
|
||||||
|
<h2 className="text-lg font-semibold text-white mb-4">Available Translation Providers</h2>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{["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-teal-500/50 text-teal-400 bg-teal-500/10"
|
||||||
|
: "border-zinc-700 text-zinc-500"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isAvailable && <Check className="h-3 w-3 mr-1" />}
|
||||||
|
{provider}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{user.plan === "free" && (
|
||||||
|
<p className="text-sm text-zinc-500 mt-4">
|
||||||
|
<Link href="/pricing" className="text-teal-400 hover:text-teal-300">
|
||||||
|
Upgrade your plan
|
||||||
|
</Link>{" "}
|
||||||
|
to access more translation providers including Google, DeepL, and OpenAI.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
364
frontend/src/app/ollama-setup/page.tsx
Normal file
364
frontend/src/app/ollama-setup/page.tsx
Normal file
@@ -0,0 +1,364 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import {
|
||||||
|
Server,
|
||||||
|
Check,
|
||||||
|
AlertCircle,
|
||||||
|
Loader2,
|
||||||
|
ExternalLink,
|
||||||
|
Copy,
|
||||||
|
Terminal,
|
||||||
|
Cpu,
|
||||||
|
HardDrive
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface OllamaModel {
|
||||||
|
name: string;
|
||||||
|
size: string;
|
||||||
|
quantization: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const recommendedModels: OllamaModel[] = [
|
||||||
|
{ name: "llama3.2:3b", size: "2 GB", quantization: "Q4_0" },
|
||||||
|
{ name: "mistral:7b", size: "4.1 GB", quantization: "Q4_0" },
|
||||||
|
{ name: "qwen2.5:7b", size: "4.7 GB", quantization: "Q4_K_M" },
|
||||||
|
{ name: "llama3.1:8b", size: "4.7 GB", quantization: "Q4_0" },
|
||||||
|
{ name: "gemma2:9b", size: "5.4 GB", quantization: "Q4_0" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function OllamaSetupPage() {
|
||||||
|
const [endpoint, setEndpoint] = useState("http://localhost:11434");
|
||||||
|
const [selectedModel, setSelectedModel] = useState("");
|
||||||
|
const [availableModels, setAvailableModels] = useState<string[]>([]);
|
||||||
|
const [testing, setTesting] = useState(false);
|
||||||
|
const [connectionStatus, setConnectionStatus] = useState<"idle" | "success" | "error">("idle");
|
||||||
|
const [errorMessage, setErrorMessage] = useState("");
|
||||||
|
const [copied, setCopied] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const testConnection = async () => {
|
||||||
|
setTesting(true);
|
||||||
|
setConnectionStatus("idle");
|
||||||
|
setErrorMessage("");
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Test connection to Ollama
|
||||||
|
const res = await fetch(`${endpoint}/api/tags`);
|
||||||
|
if (!res.ok) throw new Error("Failed to connect to Ollama");
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
const models = data.models?.map((m: any) => m.name) || [];
|
||||||
|
setAvailableModels(models);
|
||||||
|
setConnectionStatus("success");
|
||||||
|
|
||||||
|
// Auto-select first model if available
|
||||||
|
if (models.length > 0 && !selectedModel) {
|
||||||
|
setSelectedModel(models[0]);
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
setConnectionStatus("error");
|
||||||
|
setErrorMessage(error.message || "Failed to connect to Ollama");
|
||||||
|
} finally {
|
||||||
|
setTesting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyToClipboard = (text: string, id: string) => {
|
||||||
|
navigator.clipboard.writeText(text);
|
||||||
|
setCopied(id);
|
||||||
|
setTimeout(() => setCopied(null), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveSettings = () => {
|
||||||
|
// Save to localStorage or user settings
|
||||||
|
const settings = { ollamaEndpoint: endpoint, ollamaModel: selectedModel };
|
||||||
|
localStorage.setItem("ollamaSettings", JSON.stringify(settings));
|
||||||
|
|
||||||
|
// Also save to user account if logged in
|
||||||
|
const token = localStorage.getItem("token");
|
||||||
|
if (token) {
|
||||||
|
fetch("http://localhost:8000/api/auth/settings", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
ollama_endpoint: endpoint,
|
||||||
|
ollama_model: selectedModel,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
alert("Settings saved successfully!");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-b from-[#1a1a1a] to-[#262626] py-16">
|
||||||
|
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="text-center mb-12">
|
||||||
|
<Badge className="mb-4 bg-orange-500/20 text-orange-400 border-orange-500/30">
|
||||||
|
Self-Hosted
|
||||||
|
</Badge>
|
||||||
|
<h1 className="text-4xl font-bold text-white mb-4">
|
||||||
|
Configure Your Ollama Server
|
||||||
|
</h1>
|
||||||
|
<p className="text-xl text-zinc-400 max-w-2xl mx-auto">
|
||||||
|
Connect your own Ollama instance for unlimited, free translations using local AI models.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* What is Ollama */}
|
||||||
|
<div className="rounded-xl border border-zinc-800 bg-zinc-900/50 p-6 mb-8">
|
||||||
|
<h2 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
|
||||||
|
<Server className="h-5 w-5 text-orange-400" />
|
||||||
|
What is Ollama?
|
||||||
|
</h2>
|
||||||
|
<p className="text-zinc-400 mb-4">
|
||||||
|
Ollama is a free, open-source tool that lets you run large language models locally on your computer.
|
||||||
|
With Ollama, you can translate documents without sending data to external servers, ensuring complete privacy.
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-4">
|
||||||
|
<a
|
||||||
|
href="https://ollama.ai"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center gap-2 text-teal-400 hover:text-teal-300"
|
||||||
|
>
|
||||||
|
Visit Ollama Website
|
||||||
|
<ExternalLink className="h-4 w-4" />
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="https://github.com/ollama/ollama"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center gap-2 text-teal-400 hover:text-teal-300"
|
||||||
|
>
|
||||||
|
GitHub Repository
|
||||||
|
<ExternalLink className="h-4 w-4" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Installation Guide */}
|
||||||
|
<div className="rounded-xl border border-zinc-800 bg-zinc-900/50 p-6 mb-8">
|
||||||
|
<h2 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
|
||||||
|
<Terminal className="h-5 w-5 text-blue-400" />
|
||||||
|
Quick Installation Guide
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Step 1 */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-white font-medium mb-2">1. Install Ollama</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between p-3 rounded-lg bg-zinc-800">
|
||||||
|
<div>
|
||||||
|
<span className="text-zinc-400">macOS / Linux:</span>
|
||||||
|
<code className="ml-2 text-teal-400">curl -fsSL https://ollama.ai/install.sh | sh</code>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => copyToClipboard("curl -fsSL https://ollama.ai/install.sh | sh", "install")}
|
||||||
|
className="p-2 rounded hover:bg-zinc-700"
|
||||||
|
>
|
||||||
|
{copied === "install" ? (
|
||||||
|
<Check className="h-4 w-4 text-green-400" />
|
||||||
|
) : (
|
||||||
|
<Copy className="h-4 w-4 text-zinc-400" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-zinc-500">
|
||||||
|
Windows: Download from <a href="https://ollama.ai/download" className="text-teal-400 hover:underline" target="_blank">ollama.ai/download</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Step 2 */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-white font-medium mb-2">2. Pull a Translation Model</h3>
|
||||||
|
<div className="flex items-center justify-between p-3 rounded-lg bg-zinc-800">
|
||||||
|
<code className="text-teal-400">ollama pull llama3.2:3b</code>
|
||||||
|
<button
|
||||||
|
onClick={() => copyToClipboard("ollama pull llama3.2:3b", "pull")}
|
||||||
|
className="p-2 rounded hover:bg-zinc-700"
|
||||||
|
>
|
||||||
|
{copied === "pull" ? (
|
||||||
|
<Check className="h-4 w-4 text-green-400" />
|
||||||
|
) : (
|
||||||
|
<Copy className="h-4 w-4 text-zinc-400" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Step 3 */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-white font-medium mb-2">3. Start Ollama Server</h3>
|
||||||
|
<div className="flex items-center justify-between p-3 rounded-lg bg-zinc-800">
|
||||||
|
<code className="text-teal-400">ollama serve</code>
|
||||||
|
<button
|
||||||
|
onClick={() => copyToClipboard("ollama serve", "serve")}
|
||||||
|
className="p-2 rounded hover:bg-zinc-700"
|
||||||
|
>
|
||||||
|
{copied === "serve" ? (
|
||||||
|
<Check className="h-4 w-4 text-green-400" />
|
||||||
|
) : (
|
||||||
|
<Copy className="h-4 w-4 text-zinc-400" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-zinc-500 mt-2">
|
||||||
|
On macOS/Windows with the desktop app, Ollama runs automatically in the background.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recommended Models */}
|
||||||
|
<div className="rounded-xl border border-zinc-800 bg-zinc-900/50 p-6 mb-8">
|
||||||
|
<h2 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
|
||||||
|
<Cpu className="h-5 w-5 text-purple-400" />
|
||||||
|
Recommended Models for Translation
|
||||||
|
</h2>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
|
{recommendedModels.map((model) => (
|
||||||
|
<div
|
||||||
|
key={model.name}
|
||||||
|
className="flex items-center justify-between p-3 rounded-lg bg-zinc-800"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<span className="text-white font-medium">{model.name}</span>
|
||||||
|
<div className="flex items-center gap-2 mt-1">
|
||||||
|
<Badge variant="outline" className="text-xs border-zinc-700 text-zinc-400">
|
||||||
|
<HardDrive className="h-3 w-3 mr-1" />
|
||||||
|
{model.size}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => copyToClipboard(`ollama pull ${model.name}`, model.name)}
|
||||||
|
className="px-3 py-1.5 rounded bg-zinc-700 hover:bg-zinc-600 text-sm text-white"
|
||||||
|
>
|
||||||
|
{copied === model.name ? "Copied!" : "Copy command"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-zinc-500 mt-4">
|
||||||
|
💡 Tip: For best results with limited RAM (8GB), use <code className="text-teal-400">llama3.2:3b</code>.
|
||||||
|
With 16GB+ RAM, try <code className="text-teal-400">mistral:7b</code> or larger.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Configuration */}
|
||||||
|
<div className="rounded-xl border border-zinc-800 bg-zinc-900/50 p-6 mb-8">
|
||||||
|
<h2 className="text-lg font-semibold text-white mb-4">Configure Connection</h2>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="endpoint" className="text-zinc-300">Ollama Server URL</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
id="endpoint"
|
||||||
|
type="url"
|
||||||
|
value={endpoint}
|
||||||
|
onChange={(e) => setEndpoint(e.target.value)}
|
||||||
|
placeholder="http://localhost:11434"
|
||||||
|
className="bg-zinc-800 border-zinc-700 text-white"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
onClick={testConnection}
|
||||||
|
disabled={testing}
|
||||||
|
className="bg-teal-500 hover:bg-teal-600 text-white whitespace-nowrap"
|
||||||
|
>
|
||||||
|
{testing ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
"Test Connection"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Connection Status */}
|
||||||
|
{connectionStatus === "success" && (
|
||||||
|
<div className="p-3 rounded-lg bg-green-500/10 border border-green-500/20 flex items-center gap-2">
|
||||||
|
<Check className="h-5 w-5 text-green-400" />
|
||||||
|
<span className="text-green-400">Connected successfully! Found {availableModels.length} model(s).</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{connectionStatus === "error" && (
|
||||||
|
<div className="p-3 rounded-lg bg-red-500/10 border border-red-500/20 flex items-center gap-2">
|
||||||
|
<AlertCircle className="h-5 w-5 text-red-400" />
|
||||||
|
<span className="text-red-400">{errorMessage}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Model Selection */}
|
||||||
|
{availableModels.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-zinc-300">Select Model</Label>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 gap-2">
|
||||||
|
{availableModels.map((model) => (
|
||||||
|
<button
|
||||||
|
key={model}
|
||||||
|
onClick={() => setSelectedModel(model)}
|
||||||
|
className={cn(
|
||||||
|
"p-3 rounded-lg border text-left transition-colors",
|
||||||
|
selectedModel === model
|
||||||
|
? "border-teal-500 bg-teal-500/10 text-teal-400"
|
||||||
|
: "border-zinc-700 bg-zinc-800 text-zinc-300 hover:border-zinc-600"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{model}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Save Button */}
|
||||||
|
{connectionStatus === "success" && selectedModel && (
|
||||||
|
<Button
|
||||||
|
onClick={saveSettings}
|
||||||
|
className="w-full bg-teal-500 hover:bg-teal-600 text-white"
|
||||||
|
>
|
||||||
|
Save Configuration
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Benefits */}
|
||||||
|
<div className="rounded-xl border border-zinc-800 bg-gradient-to-r from-teal-500/10 to-purple-500/10 p-6">
|
||||||
|
<h2 className="text-lg font-semibold text-white mb-4">Why Self-Host?</h2>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-3xl mb-2">🔒</div>
|
||||||
|
<h3 className="font-medium text-white mb-1">Complete Privacy</h3>
|
||||||
|
<p className="text-sm text-zinc-400">Your documents never leave your computer</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-3xl mb-2">♾️</div>
|
||||||
|
<h3 className="font-medium text-white mb-1">Unlimited Usage</h3>
|
||||||
|
<p className="text-sm text-zinc-400">No monthly limits or quotas</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-3xl mb-2">💰</div>
|
||||||
|
<h3 className="font-medium text-white mb-1">Free Forever</h3>
|
||||||
|
<p className="text-sm text-zinc-400">No subscription or API costs</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,6 +5,12 @@ import { useTranslationStore } from "@/lib/store";
|
|||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Settings } from "lucide-react";
|
import { Settings } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import {
|
||||||
|
LandingHero,
|
||||||
|
FeaturesSection,
|
||||||
|
PricingPreview,
|
||||||
|
SelfHostCTA
|
||||||
|
} from "@/components/landing-sections";
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const { settings } = useTranslationStore();
|
const { settings } = useTranslationStore();
|
||||||
@@ -15,35 +21,71 @@ export default function Home() {
|
|||||||
deepl: "DeepL",
|
deepl: "DeepL",
|
||||||
libre: "LibreTranslate",
|
libre: "LibreTranslate",
|
||||||
webllm: "WebLLM",
|
webllm: "WebLLM",
|
||||||
|
openai: "OpenAI",
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-0 -m-8">
|
||||||
<div className="flex items-start justify-between">
|
{/* Hero Section */}
|
||||||
<div>
|
<LandingHero />
|
||||||
<h1 className="text-3xl font-bold text-white">Translate Documents</h1>
|
|
||||||
<p className="text-zinc-400 mt-1">
|
{/* Upload Section */}
|
||||||
Upload and translate Excel, Word, and PowerPoint files while preserving all formatting.
|
<div id="upload" className="px-8 py-12 bg-zinc-900/30">
|
||||||
</p>
|
<div className="max-w-6xl mx-auto">
|
||||||
</div>
|
<div className="flex items-start justify-between mb-6">
|
||||||
|
<div>
|
||||||
{/* Current Configuration Badge */}
|
<h2 className="text-2xl font-bold text-white">Translate Your Document</h2>
|
||||||
<Link href="/settings/services" className="flex items-center gap-2 px-3 py-2 rounded-lg bg-zinc-800/50 border border-zinc-700 hover:bg-zinc-800 transition-colors">
|
<p className="text-zinc-400 mt-1">
|
||||||
<Settings className="h-4 w-4 text-zinc-400" />
|
Upload and translate Excel, Word, and PowerPoint files while preserving all formatting.
|
||||||
<div className="flex items-center gap-2">
|
</p>
|
||||||
<Badge variant="outline" className="border-teal-500/50 text-teal-400 text-xs">
|
</div>
|
||||||
{providerNames[settings.defaultProvider]}
|
|
||||||
</Badge>
|
{/* Current Configuration Badge */}
|
||||||
{settings.defaultProvider === "ollama" && settings.ollamaModel && (
|
<Link href="/settings/services" className="flex items-center gap-2 px-3 py-2 rounded-lg bg-zinc-800/50 border border-zinc-700 hover:bg-zinc-800 transition-colors">
|
||||||
<Badge variant="outline" className="border-zinc-600 text-zinc-400 text-xs">
|
<Settings className="h-4 w-4 text-zinc-400" />
|
||||||
{settings.ollamaModel}
|
<div className="flex items-center gap-2">
|
||||||
</Badge>
|
<Badge variant="outline" className="border-teal-500/50 text-teal-400 text-xs">
|
||||||
)}
|
{providerNames[settings.defaultProvider]}
|
||||||
|
</Badge>
|
||||||
|
{settings.defaultProvider === "ollama" && settings.ollamaModel && (
|
||||||
|
<Badge variant="outline" className="border-zinc-600 text-zinc-400 text-xs">
|
||||||
|
{settings.ollamaModel}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
|
||||||
|
<FileUploader />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<FileUploader />
|
{/* 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="/ollama-setup" className="hover:text-zinc-300">Self-Host</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
421
frontend/src/app/pricing/page.tsx
Normal file
421
frontend/src/app/pricing/page.tsx
Normal file
@@ -0,0 +1,421 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { Check, Zap, Building2, Crown, Sparkles } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CreditPackage {
|
||||||
|
credits: number;
|
||||||
|
price: number;
|
||||||
|
price_per_credit: number;
|
||||||
|
popular?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const planIcons: Record<string, any> = {
|
||||||
|
free: Sparkles,
|
||||||
|
starter: Zap,
|
||||||
|
pro: Crown,
|
||||||
|
business: Building2,
|
||||||
|
enterprise: Building2,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function PricingPage() {
|
||||||
|
const [isYearly, setIsYearly] = useState(false);
|
||||||
|
const [plans, setPlans] = useState<Plan[]>([]);
|
||||||
|
const [creditPackages, setCreditPackages] = useState<CreditPackage[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
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,
|
||||||
|
features: [
|
||||||
|
"3 documents per day",
|
||||||
|
"Up to 10 pages per document",
|
||||||
|
"Ollama (self-hosted) only",
|
||||||
|
"Basic support via community",
|
||||||
|
],
|
||||||
|
docs_per_month: 3,
|
||||||
|
max_pages_per_doc: 10,
|
||||||
|
providers: ["ollama"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "starter",
|
||||||
|
name: "Starter",
|
||||||
|
price_monthly: 9,
|
||||||
|
price_yearly: 90,
|
||||||
|
features: [
|
||||||
|
"50 documents per month",
|
||||||
|
"Up to 50 pages per document",
|
||||||
|
"Google Translate included",
|
||||||
|
"LibreTranslate included",
|
||||||
|
"Email support",
|
||||||
|
],
|
||||||
|
docs_per_month: 50,
|
||||||
|
max_pages_per_doc: 50,
|
||||||
|
providers: ["ollama", "google", "libre"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "pro",
|
||||||
|
name: "Pro",
|
||||||
|
price_monthly: 29,
|
||||||
|
price_yearly: 290,
|
||||||
|
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",
|
||||||
|
],
|
||||||
|
docs_per_month: 200,
|
||||||
|
max_pages_per_doc: 200,
|
||||||
|
providers: ["ollama", "google", "deepl", "openai", "libre"],
|
||||||
|
popular: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "business",
|
||||||
|
name: "Business",
|
||||||
|
price_monthly: 79,
|
||||||
|
price_yearly: 790,
|
||||||
|
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)",
|
||||||
|
],
|
||||||
|
docs_per_month: 1000,
|
||||||
|
max_pages_per_doc: 500,
|
||||||
|
providers: ["ollama", "google", "deepl", "openai", "libre", "azure"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "enterprise",
|
||||||
|
name: "Enterprise",
|
||||||
|
price_monthly: -1,
|
||||||
|
price_yearly: -1,
|
||||||
|
features: [
|
||||||
|
"Unlimited documents",
|
||||||
|
"Unlimited pages",
|
||||||
|
"Custom integrations",
|
||||||
|
"On-premise deployment",
|
||||||
|
"SLA guarantee",
|
||||||
|
"24/7 dedicated support",
|
||||||
|
"Custom AI models",
|
||||||
|
"White-label option",
|
||||||
|
],
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-b from-[#1a1a1a] to-[#262626] py-16">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="text-center mb-16">
|
||||||
|
<Badge className="mb-4 bg-teal-500/20 text-teal-400 border-teal-500/30">
|
||||||
|
Pricing
|
||||||
|
</Badge>
|
||||||
|
<h1 className="text-4xl md:text-5xl font-bold text-white mb-4">
|
||||||
|
Simple, Transparent Pricing
|
||||||
|
</h1>
|
||||||
|
<p className="text-xl text-zinc-400 max-w-2xl mx-auto">
|
||||||
|
Choose the perfect plan for your translation needs. Start free and scale as you grow.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Billing Toggle */}
|
||||||
|
<div className="mt-8 flex items-center justify-center gap-4">
|
||||||
|
<span className={cn("text-sm", !isYearly ? "text-white" : "text-zinc-500")}>
|
||||||
|
Monthly
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsYearly(!isYearly)}
|
||||||
|
className={cn(
|
||||||
|
"relative inline-flex h-6 w-11 items-center rounded-full transition-colors",
|
||||||
|
isYearly ? "bg-teal-500" : "bg-zinc-700"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"inline-block h-4 w-4 transform rounded-full bg-white transition-transform",
|
||||||
|
isYearly ? "translate-x-6" : "translate-x-1"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<span className={cn("text-sm", isYearly ? "text-white" : "text-zinc-500")}>
|
||||||
|
Yearly
|
||||||
|
<Badge className="ml-2 bg-green-500/20 text-green-400 border-green-500/30 text-xs">
|
||||||
|
Save 17%
|
||||||
|
</Badge>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Plans Grid */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-16">
|
||||||
|
{plans.slice(0, 4).map((plan) => {
|
||||||
|
const Icon = planIcons[plan.id] || Sparkles;
|
||||||
|
const price = isYearly ? plan.price_yearly : plan.price_monthly;
|
||||||
|
const isEnterprise = plan.id === "enterprise";
|
||||||
|
const isPro = plan.popular;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={plan.id}
|
||||||
|
className={cn(
|
||||||
|
"relative rounded-2xl border p-6 flex flex-col",
|
||||||
|
isPro
|
||||||
|
? "border-teal-500 bg-gradient-to-b from-teal-500/10 to-transparent"
|
||||||
|
: "border-zinc-800 bg-zinc-900/50"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isPro && (
|
||||||
|
<Badge className="absolute -top-3 left-1/2 -translate-x-1/2 bg-teal-500 text-white">
|
||||||
|
Most Popular
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"p-2 rounded-lg",
|
||||||
|
isPro ? "bg-teal-500/20" : "bg-zinc-800"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className={cn("h-5 w-5", isPro ? "text-teal-400" : "text-zinc-400")} />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-xl font-semibold text-white">{plan.name}</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-6">
|
||||||
|
{isEnterprise || price < 0 ? (
|
||||||
|
<div className="text-3xl font-bold text-white">Custom</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span className="text-4xl font-bold text-white">
|
||||||
|
${isYearly ? Math.round(price / 12) : price}
|
||||||
|
</span>
|
||||||
|
<span className="text-zinc-500">/month</span>
|
||||||
|
{isYearly && price > 0 && (
|
||||||
|
<div className="text-sm text-zinc-500">
|
||||||
|
${price} billed yearly
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul className="space-y-3 mb-6 flex-1">
|
||||||
|
{plan.features.map((feature, idx) => (
|
||||||
|
<li key={idx} className="flex items-start gap-2">
|
||||||
|
<Check className="h-5 w-5 text-teal-400 shrink-0 mt-0.5" />
|
||||||
|
<span className="text-sm text-zinc-300">{feature}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={() => handleSubscribe(plan.id)}
|
||||||
|
className={cn(
|
||||||
|
"w-full",
|
||||||
|
isPro
|
||||||
|
? "bg-teal-500 hover:bg-teal-600 text-white"
|
||||||
|
: "bg-zinc-800 hover:bg-zinc-700 text-white"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{plan.id === "free"
|
||||||
|
? "Get Started"
|
||||||
|
: isEnterprise
|
||||||
|
? "Contact Sales"
|
||||||
|
: "Subscribe"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Enterprise Section */}
|
||||||
|
{plans.find((p) => p.id === "enterprise") && (
|
||||||
|
<div className="rounded-2xl border border-zinc-800 bg-gradient-to-r from-purple-500/10 to-teal-500/10 p-8 mb-16">
|
||||||
|
<div className="flex flex-col md:flex-row items-center justify-between gap-6">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-2xl font-bold text-white mb-2">
|
||||||
|
Need Enterprise Features?
|
||||||
|
</h3>
|
||||||
|
<p className="text-zinc-400 max-w-xl">
|
||||||
|
Get unlimited translations, custom integrations, on-premise deployment,
|
||||||
|
dedicated support, and SLA guarantees. Perfect for large organizations.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="lg"
|
||||||
|
className="bg-gradient-to-r from-purple-500 to-teal-500 hover:from-purple-600 hover:to-teal-600 text-white whitespace-nowrap"
|
||||||
|
>
|
||||||
|
Contact Sales
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Credit Packages */}
|
||||||
|
<div className="mb-16">
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<h2 className="text-2xl font-bold text-white mb-2">Need Extra Pages?</h2>
|
||||||
|
<p className="text-zinc-400">
|
||||||
|
Buy credit packages to translate more pages. Credits never expire.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
|
||||||
|
{creditPackages.map((pkg, idx) => (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className={cn(
|
||||||
|
"rounded-xl border p-4 text-center",
|
||||||
|
pkg.popular
|
||||||
|
? "border-teal-500 bg-teal-500/10"
|
||||||
|
: "border-zinc-800 bg-zinc-900/50"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{pkg.popular && (
|
||||||
|
<Badge className="mb-2 bg-teal-500/20 text-teal-400 border-teal-500/30 text-xs">
|
||||||
|
Best Value
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
<div className="text-2xl font-bold text-white">{pkg.credits}</div>
|
||||||
|
<div className="text-sm text-zinc-500 mb-2">pages</div>
|
||||||
|
<div className="text-xl font-semibold text-white">${pkg.price}</div>
|
||||||
|
<div className="text-xs text-zinc-500">
|
||||||
|
${pkg.price_per_credit.toFixed(2)}/page
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="mt-3 w-full border-zinc-700 hover:bg-zinc-800"
|
||||||
|
>
|
||||||
|
Buy
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* FAQ Section */}
|
||||||
|
<div className="max-w-3xl mx-auto">
|
||||||
|
<h2 className="text-2xl font-bold text-white text-center mb-8">
|
||||||
|
Frequently Asked Questions
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{[
|
||||||
|
{
|
||||||
|
q: "Can I use my own Ollama instance?",
|
||||||
|
a: "Yes! The Free plan lets you connect your own Ollama server for unlimited translations. You just need to configure your Ollama endpoint in the settings.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
q: "What happens if I exceed my monthly limit?",
|
||||||
|
a: "You can either wait for the next month, upgrade to a higher plan, or purchase credit packages for additional pages.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
q: "Can I cancel my subscription anytime?",
|
||||||
|
a: "Yes, you can cancel anytime. You'll continue to have access until the end of your billing period.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
q: "Do credits expire?",
|
||||||
|
a: "No, purchased credits never expire and can be used anytime.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
q: "What file formats are supported?",
|
||||||
|
a: "We support Microsoft Office documents: Word (.docx), Excel (.xlsx), and PowerPoint (.pptx). The formatting is fully preserved during translation.",
|
||||||
|
},
|
||||||
|
].map((faq, idx) => (
|
||||||
|
<div key={idx} className="rounded-lg border border-zinc-800 bg-zinc-900/50 p-4">
|
||||||
|
<h3 className="font-medium text-white mb-2">{faq.q}</h3>
|
||||||
|
<p className="text-sm text-zinc-400">{faq.a}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
309
frontend/src/components/landing-sections.tsx
Normal file
309
frontend/src/components/landing-sections.tsx
Normal file
@@ -0,0 +1,309 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import {
|
||||||
|
ArrowRight,
|
||||||
|
Check,
|
||||||
|
FileText,
|
||||||
|
Globe2,
|
||||||
|
Zap,
|
||||||
|
Shield,
|
||||||
|
Server,
|
||||||
|
Sparkles,
|
||||||
|
FileSpreadsheet,
|
||||||
|
Presentation
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
name: string;
|
||||||
|
plan: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LandingHero() {
|
||||||
|
const [user, setUser] = useState<User | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const storedUser = localStorage.getItem("user");
|
||||||
|
if (storedUser) {
|
||||||
|
try {
|
||||||
|
setUser(JSON.parse(storedUser));
|
||||||
|
} catch {
|
||||||
|
setUser(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative overflow-hidden">
|
||||||
|
{/* Background gradient */}
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-br from-teal-500/10 via-transparent to-purple-500/10" />
|
||||||
|
|
||||||
|
{/* Hero content */}
|
||||||
|
<div className="relative px-4 py-16 sm:py-24">
|
||||||
|
<div className="text-center max-w-4xl mx-auto">
|
||||||
|
<Badge className="mb-6 bg-teal-500/20 text-teal-400 border-teal-500/30">
|
||||||
|
<Sparkles className="h-3 w-3 mr-1" />
|
||||||
|
AI-Powered Document Translation
|
||||||
|
</Badge>
|
||||||
|
|
||||||
|
<h1 className="text-4xl sm:text-5xl lg:text-6xl font-bold text-white mb-6 leading-tight">
|
||||||
|
Translate Documents{" "}
|
||||||
|
<span className="text-transparent bg-clip-text bg-gradient-to-r from-teal-400 to-cyan-400">
|
||||||
|
Instantly
|
||||||
|
</span>
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p className="text-xl text-zinc-400 mb-8 max-w-2xl mx-auto">
|
||||||
|
Upload Word, Excel, and PowerPoint files. Get perfect translations while preserving
|
||||||
|
all formatting, styles, and layouts. Powered by AI.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex flex-col sm:flex-row gap-4 justify-center mb-12">
|
||||||
|
{user ? (
|
||||||
|
<Link href="#upload">
|
||||||
|
<Button size="lg" className="bg-teal-500 hover:bg-teal-600 text-white px-8">
|
||||||
|
Start Translating
|
||||||
|
<ArrowRight className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Link href="/auth/register">
|
||||||
|
<Button size="lg" className="bg-teal-500 hover:bg-teal-600 text-white px-8">
|
||||||
|
Get Started Free
|
||||||
|
<ArrowRight className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<Link href="/pricing">
|
||||||
|
<Button size="lg" variant="outline" className="border-zinc-700 text-white hover:bg-zinc-800">
|
||||||
|
View Pricing
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Supported formats */}
|
||||||
|
<div className="flex flex-wrap justify-center gap-4">
|
||||||
|
<div className="flex items-center gap-2 px-4 py-2 rounded-full bg-zinc-800/50 border border-zinc-700">
|
||||||
|
<FileText className="h-4 w-4 text-blue-400" />
|
||||||
|
<span className="text-sm text-zinc-300">Word (.docx)</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 px-4 py-2 rounded-full bg-zinc-800/50 border border-zinc-700">
|
||||||
|
<FileSpreadsheet className="h-4 w-4 text-green-400" />
|
||||||
|
<span className="text-sm text-zinc-300">Excel (.xlsx)</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 px-4 py-2 rounded-full bg-zinc-800/50 border border-zinc-700">
|
||||||
|
<Presentation className="h-4 w-4 text-orange-400" />
|
||||||
|
<span className="text-sm text-zinc-300">PowerPoint (.pptx)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FeaturesSection() {
|
||||||
|
const features = [
|
||||||
|
{
|
||||||
|
icon: Globe2,
|
||||||
|
title: "100+ Languages",
|
||||||
|
description: "Translate between any language pair with high accuracy using AI models",
|
||||||
|
color: "text-blue-400",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: FileText,
|
||||||
|
title: "Preserve Formatting",
|
||||||
|
description: "All styles, fonts, colors, tables, and charts remain intact",
|
||||||
|
color: "text-green-400",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Zap,
|
||||||
|
title: "Lightning Fast",
|
||||||
|
description: "Batch processing translates entire documents in seconds",
|
||||||
|
color: "text-amber-400",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Shield,
|
||||||
|
title: "Secure & Private",
|
||||||
|
description: "Your documents are encrypted and never stored permanently",
|
||||||
|
color: "text-purple-400",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Server,
|
||||||
|
title: "Self-Host Option",
|
||||||
|
description: "Use your own Ollama server for complete privacy and unlimited usage",
|
||||||
|
color: "text-orange-400",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Sparkles,
|
||||||
|
title: "Multiple AI Providers",
|
||||||
|
description: "Choose from Google, DeepL, OpenAI, or local Ollama models",
|
||||||
|
color: "text-teal-400",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="py-16 px-4">
|
||||||
|
<div className="max-w-6xl mx-auto">
|
||||||
|
<div className="text-center mb-12">
|
||||||
|
<h2 className="text-3xl font-bold text-white mb-4">
|
||||||
|
Everything You Need for Document Translation
|
||||||
|
</h2>
|
||||||
|
<p className="text-zinc-400 max-w-2xl 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-6">
|
||||||
|
{features.map((feature) => {
|
||||||
|
const Icon = feature.icon;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={feature.title}
|
||||||
|
className="p-6 rounded-xl border border-zinc-800 bg-zinc-900/50 hover:border-zinc-700 transition-colors"
|
||||||
|
>
|
||||||
|
<Icon className={cn("h-8 w-8 mb-4", feature.color)} />
|
||||||
|
<h3 className="text-lg font-semibold text-white mb-2">{feature.title}</h3>
|
||||||
|
<p className="text-zinc-400 text-sm">{feature.description}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PricingPreview() {
|
||||||
|
const plans = [
|
||||||
|
{
|
||||||
|
name: "Free",
|
||||||
|
price: "$0",
|
||||||
|
description: "Perfect for trying out",
|
||||||
|
features: ["3 documents/day", "10 pages/doc", "Ollama only"],
|
||||||
|
cta: "Get Started",
|
||||||
|
href: "/auth/register",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Pro",
|
||||||
|
price: "$29",
|
||||||
|
period: "/month",
|
||||||
|
description: "For professionals",
|
||||||
|
features: ["200 documents/month", "All providers", "API access", "Priority support"],
|
||||||
|
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",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="py-16 px-4 bg-zinc-900/50">
|
||||||
|
<div className="max-w-5xl mx-auto">
|
||||||
|
<div className="text-center mb-12">
|
||||||
|
<h2 className="text-3xl font-bold text-white mb-4">
|
||||||
|
Simple, Transparent Pricing
|
||||||
|
</h2>
|
||||||
|
<p className="text-zinc-400">
|
||||||
|
Start free, upgrade when you need more.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
{plans.map((plan) => (
|
||||||
|
<div
|
||||||
|
key={plan.name}
|
||||||
|
className={cn(
|
||||||
|
"relative p-6 rounded-xl border",
|
||||||
|
plan.popular
|
||||||
|
? "border-teal-500 bg-gradient-to-b from-teal-500/10 to-transparent"
|
||||||
|
: "border-zinc-800 bg-zinc-900/50"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{plan.popular && (
|
||||||
|
<Badge className="absolute -top-3 left-1/2 -translate-x-1/2 bg-teal-500 text-white">
|
||||||
|
Most Popular
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<h3 className="text-xl font-semibold text-white mb-1">{plan.name}</h3>
|
||||||
|
<p className="text-sm text-zinc-400 mb-4">{plan.description}</p>
|
||||||
|
|
||||||
|
<div className="mb-6">
|
||||||
|
<span className="text-3xl font-bold text-white">{plan.price}</span>
|
||||||
|
{plan.period && <span className="text-zinc-500">{plan.period}</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul className="space-y-2 mb-6">
|
||||||
|
{plan.features.map((feature) => (
|
||||||
|
<li key={feature} className="flex items-center gap-2 text-sm text-zinc-300">
|
||||||
|
<Check className="h-4 w-4 text-teal-400" />
|
||||||
|
{feature}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<Link href={plan.href}>
|
||||||
|
<Button
|
||||||
|
className={cn(
|
||||||
|
"w-full",
|
||||||
|
plan.popular
|
||||||
|
? "bg-teal-500 hover:bg-teal-600 text-white"
|
||||||
|
: "bg-zinc-800 hover:bg-zinc-700 text-white"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{plan.cta}
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center mt-8">
|
||||||
|
<Link href="/pricing" className="text-teal-400 hover:text-teal-300 text-sm">
|
||||||
|
View all plans and features →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SelfHostCTA() {
|
||||||
|
return (
|
||||||
|
<div className="py-16 px-4">
|
||||||
|
<div className="max-w-4xl mx-auto">
|
||||||
|
<div className="rounded-2xl border border-zinc-800 bg-gradient-to-r from-orange-500/10 to-amber-500/10 p-8 text-center">
|
||||||
|
<Server className="h-12 w-12 text-orange-400 mx-auto mb-4" />
|
||||||
|
<h2 className="text-2xl font-bold text-white mb-2">
|
||||||
|
Prefer Self-Hosting?
|
||||||
|
</h2>
|
||||||
|
<p className="text-zinc-400 mb-6 max-w-xl mx-auto">
|
||||||
|
Run your own Ollama server for complete privacy and unlimited translations.
|
||||||
|
No API costs, no quotas, your data stays on your machine.
|
||||||
|
</p>
|
||||||
|
<Link href="/ollama-setup">
|
||||||
|
<Button className="bg-orange-500 hover:bg-orange-600 text-white">
|
||||||
|
Setup Ollama
|
||||||
|
<ArrowRight className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,13 +2,18 @@
|
|||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
|
import { useState, useEffect, useCallback, memo } from "react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import {
|
import {
|
||||||
Settings,
|
Settings,
|
||||||
Cloud,
|
Cloud,
|
||||||
BookText,
|
BookText,
|
||||||
Upload,
|
Upload,
|
||||||
Shield,
|
LayoutDashboard,
|
||||||
|
LogIn,
|
||||||
|
Crown,
|
||||||
|
LogOut,
|
||||||
|
Server,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
@@ -16,6 +21,14 @@ import {
|
|||||||
TooltipProvider,
|
TooltipProvider,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
plan: string;
|
||||||
|
}
|
||||||
|
|
||||||
const navigation = [
|
const navigation = [
|
||||||
{
|
{
|
||||||
@@ -24,12 +37,6 @@ const navigation = [
|
|||||||
icon: Upload,
|
icon: Upload,
|
||||||
description: "Translate documents",
|
description: "Translate documents",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: "General Settings",
|
|
||||||
href: "/settings",
|
|
||||||
icon: Settings,
|
|
||||||
description: "Configure general settings",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: "Translation Services",
|
name: "Translation Services",
|
||||||
href: "/settings/services",
|
href: "/settings/services",
|
||||||
@@ -42,22 +49,101 @@ const navigation = [
|
|||||||
icon: BookText,
|
icon: BookText,
|
||||||
description: "System prompts and glossary",
|
description: "System prompts and glossary",
|
||||||
},
|
},
|
||||||
];
|
|
||||||
|
|
||||||
const adminNavigation = [
|
|
||||||
{
|
{
|
||||||
name: "Admin Dashboard",
|
name: "General Settings",
|
||||||
href: "/admin",
|
href: "/settings",
|
||||||
icon: Shield,
|
icon: Settings,
|
||||||
description: "System monitoring (login required)",
|
description: "Configure general settings",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
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() {
|
export function Sidebar() {
|
||||||
const pathname = usePathname();
|
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 (
|
return (
|
||||||
<TooltipProvider>
|
<TooltipProvider delayDuration={300}>
|
||||||
<aside className="fixed left-0 top-0 z-40 h-screen w-64 border-r border-zinc-800 bg-[#1a1a1a]">
|
<aside className="fixed left-0 top-0 z-40 h-screen w-64 border-r border-zinc-800 bg-[#1a1a1a]">
|
||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
<div className="flex h-16 items-center gap-3 border-b border-zinc-800 px-6">
|
<div className="flex h-16 items-center gap-3 border-b border-zinc-800 px-6">
|
||||||
@@ -95,50 +181,117 @@ export function Sidebar() {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{/* Admin Section */}
|
|
||||||
<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">Admin</p>
|
|
||||||
{adminNavigation.map((item) => {
|
|
||||||
const isActive = pathname === item.href;
|
|
||||||
const Icon = item.icon;
|
|
||||||
|
|
||||||
return (
|
{/* User Section */}
|
||||||
<Tooltip key={item.name}>
|
{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>
|
<TooltipTrigger asChild>
|
||||||
<Link
|
<Link
|
||||||
href={item.href}
|
href="/pricing"
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors",
|
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors",
|
||||||
isActive
|
pathname === "/pricing"
|
||||||
? "bg-blue-500/10 text-blue-400"
|
? "bg-amber-500/10 text-amber-400"
|
||||||
: "text-zinc-500 hover:bg-zinc-800 hover:text-zinc-300"
|
: "text-amber-400/70 hover:bg-zinc-800 hover:text-amber-400"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Icon className="h-5 w-5" />
|
<Crown className="h-5 w-5" />
|
||||||
<span>{item.name}</span>
|
<span>Upgrade Plan</span>
|
||||||
</Link>
|
</Link>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="right">
|
<TooltipContent side="right">
|
||||||
<p>{item.description}</p>
|
<p>Get more translations and features</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
)}
|
||||||
})}
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Self-Host Option */}
|
||||||
|
<div className="mt-4 pt-4 border-t border-zinc-800">
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Link
|
||||||
|
href="/ollama-setup"
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors",
|
||||||
|
pathname === "/ollama-setup"
|
||||||
|
? "bg-orange-500/10 text-orange-400"
|
||||||
|
: "text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Server className="h-5 w-5" />
|
||||||
|
<span>Self-Host (Free)</span>
|
||||||
|
</Link>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="right">
|
||||||
|
<p>Run your own Ollama for unlimited free translations</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* User section at bottom */}
|
{/* User section at bottom */}
|
||||||
<div className="absolute bottom-0 left-0 right-0 border-t border-zinc-800 p-4">
|
<div className="absolute bottom-0 left-0 right-0 border-t border-zinc-800 p-4">
|
||||||
<div className="flex items-center gap-3">
|
{user ? (
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-teal-600 text-white text-sm font-medium">
|
<div className="flex items-center justify-between">
|
||||||
U
|
<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>
|
||||||
<div className="flex flex-col">
|
) : (
|
||||||
<span className="text-sm font-medium text-white">User</span>
|
<div className="space-y-2">
|
||||||
<span className="text-xs text-zinc-500">Translator</span>
|
<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>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
|
|||||||
288
k8s/deployment.yaml
Normal file
288
k8s/deployment.yaml
Normal file
@@ -0,0 +1,288 @@
|
|||||||
|
# ============================================
|
||||||
|
# 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"
|
||||||
|
|
||||||
|
---
|
||||||
|
# Secret for sensitive data
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Secret
|
||||||
|
metadata:
|
||||||
|
name: translate-secrets
|
||||||
|
namespace: translate-api
|
||||||
|
type: Opaque
|
||||||
|
stringData:
|
||||||
|
ADMIN_USERNAME: "admin"
|
||||||
|
ADMIN_PASSWORD: "changeme123" # Change in production!
|
||||||
|
DEEPL_API_KEY: ""
|
||||||
|
OPENAI_API_KEY: ""
|
||||||
|
|
||||||
|
---
|
||||||
|
# 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: /health
|
||||||
|
port: 8000
|
||||||
|
initialDelaySeconds: 10
|
||||||
|
periodSeconds: 10
|
||||||
|
livenessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /health
|
||||||
|
port: 8000
|
||||||
|
initialDelaySeconds: 30
|
||||||
|
periodSeconds: 30
|
||||||
|
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
|
||||||
10
main.py
10
main.py
@@ -21,6 +21,10 @@ import time
|
|||||||
from config import config
|
from config import config
|
||||||
from translators import excel_translator, word_translator, pptx_translator
|
from translators import excel_translator, word_translator, pptx_translator
|
||||||
from utils import file_handler, handle_translation_error, DocumentProcessingError
|
from utils import file_handler, handle_translation_error, DocumentProcessingError
|
||||||
|
from services.translation_service import _translation_cache
|
||||||
|
|
||||||
|
# Import auth routes
|
||||||
|
from routes.auth_routes import router as auth_router
|
||||||
|
|
||||||
# Import SaaS middleware
|
# Import SaaS middleware
|
||||||
from middleware.rate_limiting import RateLimitMiddleware, RateLimitManager, RateLimitConfig
|
from middleware.rate_limiting import RateLimitMiddleware, RateLimitManager, RateLimitConfig
|
||||||
@@ -174,6 +178,9 @@ static_dir = Path(__file__).parent / "static"
|
|||||||
if static_dir.exists():
|
if static_dir.exists():
|
||||||
app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")
|
app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")
|
||||||
|
|
||||||
|
# Include auth routes
|
||||||
|
app.include_router(auth_router)
|
||||||
|
|
||||||
|
|
||||||
# Custom exception handler for ValidationError
|
# Custom exception handler for ValidationError
|
||||||
@app.exception_handler(ValidationError)
|
@app.exception_handler(ValidationError)
|
||||||
@@ -222,7 +229,8 @@ async def health_check():
|
|||||||
"rate_limits": {
|
"rate_limits": {
|
||||||
"requests_per_minute": rate_limit_config.requests_per_minute,
|
"requests_per_minute": rate_limit_config.requests_per_minute,
|
||||||
"translations_per_minute": rate_limit_config.translations_per_minute,
|
"translations_per_minute": rate_limit_config.translations_per_minute,
|
||||||
}
|
},
|
||||||
|
"translation_cache": _translation_cache.stats()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
1
models/__init__.py
Normal file
1
models/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# Models package
|
||||||
250
models/subscription.py
Normal file
250
models/subscription.py
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
"""
|
||||||
|
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"
|
||||||
|
|
||||||
|
|
||||||
|
# Plan definitions with limits
|
||||||
|
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": 9,
|
||||||
|
"price_yearly": 90, # 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": "price_starter_monthly",
|
||||||
|
"stripe_price_id_yearly": "price_starter_yearly",
|
||||||
|
},
|
||||||
|
PlanType.PRO: {
|
||||||
|
"name": "Pro",
|
||||||
|
"price_monthly": 29,
|
||||||
|
"price_yearly": 290, # 2 months free
|
||||||
|
"docs_per_month": 200,
|
||||||
|
"max_pages_per_doc": 200,
|
||||||
|
"max_file_size_mb": 100,
|
||||||
|
"providers": ["ollama", "google", "deepl", "openai", "libre"],
|
||||||
|
"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": "price_pro_monthly",
|
||||||
|
"stripe_price_id_yearly": "price_pro_yearly",
|
||||||
|
},
|
||||||
|
PlanType.BUSINESS: {
|
||||||
|
"name": "Business",
|
||||||
|
"price_monthly": 79,
|
||||||
|
"price_yearly": 790, # 2 months free
|
||||||
|
"docs_per_month": 1000,
|
||||||
|
"max_pages_per_doc": 500,
|
||||||
|
"max_file_size_mb": 250,
|
||||||
|
"providers": ["ollama", "google", "deepl", "openai", "libre", "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": "price_business_monthly",
|
||||||
|
"stripe_price_id_yearly": "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", "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"},
|
||||||
|
]
|
||||||
@@ -7,6 +7,7 @@ python-pptx==0.6.23
|
|||||||
deep-translator==1.11.4
|
deep-translator==1.11.4
|
||||||
python-dotenv==1.0.0
|
python-dotenv==1.0.0
|
||||||
pydantic==2.5.3
|
pydantic==2.5.3
|
||||||
|
pydantic[email]==2.5.3
|
||||||
aiofiles==23.2.1
|
aiofiles==23.2.1
|
||||||
Pillow==10.2.0
|
Pillow==10.2.0
|
||||||
matplotlib==3.8.2
|
matplotlib==3.8.2
|
||||||
@@ -18,3 +19,9 @@ openai>=1.0.0
|
|||||||
# SaaS robustness dependencies
|
# SaaS robustness dependencies
|
||||||
psutil==5.9.8
|
psutil==5.9.8
|
||||||
python-magic-bin==0.4.14 # For Windows, use python-magic on Linux
|
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
|
||||||
|
|
||||||
|
|||||||
1
routes/__init__.py
Normal file
1
routes/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# Routes package
|
||||||
281
routes/auth_routes.py
Normal file
281
routes/auth_routes.py
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
"""
|
||||||
|
Authentication and User API routes
|
||||||
|
"""
|
||||||
|
from fastapi import APIRouter, HTTPException, Depends, Header, Request
|
||||||
|
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||||
|
from pydantic import BaseModel, EmailStr
|
||||||
|
from typing import Optional, Dict, Any
|
||||||
|
|
||||||
|
from models.subscription import UserCreate, UserLogin, UserResponse, PlanType, PLANS, CREDIT_PACKAGES
|
||||||
|
from services.auth_service import (
|
||||||
|
create_user, authenticate_user, get_user_by_id,
|
||||||
|
create_access_token, create_refresh_token, verify_token,
|
||||||
|
check_usage_limits, update_user
|
||||||
|
)
|
||||||
|
from services.payment_service import (
|
||||||
|
create_checkout_session, create_credits_checkout,
|
||||||
|
cancel_subscription, get_billing_portal_url,
|
||||||
|
handle_webhook, is_stripe_configured
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/auth", tags=["Authentication"])
|
||||||
|
security = HTTPBearer(auto_error=False)
|
||||||
|
|
||||||
|
|
||||||
|
# Request/Response models
|
||||||
|
class TokenResponse(BaseModel):
|
||||||
|
access_token: str
|
||||||
|
refresh_token: str
|
||||||
|
token_type: str = "bearer"
|
||||||
|
user: UserResponse
|
||||||
|
|
||||||
|
|
||||||
|
class RefreshRequest(BaseModel):
|
||||||
|
refresh_token: str
|
||||||
|
|
||||||
|
|
||||||
|
class CheckoutRequest(BaseModel):
|
||||||
|
plan: PlanType
|
||||||
|
billing_period: str = "monthly"
|
||||||
|
|
||||||
|
|
||||||
|
class CreditsCheckoutRequest(BaseModel):
|
||||||
|
package_index: int
|
||||||
|
|
||||||
|
|
||||||
|
# Dependency to get current user
|
||||||
|
async def get_current_user(credentials: Optional[HTTPAuthorizationCredentials] = Depends(security)):
|
||||||
|
if not credentials:
|
||||||
|
return None
|
||||||
|
|
||||||
|
payload = verify_token(credentials.credentials)
|
||||||
|
if not payload:
|
||||||
|
return None
|
||||||
|
|
||||||
|
user = get_user_by_id(payload.get("sub"))
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
async def require_user(credentials: HTTPAuthorizationCredentials = Depends(security)):
|
||||||
|
if not credentials:
|
||||||
|
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||||
|
|
||||||
|
payload = verify_token(credentials.credentials)
|
||||||
|
if not payload:
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid or expired token")
|
||||||
|
|
||||||
|
user = get_user_by_id(payload.get("sub"))
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=401, detail="User not found")
|
||||||
|
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def user_to_response(user) -> UserResponse:
|
||||||
|
"""Convert User to UserResponse with plan limits"""
|
||||||
|
plan_limits = PLANS[user.plan]
|
||||||
|
return UserResponse(
|
||||||
|
id=user.id,
|
||||||
|
email=user.email,
|
||||||
|
name=user.name,
|
||||||
|
avatar_url=user.avatar_url,
|
||||||
|
plan=user.plan,
|
||||||
|
subscription_status=user.subscription_status,
|
||||||
|
docs_translated_this_month=user.docs_translated_this_month,
|
||||||
|
pages_translated_this_month=user.pages_translated_this_month,
|
||||||
|
api_calls_this_month=user.api_calls_this_month,
|
||||||
|
extra_credits=user.extra_credits,
|
||||||
|
created_at=user.created_at,
|
||||||
|
plan_limits={
|
||||||
|
"docs_per_month": plan_limits["docs_per_month"],
|
||||||
|
"max_pages_per_doc": plan_limits["max_pages_per_doc"],
|
||||||
|
"max_file_size_mb": plan_limits["max_file_size_mb"],
|
||||||
|
"providers": plan_limits["providers"],
|
||||||
|
"features": plan_limits["features"],
|
||||||
|
"api_access": plan_limits.get("api_access", False),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Auth endpoints
|
||||||
|
@router.post("/register", response_model=TokenResponse)
|
||||||
|
async def register(user_create: UserCreate):
|
||||||
|
"""Register a new user"""
|
||||||
|
try:
|
||||||
|
user = create_user(user_create)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
|
||||||
|
access_token = create_access_token(user.id)
|
||||||
|
refresh_token = create_refresh_token(user.id)
|
||||||
|
|
||||||
|
return TokenResponse(
|
||||||
|
access_token=access_token,
|
||||||
|
refresh_token=refresh_token,
|
||||||
|
user=user_to_response(user)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/login", response_model=TokenResponse)
|
||||||
|
async def login(credentials: UserLogin):
|
||||||
|
"""Login with email and password"""
|
||||||
|
user = authenticate_user(credentials.email, credentials.password)
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid email or password")
|
||||||
|
|
||||||
|
access_token = create_access_token(user.id)
|
||||||
|
refresh_token = create_refresh_token(user.id)
|
||||||
|
|
||||||
|
return TokenResponse(
|
||||||
|
access_token=access_token,
|
||||||
|
refresh_token=refresh_token,
|
||||||
|
user=user_to_response(user)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/refresh", response_model=TokenResponse)
|
||||||
|
async def refresh_tokens(request: RefreshRequest):
|
||||||
|
"""Refresh access token"""
|
||||||
|
payload = verify_token(request.refresh_token)
|
||||||
|
if not payload or payload.get("type") != "refresh":
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid refresh token")
|
||||||
|
|
||||||
|
user = get_user_by_id(payload.get("sub"))
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=401, detail="User not found")
|
||||||
|
|
||||||
|
access_token = create_access_token(user.id)
|
||||||
|
refresh_token = create_refresh_token(user.id)
|
||||||
|
|
||||||
|
return TokenResponse(
|
||||||
|
access_token=access_token,
|
||||||
|
refresh_token=refresh_token,
|
||||||
|
user=user_to_response(user)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me", response_model=UserResponse)
|
||||||
|
async def get_me(user=Depends(require_user)):
|
||||||
|
"""Get current user info"""
|
||||||
|
return user_to_response(user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/usage")
|
||||||
|
async def get_usage(user=Depends(require_user)):
|
||||||
|
"""Get current usage and limits"""
|
||||||
|
return check_usage_limits(user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/settings")
|
||||||
|
async def update_settings(settings: Dict[str, Any], user=Depends(require_user)):
|
||||||
|
"""Update user settings"""
|
||||||
|
allowed_fields = [
|
||||||
|
"default_source_lang", "default_target_lang", "default_provider",
|
||||||
|
"ollama_endpoint", "ollama_model", "name"
|
||||||
|
]
|
||||||
|
|
||||||
|
updates = {k: v for k, v in settings.items() if k in allowed_fields}
|
||||||
|
updated_user = update_user(user.id, updates)
|
||||||
|
|
||||||
|
if not updated_user:
|
||||||
|
raise HTTPException(status_code=400, detail="Failed to update settings")
|
||||||
|
|
||||||
|
return user_to_response(updated_user)
|
||||||
|
|
||||||
|
|
||||||
|
# Plans endpoint (public)
|
||||||
|
@router.get("/plans")
|
||||||
|
async def get_plans():
|
||||||
|
"""Get all available plans"""
|
||||||
|
plans = []
|
||||||
|
for plan_type, config in PLANS.items():
|
||||||
|
plans.append({
|
||||||
|
"id": plan_type.value,
|
||||||
|
"name": config["name"],
|
||||||
|
"price_monthly": config["price_monthly"],
|
||||||
|
"price_yearly": config["price_yearly"],
|
||||||
|
"features": config["features"],
|
||||||
|
"docs_per_month": config["docs_per_month"],
|
||||||
|
"max_pages_per_doc": config["max_pages_per_doc"],
|
||||||
|
"max_file_size_mb": config["max_file_size_mb"],
|
||||||
|
"providers": config["providers"],
|
||||||
|
"api_access": config.get("api_access", False),
|
||||||
|
"popular": plan_type == PlanType.PRO,
|
||||||
|
})
|
||||||
|
return {"plans": plans, "credit_packages": CREDIT_PACKAGES}
|
||||||
|
|
||||||
|
|
||||||
|
# Payment endpoints
|
||||||
|
@router.post("/checkout/subscription")
|
||||||
|
async def checkout_subscription(request: CheckoutRequest, user=Depends(require_user)):
|
||||||
|
"""Create Stripe checkout session for subscription"""
|
||||||
|
if not is_stripe_configured():
|
||||||
|
# Demo mode - just upgrade the user
|
||||||
|
update_user(user.id, {"plan": request.plan.value})
|
||||||
|
return {"demo_mode": True, "message": "Upgraded in demo mode", "plan": request.plan.value}
|
||||||
|
|
||||||
|
result = await create_checkout_session(
|
||||||
|
user.id,
|
||||||
|
request.plan,
|
||||||
|
request.billing_period
|
||||||
|
)
|
||||||
|
|
||||||
|
if "error" in result:
|
||||||
|
raise HTTPException(status_code=400, detail=result["error"])
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/checkout/credits")
|
||||||
|
async def checkout_credits(request: CreditsCheckoutRequest, user=Depends(require_user)):
|
||||||
|
"""Create Stripe checkout session for credits"""
|
||||||
|
if not is_stripe_configured():
|
||||||
|
# Demo mode - add credits directly
|
||||||
|
from services.auth_service import add_credits
|
||||||
|
credits = CREDIT_PACKAGES[request.package_index]["credits"]
|
||||||
|
add_credits(user.id, credits)
|
||||||
|
return {"demo_mode": True, "message": f"Added {credits} credits in demo mode"}
|
||||||
|
|
||||||
|
result = await create_credits_checkout(user.id, request.package_index)
|
||||||
|
|
||||||
|
if "error" in result:
|
||||||
|
raise HTTPException(status_code=400, detail=result["error"])
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/subscription/cancel")
|
||||||
|
async def cancel_user_subscription(user=Depends(require_user)):
|
||||||
|
"""Cancel subscription"""
|
||||||
|
result = await cancel_subscription(user.id)
|
||||||
|
|
||||||
|
if "error" in result:
|
||||||
|
raise HTTPException(status_code=400, detail=result["error"])
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/billing-portal")
|
||||||
|
async def get_billing_portal(user=Depends(require_user)):
|
||||||
|
"""Get Stripe billing portal URL"""
|
||||||
|
url = await get_billing_portal_url(user.id)
|
||||||
|
|
||||||
|
if not url:
|
||||||
|
raise HTTPException(status_code=400, detail="Billing portal not available")
|
||||||
|
|
||||||
|
return {"url": url}
|
||||||
|
|
||||||
|
|
||||||
|
# Stripe webhook
|
||||||
|
@router.post("/webhook/stripe")
|
||||||
|
async def stripe_webhook(request: Request, stripe_signature: str = Header(None)):
|
||||||
|
"""Handle Stripe webhooks"""
|
||||||
|
payload = await request.body()
|
||||||
|
|
||||||
|
result = await handle_webhook(payload, stripe_signature or "")
|
||||||
|
|
||||||
|
if "error" in result:
|
||||||
|
raise HTTPException(status_code=400, detail=result["error"])
|
||||||
|
|
||||||
|
return result
|
||||||
67
scripts/backup.sh
Normal file
67
scripts/backup.sh
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# ============================================
|
||||||
|
# Document Translation API - Backup Script
|
||||||
|
# ============================================
|
||||||
|
# Usage: ./scripts/backup.sh [backup_dir]
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
BACKUP_DIR="${1:-./backups}"
|
||||||
|
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
|
||||||
|
BACKUP_NAME="translate_backup_$TIMESTAMP"
|
||||||
|
|
||||||
|
# Colors
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
NC='\033[0m'
|
||||||
|
|
||||||
|
echo -e "${YELLOW}Starting backup: $BACKUP_NAME${NC}"
|
||||||
|
|
||||||
|
# Create backup directory
|
||||||
|
mkdir -p "$BACKUP_DIR/$BACKUP_NAME"
|
||||||
|
|
||||||
|
# Backup uploaded files
|
||||||
|
if [ -d "./uploads" ]; then
|
||||||
|
echo "Backing up uploads..."
|
||||||
|
cp -r ./uploads "$BACKUP_DIR/$BACKUP_NAME/"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Backup output files
|
||||||
|
if [ -d "./outputs" ]; then
|
||||||
|
echo "Backing up outputs..."
|
||||||
|
cp -r ./outputs "$BACKUP_DIR/$BACKUP_NAME/"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Backup configuration
|
||||||
|
echo "Backing up configuration..."
|
||||||
|
cp .env* "$BACKUP_DIR/$BACKUP_NAME/" 2>/dev/null || true
|
||||||
|
cp docker-compose*.yml "$BACKUP_DIR/$BACKUP_NAME/" 2>/dev/null || true
|
||||||
|
|
||||||
|
# Backup Docker volumes (if using Docker)
|
||||||
|
if command -v docker &> /dev/null; then
|
||||||
|
echo "Backing up Docker volumes..."
|
||||||
|
|
||||||
|
# Get volume names
|
||||||
|
VOLUMES=$(docker volume ls --format "{{.Name}}" | grep translate || true)
|
||||||
|
|
||||||
|
for vol in $VOLUMES; do
|
||||||
|
echo " Backing up volume: $vol"
|
||||||
|
docker run --rm \
|
||||||
|
-v "$vol:/data:ro" \
|
||||||
|
-v "$(pwd)/$BACKUP_DIR/$BACKUP_NAME:/backup" \
|
||||||
|
alpine tar czf "/backup/${vol}.tar.gz" -C /data . 2>/dev/null || true
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Compress backup
|
||||||
|
echo "Compressing backup..."
|
||||||
|
cd "$BACKUP_DIR"
|
||||||
|
tar czf "${BACKUP_NAME}.tar.gz" "$BACKUP_NAME"
|
||||||
|
rm -rf "$BACKUP_NAME"
|
||||||
|
|
||||||
|
# Cleanup old backups (keep last 7)
|
||||||
|
echo "Cleaning old backups..."
|
||||||
|
ls -t translate_backup_*.tar.gz 2>/dev/null | tail -n +8 | xargs rm -f 2>/dev/null || true
|
||||||
|
|
||||||
|
echo -e "${GREEN}Backup complete: $BACKUP_DIR/${BACKUP_NAME}.tar.gz${NC}"
|
||||||
168
scripts/deploy.sh
Normal file
168
scripts/deploy.sh
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# ============================================
|
||||||
|
# Document Translation API - Deployment Script
|
||||||
|
# ============================================
|
||||||
|
# Usage: ./scripts/deploy.sh [environment] [options]
|
||||||
|
# Example: ./scripts/deploy.sh production --with-ollama
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Colors for output
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
BLUE='\033[0;34m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
|
||||||
|
ENVIRONMENT="${1:-production}"
|
||||||
|
COMPOSE_FILE="docker-compose.yml"
|
||||||
|
|
||||||
|
# Parse options
|
||||||
|
PROFILES=""
|
||||||
|
while [[ $# -gt 1 ]]; do
|
||||||
|
case $2 in
|
||||||
|
--with-ollama)
|
||||||
|
PROFILES="$PROFILES --profile with-ollama"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--with-cache)
|
||||||
|
PROFILES="$PROFILES --profile with-cache"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--with-monitoring)
|
||||||
|
PROFILES="$PROFILES --profile with-monitoring"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--full)
|
||||||
|
PROFILES="--profile with-ollama --profile with-cache --profile with-monitoring"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
echo -e "${BLUE}========================================${NC}"
|
||||||
|
echo -e "${BLUE} Document Translation API Deployment${NC}"
|
||||||
|
echo -e "${BLUE}========================================${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Check prerequisites
|
||||||
|
echo -e "${YELLOW}Checking prerequisites...${NC}"
|
||||||
|
|
||||||
|
if ! command -v docker &> /dev/null; then
|
||||||
|
echo -e "${RED}Error: Docker is not installed${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! command -v docker-compose &> /dev/null && ! docker compose version &> /dev/null; then
|
||||||
|
echo -e "${RED}Error: Docker Compose is not installed${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "${GREEN}✓ Docker and Docker Compose are installed${NC}"
|
||||||
|
|
||||||
|
# Check environment file
|
||||||
|
cd "$PROJECT_ROOT"
|
||||||
|
|
||||||
|
if [ "$ENVIRONMENT" == "production" ]; then
|
||||||
|
ENV_FILE=".env.production"
|
||||||
|
else
|
||||||
|
ENV_FILE=".env"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -f "$ENV_FILE" ]; then
|
||||||
|
echo -e "${YELLOW}Creating $ENV_FILE from template...${NC}"
|
||||||
|
if [ -f ".env.example" ]; then
|
||||||
|
cp .env.example "$ENV_FILE"
|
||||||
|
echo -e "${YELLOW}Please edit $ENV_FILE with your configuration${NC}"
|
||||||
|
else
|
||||||
|
echo -e "${RED}Error: No environment file found${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "${GREEN}✓ Environment file: $ENV_FILE${NC}"
|
||||||
|
|
||||||
|
# Load environment
|
||||||
|
set -a
|
||||||
|
source "$ENV_FILE"
|
||||||
|
set +a
|
||||||
|
|
||||||
|
# Create SSL directory if needed
|
||||||
|
if [ ! -d "docker/nginx/ssl" ]; then
|
||||||
|
mkdir -p docker/nginx/ssl
|
||||||
|
echo -e "${YELLOW}Created SSL directory. Add your certificates:${NC}"
|
||||||
|
echo " - docker/nginx/ssl/fullchain.pem"
|
||||||
|
echo " - docker/nginx/ssl/privkey.pem"
|
||||||
|
echo " - docker/nginx/ssl/chain.pem"
|
||||||
|
|
||||||
|
# Generate self-signed cert for testing
|
||||||
|
if [ "$ENVIRONMENT" != "production" ]; then
|
||||||
|
echo -e "${YELLOW}Generating self-signed certificate for development...${NC}"
|
||||||
|
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
|
||||||
|
-keyout docker/nginx/ssl/privkey.pem \
|
||||||
|
-out docker/nginx/ssl/fullchain.pem \
|
||||||
|
-subj "/C=US/ST=State/L=City/O=Organization/CN=localhost" 2>/dev/null
|
||||||
|
cp docker/nginx/ssl/fullchain.pem docker/nginx/ssl/chain.pem
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Build and deploy
|
||||||
|
echo ""
|
||||||
|
echo -e "${YELLOW}Building containers...${NC}"
|
||||||
|
docker compose --env-file "$ENV_FILE" build
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo -e "${YELLOW}Starting services...${NC}"
|
||||||
|
docker compose --env-file "$ENV_FILE" $PROFILES up -d
|
||||||
|
|
||||||
|
# Wait for services to be healthy
|
||||||
|
echo ""
|
||||||
|
echo -e "${YELLOW}Waiting for services to be ready...${NC}"
|
||||||
|
sleep 10
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
echo ""
|
||||||
|
echo -e "${YELLOW}Running health checks...${NC}"
|
||||||
|
|
||||||
|
BACKEND_HEALTH=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:8000/health 2>/dev/null || echo "000")
|
||||||
|
if [ "$BACKEND_HEALTH" == "200" ]; then
|
||||||
|
echo -e "${GREEN}✓ Backend is healthy${NC}"
|
||||||
|
else
|
||||||
|
echo -e "${RED}✗ Backend health check failed (HTTP $BACKEND_HEALTH)${NC}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
FRONTEND_HEALTH=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:3000 2>/dev/null || echo "000")
|
||||||
|
if [ "$FRONTEND_HEALTH" == "200" ]; then
|
||||||
|
echo -e "${GREEN}✓ Frontend is healthy${NC}"
|
||||||
|
else
|
||||||
|
echo -e "${RED}✗ Frontend health check failed (HTTP $FRONTEND_HEALTH)${NC}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Show status
|
||||||
|
echo ""
|
||||||
|
echo -e "${BLUE}========================================${NC}"
|
||||||
|
echo -e "${BLUE} Deployment Complete!${NC}"
|
||||||
|
echo -e "${BLUE}========================================${NC}"
|
||||||
|
echo ""
|
||||||
|
echo -e "Services running:"
|
||||||
|
docker compose ps --format "table {{.Name}}\t{{.Status}}\t{{.Ports}}"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo -e "${GREEN}Access your application:${NC}"
|
||||||
|
echo -e " Frontend: http://localhost (or https://localhost)"
|
||||||
|
echo -e " API: http://localhost/api"
|
||||||
|
echo -e " Admin: http://localhost/admin"
|
||||||
|
echo -e " Health: http://localhost/health"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo -e "${YELLOW}Useful commands:${NC}"
|
||||||
|
echo " View logs: docker compose logs -f"
|
||||||
|
echo " Stop: docker compose down"
|
||||||
|
echo " Restart: docker compose restart"
|
||||||
|
echo " Update: ./scripts/deploy.sh $ENVIRONMENT"
|
||||||
87
scripts/health-check.sh
Normal file
87
scripts/health-check.sh
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# ============================================
|
||||||
|
# Document Translation API - Health Check Script
|
||||||
|
# ============================================
|
||||||
|
# Usage: ./scripts/health-check.sh [--verbose]
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
VERBOSE=false
|
||||||
|
if [ "$1" == "--verbose" ]; then
|
||||||
|
VERBOSE=true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Colors
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
NC='\033[0m'
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
BACKEND_URL="${BACKEND_URL:-http://localhost:8000}"
|
||||||
|
FRONTEND_URL="${FRONTEND_URL:-http://localhost:3000}"
|
||||||
|
NGINX_URL="${NGINX_URL:-http://localhost}"
|
||||||
|
|
||||||
|
EXIT_CODE=0
|
||||||
|
|
||||||
|
check_service() {
|
||||||
|
local name=$1
|
||||||
|
local url=$2
|
||||||
|
local expected=${3:-200}
|
||||||
|
|
||||||
|
response=$(curl -s -o /dev/null -w "%{http_code}" "$url" 2>/dev/null || echo "000")
|
||||||
|
|
||||||
|
if [ "$response" == "$expected" ]; then
|
||||||
|
echo -e "${GREEN}✓ $name: OK (HTTP $response)${NC}"
|
||||||
|
else
|
||||||
|
echo -e "${RED}✗ $name: FAILED (HTTP $response, expected $expected)${NC}"
|
||||||
|
EXIT_CODE=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$VERBOSE" == "true" ] && [ "$response" == "200" ]; then
|
||||||
|
echo " Response:"
|
||||||
|
curl -s "$url" 2>/dev/null | head -c 500
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "========================================="
|
||||||
|
echo " Health Check - Document Translation API"
|
||||||
|
echo "========================================="
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Check backend
|
||||||
|
echo "Backend Services:"
|
||||||
|
check_service "API Health" "$BACKEND_URL/health"
|
||||||
|
check_service "API Root" "$BACKEND_URL/"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Check frontend
|
||||||
|
echo "Frontend Services:"
|
||||||
|
check_service "Frontend" "$FRONTEND_URL"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Check nginx (if running)
|
||||||
|
echo "Proxy Services:"
|
||||||
|
check_service "Nginx" "$NGINX_URL/health"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Docker health
|
||||||
|
if command -v docker &> /dev/null; then
|
||||||
|
echo "Docker Container Status:"
|
||||||
|
docker compose ps --format "table {{.Name}}\t{{.Status}}" 2>/dev/null || echo " Docker Compose not running"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "========================================="
|
||||||
|
|
||||||
|
if [ $EXIT_CODE -eq 0 ]; then
|
||||||
|
echo -e "${GREEN}All health checks passed!${NC}"
|
||||||
|
else
|
||||||
|
echo -e "${RED}Some health checks failed!${NC}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
exit $EXIT_CODE
|
||||||
79
scripts/setup-ssl.sh
Normal file
79
scripts/setup-ssl.sh
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# ============================================
|
||||||
|
# Document Translation API - SSL Setup Script
|
||||||
|
# ============================================
|
||||||
|
# Usage: ./scripts/setup-ssl.sh <domain> <email>
|
||||||
|
# Example: ./scripts/setup-ssl.sh translate.example.com admin@example.com
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
DOMAIN="${1:-}"
|
||||||
|
EMAIL="${2:-}"
|
||||||
|
|
||||||
|
if [ -z "$DOMAIN" ] || [ -z "$EMAIL" ]; then
|
||||||
|
echo "Usage: ./scripts/setup-ssl.sh <domain> <email>"
|
||||||
|
echo "Example: ./scripts/setup-ssl.sh translate.example.com admin@example.com"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Colors
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
NC='\033[0m'
|
||||||
|
|
||||||
|
echo -e "${YELLOW}Setting up SSL for $DOMAIN${NC}"
|
||||||
|
|
||||||
|
# Create directory for certbot
|
||||||
|
mkdir -p ./docker/certbot/www
|
||||||
|
mkdir -p ./docker/certbot/conf
|
||||||
|
|
||||||
|
# Create initial nginx config for ACME challenge
|
||||||
|
cat > ./docker/nginx/conf.d/certbot.conf << EOF
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name $DOMAIN;
|
||||||
|
|
||||||
|
location /.well-known/acme-challenge/ {
|
||||||
|
root /var/www/certbot;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
return 301 https://\$host\$request_uri;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Start nginx with HTTP only
|
||||||
|
echo "Starting nginx for certificate request..."
|
||||||
|
docker compose up -d nginx
|
||||||
|
|
||||||
|
# Request certificate
|
||||||
|
echo "Requesting Let's Encrypt certificate..."
|
||||||
|
docker run --rm \
|
||||||
|
-v "$(pwd)/docker/certbot/www:/var/www/certbot" \
|
||||||
|
-v "$(pwd)/docker/certbot/conf:/etc/letsencrypt" \
|
||||||
|
certbot/certbot certonly \
|
||||||
|
--webroot \
|
||||||
|
--webroot-path=/var/www/certbot \
|
||||||
|
--email "$EMAIL" \
|
||||||
|
--agree-tos \
|
||||||
|
--no-eff-email \
|
||||||
|
-d "$DOMAIN"
|
||||||
|
|
||||||
|
# Copy certificates
|
||||||
|
echo "Installing certificates..."
|
||||||
|
cp ./docker/certbot/conf/live/$DOMAIN/fullchain.pem ./docker/nginx/ssl/
|
||||||
|
cp ./docker/certbot/conf/live/$DOMAIN/privkey.pem ./docker/nginx/ssl/
|
||||||
|
cp ./docker/certbot/conf/live/$DOMAIN/chain.pem ./docker/nginx/ssl/
|
||||||
|
|
||||||
|
# Remove temporary config
|
||||||
|
rm ./docker/nginx/conf.d/certbot.conf
|
||||||
|
|
||||||
|
# Restart nginx with SSL
|
||||||
|
echo "Restarting nginx with SSL..."
|
||||||
|
docker compose restart nginx
|
||||||
|
|
||||||
|
echo -e "${GREEN}SSL setup complete for $DOMAIN${NC}"
|
||||||
|
echo ""
|
||||||
|
echo "To auto-renew certificates, add this to crontab:"
|
||||||
|
echo "0 0 1 * * cd $(pwd) && ./scripts/renew-ssl.sh"
|
||||||
262
services/auth_service.py
Normal file
262
services/auth_service.py
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
"""
|
||||||
|
Authentication service with JWT tokens and password hashing
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
import hashlib
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Optional, Dict, Any
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Try to import optional dependencies
|
||||||
|
try:
|
||||||
|
import jwt
|
||||||
|
JWT_AVAILABLE = True
|
||||||
|
except ImportError:
|
||||||
|
JWT_AVAILABLE = False
|
||||||
|
|
||||||
|
try:
|
||||||
|
from passlib.context import CryptContext
|
||||||
|
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||||
|
PASSLIB_AVAILABLE = True
|
||||||
|
except ImportError:
|
||||||
|
PASSLIB_AVAILABLE = False
|
||||||
|
|
||||||
|
from models.subscription import User, UserCreate, PlanType, SubscriptionStatus, PLANS
|
||||||
|
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
SECRET_KEY = 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 (replace with database in production)
|
||||||
|
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"""
|
||||||
|
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"""
|
||||||
|
with open(USERS_FILE, 'w') as f:
|
||||||
|
json.dump(users, f, indent=2, default=str)
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_by_email(email: str) -> Optional[User]:
|
||||||
|
"""Get a user by email"""
|
||||||
|
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"""
|
||||||
|
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"""
|
||||||
|
users = load_users()
|
||||||
|
|
||||||
|
# Check if email exists
|
||||||
|
if get_user_by_email(user_create.email):
|
||||||
|
raise ValueError("Email already registered")
|
||||||
|
|
||||||
|
# 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"""
|
||||||
|
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()
|
||||||
|
})
|
||||||
|
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)
|
||||||
|
|
||||||
|
update_user(user_id, updates)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
update_user(user_id, {"extra_credits": user.extra_credits + credits})
|
||||||
|
return True
|
||||||
298
services/payment_service.py
Normal file
298
services/payment_service.py
Normal file
@@ -0,0 +1,298 @@
|
|||||||
|
"""
|
||||||
|
Stripe payment integration for subscriptions and credits
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
from typing import Optional, Dict, Any
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
# Try to import stripe
|
||||||
|
try:
|
||||||
|
import stripe
|
||||||
|
STRIPE_AVAILABLE = True
|
||||||
|
except ImportError:
|
||||||
|
STRIPE_AVAILABLE = False
|
||||||
|
stripe = None
|
||||||
|
|
||||||
|
from models.subscription import PlanType, PLANS, CREDIT_PACKAGES, SubscriptionStatus
|
||||||
|
from services.auth_service import get_user_by_id, update_user, add_credits
|
||||||
|
|
||||||
|
|
||||||
|
# Stripe configuration
|
||||||
|
STRIPE_SECRET_KEY = os.getenv("STRIPE_SECRET_KEY", "")
|
||||||
|
STRIPE_WEBHOOK_SECRET = os.getenv("STRIPE_WEBHOOK_SECRET", "")
|
||||||
|
STRIPE_PUBLISHABLE_KEY = os.getenv("STRIPE_PUBLISHABLE_KEY", "")
|
||||||
|
|
||||||
|
if STRIPE_AVAILABLE and STRIPE_SECRET_KEY:
|
||||||
|
stripe.api_key = STRIPE_SECRET_KEY
|
||||||
|
|
||||||
|
|
||||||
|
def is_stripe_configured() -> bool:
|
||||||
|
"""Check if Stripe is properly configured"""
|
||||||
|
return STRIPE_AVAILABLE and bool(STRIPE_SECRET_KEY)
|
||||||
|
|
||||||
|
|
||||||
|
async def create_checkout_session(
|
||||||
|
user_id: str,
|
||||||
|
plan: PlanType,
|
||||||
|
billing_period: str = "monthly", # monthly or yearly
|
||||||
|
success_url: str = "",
|
||||||
|
cancel_url: str = "",
|
||||||
|
) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Create a Stripe checkout session for subscription"""
|
||||||
|
if not is_stripe_configured():
|
||||||
|
return {"error": "Stripe not configured", "demo_mode": True}
|
||||||
|
|
||||||
|
user = get_user_by_id(user_id)
|
||||||
|
if not user:
|
||||||
|
return {"error": "User not found"}
|
||||||
|
|
||||||
|
plan_config = PLANS[plan]
|
||||||
|
price_id = plan_config[f"stripe_price_id_{billing_period}"]
|
||||||
|
|
||||||
|
if not price_id:
|
||||||
|
return {"error": "Plan not available for purchase"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Create or get Stripe customer
|
||||||
|
if user.stripe_customer_id:
|
||||||
|
customer_id = user.stripe_customer_id
|
||||||
|
else:
|
||||||
|
customer = stripe.Customer.create(
|
||||||
|
email=user.email,
|
||||||
|
name=user.name,
|
||||||
|
metadata={"user_id": user_id}
|
||||||
|
)
|
||||||
|
customer_id = customer.id
|
||||||
|
update_user(user_id, {"stripe_customer_id": customer_id})
|
||||||
|
|
||||||
|
# Create checkout session
|
||||||
|
session = stripe.checkout.Session.create(
|
||||||
|
customer=customer_id,
|
||||||
|
mode="subscription",
|
||||||
|
payment_method_types=["card"],
|
||||||
|
line_items=[{"price": price_id, "quantity": 1}],
|
||||||
|
success_url=success_url or f"{os.getenv('FRONTEND_URL', 'http://localhost:3000')}/dashboard?session_id={{CHECKOUT_SESSION_ID}}",
|
||||||
|
cancel_url=cancel_url or f"{os.getenv('FRONTEND_URL', 'http://localhost:3000')}/pricing",
|
||||||
|
metadata={"user_id": user_id, "plan": plan.value},
|
||||||
|
subscription_data={
|
||||||
|
"metadata": {"user_id": user_id, "plan": plan.value}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"session_id": session.id,
|
||||||
|
"url": session.url
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
return {"error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
async def create_credits_checkout(
|
||||||
|
user_id: str,
|
||||||
|
package_index: int,
|
||||||
|
success_url: str = "",
|
||||||
|
cancel_url: str = "",
|
||||||
|
) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Create a Stripe checkout session for credit purchase"""
|
||||||
|
if not is_stripe_configured():
|
||||||
|
return {"error": "Stripe not configured", "demo_mode": True}
|
||||||
|
|
||||||
|
if package_index < 0 or package_index >= len(CREDIT_PACKAGES):
|
||||||
|
return {"error": "Invalid package"}
|
||||||
|
|
||||||
|
user = get_user_by_id(user_id)
|
||||||
|
if not user:
|
||||||
|
return {"error": "User not found"}
|
||||||
|
|
||||||
|
package = CREDIT_PACKAGES[package_index]
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Create or get Stripe customer
|
||||||
|
if user.stripe_customer_id:
|
||||||
|
customer_id = user.stripe_customer_id
|
||||||
|
else:
|
||||||
|
customer = stripe.Customer.create(
|
||||||
|
email=user.email,
|
||||||
|
name=user.name,
|
||||||
|
metadata={"user_id": user_id}
|
||||||
|
)
|
||||||
|
customer_id = customer.id
|
||||||
|
update_user(user_id, {"stripe_customer_id": customer_id})
|
||||||
|
|
||||||
|
# Create checkout session
|
||||||
|
session = stripe.checkout.Session.create(
|
||||||
|
customer=customer_id,
|
||||||
|
mode="payment",
|
||||||
|
payment_method_types=["card"],
|
||||||
|
line_items=[{"price": package["stripe_price_id"], "quantity": 1}],
|
||||||
|
success_url=success_url or f"{os.getenv('FRONTEND_URL', 'http://localhost:3000')}/dashboard?credits=purchased",
|
||||||
|
cancel_url=cancel_url or f"{os.getenv('FRONTEND_URL', 'http://localhost:3000')}/pricing",
|
||||||
|
metadata={
|
||||||
|
"user_id": user_id,
|
||||||
|
"credits": package["credits"],
|
||||||
|
"type": "credits"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"session_id": session.id,
|
||||||
|
"url": session.url
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
return {"error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_webhook(payload: bytes, sig_header: str) -> Dict[str, Any]:
|
||||||
|
"""Handle Stripe webhook events"""
|
||||||
|
if not is_stripe_configured():
|
||||||
|
return {"error": "Stripe not configured"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
event = stripe.Webhook.construct_event(
|
||||||
|
payload, sig_header, STRIPE_WEBHOOK_SECRET
|
||||||
|
)
|
||||||
|
except ValueError:
|
||||||
|
return {"error": "Invalid payload"}
|
||||||
|
except stripe.error.SignatureVerificationError:
|
||||||
|
return {"error": "Invalid signature"}
|
||||||
|
|
||||||
|
# Handle the event
|
||||||
|
if event["type"] == "checkout.session.completed":
|
||||||
|
session = event["data"]["object"]
|
||||||
|
await handle_checkout_completed(session)
|
||||||
|
|
||||||
|
elif event["type"] == "customer.subscription.updated":
|
||||||
|
subscription = event["data"]["object"]
|
||||||
|
await handle_subscription_updated(subscription)
|
||||||
|
|
||||||
|
elif event["type"] == "customer.subscription.deleted":
|
||||||
|
subscription = event["data"]["object"]
|
||||||
|
await handle_subscription_deleted(subscription)
|
||||||
|
|
||||||
|
elif event["type"] == "invoice.payment_failed":
|
||||||
|
invoice = event["data"]["object"]
|
||||||
|
await handle_payment_failed(invoice)
|
||||||
|
|
||||||
|
return {"status": "success"}
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_checkout_completed(session: Dict):
|
||||||
|
"""Handle successful checkout"""
|
||||||
|
metadata = session.get("metadata", {})
|
||||||
|
user_id = metadata.get("user_id")
|
||||||
|
|
||||||
|
if not user_id:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Check if it's a credit purchase
|
||||||
|
if metadata.get("type") == "credits":
|
||||||
|
credits = int(metadata.get("credits", 0))
|
||||||
|
add_credits(user_id, credits)
|
||||||
|
return
|
||||||
|
|
||||||
|
# It's a subscription
|
||||||
|
plan = metadata.get("plan")
|
||||||
|
if plan:
|
||||||
|
subscription_id = session.get("subscription")
|
||||||
|
update_user(user_id, {
|
||||||
|
"plan": plan,
|
||||||
|
"subscription_status": SubscriptionStatus.ACTIVE.value,
|
||||||
|
"stripe_subscription_id": subscription_id,
|
||||||
|
"docs_translated_this_month": 0, # Reset on new subscription
|
||||||
|
"pages_translated_this_month": 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_subscription_updated(subscription: Dict):
|
||||||
|
"""Handle subscription updates"""
|
||||||
|
metadata = subscription.get("metadata", {})
|
||||||
|
user_id = metadata.get("user_id")
|
||||||
|
|
||||||
|
if not user_id:
|
||||||
|
return
|
||||||
|
|
||||||
|
status_map = {
|
||||||
|
"active": SubscriptionStatus.ACTIVE,
|
||||||
|
"past_due": SubscriptionStatus.PAST_DUE,
|
||||||
|
"canceled": SubscriptionStatus.CANCELED,
|
||||||
|
"trialing": SubscriptionStatus.TRIALING,
|
||||||
|
"paused": SubscriptionStatus.PAUSED,
|
||||||
|
}
|
||||||
|
|
||||||
|
stripe_status = subscription.get("status", "active")
|
||||||
|
status = status_map.get(stripe_status, SubscriptionStatus.ACTIVE)
|
||||||
|
|
||||||
|
update_user(user_id, {
|
||||||
|
"subscription_status": status.value,
|
||||||
|
"subscription_ends_at": datetime.fromtimestamp(
|
||||||
|
subscription.get("current_period_end", 0)
|
||||||
|
).isoformat() if subscription.get("current_period_end") else None
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_subscription_deleted(subscription: Dict):
|
||||||
|
"""Handle subscription cancellation"""
|
||||||
|
metadata = subscription.get("metadata", {})
|
||||||
|
user_id = metadata.get("user_id")
|
||||||
|
|
||||||
|
if not user_id:
|
||||||
|
return
|
||||||
|
|
||||||
|
update_user(user_id, {
|
||||||
|
"plan": PlanType.FREE.value,
|
||||||
|
"subscription_status": SubscriptionStatus.CANCELED.value,
|
||||||
|
"stripe_subscription_id": None,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_payment_failed(invoice: Dict):
|
||||||
|
"""Handle failed payment"""
|
||||||
|
customer_id = invoice.get("customer")
|
||||||
|
if not customer_id:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Find user by customer ID and update status
|
||||||
|
# In production, query database by stripe_customer_id
|
||||||
|
|
||||||
|
|
||||||
|
async def cancel_subscription(user_id: str) -> Dict[str, Any]:
|
||||||
|
"""Cancel a user's subscription"""
|
||||||
|
if not is_stripe_configured():
|
||||||
|
return {"error": "Stripe not configured"}
|
||||||
|
|
||||||
|
user = get_user_by_id(user_id)
|
||||||
|
if not user or not user.stripe_subscription_id:
|
||||||
|
return {"error": "No active subscription"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Cancel at period end
|
||||||
|
subscription = stripe.Subscription.modify(
|
||||||
|
user.stripe_subscription_id,
|
||||||
|
cancel_at_period_end=True
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "canceling",
|
||||||
|
"cancel_at": datetime.fromtimestamp(subscription.cancel_at).isoformat() if subscription.cancel_at else None
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
return {"error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
async def get_billing_portal_url(user_id: str) -> Optional[str]:
|
||||||
|
"""Get Stripe billing portal URL for customer"""
|
||||||
|
if not is_stripe_configured():
|
||||||
|
return None
|
||||||
|
|
||||||
|
user = get_user_by_id(user_id)
|
||||||
|
if not user or not user.stripe_customer_id:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
session = stripe.billing_portal.Session.create(
|
||||||
|
customer=user.stripe_customer_id,
|
||||||
|
return_url=f"{os.getenv('FRONTEND_URL', 'http://localhost:3000')}/dashboard"
|
||||||
|
)
|
||||||
|
return session.url
|
||||||
|
except:
|
||||||
|
return None
|
||||||
@@ -1,14 +1,87 @@
|
|||||||
"""
|
"""
|
||||||
Translation Service Abstraction
|
Translation Service Abstraction
|
||||||
Provides a unified interface for different translation providers
|
Provides a unified interface for different translation providers
|
||||||
|
Optimized for high performance with parallel processing and caching
|
||||||
"""
|
"""
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from typing import Optional, List, Dict
|
from typing import Optional, List, Dict, Tuple
|
||||||
import requests
|
import requests
|
||||||
from deep_translator import GoogleTranslator, DeeplTranslator, LibreTranslator
|
from deep_translator import GoogleTranslator, DeeplTranslator, LibreTranslator
|
||||||
from config import config
|
from config import config
|
||||||
import concurrent.futures
|
import concurrent.futures
|
||||||
import threading
|
import threading
|
||||||
|
import asyncio
|
||||||
|
from functools import lru_cache
|
||||||
|
import time
|
||||||
|
import hashlib
|
||||||
|
from collections import OrderedDict
|
||||||
|
|
||||||
|
|
||||||
|
# Global thread pool for parallel translations
|
||||||
|
_executor = concurrent.futures.ThreadPoolExecutor(max_workers=8)
|
||||||
|
|
||||||
|
|
||||||
|
class TranslationCache:
|
||||||
|
"""Thread-safe LRU cache for translations to avoid redundant API calls"""
|
||||||
|
|
||||||
|
def __init__(self, maxsize: int = 5000):
|
||||||
|
self.cache: OrderedDict = OrderedDict()
|
||||||
|
self.maxsize = maxsize
|
||||||
|
self.lock = threading.RLock()
|
||||||
|
self.hits = 0
|
||||||
|
self.misses = 0
|
||||||
|
|
||||||
|
def _make_key(self, text: str, target_language: str, source_language: str, provider: str) -> str:
|
||||||
|
"""Create a unique cache key"""
|
||||||
|
content = f"{provider}:{source_language}:{target_language}:{text}"
|
||||||
|
return hashlib.md5(content.encode('utf-8')).hexdigest()
|
||||||
|
|
||||||
|
def get(self, text: str, target_language: str, source_language: str, provider: str) -> Optional[str]:
|
||||||
|
"""Get a cached translation if available"""
|
||||||
|
key = self._make_key(text, target_language, source_language, provider)
|
||||||
|
with self.lock:
|
||||||
|
if key in self.cache:
|
||||||
|
self.hits += 1
|
||||||
|
# Move to end (most recently used)
|
||||||
|
self.cache.move_to_end(key)
|
||||||
|
return self.cache[key]
|
||||||
|
self.misses += 1
|
||||||
|
return None
|
||||||
|
|
||||||
|
def set(self, text: str, target_language: str, source_language: str, provider: str, translation: str):
|
||||||
|
"""Cache a translation result"""
|
||||||
|
key = self._make_key(text, target_language, source_language, provider)
|
||||||
|
with self.lock:
|
||||||
|
if key in self.cache:
|
||||||
|
self.cache.move_to_end(key)
|
||||||
|
self.cache[key] = translation
|
||||||
|
# Remove oldest if exceeding maxsize
|
||||||
|
while len(self.cache) > self.maxsize:
|
||||||
|
self.cache.popitem(last=False)
|
||||||
|
|
||||||
|
def clear(self):
|
||||||
|
"""Clear the cache"""
|
||||||
|
with self.lock:
|
||||||
|
self.cache.clear()
|
||||||
|
self.hits = 0
|
||||||
|
self.misses = 0
|
||||||
|
|
||||||
|
def stats(self) -> Dict:
|
||||||
|
"""Get cache statistics"""
|
||||||
|
with self.lock:
|
||||||
|
total = self.hits + self.misses
|
||||||
|
hit_rate = (self.hits / total * 100) if total > 0 else 0
|
||||||
|
return {
|
||||||
|
"size": len(self.cache),
|
||||||
|
"maxsize": self.maxsize,
|
||||||
|
"hits": self.hits,
|
||||||
|
"misses": self.misses,
|
||||||
|
"hit_rate": f"{hit_rate:.1f}%"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Global translation cache
|
||||||
|
_translation_cache = TranslationCache(maxsize=5000)
|
||||||
|
|
||||||
|
|
||||||
class TranslationProvider(ABC):
|
class TranslationProvider(ABC):
|
||||||
@@ -22,13 +95,44 @@ class TranslationProvider(ABC):
|
|||||||
def translate_batch(self, texts: List[str], target_language: str, source_language: str = 'auto') -> List[str]:
|
def translate_batch(self, texts: List[str], target_language: str, source_language: str = 'auto') -> List[str]:
|
||||||
"""Translate multiple texts at once - default implementation"""
|
"""Translate multiple texts at once - default implementation"""
|
||||||
return [self.translate(text, target_language, source_language) for text in texts]
|
return [self.translate(text, target_language, source_language) for text in texts]
|
||||||
|
|
||||||
|
def translate_batch_parallel(self, texts: List[str], target_language: str, source_language: str = 'auto', max_workers: int = 4) -> List[str]:
|
||||||
|
"""Parallel batch translation using thread pool"""
|
||||||
|
if not texts:
|
||||||
|
return []
|
||||||
|
|
||||||
|
results = [''] * len(texts)
|
||||||
|
non_empty = [(i, t) for i, t in enumerate(texts) if t and t.strip()]
|
||||||
|
|
||||||
|
if not non_empty:
|
||||||
|
return [t if t else '' for t in texts]
|
||||||
|
|
||||||
|
def translate_one(item: Tuple[int, str]) -> Tuple[int, str]:
|
||||||
|
idx, text = item
|
||||||
|
try:
|
||||||
|
return (idx, self.translate(text, target_language, source_language))
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Translation error at index {idx}: {e}")
|
||||||
|
return (idx, text)
|
||||||
|
|
||||||
|
with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
|
||||||
|
for idx, translated in executor.map(translate_one, non_empty):
|
||||||
|
results[idx] = translated
|
||||||
|
|
||||||
|
# Fill empty positions
|
||||||
|
for i, text in enumerate(texts):
|
||||||
|
if not text or not text.strip():
|
||||||
|
results[i] = text if text else ''
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
class GoogleTranslationProvider(TranslationProvider):
|
class GoogleTranslationProvider(TranslationProvider):
|
||||||
"""Google Translate implementation with batch support"""
|
"""Google Translate implementation with batch support and caching"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self._local = threading.local()
|
self._local = threading.local()
|
||||||
|
self.provider_name = "google"
|
||||||
|
|
||||||
def _get_translator(self, source_language: str, target_language: str) -> GoogleTranslator:
|
def _get_translator(self, source_language: str, target_language: str) -> GoogleTranslator:
|
||||||
"""Get or create a translator instance for the current thread"""
|
"""Get or create a translator instance for the current thread"""
|
||||||
@@ -43,9 +147,17 @@ class GoogleTranslationProvider(TranslationProvider):
|
|||||||
if not text or not text.strip():
|
if not text or not text.strip():
|
||||||
return text
|
return text
|
||||||
|
|
||||||
|
# Check cache first
|
||||||
|
cached = _translation_cache.get(text, target_language, source_language, self.provider_name)
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
|
||||||
try:
|
try:
|
||||||
translator = self._get_translator(source_language, target_language)
|
translator = self._get_translator(source_language, target_language)
|
||||||
return translator.translate(text)
|
result = translator.translate(text)
|
||||||
|
# Cache the result
|
||||||
|
_translation_cache.set(text, target_language, source_language, self.provider_name, result)
|
||||||
|
return result
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Translation error: {e}")
|
print(f"Translation error: {e}")
|
||||||
return text
|
return text
|
||||||
@@ -53,7 +165,7 @@ class GoogleTranslationProvider(TranslationProvider):
|
|||||||
def translate_batch(self, texts: List[str], target_language: str, source_language: str = 'auto', batch_size: int = 50) -> List[str]:
|
def translate_batch(self, texts: List[str], target_language: str, source_language: str = 'auto', batch_size: int = 50) -> List[str]:
|
||||||
"""
|
"""
|
||||||
Translate multiple texts using batch processing for speed.
|
Translate multiple texts using batch processing for speed.
|
||||||
Uses deep_translator's batch capability when possible.
|
Uses caching to avoid redundant translations.
|
||||||
"""
|
"""
|
||||||
if not texts:
|
if not texts:
|
||||||
return []
|
return []
|
||||||
@@ -62,15 +174,24 @@ class GoogleTranslationProvider(TranslationProvider):
|
|||||||
results = [''] * len(texts)
|
results = [''] * len(texts)
|
||||||
non_empty_indices = []
|
non_empty_indices = []
|
||||||
non_empty_texts = []
|
non_empty_texts = []
|
||||||
|
texts_to_translate = []
|
||||||
|
indices_to_translate = []
|
||||||
|
|
||||||
for i, text in enumerate(texts):
|
for i, text in enumerate(texts):
|
||||||
if text and text.strip():
|
if text and text.strip():
|
||||||
non_empty_indices.append(i)
|
# Check cache first
|
||||||
non_empty_texts.append(text)
|
cached = _translation_cache.get(text, target_language, source_language, self.provider_name)
|
||||||
|
if cached is not None:
|
||||||
|
results[i] = cached
|
||||||
|
else:
|
||||||
|
non_empty_indices.append(i)
|
||||||
|
non_empty_texts.append(text)
|
||||||
|
texts_to_translate.append(text)
|
||||||
|
indices_to_translate.append(i)
|
||||||
else:
|
else:
|
||||||
results[i] = text if text else ''
|
results[i] = text if text else ''
|
||||||
|
|
||||||
if not non_empty_texts:
|
if not texts_to_translate:
|
||||||
return results
|
return results
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -78,8 +199,8 @@ class GoogleTranslationProvider(TranslationProvider):
|
|||||||
|
|
||||||
# Process in batches
|
# Process in batches
|
||||||
translated_texts = []
|
translated_texts = []
|
||||||
for i in range(0, len(non_empty_texts), batch_size):
|
for i in range(0, len(texts_to_translate), batch_size):
|
||||||
batch = non_empty_texts[i:i + batch_size]
|
batch = texts_to_translate[i:i + batch_size]
|
||||||
try:
|
try:
|
||||||
# Use translate_batch if available
|
# Use translate_batch if available
|
||||||
if hasattr(translator, 'translate_batch'):
|
if hasattr(translator, 'translate_batch'):
|
||||||
@@ -107,16 +228,19 @@ class GoogleTranslationProvider(TranslationProvider):
|
|||||||
except:
|
except:
|
||||||
translated_texts.append(text)
|
translated_texts.append(text)
|
||||||
|
|
||||||
# Map back to original positions
|
# Map back to original positions and cache results
|
||||||
for idx, translated in zip(non_empty_indices, translated_texts):
|
for idx, (original, translated) in zip(indices_to_translate, zip(texts_to_translate, translated_texts)):
|
||||||
results[idx] = translated if translated else texts[idx]
|
result = translated if translated else texts[idx]
|
||||||
|
results[idx] = result
|
||||||
|
# Cache successful translations
|
||||||
|
_translation_cache.set(texts[idx], target_language, source_language, self.provider_name, result)
|
||||||
|
|
||||||
return results
|
return results
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Batch translation failed: {e}")
|
print(f"Batch translation failed: {e}")
|
||||||
# Fallback to individual translations
|
# Fallback to individual translations
|
||||||
for idx, text in zip(non_empty_indices, non_empty_texts):
|
for idx, text in zip(indices_to_translate, texts_to_translate):
|
||||||
try:
|
try:
|
||||||
results[idx] = GoogleTranslator(source=source_language, target=target_language).translate(text) or text
|
results[idx] = GoogleTranslator(source=source_language, target=target_language).translate(text) or text
|
||||||
except:
|
except:
|
||||||
|
|||||||
Reference in New Issue
Block a user