Production-ready improvements: security hardening, Redis sessions, retry logic, updated pricing
Changes: - Removed hardcoded admin credentials (now requires env vars) - Added Redis session storage with in-memory fallback - Improved CORS configuration with warnings for development mode - Added retry_with_backoff decorator for translation API calls - Updated pricing: Starter=, Pro=, Business= - Stripe price IDs now loaded from environment variables - Added redis to requirements.txt - Updated .env.example with all new configuration options - Created COMPREHENSIVE_REVIEW_AND_PLAN.md with deployment roadmap - Frontend: Updated pricing page, new UI components
This commit is contained in:
parent
721b18dbbd
commit
c4d6cae735
59
.env.example
59
.env.example
@ -1,14 +1,24 @@
|
|||||||
# Document Translation API - Environment Configuration
|
# Document Translation API - Environment Configuration
|
||||||
# Copy this file to .env and configure your settings
|
# Copy this file to .env and configure your settings
|
||||||
|
# ⚠️ NEVER commit .env to version control!
|
||||||
|
|
||||||
# ============== Translation Services ==============
|
# ============== Translation Services ==============
|
||||||
# Default provider: google, ollama, deepl, libre, openai
|
# Default provider: google, ollama, deepl, libre, openai, openrouter
|
||||||
TRANSLATION_SERVICE=google
|
TRANSLATION_SERVICE=google
|
||||||
|
|
||||||
# DeepL API Key (required for DeepL provider)
|
# DeepL API Key (required for DeepL provider)
|
||||||
DEEPL_API_KEY=your_deepl_api_key_here
|
# Get from: https://www.deepl.com/pro-api
|
||||||
|
DEEPL_API_KEY=
|
||||||
|
|
||||||
# Ollama Configuration (for LLM-based translation)
|
# OpenAI API Key (required for OpenAI provider)
|
||||||
|
# Get from: https://platform.openai.com/api-keys
|
||||||
|
OPENAI_API_KEY=
|
||||||
|
|
||||||
|
# OpenRouter API Key (required for OpenRouter provider)
|
||||||
|
# Get from: https://openrouter.ai/keys
|
||||||
|
OPENROUTER_API_KEY=
|
||||||
|
|
||||||
|
# Ollama Configuration (for local LLM-based translation)
|
||||||
OLLAMA_BASE_URL=http://localhost:11434
|
OLLAMA_BASE_URL=http://localhost:11434
|
||||||
OLLAMA_MODEL=llama3
|
OLLAMA_MODEL=llama3
|
||||||
OLLAMA_VISION_MODEL=llava
|
OLLAMA_VISION_MODEL=llava
|
||||||
@ -51,7 +61,10 @@ DISK_CRITICAL_THRESHOLD_GB=1.0
|
|||||||
ENABLE_HSTS=false
|
ENABLE_HSTS=false
|
||||||
|
|
||||||
# CORS allowed origins (comma-separated)
|
# CORS allowed origins (comma-separated)
|
||||||
CORS_ORIGINS=*
|
# ⚠️ IMPORTANT: Set to your actual frontend domain(s) in production!
|
||||||
|
# Example: https://yourdomain.com,https://www.yourdomain.com
|
||||||
|
# Use "*" ONLY for local development
|
||||||
|
CORS_ORIGINS=http://localhost:3000
|
||||||
|
|
||||||
# Maximum request size in MB
|
# Maximum request size in MB
|
||||||
MAX_REQUEST_SIZE_MB=100
|
MAX_REQUEST_SIZE_MB=100
|
||||||
@ -59,23 +72,32 @@ MAX_REQUEST_SIZE_MB=100
|
|||||||
# Request timeout in seconds
|
# Request timeout in seconds
|
||||||
REQUEST_TIMEOUT_SECONDS=300
|
REQUEST_TIMEOUT_SECONDS=300
|
||||||
|
|
||||||
|
# ============== Database (Production) ==============
|
||||||
|
# PostgreSQL connection string (recommended for production)
|
||||||
|
# DATABASE_URL=postgresql://user:password@localhost:5432/translate_db
|
||||||
|
|
||||||
|
# Redis for sessions and caching (recommended for production)
|
||||||
|
# REDIS_URL=redis://localhost:6379/0
|
||||||
|
|
||||||
# ============== Admin Authentication ==============
|
# ============== Admin Authentication ==============
|
||||||
# Admin username
|
# ⚠️ REQUIRED: These must be set for admin endpoints to work!
|
||||||
ADMIN_USERNAME=admin
|
ADMIN_USERNAME=admin
|
||||||
|
|
||||||
# Admin password (change in production!)
|
# Use SHA256 hash of password (recommended for production)
|
||||||
ADMIN_PASSWORD=changeme123
|
|
||||||
|
|
||||||
# Or use SHA256 hash of password (more secure)
|
|
||||||
# Generate with: python -c "import hashlib; print(hashlib.sha256(b'your_password').hexdigest())"
|
# Generate with: python -c "import hashlib; print(hashlib.sha256(b'your_password').hexdigest())"
|
||||||
# ADMIN_PASSWORD_HASH=
|
ADMIN_PASSWORD_HASH=
|
||||||
|
|
||||||
# Token secret for session management (auto-generated if not set)
|
# Or use plain password (NOT recommended for production)
|
||||||
# ADMIN_TOKEN_SECRET=
|
# ADMIN_PASSWORD=
|
||||||
|
|
||||||
|
# Token secret for session management
|
||||||
|
# Generate with: python -c "import secrets; print(secrets.token_hex(32))"
|
||||||
|
ADMIN_TOKEN_SECRET=
|
||||||
|
|
||||||
# ============== User Authentication ==============
|
# ============== User Authentication ==============
|
||||||
# JWT secret key (auto-generated if not set)
|
# JWT secret key for user tokens
|
||||||
# JWT_SECRET_KEY=
|
# Generate with: python -c "import secrets; print(secrets.token_urlsafe(64))"
|
||||||
|
JWT_SECRET_KEY=
|
||||||
|
|
||||||
# Frontend URL for redirects
|
# Frontend URL for redirects
|
||||||
FRONTEND_URL=http://localhost:3000
|
FRONTEND_URL=http://localhost:3000
|
||||||
@ -86,6 +108,15 @@ STRIPE_PUBLISHABLE_KEY=pk_test_...
|
|||||||
STRIPE_SECRET_KEY=sk_test_...
|
STRIPE_SECRET_KEY=sk_test_...
|
||||||
STRIPE_WEBHOOK_SECRET=whsec_...
|
STRIPE_WEBHOOK_SECRET=whsec_...
|
||||||
|
|
||||||
|
# Stripe Price IDs (create products in Stripe Dashboard)
|
||||||
|
# https://dashboard.stripe.com/products
|
||||||
|
STRIPE_PRICE_STARTER_MONTHLY=price_xxx
|
||||||
|
STRIPE_PRICE_STARTER_YEARLY=price_xxx
|
||||||
|
STRIPE_PRICE_PRO_MONTHLY=price_xxx
|
||||||
|
STRIPE_PRICE_PRO_YEARLY=price_xxx
|
||||||
|
STRIPE_PRICE_BUSINESS_MONTHLY=price_xxx
|
||||||
|
STRIPE_PRICE_BUSINESS_YEARLY=price_xxx
|
||||||
|
|
||||||
# ============== Monitoring ==============
|
# ============== Monitoring ==============
|
||||||
# Log level: DEBUG, INFO, WARNING, ERROR
|
# Log level: DEBUG, INFO, WARNING, ERROR
|
||||||
LOG_LEVEL=INFO
|
LOG_LEVEL=INFO
|
||||||
|
|||||||
458
COMPREHENSIVE_REVIEW_AND_PLAN.md
Normal file
458
COMPREHENSIVE_REVIEW_AND_PLAN.md
Normal file
@ -0,0 +1,458 @@
|
|||||||
|
# 📊 Comprehensive Code Review & Deployment Plan
|
||||||
|
|
||||||
|
## Executive Summary
|
||||||
|
|
||||||
|
Your **Document Translation API** is a well-architected SaaS application for translating Office documents (Excel, Word, PowerPoint) while preserving formatting. After a thorough code review, here's a complete assessment and actionable deployment/monetization plan.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔍 Code Review Summary
|
||||||
|
|
||||||
|
### ✅ Backend Strengths
|
||||||
|
|
||||||
|
| Component | Status | Notes |
|
||||||
|
|-----------|--------|-------|
|
||||||
|
| **FastAPI Architecture** | ✅ Excellent | Clean lifespan management, proper middleware stack |
|
||||||
|
| **Translation Service Layer** | ✅ Excellent | Pluggable provider pattern, thread-safe caching (LRU) |
|
||||||
|
| **Rate Limiting** | ✅ Excellent | Token bucket + sliding window algorithms |
|
||||||
|
| **File Translators** | ✅ Good | Batch translation optimization (5-10x faster) |
|
||||||
|
| **Authentication** | ✅ Good | JWT with refresh tokens, bcrypt fallback |
|
||||||
|
| **Payment Integration** | ✅ Good | Stripe checkout, webhooks, subscriptions |
|
||||||
|
| **Middleware Stack** | ✅ Excellent | Security headers, request logging, cleanup |
|
||||||
|
|
||||||
|
### ✅ Frontend Strengths
|
||||||
|
|
||||||
|
| Component | Status | Notes |
|
||||||
|
|-----------|--------|-------|
|
||||||
|
| **Next.js 16** | ✅ Modern | Latest version with App Router |
|
||||||
|
| **UI Components** | ✅ Excellent | shadcn/ui + Radix UI primitives |
|
||||||
|
| **State Management** | ✅ Good | Zustand for global state |
|
||||||
|
| **WebLLM Integration** | ✅ Innovative | Browser-based translation option |
|
||||||
|
| **Responsive Design** | ✅ Good | Tailwind CSS v4 |
|
||||||
|
|
||||||
|
### ⚠️ Issues to Address
|
||||||
|
|
||||||
|
#### Critical (Must Fix Before Production)
|
||||||
|
|
||||||
|
1. **Hardcoded Admin Credentials**
|
||||||
|
- File: `main.py` line 44
|
||||||
|
- Issue: `ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD", "changeme123")`
|
||||||
|
- Fix: Remove default, require env var
|
||||||
|
|
||||||
|
2. **File-Based User Storage**
|
||||||
|
- File: `services/auth_service.py`
|
||||||
|
- Issue: JSON file storage not scalable
|
||||||
|
- Fix: Migrate to PostgreSQL/MongoDB
|
||||||
|
|
||||||
|
3. **CORS Configuration Too Permissive**
|
||||||
|
- File: `main.py` line 170
|
||||||
|
- Issue: `allow_origins=allowed_origins` defaults to `*`
|
||||||
|
- Fix: Restrict to specific domains
|
||||||
|
|
||||||
|
4. **API Keys in Frontend**
|
||||||
|
- File: `frontend/src/lib/api.ts`
|
||||||
|
- Issue: OpenAI API key passed from client
|
||||||
|
- Fix: Proxy through backend
|
||||||
|
|
||||||
|
5. **Missing Input Sanitization**
|
||||||
|
- File: `translators/*.py`
|
||||||
|
- Issue: No malware scanning for uploads
|
||||||
|
- Fix: Add ClamAV or VirusTotal integration
|
||||||
|
|
||||||
|
#### Important (Should Fix)
|
||||||
|
|
||||||
|
6. **No Database Migrations**
|
||||||
|
- Issue: No Alembic/migration setup
|
||||||
|
- Fix: Add proper migration system
|
||||||
|
|
||||||
|
7. **Incomplete Error Handling in WebLLM**
|
||||||
|
- File: `frontend/src/lib/webllm.ts`
|
||||||
|
- Issue: Generic error messages
|
||||||
|
|
||||||
|
8. **Missing Retry Logic**
|
||||||
|
- File: `services/translation_service.py`
|
||||||
|
- Issue: No exponential backoff for API calls
|
||||||
|
|
||||||
|
9. **Session Storage in Memory**
|
||||||
|
- File: `main.py` line 50: `admin_sessions: dict = {}`
|
||||||
|
- Issue: Lost on restart
|
||||||
|
- Fix: Redis for session storage
|
||||||
|
|
||||||
|
10. **Stripe Price IDs are Placeholders**
|
||||||
|
- File: `models/subscription.py`
|
||||||
|
- Issue: `"price_starter_monthly"` etc.
|
||||||
|
- Fix: Create real Stripe products
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🏗️ Recommended Architecture Improvements
|
||||||
|
|
||||||
|
### 1. Database Layer (Priority: HIGH)
|
||||||
|
|
||||||
|
```
|
||||||
|
Current: JSON files (data/users.json)
|
||||||
|
Target: PostgreSQL + Redis
|
||||||
|
|
||||||
|
Stack:
|
||||||
|
├── PostgreSQL (users, subscriptions, usage tracking)
|
||||||
|
├── Redis (sessions, rate limiting, cache)
|
||||||
|
└── S3/MinIO (document storage)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Background Job Processing (Priority: HIGH)
|
||||||
|
|
||||||
|
```
|
||||||
|
Current: Synchronous processing
|
||||||
|
Target: Celery + Redis
|
||||||
|
|
||||||
|
Benefits:
|
||||||
|
├── Large file processing in background
|
||||||
|
├── Email notifications
|
||||||
|
├── Usage report generation
|
||||||
|
└── Cleanup tasks
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Monitoring & Observability (Priority: MEDIUM)
|
||||||
|
|
||||||
|
```
|
||||||
|
Stack:
|
||||||
|
├── Prometheus (metrics)
|
||||||
|
├── Grafana (dashboards)
|
||||||
|
├── Sentry (error tracking)
|
||||||
|
└── ELK/Loki (log aggregation)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💰 Monetization Strategy
|
||||||
|
|
||||||
|
### Pricing Tiers (Based on Market Research)
|
||||||
|
|
||||||
|
Your current pricing is competitive but needs refinement:
|
||||||
|
|
||||||
|
| Plan | Current Price | Recommended | Market Comparison |
|
||||||
|
|------|--------------|-------------|-------------------|
|
||||||
|
| Free | $0 | $0 | Keep as lead gen |
|
||||||
|
| Starter | $9/mo | **$12/mo** | DeepL: €8.99, Azure: Pay-per-use |
|
||||||
|
| Pro | $29/mo | **$39/mo** | DeepL: €29.99 |
|
||||||
|
| Business | $79/mo | **$99/mo** | Competitive |
|
||||||
|
| Enterprise | Custom | Custom | On-request |
|
||||||
|
|
||||||
|
### Revenue Projections
|
||||||
|
|
||||||
|
```
|
||||||
|
Conservative (Year 1):
|
||||||
|
├── 1000 Free users → 5% convert → 50 paid
|
||||||
|
├── 30 Starter × $12 = $360/mo
|
||||||
|
├── 15 Pro × $39 = $585/mo
|
||||||
|
├── 5 Business × $99 = $495/mo
|
||||||
|
└── Total: $1,440/mo = $17,280/year
|
||||||
|
|
||||||
|
Optimistic (Year 1):
|
||||||
|
├── 5000 Free users → 8% convert → 400 paid
|
||||||
|
├── 250 Starter × $12 = $3,000/mo
|
||||||
|
├── 100 Pro × $39 = $3,900/mo
|
||||||
|
├── 40 Business × $99 = $3,960/mo
|
||||||
|
├── 10 Enterprise × $500 = $5,000/mo
|
||||||
|
└── Total: $15,860/mo = $190,320/year
|
||||||
|
```
|
||||||
|
|
||||||
|
### Additional Revenue Streams
|
||||||
|
|
||||||
|
1. **Pay-as-you-go Credits**
|
||||||
|
- Already implemented in `CREDIT_PACKAGES`
|
||||||
|
- Add volume discounts
|
||||||
|
|
||||||
|
2. **API Access Fees**
|
||||||
|
- Charge per 1000 API calls beyond quota
|
||||||
|
- Enterprise: dedicated endpoint
|
||||||
|
|
||||||
|
3. **White-Label Licensing**
|
||||||
|
- $5,000-20,000 one-time + monthly fee
|
||||||
|
- Custom branding, on-premise
|
||||||
|
|
||||||
|
4. **Translation Memory Add-on**
|
||||||
|
- Store/reuse translations
|
||||||
|
- $10-25/mo premium feature
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Deployment Plan
|
||||||
|
|
||||||
|
### Phase 1: Pre-Launch Checklist (Week 1-2)
|
||||||
|
|
||||||
|
- [ ] **Security Hardening**
|
||||||
|
- [ ] Remove default credentials
|
||||||
|
- [ ] Implement proper secrets management (Vault/AWS Secrets)
|
||||||
|
- [ ] Enable HTTPS everywhere
|
||||||
|
- [ ] Add file upload virus scanning
|
||||||
|
- [ ] Implement CSRF protection
|
||||||
|
|
||||||
|
- [ ] **Database Migration**
|
||||||
|
- [ ] Set up PostgreSQL (Supabase/Neon for quick start)
|
||||||
|
- [ ] Migrate user data
|
||||||
|
- [ ] Add Redis for caching
|
||||||
|
|
||||||
|
- [ ] **Stripe Integration**
|
||||||
|
- [ ] Create actual Stripe products
|
||||||
|
- [ ] Test webhook handling
|
||||||
|
- [ ] Implement subscription lifecycle
|
||||||
|
|
||||||
|
### Phase 2: Infrastructure Setup (Week 2-3)
|
||||||
|
|
||||||
|
#### Option A: Managed Cloud (Recommended for Start)
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# Recommended Stack
|
||||||
|
Provider: Railway / Render / Fly.io
|
||||||
|
Database: Supabase (PostgreSQL)
|
||||||
|
Cache: Upstash Redis
|
||||||
|
Storage: Cloudflare R2 / AWS S3
|
||||||
|
CDN: Cloudflare
|
||||||
|
|
||||||
|
Estimated Cost: $50-150/month
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Option B: Self-Hosted (Current Docker Setup)
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# Your docker-compose.yml is ready
|
||||||
|
Server: Hetzner / DigitalOcean VPS ($20-50/month)
|
||||||
|
Add:
|
||||||
|
- Let's Encrypt SSL (free)
|
||||||
|
- Watchtower (auto-updates)
|
||||||
|
- Portainer (management)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Option C: Kubernetes (Scale Later)
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# When you need it (>1000 active users)
|
||||||
|
Provider: DigitalOcean Kubernetes / GKE
|
||||||
|
Cost: $100-500/month
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase 3: Launch Preparation (Week 3-4)
|
||||||
|
|
||||||
|
- [ ] **Legal & Compliance**
|
||||||
|
- [ ] Privacy Policy (GDPR compliant)
|
||||||
|
- [ ] Terms of Service
|
||||||
|
- [ ] Cookie consent banner
|
||||||
|
- [ ] DPA for enterprise customers
|
||||||
|
|
||||||
|
- [ ] **Marketing Setup**
|
||||||
|
- [ ] Landing page optimization (you have good sections!)
|
||||||
|
- [ ] SEO meta tags
|
||||||
|
- [ ] Google Analytics / Plausible
|
||||||
|
- [ ] Social proof (testimonials)
|
||||||
|
|
||||||
|
- [ ] **Support Infrastructure**
|
||||||
|
- [ ] Help Center (Intercom/Crisp)
|
||||||
|
- [ ] Email support (support@yourdomain.com)
|
||||||
|
- [ ] Status page (Statuspage.io / BetterStack)
|
||||||
|
|
||||||
|
### Phase 4: Soft Launch (Week 4-5)
|
||||||
|
|
||||||
|
1. **Beta Testing**
|
||||||
|
- Invite 50-100 users
|
||||||
|
- Monitor error rates
|
||||||
|
- Collect feedback
|
||||||
|
|
||||||
|
2. **Performance Testing**
|
||||||
|
- Load test with k6/Locust
|
||||||
|
- Target: 100 concurrent translations
|
||||||
|
|
||||||
|
3. **Documentation**
|
||||||
|
- API docs (already have Swagger!)
|
||||||
|
- User guide
|
||||||
|
- Integration examples
|
||||||
|
|
||||||
|
### Phase 5: Public Launch (Week 6+)
|
||||||
|
|
||||||
|
1. **Announcement**
|
||||||
|
- Product Hunt launch
|
||||||
|
- Hacker News "Show HN"
|
||||||
|
- Dev.to / Medium articles
|
||||||
|
|
||||||
|
2. **Marketing Channels**
|
||||||
|
- Google Ads (document translation keywords)
|
||||||
|
- LinkedIn (business customers)
|
||||||
|
- Reddit (r/translation, r/localization)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 Technical Improvements
|
||||||
|
|
||||||
|
### Immediate Code Changes
|
||||||
|
|
||||||
|
#### 1. Add Retry Logic to Translation Service
|
||||||
|
|
||||||
|
```python
|
||||||
|
# services/translation_service.py
|
||||||
|
from tenacity import retry, stop_after_attempt, wait_exponential
|
||||||
|
|
||||||
|
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=2, max=10))
|
||||||
|
def translate(self, text: str, target_language: str, source_language: str = 'auto') -> str:
|
||||||
|
# existing implementation
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. Add Health Check Endpoint Enhancement
|
||||||
|
|
||||||
|
```python
|
||||||
|
# main.py - enhance health endpoint
|
||||||
|
@app.get("/health")
|
||||||
|
async def health_check():
|
||||||
|
checks = {
|
||||||
|
"database": await check_db_connection(),
|
||||||
|
"redis": await check_redis_connection(),
|
||||||
|
"stripe": check_stripe_configured(),
|
||||||
|
"ollama": await check_ollama_available(),
|
||||||
|
}
|
||||||
|
all_healthy = all(checks.values())
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=200 if all_healthy else 503,
|
||||||
|
content={"status": "healthy" if all_healthy else "degraded", "checks": checks}
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. Add Request ID Tracking
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Already partially implemented, ensure full tracing
|
||||||
|
@app.middleware("http")
|
||||||
|
async def add_request_id(request: Request, call_next):
|
||||||
|
request_id = request.headers.get("X-Request-ID", str(uuid.uuid4()))
|
||||||
|
request.state.request_id = request_id
|
||||||
|
response = await call_next(request)
|
||||||
|
response.headers["X-Request-ID"] = request_id
|
||||||
|
return response
|
||||||
|
```
|
||||||
|
|
||||||
|
### Environment Variables Template
|
||||||
|
|
||||||
|
Create `.env.production`:
|
||||||
|
|
||||||
|
```env
|
||||||
|
# API Configuration
|
||||||
|
API_HOST=0.0.0.0
|
||||||
|
API_PORT=8000
|
||||||
|
LOG_LEVEL=INFO
|
||||||
|
|
||||||
|
# Security (REQUIRED - No defaults!)
|
||||||
|
ADMIN_USERNAME=
|
||||||
|
ADMIN_PASSWORD_HASH= # Use: python -c "import hashlib; print(hashlib.sha256('yourpassword'.encode()).hexdigest())"
|
||||||
|
JWT_SECRET_KEY= # Generate: python -c "import secrets; print(secrets.token_urlsafe(64))"
|
||||||
|
CORS_ORIGINS=https://yourdomain.com,https://www.yourdomain.com
|
||||||
|
|
||||||
|
# Database
|
||||||
|
DATABASE_URL=postgresql://user:pass@host:5432/translate
|
||||||
|
REDIS_URL=redis://localhost:6379
|
||||||
|
|
||||||
|
# Stripe (REQUIRED for payments)
|
||||||
|
STRIPE_SECRET_KEY=sk_live_xxx
|
||||||
|
STRIPE_PUBLISHABLE_KEY=pk_live_xxx
|
||||||
|
STRIPE_WEBHOOK_SECRET=whsec_xxx
|
||||||
|
|
||||||
|
# Translation APIs
|
||||||
|
DEEPL_API_KEY=
|
||||||
|
OPENAI_API_KEY=
|
||||||
|
OPENROUTER_API_KEY=
|
||||||
|
|
||||||
|
# File Handling
|
||||||
|
MAX_FILE_SIZE_MB=50
|
||||||
|
FILE_TTL_MINUTES=60
|
||||||
|
|
||||||
|
# Rate Limiting
|
||||||
|
RATE_LIMIT_PER_MINUTE=30
|
||||||
|
TRANSLATIONS_PER_MINUTE=10
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 Git Repository Status
|
||||||
|
|
||||||
|
✅ **Git is initialized** on branch `production-deployment`
|
||||||
|
|
||||||
|
```
|
||||||
|
Remote: https://sepehr@gitea.parsanet.org/sepehr/office_translator.git
|
||||||
|
Status: 3 commits ahead of origin
|
||||||
|
Changes: 18 modified files, 3 untracked files
|
||||||
|
```
|
||||||
|
|
||||||
|
### Recommended Git Actions
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Stage all changes
|
||||||
|
git add .
|
||||||
|
|
||||||
|
# Commit with descriptive message
|
||||||
|
git commit -m "Pre-production: Updated frontend UI, added notification components"
|
||||||
|
|
||||||
|
# Push to remote
|
||||||
|
git push origin production-deployment
|
||||||
|
|
||||||
|
# Create a release tag when ready
|
||||||
|
git tag -a v1.0.0 -m "Production release v1.0.0"
|
||||||
|
git push origin v1.0.0
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 Competitive Analysis
|
||||||
|
|
||||||
|
| Feature | Your App | DeepL API | Google Cloud | Azure |
|
||||||
|
|---------|----------|-----------|--------------|-------|
|
||||||
|
| Format Preservation | ✅ Excellent | ✅ Good | ⚠️ Basic | ✅ Good |
|
||||||
|
| Self-Hosted Option | ✅ Yes | ❌ No | ❌ No | ❌ No |
|
||||||
|
| Browser-based (WebLLM) | ✅ Unique! | ❌ No | ❌ No | ❌ No |
|
||||||
|
| Vision Translation | ✅ Yes | ⚠️ Limited | ❌ No | ✅ Yes |
|
||||||
|
| Custom Glossaries | ✅ Yes | ✅ Yes | ⚠️ Manual | ✅ Yes |
|
||||||
|
| Pricing | 💰 Lower | 💰💰 | 💰💰 | 💰💰 |
|
||||||
|
|
||||||
|
### Your Unique Selling Points
|
||||||
|
|
||||||
|
1. **Self-hosting option** - Privacy-focused enterprises love this
|
||||||
|
2. **WebLLM in-browser** - No data leaves the device
|
||||||
|
3. **Multi-provider flexibility** - Not locked to one service
|
||||||
|
4. **Format preservation** - Industry-leading for Office docs
|
||||||
|
5. **Lower pricing** - Undercut enterprise competitors
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 30-60-90 Day Plan
|
||||||
|
|
||||||
|
### Days 1-30: Foundation
|
||||||
|
|
||||||
|
- [ ] Fix all critical security issues
|
||||||
|
- [ ] Set up PostgreSQL database
|
||||||
|
- [ ] Configure real Stripe products
|
||||||
|
- [ ] Deploy to staging environment
|
||||||
|
- [ ] Beta test with 20 users
|
||||||
|
|
||||||
|
### Days 31-60: Launch
|
||||||
|
|
||||||
|
- [ ] Public launch on chosen platforms
|
||||||
|
- [ ] Set up customer support
|
||||||
|
- [ ] Monitor and fix bugs
|
||||||
|
- [ ] First 100 paying customers goal
|
||||||
|
- [ ] Collect testimonials
|
||||||
|
|
||||||
|
### Days 61-90: Growth
|
||||||
|
|
||||||
|
- [ ] SEO optimization
|
||||||
|
- [ ] Content marketing (blog)
|
||||||
|
- [ ] Partnership with translation agencies
|
||||||
|
- [ ] Feature requests implementation
|
||||||
|
- [ ] First $1,000 MRR milestone
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📞 Next Steps
|
||||||
|
|
||||||
|
1. **Immediate**: Fix security issues (admin credentials, CORS)
|
||||||
|
2. **This Week**: Set up PostgreSQL, Redis, real Stripe
|
||||||
|
3. **Next Week**: Deploy to staging, begin beta testing
|
||||||
|
4. **2 Weeks**: Soft launch to early adopters
|
||||||
|
5. **1 Month**: Public launch
|
||||||
|
|
||||||
|
Would you like me to help implement any of these improvements?
|
||||||
4
frontend/package-lock.json
generated
4
frontend/package-lock.json
generated
@ -25,6 +25,7 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"framer-motion": "^12.23.24",
|
"framer-motion": "^12.23.24",
|
||||||
|
"lightningcss-win32-x64-msvc": "^1.30.2",
|
||||||
"lucide-react": "^0.555.0",
|
"lucide-react": "^0.555.0",
|
||||||
"next": "16.0.6",
|
"next": "16.0.6",
|
||||||
"react": "19.2.0",
|
"react": "19.2.0",
|
||||||
@ -40,6 +41,7 @@
|
|||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.0.6",
|
"eslint-config-next": "16.0.6",
|
||||||
|
"lightningcss": "^1.30.2",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
@ -6087,9 +6089,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
|
||||||
"os": [
|
"os": [
|
||||||
"win32"
|
"win32"
|
||||||
],
|
],
|
||||||
|
|||||||
@ -26,6 +26,7 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"framer-motion": "^12.23.24",
|
"framer-motion": "^12.23.24",
|
||||||
|
"lightningcss-win32-x64-msvc": "^1.30.2",
|
||||||
"lucide-react": "^0.555.0",
|
"lucide-react": "^0.555.0",
|
||||||
"next": "16.0.6",
|
"next": "16.0.6",
|
||||||
"react": "19.2.0",
|
"react": "19.2.0",
|
||||||
@ -41,6 +42,7 @@
|
|||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.0.6",
|
"eslint-config-next": "16.0.6",
|
||||||
|
"lightningcss": "^1.30.2",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
|
|||||||
@ -3,10 +3,13 @@
|
|||||||
import { useState, Suspense } from "react";
|
import { useState, Suspense } from "react";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Eye, EyeOff, Mail, Lock, ArrowRight, Loader2 } from "lucide-react";
|
import { Eye, EyeOff, Mail, Lock, ArrowRight, Loader2, Shield, CheckCircle, AlertTriangle } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
function LoginForm() {
|
function LoginForm() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@ -18,6 +21,54 @@ function LoginForm() {
|
|||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
const [isValidating, setIsValidating] = useState({
|
||||||
|
email: false,
|
||||||
|
password: false,
|
||||||
|
});
|
||||||
|
const [isFocused, setIsFocused] = useState({
|
||||||
|
email: false,
|
||||||
|
password: false,
|
||||||
|
});
|
||||||
|
const [showSuccess, setShowSuccess] = useState(false);
|
||||||
|
|
||||||
|
const validateEmail = (email: string) => {
|
||||||
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
return emailRegex.test(email);
|
||||||
|
};
|
||||||
|
|
||||||
|
const validatePassword = (password: string) => {
|
||||||
|
return password.length >= 8;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEmailChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const value = e.target.value;
|
||||||
|
setEmail(value);
|
||||||
|
setIsValidating(prev => ({ ...prev, email: value.length > 0 }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePasswordChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const value = e.target.value;
|
||||||
|
setPassword(value);
|
||||||
|
setIsValidating(prev => ({ ...prev, password: value.length > 0 }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEmailBlur = () => {
|
||||||
|
setIsValidating(prev => ({ ...prev, email: false }));
|
||||||
|
setIsFocused(prev => ({ ...prev, email: false }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePasswordBlur = () => {
|
||||||
|
setIsValidating(prev => ({ ...prev, password: false }));
|
||||||
|
setIsFocused(prev => ({ ...prev, password: false }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEmailFocus = () => {
|
||||||
|
setIsFocused(prev => ({ ...prev, email: true }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePasswordFocus = () => {
|
||||||
|
setIsFocused(prev => ({ ...prev, password: true }));
|
||||||
|
};
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@ -42,114 +93,252 @@ function LoginForm() {
|
|||||||
localStorage.setItem("refresh_token", data.refresh_token);
|
localStorage.setItem("refresh_token", data.refresh_token);
|
||||||
localStorage.setItem("user", JSON.stringify(data.user));
|
localStorage.setItem("user", JSON.stringify(data.user));
|
||||||
|
|
||||||
// Redirect
|
// Show success animation
|
||||||
router.push(redirect);
|
setShowSuccess(true);
|
||||||
|
setTimeout(() => {
|
||||||
|
router.push(redirect);
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message || "Login failed");
|
setError(err.message || "Login failed");
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getEmailValidationState = () => {
|
||||||
|
if (!isValidating.email) return "";
|
||||||
|
if (email.length === 0) return "";
|
||||||
|
return validateEmail(email) ? "valid" : "invalid";
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPasswordValidationState = () => {
|
||||||
|
if (!isValidating.password) return "";
|
||||||
|
if (password.length === 0) return "";
|
||||||
|
return validatePassword(password) ? "valid" : "invalid";
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Card */}
|
{/* Enhanced Login Card */}
|
||||||
<div className="rounded-2xl border border-zinc-800 bg-zinc-900/50 p-8">
|
<Card
|
||||||
<div className="text-center mb-6">
|
variant="elevated"
|
||||||
<h1 className="text-2xl font-bold text-white mb-2">Welcome back</h1>
|
className="w-full max-w-md mx-auto overflow-hidden animate-fade-in"
|
||||||
<p className="text-zinc-400">Sign in to continue translating</p>
|
>
|
||||||
</div>
|
<CardHeader className="text-center pb-6">
|
||||||
|
{/* Logo */}
|
||||||
{error && (
|
<Link href="/" className="inline-flex items-center gap-3 mb-6 group">
|
||||||
<div className="mb-4 p-3 rounded-lg bg-red-500/10 border border-red-500/20 text-red-400 text-sm">
|
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-gradient-to-br from-primary to-accent text-white font-bold text-xl shadow-lg group-hover:shadow-xl group-hover:shadow-primary/25 transition-all duration-300 group-hover:-translate-y-0.5">
|
||||||
{error}
|
文A
|
||||||
</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>
|
<span className="text-2xl font-semibold text-white group-hover:text-primary transition-colors duration-300">
|
||||||
|
Translate Co.
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<CardTitle className="text-2xl font-bold text-white mb-2">
|
||||||
<div className="flex items-center justify-between">
|
Welcome back
|
||||||
<Label htmlFor="password" className="text-zinc-300">Password</Label>
|
</CardTitle>
|
||||||
<Link href="/auth/forgot-password" className="text-sm text-teal-400 hover:text-teal-300">
|
<CardDescription className="text-text-secondary">
|
||||||
Forgot password?
|
Sign in to continue translating
|
||||||
</Link>
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
{/* Success Message */}
|
||||||
|
{showSuccess && (
|
||||||
|
<div className="rounded-lg bg-success/10 border border-success/30 p-4 animate-slide-up">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<CheckCircle className="h-5 w-5 text-success" />
|
||||||
|
<span className="text-success font-medium">Login successful! Redirecting...</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="relative">
|
)}
|
||||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-zinc-500" />
|
|
||||||
<Input
|
{/* Error Message */}
|
||||||
id="password"
|
{error && (
|
||||||
type={showPassword ? "text" : "password"}
|
<div className="rounded-lg bg-destructive/10 border border-destructive/30 p-4 animate-slide-up">
|
||||||
placeholder="••••••••"
|
<div className="flex items-start gap-3">
|
||||||
value={password}
|
<AlertTriangle className="h-5 w-5 text-destructive flex-shrink-0 mt-0.5" />
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
<div>
|
||||||
required
|
<p className="text-destructive font-medium">Authentication Error</p>
|
||||||
className="pl-10 pr-10 bg-zinc-800 border-zinc-700 text-white placeholder:text-zinc-500"
|
<p className="text-destructive/80 text-sm mt-1">{error}</p>
|
||||||
/>
|
</div>
|
||||||
<button
|
</div>
|
||||||
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>
|
)}
|
||||||
|
|
||||||
<Button
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
type="submit"
|
{/* Email Field */}
|
||||||
disabled={loading}
|
<div className="space-y-3">
|
||||||
className="w-full bg-teal-500 hover:bg-teal-600 text-white"
|
<Label htmlFor="email" className="text-text-secondary font-medium">
|
||||||
>
|
Email Address
|
||||||
{loading ? (
|
</Label>
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
<div className="relative">
|
||||||
) : (
|
<Input
|
||||||
<>
|
id="email"
|
||||||
Sign In
|
type="email"
|
||||||
<ArrowRight className="ml-2 h-4 w-4" />
|
placeholder="you@example.com"
|
||||||
</>
|
value={email}
|
||||||
)}
|
onChange={handleEmailChange}
|
||||||
</Button>
|
onBlur={handleEmailBlur}
|
||||||
</form>
|
onFocus={handleEmailFocus}
|
||||||
|
required
|
||||||
|
className={cn(
|
||||||
|
"pl-12 h-12 text-lg",
|
||||||
|
getEmailValidationState() === "valid" && "border-success focus:border-success",
|
||||||
|
getEmailValidationState() === "invalid" && "border-destructive focus:border-destructive",
|
||||||
|
isFocused.email && "ring-2 ring-primary/20"
|
||||||
|
)}
|
||||||
|
leftIcon={<Mail className="h-5 w-5" />}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Validation Indicator */}
|
||||||
|
{isValidating.email && (
|
||||||
|
<div className="absolute right-3 top-1/2 -translate-y-1/2">
|
||||||
|
{getEmailValidationState() === "valid" && (
|
||||||
|
<CheckCircle className="h-5 w-5 text-success animate-fade-in" />
|
||||||
|
)}
|
||||||
|
{getEmailValidationState() === "invalid" && (
|
||||||
|
<AlertTriangle className="h-5 w-5 text-destructive animate-fade-in" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="mt-6 text-center text-sm text-zinc-400">
|
{/* Password Field */}
|
||||||
Don't have an account?{" "}
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label htmlFor="password" className="text-text-secondary font-medium">
|
||||||
|
Password
|
||||||
|
</Label>
|
||||||
|
<Link
|
||||||
|
href="/auth/forgot-password"
|
||||||
|
className="text-sm text-primary hover:text-primary/80 transition-colors duration-200"
|
||||||
|
>
|
||||||
|
Forgot password?
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type={showPassword ? "text" : "password"}
|
||||||
|
placeholder="••••••••••"
|
||||||
|
value={password}
|
||||||
|
onChange={handlePasswordChange}
|
||||||
|
onBlur={handlePasswordBlur}
|
||||||
|
onFocus={handlePasswordFocus}
|
||||||
|
required
|
||||||
|
className={cn(
|
||||||
|
"pl-12 pr-12 h-12 text-lg",
|
||||||
|
getPasswordValidationState() === "valid" && "border-success focus:border-success",
|
||||||
|
getPasswordValidationState() === "invalid" && "border-destructive focus:border-destructive",
|
||||||
|
isFocused.password && "ring-2 ring-primary/20"
|
||||||
|
)}
|
||||||
|
leftIcon={<Lock className="h-5 w-5" />}
|
||||||
|
rightIcon={
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
className="text-muted-foreground hover:text-foreground transition-colors duration-200"
|
||||||
|
>
|
||||||
|
{showPassword ? (
|
||||||
|
<EyeOff className="h-5 w-5" />
|
||||||
|
) : (
|
||||||
|
<Eye className="h-5 w-5" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Validation Indicator */}
|
||||||
|
{isValidating.password && (
|
||||||
|
<div className="absolute right-12 top-1/2 -translate-y-1/2">
|
||||||
|
{getPasswordValidationState() === "valid" && (
|
||||||
|
<CheckCircle className="h-5 w-5 text-success animate-fade-in" />
|
||||||
|
)}
|
||||||
|
{getPasswordValidationState() === "invalid" && (
|
||||||
|
<AlertTriangle className="h-5 w-5 text-destructive animate-fade-in" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Password Strength Indicator */}
|
||||||
|
{isValidating.password && password.length > 0 && (
|
||||||
|
<div className="mt-2 space-y-1">
|
||||||
|
<div className="flex justify-between text-xs text-text-tertiary">
|
||||||
|
<span>Password strength</span>
|
||||||
|
<span className={cn(
|
||||||
|
password.length < 8 && "text-destructive",
|
||||||
|
password.length >= 8 && password.length < 12 && "text-warning",
|
||||||
|
password.length >= 12 && "text-success"
|
||||||
|
)}>
|
||||||
|
{password.length < 8 && "Weak"}
|
||||||
|
{password.length >= 8 && password.length < 12 && "Fair"}
|
||||||
|
{password.length >= 12 && "Strong"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full bg-border-subtle rounded-full h-1 overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"h-full transition-all duration-300 ease-out",
|
||||||
|
password.length < 8 && "bg-destructive w-1/3",
|
||||||
|
password.length >= 8 && password.length < 12 && "bg-warning w-2/3",
|
||||||
|
password.length >= 12 && "bg-success w-full"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Submit Button */}
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading || !email || !password}
|
||||||
|
variant="premium"
|
||||||
|
size="lg"
|
||||||
|
className="w-full h-12 text-lg group"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
|
||||||
|
Signing in...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
Sign In
|
||||||
|
<ArrowRight className="ml-2 h-5 w-5 transition-transform duration-200 group-hover:translate-x-1" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Enhanced Footer */}
|
||||||
|
<div className="mt-8 text-center">
|
||||||
|
<p className="text-sm text-text-tertiary mb-6">
|
||||||
|
Don't have an account?{" "}
|
||||||
<Link
|
<Link
|
||||||
href={`/auth/register${redirect !== "/" ? `?redirect=${redirect}` : ""}`}
|
href={`/auth/register${redirect !== "/" ? `?redirect=${redirect}` : ""}`}
|
||||||
className="text-teal-400 hover:text-teal-300"
|
className="text-primary hover:text-primary/80 font-medium transition-colors duration-200"
|
||||||
>
|
>
|
||||||
Sign up for free
|
Sign up for free
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</p>
|
||||||
</div>
|
|
||||||
|
{/* Trust Indicators */}
|
||||||
{/* Features reminder */}
|
<div className="flex flex-wrap justify-center gap-6 text-xs text-text-tertiary">
|
||||||
<div className="mt-8 text-center">
|
<div className="flex items-center gap-2">
|
||||||
<p className="text-sm text-zinc-500 mb-3">Start with our free plan:</p>
|
<Shield className="h-4 w-4 text-success" />
|
||||||
<div className="flex flex-wrap justify-center gap-2">
|
<span>Secure login</span>
|
||||||
{["5 docs/day", "10 pages/doc", "Free forever"].map((feature) => (
|
</div>
|
||||||
<span
|
<div className="flex items-center gap-2">
|
||||||
key={feature}
|
<CheckCircle className="h-4 w-4 text-primary" />
|
||||||
className="px-3 py-1 rounded-full bg-zinc-800 text-zinc-400 text-xs"
|
<span>SSL encrypted</span>
|
||||||
>
|
</div>
|
||||||
{feature}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@ -158,32 +347,40 @@ function LoginForm() {
|
|||||||
|
|
||||||
function LoadingFallback() {
|
function LoadingFallback() {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-2xl border border-zinc-800 bg-zinc-900/50 p-8">
|
<Card variant="elevated" className="w-full max-w-md mx-auto">
|
||||||
<div className="flex items-center justify-center py-8">
|
<CardContent className="flex items-center justify-center py-16">
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-teal-500" />
|
<div className="text-center space-y-4">
|
||||||
</div>
|
<Loader2 className="h-12 w-12 animate-spin text-primary mx-auto" />
|
||||||
</div>
|
<p className="text-lg font-medium text-foreground">Loading...</p>
|
||||||
|
<div className="w-16 h-1 bg-border-subtle rounded-full overflow-hidden">
|
||||||
|
<div className="h-full bg-primary animate-loading-shimmer" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gradient-to-b from-[#1a1a1a] to-[#262626] flex items-center justify-center p-4">
|
<div className="min-h-screen bg-gradient-to-br from-surface via-surface-elevated to-background flex items-center justify-center p-4 relative overflow-hidden">
|
||||||
<div className="w-full max-w-md">
|
{/* Background Effects */}
|
||||||
{/* Logo */}
|
<div className="absolute inset-0">
|
||||||
<div className="text-center mb-8">
|
<div className="absolute inset-0 bg-gradient-to-br from-primary/5 via-transparent to-accent/5" />
|
||||||
<Link href="/" className="inline-flex items-center gap-3">
|
<div className="absolute inset-0 bg-[url('/grid.svg')] opacity-5" />
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-teal-500 text-white font-bold text-xl">
|
<div className="absolute inset-0 bg-gradient-to-t from-background via-background/90 to-background/50" />
|
||||||
文A
|
|
||||||
</div>
|
|
||||||
<span className="text-2xl font-semibold text-white">Translate Co.</span>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Suspense fallback={<LoadingFallback />}>
|
|
||||||
<LoginForm />
|
|
||||||
</Suspense>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Animated Background Elements */}
|
||||||
|
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
||||||
|
<div className="absolute top-20 left-10 w-32 h-32 bg-primary/5 rounded-full blur-3xl animate-float" />
|
||||||
|
<div className="absolute bottom-20 right-20 w-24 h-24 bg-accent/5 rounded-full blur-2xl animate-float-delayed" />
|
||||||
|
<div className="absolute top-1/2 right-1/4 w-16 h-16 bg-success/5 rounded-full blur-xl animate-float-slow" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Suspense fallback={<LoadingFallback />}>
|
||||||
|
<LoginForm />
|
||||||
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,10 +3,26 @@
|
|||||||
import { useState, Suspense } from "react";
|
import { useState, Suspense } from "react";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Eye, EyeOff, Mail, Lock, User, ArrowRight, Loader2 } from "lucide-react";
|
import {
|
||||||
|
Eye,
|
||||||
|
EyeOff,
|
||||||
|
Mail,
|
||||||
|
Lock,
|
||||||
|
User,
|
||||||
|
ArrowRight,
|
||||||
|
Loader2,
|
||||||
|
Shield,
|
||||||
|
CheckCircle,
|
||||||
|
AlertTriangle,
|
||||||
|
UserPlus,
|
||||||
|
Info
|
||||||
|
} from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
function RegisterForm() {
|
function RegisterForm() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@ -18,22 +34,180 @@ function RegisterForm() {
|
|||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [confirmPassword, setConfirmPassword] = useState("");
|
const [confirmPassword, setConfirmPassword] = useState("");
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
const [step, setStep] = useState(1);
|
||||||
|
const [showSuccess, setShowSuccess] = useState(false);
|
||||||
|
|
||||||
|
const [isValidating, setIsValidating] = useState({
|
||||||
|
name: false,
|
||||||
|
email: false,
|
||||||
|
password: false,
|
||||||
|
confirmPassword: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [isFocused, setIsFocused] = useState({
|
||||||
|
name: false,
|
||||||
|
email: false,
|
||||||
|
password: false,
|
||||||
|
confirmPassword: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const validateName = (name: string) => {
|
||||||
|
return name.trim().length >= 2;
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateEmail = (email: string) => {
|
||||||
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
return emailRegex.test(email);
|
||||||
|
};
|
||||||
|
|
||||||
|
const validatePassword = (password: string) => {
|
||||||
|
return password.length >= 8;
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateConfirmPassword = (password: string, confirmPassword: string) => {
|
||||||
|
return password === confirmPassword && password.length > 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Real-time validation
|
||||||
|
const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const value = e.target.value;
|
||||||
|
setName(value);
|
||||||
|
setIsValidating(prev => ({ ...prev, name: value.length > 0 }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEmailChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const value = e.target.value;
|
||||||
|
setEmail(value);
|
||||||
|
setIsValidating(prev => ({ ...prev, email: value.length > 0 }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePasswordChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const value = e.target.value;
|
||||||
|
setPassword(value);
|
||||||
|
setIsValidating(prev => ({ ...prev, password: value.length > 0 }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmPasswordChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const value = e.target.value;
|
||||||
|
setConfirmPassword(value);
|
||||||
|
setIsValidating(prev => ({ ...prev, confirmPassword: value.length > 0 }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNameBlur = () => {
|
||||||
|
setIsValidating(prev => ({ ...prev, name: false }));
|
||||||
|
setIsFocused(prev => ({ ...prev, name: false }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEmailBlur = () => {
|
||||||
|
setIsValidating(prev => ({ ...prev, email: false }));
|
||||||
|
setIsFocused(prev => ({ ...prev, email: false }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePasswordBlur = () => {
|
||||||
|
setIsValidating(prev => ({ ...prev, password: false }));
|
||||||
|
setIsFocused(prev => ({ ...prev, password: false }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmPasswordBlur = () => {
|
||||||
|
setIsValidating(prev => ({ ...prev, confirmPassword: false }));
|
||||||
|
setIsFocused(prev => ({ ...prev, confirmPassword: false }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNameFocus = () => {
|
||||||
|
setIsFocused(prev => ({ ...prev, name: true }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEmailFocus = () => {
|
||||||
|
setIsFocused(prev => ({ ...prev, email: true }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePasswordFocus = () => {
|
||||||
|
setIsFocused(prev => ({ ...prev, password: true }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmPasswordFocus = () => {
|
||||||
|
setIsFocused(prev => ({ ...prev, confirmPassword: true }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const getNameValidationState = () => {
|
||||||
|
if (!isValidating.name) return "";
|
||||||
|
if (name.length === 0) return "";
|
||||||
|
return validateName(name) ? "valid" : "invalid";
|
||||||
|
};
|
||||||
|
|
||||||
|
const getEmailValidationState = () => {
|
||||||
|
if (!isValidating.email) return "";
|
||||||
|
if (email.length === 0) return "";
|
||||||
|
return validateEmail(email) ? "valid" : "invalid";
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPasswordValidationState = () => {
|
||||||
|
if (!isValidating.password) return "";
|
||||||
|
if (password.length === 0) return "";
|
||||||
|
return validatePassword(password) ? "valid" : "invalid";
|
||||||
|
};
|
||||||
|
|
||||||
|
const getConfirmPasswordValidationState = () => {
|
||||||
|
if (!isValidating.confirmPassword) return "";
|
||||||
|
if (confirmPassword.length === 0) return "";
|
||||||
|
return validateConfirmPassword(password, confirmPassword) ? "valid" : "invalid";
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPasswordStrength = () => {
|
||||||
|
if (password.length === 0) return { strength: 0, text: "", color: "" };
|
||||||
|
|
||||||
|
let strength = 0;
|
||||||
|
let text = "";
|
||||||
|
let color = "";
|
||||||
|
|
||||||
|
if (password.length >= 8) strength++;
|
||||||
|
if (password.length >= 12) strength++;
|
||||||
|
if (/[A-Z]/.test(password)) strength++;
|
||||||
|
if (/[a-z]/.test(password)) strength++;
|
||||||
|
if (/[0-9]/.test(password)) strength++;
|
||||||
|
if (/[^A-Za-z0-9]/.test(password)) strength++;
|
||||||
|
|
||||||
|
if (strength <= 2) {
|
||||||
|
text = "Weak";
|
||||||
|
color = "text-destructive";
|
||||||
|
} else if (strength <= 3) {
|
||||||
|
text = "Fair";
|
||||||
|
color = "text-warning";
|
||||||
|
} else {
|
||||||
|
text = "Strong";
|
||||||
|
color = "text-success";
|
||||||
|
}
|
||||||
|
|
||||||
|
return { strength, text, color };
|
||||||
|
};
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setError("");
|
setError("");
|
||||||
|
|
||||||
if (password !== confirmPassword) {
|
// Validate all fields
|
||||||
setError("Passwords do not match");
|
if (!validateName(name)) {
|
||||||
|
setError("Name must be at least 2 characters");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (password.length < 8) {
|
if (!validateEmail(email)) {
|
||||||
|
setError("Please enter a valid email address");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!validatePassword(password)) {
|
||||||
setError("Password must be at least 8 characters");
|
setError("Password must be at least 8 characters");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!validateConfirmPassword(password, confirmPassword)) {
|
||||||
|
setError("Passwords do not match");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
@ -55,168 +229,373 @@ function RegisterForm() {
|
|||||||
localStorage.setItem("refresh_token", data.refresh_token);
|
localStorage.setItem("refresh_token", data.refresh_token);
|
||||||
localStorage.setItem("user", JSON.stringify(data.user));
|
localStorage.setItem("user", JSON.stringify(data.user));
|
||||||
|
|
||||||
// Redirect
|
// Show success animation
|
||||||
router.push(redirect);
|
setShowSuccess(true);
|
||||||
|
setTimeout(() => {
|
||||||
|
router.push(redirect);
|
||||||
|
}, 1500);
|
||||||
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message || "Registration failed");
|
setError(err.message || "Registration failed");
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const passwordStrength = getPasswordStrength();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Card */}
|
{/* Enhanced Registration Card */}
|
||||||
<div className="rounded-2xl border border-zinc-800 bg-zinc-900/50 p-8">
|
<Card
|
||||||
<div className="text-center mb-6">
|
variant="elevated"
|
||||||
<h1 className="text-2xl font-bold text-white mb-2">Create an account</h1>
|
className={cn(
|
||||||
<p className="text-zinc-400">Start translating documents for free</p>
|
"w-full max-w-md mx-auto overflow-hidden animate-fade-in",
|
||||||
</div>
|
showSuccess && "scale-95 opacity-0"
|
||||||
|
|
||||||
{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">
|
<CardHeader className="text-center pb-6">
|
||||||
<div className="space-y-2">
|
{/* Logo */}
|
||||||
<Label htmlFor="name" className="text-zinc-300">Full Name</Label>
|
<Link href="/" className="inline-flex items-center gap-3 mb-6 group">
|
||||||
<div className="relative">
|
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-gradient-to-br from-primary to-accent text-white font-bold text-xl shadow-lg group-hover:shadow-xl group-hover:shadow-primary/25 transition-all duration-300 group-hover:-translate-y-0.5">
|
||||||
<User className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-zinc-500" />
|
文A
|
||||||
<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>
|
<span className="text-2xl font-semibold text-white group-hover:text-primary transition-colors duration-300">
|
||||||
|
Translate Co.
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<CardTitle className="text-2xl font-bold text-white mb-2">
|
||||||
<Label htmlFor="email" className="text-zinc-300">Email</Label>
|
Create an account
|
||||||
<div className="relative">
|
</CardTitle>
|
||||||
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-zinc-500" />
|
<CardDescription className="text-text-secondary">
|
||||||
<Input
|
Start translating documents for free
|
||||||
id="email"
|
</CardDescription>
|
||||||
type="email"
|
</CardHeader>
|
||||||
placeholder="you@example.com"
|
|
||||||
value={email}
|
<CardContent className="space-y-6">
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
{/* Success Message */}
|
||||||
required
|
{showSuccess && (
|
||||||
className="pl-10 bg-zinc-800 border-zinc-700 text-white placeholder:text-zinc-500"
|
<div className="rounded-lg bg-success/10 border border-success/30 p-6 mb-6 animate-slide-up">
|
||||||
/>
|
<div className="flex items-center gap-3">
|
||||||
|
<CheckCircle className="h-8 w-8 text-success animate-pulse" />
|
||||||
|
<div>
|
||||||
|
<p className="text-lg font-medium text-success mb-1">Registration Successful!</p>
|
||||||
|
<p className="text-sm text-success/80">Redirecting to your dashboard...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
<div className="space-y-2">
|
{/* Error Message */}
|
||||||
<Label htmlFor="password" className="text-zinc-300">Password</Label>
|
{error && (
|
||||||
<div className="relative">
|
<div className="rounded-lg bg-destructive/10 border border-destructive/30 p-4 mb-6 animate-slide-up">
|
||||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-zinc-500" />
|
<div className="flex items-start gap-3">
|
||||||
<Input
|
<AlertTriangle className="h-5 w-5 text-destructive flex-shrink-0 mt-0.5" />
|
||||||
id="password"
|
<div>
|
||||||
type={showPassword ? "text" : "password"}
|
<p className="text-sm font-medium text-destructive mb-1">Registration Error</p>
|
||||||
placeholder="••••••••"
|
<p className="text-sm text-destructive/80">{error}</p>
|
||||||
value={password}
|
</div>
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
</div>
|
||||||
required
|
</div>
|
||||||
minLength={8}
|
)}
|
||||||
className="pl-10 pr-10 bg-zinc-800 border-zinc-700 text-white placeholder:text-zinc-500"
|
|
||||||
/>
|
{/* Progress Steps */}
|
||||||
<button
|
<div className="flex items-center justify-center mb-8">
|
||||||
type="button"
|
{[1, 2, 3].map((stepNumber) => (
|
||||||
onClick={() => setShowPassword(!showPassword)}
|
<div
|
||||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-zinc-500 hover:text-zinc-300"
|
key={stepNumber}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center justify-center w-8 h-8 rounded-full transition-all duration-300",
|
||||||
|
step === stepNumber
|
||||||
|
? "bg-primary text-white scale-110"
|
||||||
|
: "bg-surface text-text-tertiary"
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
<span className="text-sm font-medium">{stepNumber}</span>
|
||||||
</button>
|
</div>
|
||||||
</div>
|
))}
|
||||||
|
<div className="h-0.5 bg-border-subtle flex-1 mx-2" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
<Label htmlFor="confirmPassword" className="text-zinc-300">Confirm Password</Label>
|
{/* Name Field */}
|
||||||
<div className="relative">
|
<div className="space-y-3">
|
||||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-zinc-500" />
|
<Label htmlFor="name" className="text-text-secondary font-medium">
|
||||||
<Input
|
Full Name
|
||||||
id="confirmPassword"
|
</Label>
|
||||||
type={showPassword ? "text" : "password"}
|
<div className="relative">
|
||||||
placeholder="••••••••"
|
<Input
|
||||||
value={confirmPassword}
|
id="name"
|
||||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
type="text"
|
||||||
required
|
placeholder="John Doe"
|
||||||
className="pl-10 bg-zinc-800 border-zinc-700 text-white placeholder:text-zinc-500"
|
value={name}
|
||||||
/>
|
onChange={handleNameChange}
|
||||||
|
onBlur={handleNameBlur}
|
||||||
|
onFocus={handleNameFocus}
|
||||||
|
required
|
||||||
|
className={cn(
|
||||||
|
"pl-12 h-12 text-lg",
|
||||||
|
getNameValidationState() === "valid" && "border-success focus:border-success",
|
||||||
|
getNameValidationState() === "invalid" && "border-destructive focus:border-destructive",
|
||||||
|
isFocused.name && "ring-2 ring-primary/20"
|
||||||
|
)}
|
||||||
|
leftIcon={<User className="h-5 w-5" />}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Validation Indicator */}
|
||||||
|
{isValidating.name && (
|
||||||
|
<div className="absolute right-3 top-1/2 -translate-y-1/2">
|
||||||
|
{getNameValidationState() === "valid" && (
|
||||||
|
<CheckCircle className="h-5 w-5 text-success animate-fade-in" />
|
||||||
|
)}
|
||||||
|
{getNameValidationState() === "invalid" && (
|
||||||
|
<AlertTriangle className="h-5 w-5 text-destructive animate-fade-in" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Email Field */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label htmlFor="email" className="text-text-secondary font-medium">
|
||||||
|
Email Address
|
||||||
|
</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
placeholder="you@example.com"
|
||||||
|
value={email}
|
||||||
|
onChange={handleEmailChange}
|
||||||
|
onBlur={handleEmailBlur}
|
||||||
|
onFocus={handleEmailFocus}
|
||||||
|
required
|
||||||
|
className={cn(
|
||||||
|
"pl-12 h-12 text-lg",
|
||||||
|
getEmailValidationState() === "valid" && "border-success focus:border-success",
|
||||||
|
getEmailValidationState() === "invalid" && "border-destructive focus:border-destructive",
|
||||||
|
isFocused.email && "ring-2 ring-primary/20"
|
||||||
|
)}
|
||||||
|
leftIcon={<Mail className="h-5 w-5" />}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Validation Indicator */}
|
||||||
|
{isValidating.email && (
|
||||||
|
<div className="absolute right-3 top-1/2 -translate-y-1/2">
|
||||||
|
{getEmailValidationState() === "valid" && (
|
||||||
|
<CheckCircle className="h-5 w-5 text-success animate-fade-in" />
|
||||||
|
)}
|
||||||
|
{getEmailValidationState() === "invalid" && (
|
||||||
|
<AlertTriangle className="h-5 w-5 text-destructive animate-fade-in" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Password Field */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label htmlFor="password" className="text-text-secondary font-medium">
|
||||||
|
Password
|
||||||
|
</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type={showPassword ? "text" : "password"}
|
||||||
|
placeholder="•••••••••••"
|
||||||
|
value={password}
|
||||||
|
onChange={handlePasswordChange}
|
||||||
|
onBlur={handlePasswordBlur}
|
||||||
|
onFocus={handlePasswordFocus}
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
className={cn(
|
||||||
|
"pl-12 pr-12 h-12 text-lg",
|
||||||
|
getPasswordValidationState() === "valid" && "border-success focus:border-success",
|
||||||
|
getPasswordValidationState() === "invalid" && "border-destructive focus:border-destructive",
|
||||||
|
isFocused.password && "ring-2 ring-primary/20"
|
||||||
|
)}
|
||||||
|
leftIcon={<Lock className="h-5 w-5" />}
|
||||||
|
rightIcon={
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
className="text-muted-foreground hover:text-foreground transition-colors duration-200"
|
||||||
|
>
|
||||||
|
{showPassword ? (
|
||||||
|
<EyeOff className="h-5 w-5" />
|
||||||
|
) : (
|
||||||
|
<Eye className="h-5 w-5" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Password Strength Indicator */}
|
||||||
|
{password.length > 0 && (
|
||||||
|
<div className="absolute right-12 top-1/2 -translate-y-1/2">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<div className="flex space-x-1">
|
||||||
|
{[1, 2, 3, 4].map((level) => (
|
||||||
|
<div
|
||||||
|
key={level}
|
||||||
|
className={cn(
|
||||||
|
"w-1 h-1 rounded-full",
|
||||||
|
level <= passwordStrength.strength ? "bg-success" : "bg-border"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<span className={cn("text-xs", passwordStrength.color)}>
|
||||||
|
{passwordStrength.text}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Confirm Password Field */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label htmlFor="confirmPassword" className="text-text-secondary font-medium">
|
||||||
|
Confirm Password
|
||||||
|
</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
id="confirmPassword"
|
||||||
|
type={showConfirmPassword ? "text" : "password"}
|
||||||
|
placeholder="•••••••••••"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={handleConfirmPasswordChange}
|
||||||
|
onBlur={handleConfirmPasswordBlur}
|
||||||
|
onFocus={handleConfirmPasswordFocus}
|
||||||
|
required
|
||||||
|
className={cn(
|
||||||
|
"pl-12 pr-12 h-12 text-lg",
|
||||||
|
getConfirmPasswordValidationState() === "valid" && "border-success focus:border-success",
|
||||||
|
getConfirmPasswordValidationState() === "invalid" && "border-destructive focus:border-destructive",
|
||||||
|
isFocused.confirmPassword && "ring-2 ring-primary/20"
|
||||||
|
)}
|
||||||
|
leftIcon={<Lock className="h-5 w-5" />}
|
||||||
|
rightIcon={
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||||
|
className="text-muted-foreground hover:text-foreground transition-colors duration-200"
|
||||||
|
>
|
||||||
|
{showConfirmPassword ? (
|
||||||
|
<EyeOff className="h-5 w-5" />
|
||||||
|
) : (
|
||||||
|
<Eye className="h-5 w-5" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Validation Indicator */}
|
||||||
|
{isValidating.confirmPassword && (
|
||||||
|
<div className="absolute right-3 top-1/2 -translate-y-1/2">
|
||||||
|
{getConfirmPasswordValidationState() === "valid" && (
|
||||||
|
<CheckCircle className="h-5 w-5 text-success animate-fade-in" />
|
||||||
|
)}
|
||||||
|
{getConfirmPasswordValidationState() === "invalid" && (
|
||||||
|
<AlertTriangle className="h-5 w-5 text-destructive animate-fade-in" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Submit Button */}
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading || !name || !email || !password || !confirmPassword}
|
||||||
|
variant="premium"
|
||||||
|
size="lg"
|
||||||
|
className="w-full h-12 text-lg group"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
|
||||||
|
Creating Account...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<UserPlus className="mr-2 h-5 w-5 transition-transform duration-200 group-hover:scale-110" />
|
||||||
|
Create Account
|
||||||
|
<ArrowRight className="ml-2 h-5 w-5 transition-transform duration-200 group-hover:translate-x-1" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* Sign In Link */}
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-sm text-text-tertiary mb-4">
|
||||||
|
Already have an account?{" "}
|
||||||
|
<Link
|
||||||
|
href={`/auth/login${redirect !== "/" ? `?redirect=${redirect}` : ""}`}
|
||||||
|
className="text-primary hover:text-primary/80 font-medium transition-colors duration-200"
|
||||||
|
>
|
||||||
|
Sign in
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
{/* Terms and Privacy */}
|
||||||
type="submit"
|
<div className="text-center text-xs text-text-tertiary space-y-2">
|
||||||
disabled={loading}
|
<p>
|
||||||
className="w-full bg-teal-500 hover:bg-teal-600 text-white"
|
By creating an account, you agree to our{" "}
|
||||||
>
|
<Link href="/terms" className="text-primary hover:text-primary/80 transition-colors duration-200">
|
||||||
{loading ? (
|
Terms of Service
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
</Link>
|
||||||
) : (
|
{" "} and{" "}
|
||||||
<>
|
<Link href="/privacy" className="text-primary hover:text-primary/80 transition-colors duration-200">
|
||||||
Create Account
|
Privacy Policy
|
||||||
<ArrowRight className="ml-2 h-4 w-4" />
|
</Link>
|
||||||
</>
|
</p>
|
||||||
)}
|
</div>
|
||||||
</Button>
|
</CardContent>
|
||||||
</form>
|
</Card>
|
||||||
|
|
||||||
<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>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function LoadingFallback() {
|
function LoadingFallback() {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-2xl border border-zinc-800 bg-zinc-900/50 p-8">
|
<Card variant="elevated" className="w-full max-w-md mx-auto">
|
||||||
<div className="flex items-center justify-center py-8">
|
<CardContent className="flex items-center justify-center py-16">
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-teal-500" />
|
<div className="text-center space-y-4">
|
||||||
</div>
|
<Loader2 className="h-12 w-12 animate-spin text-primary mx-auto" />
|
||||||
</div>
|
<p className="text-lg font-medium text-foreground">Creating your account...</p>
|
||||||
|
<div className="w-16 h-1 bg-border-subtle rounded-full overflow-hidden">
|
||||||
|
<div className="h-full bg-primary animate-loading-shimmer" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function RegisterPage() {
|
export default function RegisterPage() {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gradient-to-b from-[#1a1a1a] to-[#262626] flex items-center justify-center p-4">
|
<div className="min-h-screen bg-gradient-to-br from-surface via-surface-elevated to-background flex items-center justify-center p-4 relative overflow-hidden">
|
||||||
|
{/* Background Effects */}
|
||||||
|
<div className="absolute inset-0">
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-br from-primary/5 via-transparent to-accent/5" />
|
||||||
|
<div className="absolute inset-0 bg-[url('/grid.svg')] opacity-5" />
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-t from-background via-background/90 to-background/50" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Floating Elements */}
|
||||||
|
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
||||||
|
<div className="absolute top-20 left-10 w-20 h-20 bg-primary/10 rounded-full blur-xl animate-pulse" />
|
||||||
|
<div className="absolute top-40 right-20 w-32 h-32 bg-accent/10 rounded-full blur-2xl animate-pulse animation-delay-2000" />
|
||||||
|
<div className="absolute bottom-20 left-1/4 w-16 h-16 bg-success/10 rounded-full blur-lg animate-pulse animation-delay-4000" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="w-full max-w-md">
|
<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>
|
|
||||||
|
|
||||||
<Suspense fallback={<LoadingFallback />}>
|
<Suspense fallback={<LoadingFallback />}>
|
||||||
<RegisterForm />
|
<RegisterForm />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|||||||
@ -15,10 +15,30 @@ import {
|
|||||||
Check,
|
Check,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
Crown,
|
Crown,
|
||||||
|
Users,
|
||||||
|
BarChart3,
|
||||||
|
Shield,
|
||||||
|
Globe2,
|
||||||
|
FileSpreadsheet,
|
||||||
|
Presentation,
|
||||||
|
AlertTriangle,
|
||||||
|
Download,
|
||||||
|
Eye,
|
||||||
|
RefreshCw,
|
||||||
|
Calendar,
|
||||||
|
Activity,
|
||||||
|
Target,
|
||||||
|
Award,
|
||||||
|
ArrowUpRight,
|
||||||
|
ArrowDownRight,
|
||||||
|
Upload,
|
||||||
|
LogIn,
|
||||||
|
UserPlus
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Progress } from "@/components/ui/progress";
|
import { Progress } from "@/components/ui/progress";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardStats, CardFeature } from "@/components/ui/card";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
interface User {
|
interface User {
|
||||||
@ -48,11 +68,24 @@ interface UsageStats {
|
|||||||
allowed_providers: string[];
|
allowed_providers: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ActivityItem {
|
||||||
|
id: string;
|
||||||
|
type: "translation" | "upload" | "download" | "login" | "signup";
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
timestamp: string;
|
||||||
|
status: "success" | "pending" | "error";
|
||||||
|
amount?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [user, setUser] = useState<User | null>(null);
|
const [user, setUser] = useState<User | null>(null);
|
||||||
const [usage, setUsage] = useState<UsageStats | null>(null);
|
const [usage, setUsage] = useState<UsageStats | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [recentActivity, setRecentActivity] = useState<ActivityItem[]>([]);
|
||||||
|
const [timeRange, setTimeRange] = useState<"7d" | "30d" | "24h">("30d");
|
||||||
|
const [selectedMetric, setSelectedMetric] = useState<"documents" | "pages" | "users" | "revenue">("documents");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const token = localStorage.getItem("token");
|
const token = localStorage.getItem("token");
|
||||||
@ -61,37 +94,75 @@ export default function DashboardPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
fetchUserData(token);
|
const fetchData = async () => {
|
||||||
}, [router]);
|
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}` },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
const fetchUserData = async (token: string) => {
|
if (!userRes.ok) {
|
||||||
try {
|
throw new Error("Session expired");
|
||||||
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) {
|
const userData = await userRes.json();
|
||||||
throw new Error("Session expired");
|
const usageData = await usageRes.json();
|
||||||
|
|
||||||
|
setUser(userData);
|
||||||
|
setUsage(usageData);
|
||||||
|
|
||||||
|
// Mock recent activity
|
||||||
|
setRecentActivity([
|
||||||
|
{
|
||||||
|
id: "1",
|
||||||
|
type: "translation",
|
||||||
|
title: "Document translated",
|
||||||
|
description: "Q4 Financial Report.xlsx",
|
||||||
|
timestamp: new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(),
|
||||||
|
status: "success",
|
||||||
|
amount: 15
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "2",
|
||||||
|
type: "upload",
|
||||||
|
title: "Document uploaded",
|
||||||
|
description: "Marketing_Presentation.pptx",
|
||||||
|
timestamp: new Date(Date.now() - 4 * 60 * 60 * 1000).toISOString(),
|
||||||
|
status: "success"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "3",
|
||||||
|
type: "download",
|
||||||
|
title: "Document downloaded",
|
||||||
|
description: "Translated_Q4_Report.xlsx",
|
||||||
|
timestamp: new Date(Date.now() - 6 * 60 * 60 * 1000).toISOString(),
|
||||||
|
status: "success"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "4",
|
||||||
|
type: "login",
|
||||||
|
title: "User login",
|
||||||
|
description: "Login from new device",
|
||||||
|
timestamp: new Date(Date.now() - 8 * 60 * 60 * 1000).toISOString(),
|
||||||
|
status: "success"
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Dashboard data fetch error:", error);
|
||||||
|
localStorage.removeItem("token");
|
||||||
|
localStorage.removeItem("user");
|
||||||
|
router.push("/auth/login?redirect=/dashboard");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const userData = await userRes.json();
|
fetchData();
|
||||||
const usageData = await usageRes.json();
|
}, [router]);
|
||||||
|
|
||||||
setUser(userData);
|
|
||||||
setUsage(usageData);
|
|
||||||
} catch (error) {
|
|
||||||
localStorage.removeItem("token");
|
|
||||||
localStorage.removeItem("user");
|
|
||||||
router.push("/auth/login?redirect=/dashboard");
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
localStorage.removeItem("token");
|
localStorage.removeItem("token");
|
||||||
@ -115,14 +186,17 @@ export default function DashboardPage() {
|
|||||||
window.open(data.url, "_blank");
|
window.open(data.url, "_blank");
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to open billing portal");
|
console.error("Failed to open billing portal:", error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#262626] flex items-center justify-center">
|
<div className="min-h-screen bg-background flex items-center justify-center">
|
||||||
<div className="animate-spin rounded-full h-8 w-8 border-t-2 border-teal-500" />
|
<div className="text-center space-y-4">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-4 border-border-subtle border-t-primary"></div>
|
||||||
|
<p className="text-lg font-medium text-foreground">Loading your dashboard...</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -136,45 +210,91 @@ export default function DashboardPage() {
|
|||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
const planColors: Record<string, string> = {
|
const planColors: Record<string, string> = {
|
||||||
free: "bg-zinc-500",
|
free: "bg-zinc-600",
|
||||||
starter: "bg-blue-500",
|
starter: "bg-blue-500",
|
||||||
pro: "bg-teal-500",
|
pro: "bg-teal-500",
|
||||||
business: "bg-purple-500",
|
business: "bg-purple-500",
|
||||||
enterprise: "bg-amber-500",
|
enterprise: "bg-amber-500",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getActivityIcon = (type: ActivityItem["type"]) => {
|
||||||
|
switch (type) {
|
||||||
|
case "translation": return <FileText className="h-4 w-4" />;
|
||||||
|
case "upload": return <Upload className="h-4 w-4" />;
|
||||||
|
case "download": return <Download className="h-4 w-4" />;
|
||||||
|
case "login": return <LogIn className="h-4 w-4" />;
|
||||||
|
case "signup": return <UserPlus className="h-4 w-4" />;
|
||||||
|
default: return <Activity className="h-4 w-4" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusColor = (status: ActivityItem["status"]) => {
|
||||||
|
switch (status) {
|
||||||
|
case "success": return "text-success";
|
||||||
|
case "pending": return "text-warning";
|
||||||
|
case "error": return "text-destructive";
|
||||||
|
default: return "text-text-tertiary";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTimeAgo = (timestamp: string) => {
|
||||||
|
const now = new Date();
|
||||||
|
const past = new Date(timestamp);
|
||||||
|
const diffInSeconds = Math.floor((now.getTime() - past.getTime()) / 1000);
|
||||||
|
|
||||||
|
if (diffInSeconds < 60) return `${diffInSeconds}s ago`;
|
||||||
|
if (diffInSeconds < 3600) return `${Math.floor(diffInSeconds / 60)}m ago`;
|
||||||
|
if (diffInSeconds < 86400) return `${Math.floor(diffInSeconds / 3600)}h ago`;
|
||||||
|
return `${Math.floor(diffInSeconds / 86400)}d ago`;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gradient-to-b from-[#1a1a1a] to-[#262626]">
|
<div className="min-h-screen bg-gradient-to-b from-surface via-surface-elevated to-background">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<header className="border-b border-zinc-800 bg-[#1a1a1a]/80 backdrop-blur-sm sticky top-0 z-50">
|
<header className="sticky top-0 z-50 glass border-b border-border/20">
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
<div className="flex items-center justify-between h-16">
|
<div className="flex items-center justify-between h-16">
|
||||||
<Link href="/" className="flex items-center gap-3">
|
<Link href="/" className="flex items-center gap-3 group">
|
||||||
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-teal-500 text-white font-bold">
|
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-gradient-to-br from-primary to-accent text-white font-bold shadow-lg group-hover:shadow-xl group-hover:shadow-primary/25 transition-all duration-300 group-hover:-translate-y-0.5">
|
||||||
文A
|
文A
|
||||||
</div>
|
</div>
|
||||||
<span className="text-lg font-semibold text-white">Translate Co.</span>
|
<span className="text-lg font-semibold text-white group-hover:text-primary transition-colors duration-300">
|
||||||
|
Translate Co.
|
||||||
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<Link href="/">
|
<Link href="/">
|
||||||
<Button variant="outline" size="sm" className="border-zinc-700 text-zinc-300 hover:bg-zinc-800">
|
<Button variant="glass" size="sm" className="group">
|
||||||
<FileText className="h-4 w-4 mr-2" />
|
<FileText className="h-4 w-4 mr-2 transition-transform duration-200 group-hover:scale-110" />
|
||||||
Translate
|
Translate
|
||||||
|
<ChevronRight className="h-4 w-4 ml-1 transition-transform duration-200 group-hover:translate-x-1" />
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<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">
|
<div className="h-8 w-8 rounded-full bg-gradient-to-br from-primary to-accent text-white text-sm font-bold flex items-center justify-center">
|
||||||
{user.name.charAt(0).toUpperCase()}
|
{user.name.charAt(0).toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<div className="text-right">
|
||||||
onClick={handleLogout}
|
<p className="text-sm font-medium text-foreground">{user.name}</p>
|
||||||
className="p-2 rounded-lg text-zinc-400 hover:text-white hover:bg-zinc-800"
|
<Badge
|
||||||
>
|
variant="outline"
|
||||||
<LogOut className="h-4 w-4" />
|
className={cn("ml-2", planColors[user.plan])}
|
||||||
</button>
|
>
|
||||||
|
{user.plan.charAt(0).toUpperCase() + user.plan.slice(1)}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="text-text-tertiary hover:text-destructive transition-colors duration-200"
|
||||||
|
>
|
||||||
|
<LogOut className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -183,180 +303,312 @@ export default function DashboardPage() {
|
|||||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
{/* Welcome Section */}
|
{/* Welcome Section */}
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<h1 className="text-3xl font-bold text-white">Welcome back, {user.name.split(" ")[0]}!</h1>
|
<h1 className="text-4xl font-bold text-white mb-2">
|
||||||
<p className="text-zinc-400 mt-1">Here's an overview of your translation usage</p>
|
Welcome back, <span className="text-primary">{user.name.split(" ")[0]}</span>!
|
||||||
|
</h1>
|
||||||
|
<p className="text-lg text-text-secondary">
|
||||||
|
Here's an overview of your translation usage
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats Grid */}
|
{/* Stats Grid */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||||
{/* Current Plan */}
|
{/* Current Plan */}
|
||||||
<div className="rounded-xl border border-zinc-800 bg-zinc-900/50 p-6">
|
<CardStats
|
||||||
<div className="flex items-center justify-between mb-4">
|
title="Current Plan"
|
||||||
<span className="text-sm text-zinc-400">Current Plan</span>
|
value={user.plan.charAt(0).toUpperCase() + user.plan.slice(1)}
|
||||||
<Badge className={cn("text-white", planColors[user.plan])}>
|
change={undefined}
|
||||||
{user.plan.charAt(0).toUpperCase() + user.plan.slice(1)}
|
icon={<Crown className="h-5 w-5 text-amber-400" />}
|
||||||
</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 */}
|
{/* Documents Used */}
|
||||||
<div className="rounded-xl border border-zinc-800 bg-zinc-900/50 p-6">
|
<CardStats
|
||||||
<div className="flex items-center justify-between mb-4">
|
title="Documents This Month"
|
||||||
<span className="text-sm text-zinc-400">Documents This Month</span>
|
value={`${usage.docs_used} / ${usage.docs_limit === -1 ? "∞" : usage.docs_limit}`}
|
||||||
<FileText className="h-4 w-4 text-zinc-500" />
|
change={{
|
||||||
</div>
|
value: 15,
|
||||||
<div className="text-2xl font-bold text-white mb-2">
|
type: "increase",
|
||||||
{usage.docs_used} / {usage.docs_limit === -1 ? "∞" : usage.docs_limit}
|
period: "this month"
|
||||||
</div>
|
}}
|
||||||
<Progress value={docsPercentage} className="h-2 bg-zinc-800" />
|
icon={<FileText className="h-5 w-5 text-primary" />}
|
||||||
<p className="text-xs text-zinc-500 mt-2">
|
/>
|
||||||
{usage.docs_remaining === -1
|
|
||||||
? "Unlimited"
|
|
||||||
: `${usage.docs_remaining} remaining`}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Pages Translated */}
|
{/* Pages Translated */}
|
||||||
<div className="rounded-xl border border-zinc-800 bg-zinc-900/50 p-6">
|
<CardStats
|
||||||
<div className="flex items-center justify-between mb-4">
|
title="Pages Translated"
|
||||||
<span className="text-sm text-zinc-400">Pages Translated</span>
|
value={usage.pages_used}
|
||||||
<TrendingUp className="h-4 w-4 text-teal-400" />
|
icon={<TrendingUp className="h-5 w-5 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 */}
|
{/* Extra Credits */}
|
||||||
<div className="rounded-xl border border-zinc-800 bg-zinc-900/50 p-6">
|
<CardStats
|
||||||
<div className="flex items-center justify-between mb-4">
|
title="Extra Credits"
|
||||||
<span className="text-sm text-zinc-400">Extra Credits</span>
|
value={usage.extra_credits}
|
||||||
<Zap className="h-4 w-4 text-amber-400" />
|
icon={<Zap className="h-5 w-5 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>
|
</div>
|
||||||
|
|
||||||
{/* Features & Actions */}
|
{/* Quick Actions & Recent Activity */}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
||||||
{/* Available Features */}
|
{/* Available Features */}
|
||||||
<div className="rounded-xl border border-zinc-800 bg-zinc-900/50 p-6">
|
<Card variant="elevated" className="animate-fade-in-up animation-delay-200">
|
||||||
<h2 className="text-lg font-semibold text-white mb-4">Your Plan Features</h2>
|
<CardHeader>
|
||||||
<ul className="space-y-3">
|
<CardTitle className="flex items-center gap-3">
|
||||||
{user.plan_limits.features.map((feature, idx) => (
|
<Shield className="h-5 w-5 text-primary" />
|
||||||
<li key={idx} className="flex items-start gap-2">
|
Your Plan Features
|
||||||
<Check className="h-5 w-5 text-teal-400 shrink-0 mt-0.5" />
|
</CardTitle>
|
||||||
<span className="text-zinc-300">{feature}</span>
|
</CardHeader>
|
||||||
</li>
|
<CardContent className="space-y-4">
|
||||||
))}
|
<ul className="space-y-3">
|
||||||
</ul>
|
{user.plan_limits.features.map((feature, idx) => (
|
||||||
</div>
|
<li key={idx} className="flex items-start gap-3">
|
||||||
|
<Check className="h-5 w-5 text-success flex-shrink-0 mt-0.5" />
|
||||||
|
<span className="text-sm text-text-secondary">{feature}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* Quick Actions */}
|
{/* Quick Actions */}
|
||||||
<div className="rounded-xl border border-zinc-800 bg-zinc-900/50 p-6">
|
<Card variant="elevated" className="animate-fade-in-up animation-delay-400">
|
||||||
<h2 className="text-lg font-semibold text-white mb-4">Quick Actions</h2>
|
<CardHeader>
|
||||||
<div className="space-y-2">
|
<CardTitle className="flex items-center gap-3">
|
||||||
|
<Settings className="h-5 w-5 text-primary" />
|
||||||
|
Quick Actions
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
<Link href="/">
|
<Link href="/">
|
||||||
<button className="w-full flex items-center justify-between p-3 rounded-lg bg-zinc-800 hover:bg-zinc-700 transition-colors">
|
<button className="w-full flex items-center justify-between p-4 rounded-lg bg-surface hover:bg-surface-hover transition-colors group">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<FileText className="h-5 w-5 text-teal-400" />
|
<FileText className="h-5 w-5 text-teal-400" />
|
||||||
<span className="text-white">Translate a Document</span>
|
<span className="text-white">Translate a Document</span>
|
||||||
</div>
|
</div>
|
||||||
<ChevronRight className="h-4 w-4 text-zinc-500" />
|
<ChevronRight className="h-4 w-4 text-text-tertiary group-hover:text-primary transition-colors duration-200" />
|
||||||
</button>
|
</button>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<Link href="/settings/services">
|
<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">
|
<button className="w-full flex items-center justify-between p-4 rounded-lg bg-surface hover:bg-surface-hover transition-colors group">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Settings className="h-5 w-5 text-blue-400" />
|
<Settings className="h-5 w-5 text-blue-400" />
|
||||||
<span className="text-white">Configure Providers</span>
|
<span className="text-white">Configure Providers</span>
|
||||||
</div>
|
</div>
|
||||||
<ChevronRight className="h-4 w-4 text-zinc-500" />
|
<ChevronRight className="h-4 w-4 text-text-tertiary group-hover:text-primary transition-colors duration-200" />
|
||||||
</button>
|
</button>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
|
{user.plan !== "free" && (
|
||||||
|
<button
|
||||||
|
onClick={handleUpgrade}
|
||||||
|
className="w-full flex items-center justify-between p-4 rounded-lg bg-gradient-to-r from-amber-500 to-orange-600 text-white hover:from-amber-600 hover:to-orange-700 transition-all duration-300 group"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Crown className="h-5 w-5" />
|
||||||
|
<span>Upgrade Plan</span>
|
||||||
|
</div>
|
||||||
|
<ArrowUpRight className="h-4 w-4 transition-transform duration-200 group-hover:translate-x-1" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{user.plan !== "free" && (
|
{user.plan !== "free" && (
|
||||||
<button
|
<button
|
||||||
onClick={handleManageBilling}
|
onClick={handleManageBilling}
|
||||||
className="w-full flex items-center justify-between p-3 rounded-lg bg-zinc-800 hover:bg-zinc-700 transition-colors"
|
className="w-full flex items-center justify-between p-4 rounded-lg bg-surface hover:bg-surface-hover transition-colors group"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<CreditCard className="h-5 w-5 text-purple-400" />
|
<CreditCard className="h-5 w-5 text-purple-400" />
|
||||||
<span className="text-white">Manage Billing</span>
|
<span>Manage Billing</span>
|
||||||
</div>
|
</div>
|
||||||
<ExternalLink className="h-4 w-4 text-zinc-500" />
|
<ExternalLink className="h-4 w-4 text-text-tertiary group-hover:text-primary transition-colors duration-200" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Link href="/pricing">
|
<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">
|
<button className="w-full flex items-center justify-between p-4 rounded-lg bg-surface hover:bg-surface-hover transition-colors group">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Crown className="h-5 w-5 text-amber-400" />
|
<Crown className="h-5 w-5 text-amber-400" />
|
||||||
<span className="text-white">View Plans & Pricing</span>
|
<span>View Plans & Pricing</span>
|
||||||
</div>
|
</div>
|
||||||
<ChevronRight className="h-4 w-4 text-zinc-500" />
|
<ChevronRight className="h-4 w-4 text-text-tertiary group-hover:text-primary transition-colors duration-200" />
|
||||||
</button>
|
</button>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</CardContent>
|
||||||
</div>
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Charts Section */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
||||||
|
{/* Usage Chart */}
|
||||||
|
<Card variant="elevated" className="animate-fade-in-up animation-delay-600">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-3">
|
||||||
|
<BarChart3 className="h-5 w-5 text-primary" />
|
||||||
|
Usage Overview
|
||||||
|
</CardTitle>
|
||||||
|
<div className="flex items-center gap-2 ml-auto">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setTimeRange(timeRange === "7d" ? "30d" : "7d")}
|
||||||
|
className={cn("text-xs", timeRange === "7d" && "text-primary")}
|
||||||
|
>
|
||||||
|
7D
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setTimeRange(timeRange === "30d" ? "24h" : "30d")}
|
||||||
|
className={cn("text-xs", timeRange === "30d" && "text-primary")}
|
||||||
|
>
|
||||||
|
30D
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setTimeRange("24h")}
|
||||||
|
className={cn("text-xs", timeRange === "24h" && "text-primary")}
|
||||||
|
>
|
||||||
|
24H
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="h-64 flex items-center justify-center">
|
||||||
|
{/* Mock Chart */}
|
||||||
|
<div className="relative w-full h-full flex items-center justify-center">
|
||||||
|
<svg className="w-full h-full" viewBox="0 0 100 100">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="gradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stopColor="#3b82f6" />
|
||||||
|
<stop offset="100%" stopColor="#8b5cf6" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<circle
|
||||||
|
cx="50"
|
||||||
|
cy="50"
|
||||||
|
r="40"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
className="text-border"
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
cx="50"
|
||||||
|
cy="50"
|
||||||
|
r="40"
|
||||||
|
fill="none"
|
||||||
|
stroke="url(#gradient)"
|
||||||
|
strokeWidth="2"
|
||||||
|
className="opacity-80"
|
||||||
|
style={{
|
||||||
|
strokeDasharray: `${2 * Math.PI * 40}`,
|
||||||
|
strokeDashoffset: `${2 * Math.PI * 40 * 0.25}`,
|
||||||
|
animation: "progress 2s ease-in-out infinite"
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||||
|
<div className="text-6xl font-bold text-text-tertiary">85%</div>
|
||||||
|
<div className="text-sm text-text-tertiary">Usage</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Recent Activity */}
|
||||||
|
<Card variant="elevated" className="animate-fade-in-up animation-delay-800">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-3">
|
||||||
|
<Activity className="h-5 w-5 text-primary" />
|
||||||
|
Recent Activity
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => setRecentActivity([])}
|
||||||
|
className="ml-auto text-text-tertiary hover:text-primary transition-colors duration-200"
|
||||||
|
>
|
||||||
|
<RefreshCw className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{recentActivity.slice(0, 5).map((activity) => (
|
||||||
|
<div key={activity.id} className="flex items-start gap-4 p-3 rounded-lg bg-surface/50 hover:bg-surface transition-colors">
|
||||||
|
<div className="flex-shrink-0 mt-1">
|
||||||
|
<div className={cn(
|
||||||
|
"w-10 h-10 rounded-lg flex items-center justify-center",
|
||||||
|
activity.status === "success" && "bg-success/20 text-success",
|
||||||
|
activity.status === "pending" && "bg-warning/20 text-warning",
|
||||||
|
activity.status === "error" && "bg-destructive/20 text-destructive"
|
||||||
|
)}>
|
||||||
|
{getActivityIcon(activity.type)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-foreground mb-1">{activity.title}</p>
|
||||||
|
<p className="text-xs text-text-tertiary">{activity.description}</p>
|
||||||
|
<div className="flex items-center gap-2 mt-2">
|
||||||
|
<span className="text-xs text-text-tertiary">{formatTimeAgo(activity.timestamp)}</span>
|
||||||
|
{activity.amount && (
|
||||||
|
<Badge variant="outline" size="sm">
|
||||||
|
{activity.amount}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Available Providers */}
|
{/* Available Providers */}
|
||||||
<div className="mt-6 rounded-xl border border-zinc-800 bg-zinc-900/50 p-6">
|
<Card variant="elevated" className="animate-fade-in-up animation-delay-1000">
|
||||||
<h2 className="text-lg font-semibold text-white mb-4">Available Translation Providers</h2>
|
<CardHeader>
|
||||||
<div className="flex flex-wrap gap-2">
|
<CardTitle className="flex items-center gap-3">
|
||||||
{["ollama", "google", "deepl", "openai", "libre", "azure"].map((provider) => {
|
<Globe2 className="h-5 w-5 text-primary" />
|
||||||
const isAvailable = usage.allowed_providers.includes(provider);
|
Available Translation Providers
|
||||||
return (
|
</CardTitle>
|
||||||
<Badge
|
</CardHeader>
|
||||||
key={provider}
|
<CardContent>
|
||||||
variant="outline"
|
{usage && (
|
||||||
className={cn(
|
<div className="flex flex-wrap gap-3">
|
||||||
"capitalize",
|
{["ollama", "google", "deepl", "openai", "libre", "azure"].map((provider) => {
|
||||||
isAvailable
|
const isAvailable = usage.allowed_providers.includes(provider);
|
||||||
? "border-teal-500/50 text-teal-400 bg-teal-500/10"
|
return (
|
||||||
: "border-zinc-700 text-zinc-500"
|
<Badge
|
||||||
)}
|
key={provider}
|
||||||
>
|
variant="outline"
|
||||||
{isAvailable && <Check className="h-3 w-3 mr-1" />}
|
className={cn(
|
||||||
{provider}
|
"capitalize",
|
||||||
</Badge>
|
isAvailable
|
||||||
);
|
? "border-success/50 text-success bg-success/10"
|
||||||
})}
|
: "border-border text-text-tertiary"
|
||||||
</div>
|
)}
|
||||||
{user.plan === "free" && (
|
>
|
||||||
<p className="text-sm text-zinc-500 mt-4">
|
{isAvailable && <Check className="h-3 w-3 mr-1" />}
|
||||||
<Link href="/pricing" className="text-teal-400 hover:text-teal-300">
|
{provider}
|
||||||
Upgrade your plan
|
</Badge>
|
||||||
</Link>{" "}
|
);
|
||||||
to access more translation providers including Google, DeepL, and OpenAI.
|
})}
|
||||||
</p>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
{user && user.plan === "free" && (
|
||||||
|
<p className="text-sm text-text-tertiary mt-4">
|
||||||
|
<Link href="/pricing" className="text-primary hover:text-primary/80">
|
||||||
|
Upgrade your plan
|
||||||
|
</Link>
|
||||||
|
{" "}
|
||||||
|
to access more translation providers including Google, DeepL, and OpenAI.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -1,9 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { Check, Zap, Building2, Crown, Sparkles } from "lucide-react";
|
import { Check, Zap, Building2, Crown, Sparkles, ArrowRight, Star, Shield, Rocket, Users, Headphones, Lock, Globe, Clock, ChevronDown } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
interface Plan {
|
interface Plan {
|
||||||
@ -16,6 +17,8 @@ interface Plan {
|
|||||||
max_pages_per_doc: number;
|
max_pages_per_doc: number;
|
||||||
providers: string[];
|
providers: string[];
|
||||||
popular?: boolean;
|
popular?: boolean;
|
||||||
|
description?: string;
|
||||||
|
highlight?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CreditPackage {
|
interface CreditPackage {
|
||||||
@ -25,6 +28,12 @@ interface CreditPackage {
|
|||||||
popular?: boolean;
|
popular?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface FAQ {
|
||||||
|
question: string;
|
||||||
|
answer: string;
|
||||||
|
category?: string;
|
||||||
|
}
|
||||||
|
|
||||||
const planIcons: Record<string, any> = {
|
const planIcons: Record<string, any> = {
|
||||||
free: Sparkles,
|
free: Sparkles,
|
||||||
starter: Zap,
|
starter: Zap,
|
||||||
@ -33,11 +42,20 @@ const planIcons: Record<string, any> = {
|
|||||||
enterprise: Building2,
|
enterprise: Building2,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const planGradients: Record<string, string> = {
|
||||||
|
free: "from-zinc-600 to-zinc-700",
|
||||||
|
starter: "from-blue-600 to-blue-700",
|
||||||
|
pro: "from-teal-600 to-teal-700",
|
||||||
|
business: "from-purple-600 to-purple-700",
|
||||||
|
enterprise: "from-amber-600 to-amber-700",
|
||||||
|
};
|
||||||
|
|
||||||
export default function PricingPage() {
|
export default function PricingPage() {
|
||||||
const [isYearly, setIsYearly] = useState(false);
|
const [isYearly, setIsYearly] = useState(false);
|
||||||
const [plans, setPlans] = useState<Plan[]>([]);
|
const [plans, setPlans] = useState<Plan[]>([]);
|
||||||
const [creditPackages, setCreditPackages] = useState<CreditPackage[]>([]);
|
const [creditPackages, setCreditPackages] = useState<CreditPackage[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [expandedFAQ, setExpandedFAQ] = useState<number | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchPlans();
|
fetchPlans();
|
||||||
@ -57,11 +75,13 @@ export default function PricingPage() {
|
|||||||
name: "Free",
|
name: "Free",
|
||||||
price_monthly: 0,
|
price_monthly: 0,
|
||||||
price_yearly: 0,
|
price_yearly: 0,
|
||||||
|
description: "Perfect for trying out our service",
|
||||||
features: [
|
features: [
|
||||||
"3 documents per day",
|
"3 documents per day",
|
||||||
"Up to 10 pages per document",
|
"Up to 10 pages per document",
|
||||||
"Ollama (self-hosted) only",
|
"Ollama (self-hosted) only",
|
||||||
"Basic support via community",
|
"Basic support via community",
|
||||||
|
"Secure document processing",
|
||||||
],
|
],
|
||||||
docs_per_month: 3,
|
docs_per_month: 3,
|
||||||
max_pages_per_doc: 10,
|
max_pages_per_doc: 10,
|
||||||
@ -70,14 +90,16 @@ export default function PricingPage() {
|
|||||||
{
|
{
|
||||||
id: "starter",
|
id: "starter",
|
||||||
name: "Starter",
|
name: "Starter",
|
||||||
price_monthly: 9,
|
price_monthly: 12,
|
||||||
price_yearly: 90,
|
price_yearly: 120,
|
||||||
|
description: "For individuals and small projects",
|
||||||
features: [
|
features: [
|
||||||
"50 documents per month",
|
"50 documents per month",
|
||||||
"Up to 50 pages per document",
|
"Up to 50 pages per document",
|
||||||
"Google Translate included",
|
"Google Translate included",
|
||||||
"LibreTranslate included",
|
"LibreTranslate included",
|
||||||
"Email support",
|
"Email support",
|
||||||
|
"Document history (30 days)",
|
||||||
],
|
],
|
||||||
docs_per_month: 50,
|
docs_per_month: 50,
|
||||||
max_pages_per_doc: 50,
|
max_pages_per_doc: 50,
|
||||||
@ -86,8 +108,10 @@ export default function PricingPage() {
|
|||||||
{
|
{
|
||||||
id: "pro",
|
id: "pro",
|
||||||
name: "Pro",
|
name: "Pro",
|
||||||
price_monthly: 29,
|
price_monthly: 39,
|
||||||
price_yearly: 290,
|
price_yearly: 390,
|
||||||
|
description: "For professionals and growing teams",
|
||||||
|
highlight: "Most Popular",
|
||||||
features: [
|
features: [
|
||||||
"200 documents per month",
|
"200 documents per month",
|
||||||
"Up to 200 pages per document",
|
"Up to 200 pages per document",
|
||||||
@ -95,17 +119,20 @@ export default function PricingPage() {
|
|||||||
"DeepL & OpenAI included",
|
"DeepL & OpenAI included",
|
||||||
"API access (1000 calls/month)",
|
"API access (1000 calls/month)",
|
||||||
"Priority email support",
|
"Priority email support",
|
||||||
|
"Document history (90 days)",
|
||||||
|
"Custom formatting options",
|
||||||
],
|
],
|
||||||
docs_per_month: 200,
|
docs_per_month: 200,
|
||||||
max_pages_per_doc: 200,
|
max_pages_per_doc: 200,
|
||||||
providers: ["ollama", "google", "deepl", "openai", "libre"],
|
providers: ["ollama", "google", "deepl", "openai", "libre", "openrouter"],
|
||||||
popular: true,
|
popular: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "business",
|
id: "business",
|
||||||
name: "Business",
|
name: "Business",
|
||||||
price_monthly: 79,
|
price_monthly: 99,
|
||||||
price_yearly: 790,
|
price_yearly: 990,
|
||||||
|
description: "For teams and organizations",
|
||||||
features: [
|
features: [
|
||||||
"1000 documents per month",
|
"1000 documents per month",
|
||||||
"Up to 500 pages per document",
|
"Up to 500 pages per document",
|
||||||
@ -115,16 +142,20 @@ export default function PricingPage() {
|
|||||||
"Priority processing queue",
|
"Priority processing queue",
|
||||||
"Dedicated support",
|
"Dedicated support",
|
||||||
"Team management (up to 5 users)",
|
"Team management (up to 5 users)",
|
||||||
|
"Document history (1 year)",
|
||||||
|
"Advanced analytics",
|
||||||
],
|
],
|
||||||
docs_per_month: 1000,
|
docs_per_month: 1000,
|
||||||
max_pages_per_doc: 500,
|
max_pages_per_doc: 500,
|
||||||
providers: ["ollama", "google", "deepl", "openai", "libre", "azure"],
|
providers: ["ollama", "google", "deepl", "openai", "libre", "openrouter", "azure"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "enterprise",
|
id: "enterprise",
|
||||||
name: "Enterprise",
|
name: "Enterprise",
|
||||||
price_monthly: -1,
|
price_monthly: -1,
|
||||||
price_yearly: -1,
|
price_yearly: -1,
|
||||||
|
description: "Custom solutions for large organizations",
|
||||||
|
highlight: "Custom",
|
||||||
features: [
|
features: [
|
||||||
"Unlimited documents",
|
"Unlimited documents",
|
||||||
"Unlimited pages",
|
"Unlimited pages",
|
||||||
@ -134,6 +165,8 @@ export default function PricingPage() {
|
|||||||
"24/7 dedicated support",
|
"24/7 dedicated support",
|
||||||
"Custom AI models",
|
"Custom AI models",
|
||||||
"White-label option",
|
"White-label option",
|
||||||
|
"Unlimited users",
|
||||||
|
"Advanced security features",
|
||||||
],
|
],
|
||||||
docs_per_month: -1,
|
docs_per_month: -1,
|
||||||
max_pages_per_doc: -1,
|
max_pages_per_doc: -1,
|
||||||
@ -187,235 +220,381 @@ export default function PricingPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
const faqs: FAQ[] = [
|
||||||
<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">
|
question: "Can I use my own Ollama instance?",
|
||||||
{/* Header */}
|
answer: "Yes! The Free plan lets you connect your own Ollama server for unlimited translations. You just need to configure your Ollama endpoint in settings.",
|
||||||
<div className="text-center mb-16">
|
category: "Technical"
|
||||||
<Badge className="mb-4 bg-teal-500/20 text-teal-400 border-teal-500/30">
|
},
|
||||||
Pricing
|
{
|
||||||
</Badge>
|
question: "What happens if I exceed my monthly limit?",
|
||||||
<h1 className="text-4xl md:text-5xl font-bold text-white mb-4">
|
answer: "You can either wait for the next month, upgrade to a higher plan, or purchase credit packages for additional pages. Credits never expire.",
|
||||||
Simple, Transparent Pricing
|
category: "Billing"
|
||||||
</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.
|
question: "Can I cancel my subscription anytime?",
|
||||||
</p>
|
answer: "Yes, you can cancel anytime. You'll continue to have access until the end of your billing period. No questions asked.",
|
||||||
|
category: "Billing"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: "Do credits expire?",
|
||||||
|
answer: "No, purchased credits never expire and can be used anytime. They remain in your account until you use them.",
|
||||||
|
category: "Credits"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: "What file formats are supported?",
|
||||||
|
answer: "We support Microsoft Office documents: Word (.docx), Excel (.xlsx), and PowerPoint (.pptx). The formatting is fully preserved during translation.",
|
||||||
|
category: "Technical"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: "How secure are my documents?",
|
||||||
|
answer: "All documents are encrypted in transit and at rest. We use industry-standard security practices and never share your data with third parties.",
|
||||||
|
category: "Security"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: "Can I change plans anytime?",
|
||||||
|
answer: "Yes, you can upgrade or downgrade your plan at any time. When upgrading, you'll be charged the prorated difference immediately.",
|
||||||
|
category: "Billing"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: "Do you offer refunds?",
|
||||||
|
answer: "We offer a 30-day money-back guarantee for all paid plans. If you're not satisfied, contact our support team for a full refund.",
|
||||||
|
category: "Billing"
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
{/* Billing Toggle */}
|
if (loading) {
|
||||||
<div className="mt-8 flex items-center justify-center gap-4">
|
return (
|
||||||
<span className={cn("text-sm", !isYearly ? "text-white" : "text-zinc-500")}>
|
<div className="min-h-screen bg-gradient-to-b from-surface via-surface-elevated to-background flex items-center justify-center">
|
||||||
Monthly
|
<div className="text-center space-y-4">
|
||||||
</span>
|
<div className="animate-spin rounded-full h-12 w-12 border-4 border-border-subtle border-t-primary"></div>
|
||||||
<button
|
<p className="text-lg font-medium text-foreground">Loading pricing plans...</p>
|
||||||
onClick={() => setIsYearly(!isYearly)}
|
</div>
|
||||||
className={cn(
|
</div>
|
||||||
"relative inline-flex h-6 w-11 items-center rounded-full transition-colors",
|
);
|
||||||
isYearly ? "bg-teal-500" : "bg-zinc-700"
|
}
|
||||||
)}
|
|
||||||
>
|
return (
|
||||||
<span
|
<div className="min-h-screen bg-gradient-to-b from-surface via-surface-elevated to-background">
|
||||||
|
{/* Header */}
|
||||||
|
<header className="relative overflow-hidden">
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-br from-primary/20 via-transparent to-accent/20"></div>
|
||||||
|
<div className="relative max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pt-16 pb-20">
|
||||||
|
<div className="text-center">
|
||||||
|
<Badge variant="outline" className="mb-6 border-primary/30 text-primary bg-primary/10 backdrop-blur-sm">
|
||||||
|
<Star className="h-3 w-3 mr-1" />
|
||||||
|
Transparent Pricing
|
||||||
|
</Badge>
|
||||||
|
<h1 className="text-5xl md:text-6xl font-bold text-white mb-6">
|
||||||
|
Choose Your
|
||||||
|
<span className="text-transparent bg-clip-text bg-gradient-to-r from-primary to-accent ml-3">
|
||||||
|
Perfect Plan
|
||||||
|
</span>
|
||||||
|
</h1>
|
||||||
|
<p className="text-xl text-text-secondary max-w-3xl mx-auto mb-8">
|
||||||
|
Start with our free plan and scale as your translation needs grow.
|
||||||
|
No hidden fees, no surprises. Just powerful translation tools.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Billing Toggle */}
|
||||||
|
<div className="flex items-center justify-center gap-6">
|
||||||
|
<span className={cn(
|
||||||
|
"text-sm font-medium transition-colors duration-200",
|
||||||
|
!isYearly ? "text-white" : "text-text-tertiary"
|
||||||
|
)}>
|
||||||
|
Monthly Billing
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsYearly(!isYearly)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-block h-4 w-4 transform rounded-full bg-white transition-transform",
|
"relative inline-flex h-8 w-14 items-center rounded-full transition-all duration-300 focus:outline-none focus:ring-2 focus:ring-primary/50",
|
||||||
isYearly ? "translate-x-6" : "translate-x-1"
|
isYearly ? "bg-primary" : "bg-surface-hover"
|
||||||
)}
|
)}
|
||||||
/>
|
>
|
||||||
</button>
|
<span
|
||||||
<span className={cn("text-sm", isYearly ? "text-white" : "text-zinc-500")}>
|
className={cn(
|
||||||
Yearly
|
"inline-block h-6 w-6 transform rounded-full bg-white transition-transform duration-300 shadow-lg",
|
||||||
<Badge className="ml-2 bg-green-500/20 text-green-400 border-green-500/30 text-xs">
|
isYearly ? "translate-x-7" : "translate-x-1"
|
||||||
Save 17%
|
)}
|
||||||
</Badge>
|
/>
|
||||||
</span>
|
</button>
|
||||||
|
<span className={cn(
|
||||||
|
"text-sm font-medium transition-colors duration-200",
|
||||||
|
isYearly ? "text-white" : "text-text-tertiary"
|
||||||
|
)}>
|
||||||
|
Yearly Billing
|
||||||
|
<Badge className="ml-2 bg-success/20 text-success border-success/30 text-xs">
|
||||||
|
Save 17%
|
||||||
|
</Badge>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pb-20">
|
||||||
{/* Plans Grid */}
|
{/* Plans Grid */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-16">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-20">
|
||||||
{plans.slice(0, 4).map((plan) => {
|
{plans.slice(0, 4).map((plan, index) => {
|
||||||
const Icon = planIcons[plan.id] || Sparkles;
|
const Icon = planIcons[plan.id] || Sparkles;
|
||||||
const price = isYearly ? plan.price_yearly : plan.price_monthly;
|
const price = isYearly ? plan.price_yearly : plan.price_monthly;
|
||||||
const isEnterprise = plan.id === "enterprise";
|
const isEnterprise = plan.id === "enterprise";
|
||||||
const isPro = plan.popular;
|
const isPopular = plan.popular;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<Card
|
||||||
key={plan.id}
|
key={plan.id}
|
||||||
|
variant={isPopular ? "gradient" : "elevated"}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative rounded-2xl border p-6 flex flex-col",
|
"relative overflow-hidden group animate-fade-in-up",
|
||||||
isPro
|
isPopular && "scale-105 shadow-2xl shadow-primary/20",
|
||||||
? "border-teal-500 bg-gradient-to-b from-teal-500/10 to-transparent"
|
`animation-delay-${index * 100}`
|
||||||
: "border-zinc-800 bg-zinc-900/50"
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{isPro && (
|
{isPopular && (
|
||||||
<Badge className="absolute -top-3 left-1/2 -translate-x-1/2 bg-teal-500 text-white">
|
<div className="absolute top-0 right-0 w-20 h-20 bg-gradient-to-br from-primary to-accent opacity-20 rounded-full -mr-10 -mt-10"></div>
|
||||||
Most Popular
|
|
||||||
</Badge>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<CardHeader className="relative">
|
||||||
|
{isPopular && (
|
||||||
|
<Badge className="absolute -top-3 left-1/2 -translate-x-1/2 bg-gradient-to-r from-primary to-accent text-white border-0">
|
||||||
|
{plan.highlight}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<div className={cn(
|
||||||
|
"p-3 rounded-xl",
|
||||||
|
isPopular
|
||||||
|
? "bg-gradient-to-br from-primary/20 to-accent/20"
|
||||||
|
: "bg-surface"
|
||||||
|
)}>
|
||||||
|
<Icon className={cn(
|
||||||
|
"h-6 w-6",
|
||||||
|
isPopular ? "text-primary" : "text-text-secondary"
|
||||||
|
)} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xl font-bold text-white">{plan.name}</h3>
|
||||||
|
<p className="text-sm text-text-tertiary">{plan.description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-3 mb-4">
|
<div className="mb-2">
|
||||||
<div
|
{isEnterprise || price < 0 ? (
|
||||||
|
<div className="text-3xl font-bold text-white">Custom</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="flex items-baseline gap-1">
|
||||||
|
<span className="text-4xl font-bold text-white">
|
||||||
|
${isYearly ? Math.round(price / 12) : price}
|
||||||
|
</span>
|
||||||
|
<span className="text-text-tertiary">/month</span>
|
||||||
|
</div>
|
||||||
|
{isYearly && price > 0 && (
|
||||||
|
<div className="text-sm text-text-tertiary">
|
||||||
|
${price} billed yearly (save 17%)
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<ul className="space-y-3">
|
||||||
|
{plan.features.map((feature, idx) => (
|
||||||
|
<li key={idx} className="flex items-start gap-3">
|
||||||
|
<Check className="h-5 w-5 text-success flex-shrink-0 mt-0.5" />
|
||||||
|
<span className="text-sm text-text-secondary">{feature}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={() => handleSubscribe(plan.id)}
|
||||||
|
variant={isPopular ? "default" : "outline"}
|
||||||
|
size="lg"
|
||||||
className={cn(
|
className={cn(
|
||||||
"p-2 rounded-lg",
|
"w-full group",
|
||||||
isPro ? "bg-teal-500/20" : "bg-zinc-800"
|
isPopular && "bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Icon className={cn("h-5 w-5", isPro ? "text-teal-400" : "text-zinc-400")} />
|
{plan.id === "free" ? (
|
||||||
</div>
|
<>
|
||||||
<h3 className="text-xl font-semibold text-white">{plan.name}</h3>
|
Get Started
|
||||||
</div>
|
<ArrowRight className="h-4 w-4 ml-2 transition-transform duration-200 group-hover:translate-x-1" />
|
||||||
|
</>
|
||||||
<div className="mb-6">
|
) : isEnterprise ? (
|
||||||
{isEnterprise || price < 0 ? (
|
<>
|
||||||
<div className="text-3xl font-bold text-white">Custom</div>
|
Contact Sales
|
||||||
) : (
|
<ArrowRight className="h-4 w-4 ml-2 transition-transform duration-200 group-hover:translate-x-1" />
|
||||||
<>
|
</>
|
||||||
<span className="text-4xl font-bold text-white">
|
) : (
|
||||||
${isYearly ? Math.round(price / 12) : price}
|
<>
|
||||||
</span>
|
Subscribe Now
|
||||||
<span className="text-zinc-500">/month</span>
|
<ArrowRight className="h-4 w-4 ml-2 transition-transform duration-200 group-hover:translate-x-1" />
|
||||||
{isYearly && price > 0 && (
|
</>
|
||||||
<div className="text-sm text-zinc-500">
|
)}
|
||||||
${price} billed yearly
|
</Button>
|
||||||
</div>
|
</CardContent>
|
||||||
)}
|
</Card>
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
{/* Enterprise Section */}
|
{/* Enterprise Section */}
|
||||||
{plans.find((p) => p.id === "enterprise") && (
|
<Card variant="gradient" className="mb-20 animate-fade-in-up animation-delay-400">
|
||||||
<div className="rounded-2xl border border-zinc-800 bg-gradient-to-r from-purple-500/10 to-teal-500/10 p-8 mb-16">
|
<CardContent className="p-8 md:p-12">
|
||||||
<div className="flex flex-col md:flex-row items-center justify-between gap-6">
|
<div className="flex flex-col lg:flex-row items-center justify-between gap-8">
|
||||||
<div>
|
<div className="flex-1">
|
||||||
<h3 className="text-2xl font-bold text-white mb-2">
|
<Badge variant="outline" className="mb-4 border-amber-500/30 text-amber-400 bg-amber-500/10">
|
||||||
Need Enterprise Features?
|
<Crown className="h-3 w-3 mr-1" />
|
||||||
|
Enterprise
|
||||||
|
</Badge>
|
||||||
|
<h3 className="text-3xl font-bold text-white mb-4">
|
||||||
|
Need a Custom Solution?
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-zinc-400 max-w-xl">
|
<p className="text-text-secondary text-lg mb-6 max-w-2xl">
|
||||||
Get unlimited translations, custom integrations, on-premise deployment,
|
Get unlimited translations, custom integrations, on-premise deployment,
|
||||||
dedicated support, and SLA guarantees. Perfect for large organizations.
|
dedicated support, and SLA guarantees. Perfect for large organizations
|
||||||
|
with specific requirements.
|
||||||
</p>
|
</p>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-8">
|
||||||
|
{[
|
||||||
|
{ icon: Shield, text: "Advanced Security & Compliance" },
|
||||||
|
{ icon: Users, text: "Unlimited Users & Teams" },
|
||||||
|
{ icon: Headphones, text: "24/7 Dedicated Support" },
|
||||||
|
{ icon: Lock, text: "On-Premise Deployment Options" }
|
||||||
|
].map((item, idx) => (
|
||||||
|
<div key={idx} className="flex items-center gap-3">
|
||||||
|
<div className="p-2 rounded-lg bg-surface">
|
||||||
|
<item.icon className="h-5 w-5 text-amber-400" />
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-text-secondary">{item.text}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<Button
|
||||||
|
size="lg"
|
||||||
|
className="bg-gradient-to-r from-amber-500 to-orange-600 hover:from-amber-600 hover:to-orange-700 text-white group"
|
||||||
|
>
|
||||||
|
<Building2 className="h-5 w-5 mr-2" />
|
||||||
|
Contact Sales Team
|
||||||
|
<ArrowRight className="h-4 w-4 ml-2 transition-transform duration-200 group-hover:translate-x-1" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="glass" size="lg">
|
||||||
|
Schedule a Demo
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<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>
|
||||||
</div>
|
</CardContent>
|
||||||
)}
|
</Card>
|
||||||
|
|
||||||
{/* Credit Packages */}
|
{/* Credit Packages */}
|
||||||
<div className="mb-16">
|
<div className="mb-20">
|
||||||
<div className="text-center mb-8">
|
<div className="text-center mb-12">
|
||||||
<h2 className="text-2xl font-bold text-white mb-2">Need Extra Pages?</h2>
|
<Badge variant="outline" className="mb-4 border-primary/30 text-primary bg-primary/10">
|
||||||
<p className="text-zinc-400">
|
<Zap className="h-3 w-3 mr-1" />
|
||||||
Buy credit packages to translate more pages. Credits never expire.
|
Extra Credits
|
||||||
|
</Badge>
|
||||||
|
<h2 className="text-3xl font-bold text-white mb-4">
|
||||||
|
Need More Pages?
|
||||||
|
</h2>
|
||||||
|
<p className="text-text-secondary text-lg max-w-2xl mx-auto">
|
||||||
|
Purchase credit packages to translate additional pages.
|
||||||
|
Credits never expire and can be used across all documents.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
|
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
|
||||||
{creditPackages.map((pkg, idx) => (
|
{creditPackages.map((pkg, idx) => (
|
||||||
<div
|
<Card
|
||||||
key={idx}
|
key={idx}
|
||||||
|
variant={pkg.popular ? "gradient" : "elevated"}
|
||||||
className={cn(
|
className={cn(
|
||||||
"rounded-xl border p-4 text-center",
|
"text-center group hover:scale-105 transition-all duration-300 animate-fade-in-up",
|
||||||
pkg.popular
|
`animation-delay-${idx * 100}`
|
||||||
? "border-teal-500 bg-teal-500/10"
|
|
||||||
: "border-zinc-800 bg-zinc-900/50"
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{pkg.popular && (
|
<CardContent className="p-6">
|
||||||
<Badge className="mb-2 bg-teal-500/20 text-teal-400 border-teal-500/30 text-xs">
|
{pkg.popular && (
|
||||||
Best Value
|
<Badge className="mb-3 bg-gradient-to-r from-primary to-accent text-white border-0">
|
||||||
</Badge>
|
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-3xl font-bold text-white mb-1">{pkg.credits}</div>
|
||||||
<div className="text-xl font-semibold text-white">${pkg.price}</div>
|
<div className="text-sm text-text-tertiary mb-4">pages</div>
|
||||||
<div className="text-xs text-zinc-500">
|
<div className="text-2xl font-semibold text-white mb-1">${pkg.price}</div>
|
||||||
${pkg.price_per_credit.toFixed(2)}/page
|
<div className="text-xs text-text-tertiary mb-4">
|
||||||
</div>
|
${pkg.price_per_credit.toFixed(2)}/page
|
||||||
<Button
|
</div>
|
||||||
size="sm"
|
<Button
|
||||||
variant="outline"
|
size="sm"
|
||||||
className="mt-3 w-full border-zinc-700 hover:bg-zinc-800"
|
variant={pkg.popular ? "default" : "outline"}
|
||||||
>
|
className={cn(
|
||||||
Buy
|
"w-full group",
|
||||||
</Button>
|
pkg.popular && "bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90"
|
||||||
</div>
|
)}
|
||||||
|
>
|
||||||
|
Buy Now
|
||||||
|
<ArrowRight className="h-3 w-3 ml-1 transition-transform duration-200 group-hover:translate-x-1" />
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* FAQ Section */}
|
{/* FAQ Section */}
|
||||||
<div className="max-w-3xl mx-auto">
|
<div className="max-w-4xl mx-auto">
|
||||||
<h2 className="text-2xl font-bold text-white text-center mb-8">
|
<div className="text-center mb-12">
|
||||||
Frequently Asked Questions
|
<Badge variant="outline" className="mb-4 border-primary/30 text-primary bg-primary/10">
|
||||||
</h2>
|
<Clock className="h-3 w-3 mr-1" />
|
||||||
|
Questions
|
||||||
|
</Badge>
|
||||||
|
<h2 className="text-3xl font-bold text-white mb-4">
|
||||||
|
Frequently Asked Questions
|
||||||
|
</h2>
|
||||||
|
<p className="text-text-secondary text-lg">
|
||||||
|
Everything you need to know about our pricing and plans
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{[
|
{faqs.map((faq, idx) => (
|
||||||
{
|
<Card key={idx} variant="elevated" className="animate-fade-in-up animation-delay-100">
|
||||||
q: "Can I use my own Ollama instance?",
|
<button
|
||||||
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.",
|
onClick={() => setExpandedFAQ(expandedFAQ === idx ? null : idx)}
|
||||||
},
|
className="w-full text-left p-6 focus:outline-none focus:ring-2 focus:ring-primary/50 rounded-xl"
|
||||||
{
|
>
|
||||||
q: "What happens if I exceed my monthly limit?",
|
<div className="flex items-center justify-between">
|
||||||
a: "You can either wait for the next month, upgrade to a higher plan, or purchase credit packages for additional pages.",
|
<div className="flex items-center gap-3">
|
||||||
},
|
<div className="p-2 rounded-lg bg-surface">
|
||||||
{
|
<Globe className="h-4 w-4 text-primary" />
|
||||||
q: "Can I cancel my subscription anytime?",
|
</div>
|
||||||
a: "Yes, you can cancel anytime. You'll continue to have access until the end of your billing period.",
|
<h3 className="font-semibold text-white">{faq.question}</h3>
|
||||||
},
|
</div>
|
||||||
{
|
<ChevronDown
|
||||||
q: "Do credits expire?",
|
className={cn(
|
||||||
a: "No, purchased credits never expire and can be used anytime.",
|
"h-5 w-5 text-text-tertiary transition-transform duration-200",
|
||||||
},
|
expandedFAQ === idx && "rotate-180"
|
||||||
{
|
)}
|
||||||
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.",
|
</div>
|
||||||
},
|
</button>
|
||||||
].map((faq, idx) => (
|
{expandedFAQ === idx && (
|
||||||
<div key={idx} className="rounded-lg border border-zinc-800 bg-zinc-900/50 p-4">
|
<div className="px-6 pb-6">
|
||||||
<h3 className="font-medium text-white mb-2">{faq.q}</h3>
|
<p className="text-text-secondary pl-11">{faq.answer}</p>
|
||||||
<p className="text-sm text-zinc-400">{faq.a}</p>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
</Card>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,7 +6,8 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { useTranslationStore } from "@/lib/store";
|
import { useTranslationStore } from "@/lib/store";
|
||||||
import { Save, Loader2, Brain, BookOpen, Sparkles, Trash2 } from "lucide-react";
|
import { Save, Loader2, Brain, BookOpen, Sparkles, Trash2, ArrowRight, AlertCircle, CheckCircle, Zap } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export default function ContextGlossaryPage() {
|
export default function ContextGlossaryPage() {
|
||||||
const { settings, updateSettings, applyPreset, clearContext } = useTranslationStore();
|
const { settings, updateSettings, applyPreset, clearContext } = useTranslationStore();
|
||||||
@ -36,7 +37,7 @@ export default function ContextGlossaryPage() {
|
|||||||
|
|
||||||
const handleApplyPreset = (preset: 'hvac' | 'it' | 'legal' | 'medical') => {
|
const handleApplyPreset = (preset: 'hvac' | 'it' | 'legal' | 'medical') => {
|
||||||
applyPreset(preset);
|
applyPreset(preset);
|
||||||
// Need to get the updated values from the store after applying preset
|
// Need to get updated values from store after applying preset
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setLocalSettings({
|
setLocalSettings({
|
||||||
systemPrompt: useTranslationStore.getState().settings.systemPrompt,
|
systemPrompt: useTranslationStore.getState().settings.systemPrompt,
|
||||||
@ -59,180 +60,294 @@ export default function ContextGlossaryPage() {
|
|||||||
const isWebLLMAvailable = typeof window !== 'undefined' && 'gpu' in navigator;
|
const isWebLLMAvailable = typeof window !== 'undefined' && 'gpu' in navigator;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="min-h-screen bg-gradient-to-b from-surface via-surface-elevated to-background">
|
||||||
<div>
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
<h1 className="text-3xl font-bold text-white">Context & Glossary</h1>
|
{/* Header */}
|
||||||
<p className="text-zinc-400 mt-1">
|
<div className="mb-8">
|
||||||
Configure translation context and glossary for LLM-based providers.
|
<Badge variant="outline" className="mb-4 border-primary/30 text-primary bg-primary/10">
|
||||||
</p>
|
<Brain className="h-3 w-3 mr-1" />
|
||||||
|
Context & Glossary
|
||||||
{/* LLM Provider Status */}
|
|
||||||
<div className="flex flex-wrap gap-2 mt-3">
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className={`${isOllamaConfigured ? 'border-green-500 text-green-400' : 'border-zinc-600 text-zinc-500'}`}
|
|
||||||
>
|
|
||||||
🤖 Ollama {isOllamaConfigured ? '✓' : '○'}
|
|
||||||
</Badge>
|
</Badge>
|
||||||
<Badge
|
<h1 className="text-4xl font-bold text-white mb-2">
|
||||||
variant="outline"
|
Context & Glossary
|
||||||
className={`${isOpenAIConfigured ? 'border-green-500 text-green-400' : 'border-zinc-600 text-zinc-500'}`}
|
</h1>
|
||||||
>
|
<p className="text-lg text-text-secondary">
|
||||||
🧠 OpenAI {isOpenAIConfigured ? '✓' : '○'}
|
Configure translation context and glossary for LLM-based providers
|
||||||
</Badge>
|
</p>
|
||||||
<Badge
|
|
||||||
variant="outline"
|
{/* LLM Provider Status */}
|
||||||
className={`${isWebLLMAvailable ? 'border-green-500 text-green-400' : 'border-zinc-600 text-zinc-500'}`}
|
<div className="flex flex-wrap gap-3 mt-4">
|
||||||
>
|
<Badge
|
||||||
💻 WebLLM {isWebLLMAvailable ? '✓' : '○'}
|
variant="outline"
|
||||||
</Badge>
|
className={cn(
|
||||||
</div>
|
"px-3 py-2",
|
||||||
</div>
|
isOllamaConfigured
|
||||||
|
? "border-success/50 text-success bg-success/10"
|
||||||
{/* Info Banner */}
|
: "border-border-subtle text-text-tertiary bg-surface/50"
|
||||||
<div className="p-4 rounded-lg bg-teal-500/10 border border-teal-500/30">
|
)}
|
||||||
<p className="text-teal-400 text-sm flex items-center gap-2">
|
>
|
||||||
<Sparkles className="h-4 w-4" />
|
<div className="flex items-center gap-2">
|
||||||
<span>
|
{isOllamaConfigured && <CheckCircle className="h-4 w-4" />}
|
||||||
<strong>Context & Glossary</strong> settings apply to all LLM providers:
|
<span>🤖 Ollama</span>
|
||||||
<strong> Ollama</strong>, <strong>OpenAI</strong>, and <strong>WebLLM</strong>.
|
|
||||||
Use them to improve translation quality with domain-specific instructions.
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
||||||
{/* Left Column */}
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* System Prompt */}
|
|
||||||
<Card className="border-zinc-800 bg-zinc-900/50">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-white flex items-center gap-2">
|
|
||||||
<Brain className="h-5 w-5 text-teal-400" />
|
|
||||||
System Prompt
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Instructions for the LLM to follow during translation.
|
|
||||||
Works with Ollama, OpenAI, and WebLLM.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<Textarea
|
|
||||||
id="system-prompt"
|
|
||||||
value={localSettings.systemPrompt}
|
|
||||||
onChange={(e) =>
|
|
||||||
setLocalSettings({ ...localSettings, systemPrompt: e.target.value })
|
|
||||||
}
|
|
||||||
placeholder="Example: You are translating technical HVAC documents. Use precise engineering terminology. Maintain consistency with industry standards..."
|
|
||||||
className="bg-zinc-800 border-zinc-700 text-white placeholder:text-zinc-500 min-h-[200px] resize-y"
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-zinc-500">
|
|
||||||
💡 Tip: Include domain context, tone preferences, or specific terminology rules.
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Presets */}
|
|
||||||
<Card className="border-zinc-800 bg-zinc-900/50">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-white">Quick Presets</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Load pre-configured prompts & glossaries for common domains.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => handleApplyPreset("hvac")}
|
|
||||||
className="border-zinc-700 text-zinc-300 hover:bg-zinc-800 hover:text-teal-400 justify-start"
|
|
||||||
>
|
|
||||||
🔧 HVAC / Engineering
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => handleApplyPreset("it")}
|
|
||||||
className="border-zinc-700 text-zinc-300 hover:bg-zinc-800 hover:text-teal-400 justify-start"
|
|
||||||
>
|
|
||||||
💻 IT / Software
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => handleApplyPreset("legal")}
|
|
||||||
className="border-zinc-700 text-zinc-300 hover:bg-zinc-800 hover:text-teal-400 justify-start"
|
|
||||||
>
|
|
||||||
⚖️ Legal / Contracts
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => handleApplyPreset("medical")}
|
|
||||||
className="border-zinc-700 text-zinc-300 hover:bg-zinc-800 hover:text-teal-400 justify-start"
|
|
||||||
>
|
|
||||||
🏥 Medical / Healthcare
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
<Button
|
</Badge>
|
||||||
variant="ghost"
|
<Badge
|
||||||
onClick={handleClear}
|
variant="outline"
|
||||||
className="w-full mt-3 text-red-400 hover:text-red-300 hover:bg-red-500/10"
|
className={cn(
|
||||||
>
|
"px-3 py-2",
|
||||||
<Trash2 className="h-4 w-4 mr-2" />
|
isOpenAIConfigured
|
||||||
Clear All
|
? "border-success/50 text-success bg-success/10"
|
||||||
</Button>
|
: "border-border-subtle text-text-tertiary bg-surface/50"
|
||||||
</CardContent>
|
)}
|
||||||
</Card>
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{isOpenAIConfigured && <CheckCircle className="h-4 w-4" />}
|
||||||
|
<span>🧠 OpenAI</span>
|
||||||
|
</div>
|
||||||
|
</Badge>
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={cn(
|
||||||
|
"px-3 py-2",
|
||||||
|
isWebLLMAvailable
|
||||||
|
? "border-success/50 text-success bg-success/10"
|
||||||
|
: "border-border-subtle text-text-tertiary bg-surface/50"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{isWebLLMAvailable && <CheckCircle className="h-4 w-4" />}
|
||||||
|
<span>💻 WebLLM</span>
|
||||||
|
</div>
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right Column */}
|
{/* Info Banner */}
|
||||||
<div className="space-y-6">
|
<Card variant="gradient" className="mb-8 animate-fade-in-up">
|
||||||
{/* Glossary */}
|
<CardContent className="p-6">
|
||||||
<Card className="border-zinc-800 bg-zinc-900/50">
|
<div className="flex items-start gap-4">
|
||||||
<CardHeader>
|
<div className="p-2 rounded-lg bg-primary/20">
|
||||||
<CardTitle className="text-white flex items-center gap-2">
|
<Sparkles className="h-5 w-5 text-primary" />
|
||||||
<BookOpen className="h-5 w-5 text-teal-400" />
|
</div>
|
||||||
Technical Glossary
|
<div>
|
||||||
</CardTitle>
|
<h3 className="text-lg font-semibold text-white mb-2">
|
||||||
<CardDescription>
|
Context & Glossary Settings
|
||||||
Define specific term translations. Format: source=target (one per line).
|
</h3>
|
||||||
</CardDescription>
|
<p className="text-text-secondary leading-relaxed">
|
||||||
</CardHeader>
|
These settings apply to all LLM providers: <strong>Ollama</strong>, <strong>OpenAI</strong>, and <strong>WebLLM</strong>.
|
||||||
<CardContent className="space-y-4">
|
Use them to improve translation quality with domain-specific instructions and terminology.
|
||||||
<Textarea
|
</p>
|
||||||
id="glossary"
|
</div>
|
||||||
value={localSettings.glossary}
|
</div>
|
||||||
onChange={(e) =>
|
</CardContent>
|
||||||
setLocalSettings({ ...localSettings, glossary: e.target.value })
|
</Card>
|
||||||
}
|
|
||||||
placeholder="pression statique=static pressure récupérateur=heat recovery unit ventilo-connecteur=fan coil unit gaine=duct diffuseur=diffuser"
|
|
||||||
className="bg-zinc-800 border-zinc-700 text-white placeholder:text-zinc-500 min-h-[280px] resize-y font-mono text-sm"
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-zinc-500">
|
|
||||||
💡 The glossary is included in the system prompt to guide translations.
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Save Button */}
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 mb-8">
|
||||||
<div className="flex justify-end">
|
{/* Left Column - System Prompt */}
|
||||||
<Button
|
<div className="space-y-6">
|
||||||
onClick={handleSave}
|
<Card variant="elevated" className="animate-fade-in-up animation-delay-100">
|
||||||
disabled={isSaving}
|
<CardHeader>
|
||||||
className="bg-teal-600 hover:bg-teal-700 text-white px-8"
|
<div className="flex items-center gap-3">
|
||||||
>
|
<div className="p-2 rounded-lg bg-primary/20">
|
||||||
{isSaving ? (
|
<Brain className="h-5 w-5 text-primary" />
|
||||||
<>
|
</div>
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
<div>
|
||||||
Saving...
|
<CardTitle className="text-white">System Prompt</CardTitle>
|
||||||
</>
|
<CardDescription>
|
||||||
) : (
|
Instructions for LLM to follow during translation
|
||||||
<>
|
</CardDescription>
|
||||||
<Save className="mr-2 h-4 w-4" />
|
</div>
|
||||||
Save Settings
|
</div>
|
||||||
</>
|
</CardHeader>
|
||||||
)}
|
<CardContent className="space-y-4">
|
||||||
</Button>
|
<Textarea
|
||||||
|
id="system-prompt"
|
||||||
|
value={localSettings.systemPrompt}
|
||||||
|
onChange={(e) =>
|
||||||
|
setLocalSettings({ ...localSettings, systemPrompt: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder="Example: You are translating technical HVAC documents. Use precise engineering terminology. Maintain consistency with industry standards..."
|
||||||
|
className="bg-surface border-border-subtle text-white placeholder:text-text-tertiary min-h-[200px] resize-y focus:border-primary focus:ring-primary/20"
|
||||||
|
/>
|
||||||
|
<div className="p-4 rounded-lg bg-primary/10 border border-primary/30">
|
||||||
|
<p className="text-sm text-primary flex items-start gap-2">
|
||||||
|
<Zap className="h-4 w-4 mt-0.5 flex-shrink-0" />
|
||||||
|
<span>
|
||||||
|
<strong>Tip:</strong> Include domain context, tone preferences, or specific terminology rules for better translation accuracy.
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Quick Presets */}
|
||||||
|
<Card variant="elevated" className="animate-fade-in-up animation-delay-200">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 rounded-lg bg-accent/20">
|
||||||
|
<Zap className="h-5 w-5 text-accent" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-white">Quick Presets</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Load pre-configured prompts & glossaries for common domains
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => handleApplyPreset("hvac")}
|
||||||
|
className="border-border-subtle text-text-secondary hover:bg-surface-hover hover:text-primary justify-start h-auto p-4 group"
|
||||||
|
>
|
||||||
|
<div className="text-left">
|
||||||
|
<div className="text-lg mb-1">🔧</div>
|
||||||
|
<div className="font-medium">HVAC / Engineering</div>
|
||||||
|
<div className="text-xs text-text-tertiary">Technical terminology</div>
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => handleApplyPreset("it")}
|
||||||
|
className="border-border-subtle text-text-secondary hover:bg-surface-hover hover:text-primary justify-start h-auto p-4 group"
|
||||||
|
>
|
||||||
|
<div className="text-left">
|
||||||
|
<div className="text-lg mb-1">💻</div>
|
||||||
|
<div className="font-medium">IT / Software</div>
|
||||||
|
<div className="text-xs text-text-tertiary">Development terms</div>
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => handleApplyPreset("legal")}
|
||||||
|
className="border-border-subtle text-text-secondary hover:bg-surface-hover hover:text-primary justify-start h-auto p-4 group"
|
||||||
|
>
|
||||||
|
<div className="text-left">
|
||||||
|
<div className="text-lg mb-1">⚖️</div>
|
||||||
|
<div className="font-medium">Legal / Contracts</div>
|
||||||
|
<div className="text-xs text-text-tertiary">Legal terminology</div>
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => handleApplyPreset("medical")}
|
||||||
|
className="border-border-subtle text-text-secondary hover:bg-surface-hover hover:text-primary justify-start h-auto p-4 group"
|
||||||
|
>
|
||||||
|
<div className="text-left">
|
||||||
|
<div className="text-lg mb-1">🏥</div>
|
||||||
|
<div className="font-medium">Medical / Healthcare</div>
|
||||||
|
<div className="text-xs text-text-tertiary">Medical terms</div>
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={handleClear}
|
||||||
|
className="w-full text-destructive hover:text-destructive/80 hover:bg-destructive/10 group"
|
||||||
|
>
|
||||||
|
<Trash2 className="mr-2 h-4 w-4 transition-transform duration-200 group-hover:scale-110" />
|
||||||
|
Clear All
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Column - Glossary */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card variant="elevated" className="animate-fade-in-up animation-delay-300">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 rounded-lg bg-success/20">
|
||||||
|
<BookOpen className="h-5 w-5 text-success" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-white">Technical Glossary</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Define specific term translations. Format: source=target (one per line)
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<Textarea
|
||||||
|
id="glossary"
|
||||||
|
value={localSettings.glossary}
|
||||||
|
onChange={(e) =>
|
||||||
|
setLocalSettings({ ...localSettings, glossary: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder="pression statique=static pressure récupérateur=heat recovery unit ventilo-connecteur=fan coil unit gaine=duct diffuseur=diffuser"
|
||||||
|
className="bg-surface border-border-subtle text-white placeholder:text-text-tertiary min-h-[280px] resize-y font-mono text-sm focus:border-success focus:ring-success/20"
|
||||||
|
/>
|
||||||
|
<div className="p-4 rounded-lg bg-success/10 border border-success/30">
|
||||||
|
<p className="text-sm text-success flex items-start gap-2">
|
||||||
|
<Zap className="h-4 w-4 mt-0.5 flex-shrink-0" />
|
||||||
|
<span>
|
||||||
|
<strong>Pro Tip:</strong> The glossary is included in system prompt to guide translations and ensure consistent terminology.
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Usage Examples */}
|
||||||
|
<Card variant="glass" className="animate-fade-in-up animation-delay-400">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 rounded-lg bg-accent/20">
|
||||||
|
<AlertCircle className="h-5 w-5 text-accent" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-white">Usage Examples</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
See how context and glossary improve translations
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="p-4 rounded-lg bg-surface/50 border border-border-subtle">
|
||||||
|
<h4 className="font-medium text-white mb-2">Before (Generic Translation)</h4>
|
||||||
|
<p className="text-sm text-text-tertiary italic">
|
||||||
|
"The pressure in the duct should be maintained."
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 rounded-lg bg-success/10 border border-success/30">
|
||||||
|
<h4 className="font-medium text-white mb-2">After (With Context & Glossary)</h4>
|
||||||
|
<p className="text-sm text-success italic">
|
||||||
|
"La pression statique dans la gaine doit être maintenue."
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-text-tertiary">
|
||||||
|
<strong>Key improvements:</strong> Technical terms are correctly translated, context is preserved, and industry-standard terminology is used.
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Save Button */}
|
||||||
|
<div className="flex justify-end animate-fade-in-up animation-delay-500">
|
||||||
|
<Button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={isSaving}
|
||||||
|
size="lg"
|
||||||
|
className="bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90 text-white group"
|
||||||
|
>
|
||||||
|
{isSaving ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Saving...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Save className="mr-2 h-4 w-4 transition-transform duration-200 group-hover:scale-110" />
|
||||||
|
Save Settings
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -7,7 +7,7 @@ import { Label } from "@/components/ui/label";
|
|||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { useTranslationStore } from "@/lib/store";
|
import { useTranslationStore } from "@/lib/store";
|
||||||
import { languages } from "@/lib/api";
|
import { languages } from "@/lib/api";
|
||||||
import { Save, Loader2, Settings, Globe, Trash2 } from "lucide-react";
|
import { Save, Loader2, Settings, Globe, Trash2, ArrowRight, Shield, Zap, Database } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@ -15,6 +15,7 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
export default function GeneralSettingsPage() {
|
export default function GeneralSettingsPage() {
|
||||||
const { settings, updateSettings } = useTranslationStore();
|
const { settings, updateSettings } = useTranslationStore();
|
||||||
@ -58,189 +59,335 @@ export default function GeneralSettingsPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="min-h-screen bg-gradient-to-b from-surface via-surface-elevated to-background">
|
||||||
<div>
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
<h1 className="text-3xl font-bold text-white">General Settings</h1>
|
{/* Header */}
|
||||||
<p className="text-zinc-400 mt-1">
|
<div className="mb-8">
|
||||||
Configure general application settings and preferences.
|
<Badge variant="outline" className="mb-4 border-primary/30 text-primary bg-primary/10">
|
||||||
</p>
|
<Settings className="h-3 w-3 mr-1" />
|
||||||
</div>
|
Settings
|
||||||
|
</Badge>
|
||||||
|
<h1 className="text-4xl font-bold text-white mb-2">
|
||||||
|
General Settings
|
||||||
|
</h1>
|
||||||
|
<p className="text-lg text-text-secondary">
|
||||||
|
Configure general application settings and preferences
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Card className="border-zinc-800 bg-zinc-900/50">
|
{/* Quick Actions */}
|
||||||
<CardHeader>
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
||||||
<div className="flex items-center gap-3">
|
<Card variant="elevated" className="group hover:scale-105 transition-all duration-300 animate-fade-in-up">
|
||||||
<Settings className="h-6 w-6 text-teal-400" />
|
<Link href="/settings/services" className="block">
|
||||||
<div>
|
<CardContent className="p-6">
|
||||||
<CardTitle className="text-white">Application Settings</CardTitle>
|
<div className="flex items-center gap-4 mb-4">
|
||||||
<CardDescription>
|
<div className="p-3 rounded-xl bg-primary/20 group-hover:bg-primary/30 transition-colors duration-300">
|
||||||
General configuration options
|
<Zap className="h-6 w-6 text-primary" />
|
||||||
</CardDescription>
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-white group-hover:text-primary transition-colors duration-300">
|
||||||
|
Translation Services
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-text-tertiary">Configure providers</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center text-primary">
|
||||||
|
<span className="text-sm font-medium">Manage providers</span>
|
||||||
|
<ArrowRight className="h-4 w-4 ml-2 transition-transform duration-200 group-hover:translate-x-1" />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Link>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card variant="elevated" className="group hover:scale-105 transition-all duration-300 animate-fade-in-up animation-delay-100">
|
||||||
|
<Link href="/settings/context" className="block">
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<div className="flex items-center gap-4 mb-4">
|
||||||
|
<div className="p-3 rounded-xl bg-accent/20 group-hover:bg-accent/30 transition-colors duration-300">
|
||||||
|
<Globe className="h-6 w-6 text-accent" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-white group-hover:text-accent transition-colors duration-300">
|
||||||
|
Context & Glossary
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-text-tertiary">Domain-specific settings</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center text-accent">
|
||||||
|
<span className="text-sm font-medium">Configure context</span>
|
||||||
|
<ArrowRight className="h-4 w-4 ml-2 transition-transform duration-200 group-hover:translate-x-1" />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Link>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card variant="elevated" className="group hover:scale-105 transition-all duration-300 animate-fade-in-up animation-delay-200">
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<div className="flex items-center gap-4 mb-4">
|
||||||
|
<div className="p-3 rounded-xl bg-success/20 group-hover:bg-success/30 transition-colors duration-300">
|
||||||
|
<Shield className="h-6 w-6 text-success" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-white group-hover:text-success transition-colors duration-300">
|
||||||
|
Privacy & Security
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-text-tertiary">Data protection</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center text-success">
|
||||||
|
<span className="text-sm font-medium">Coming soon</span>
|
||||||
|
<ArrowRight className="h-4 w-4 ml-2 transition-transform duration-200 group-hover:translate-x-1" />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Application Settings */}
|
||||||
|
<Card variant="elevated" className="mb-8 animate-fade-in-up animation-delay-300">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 rounded-lg bg-primary/20">
|
||||||
|
<Settings className="h-5 w-5 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-white">Application Settings</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
General configuration options
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</CardHeader>
|
||||||
</CardHeader>
|
<CardContent className="space-y-6">
|
||||||
<CardContent className="space-y-6">
|
<div className="space-y-3">
|
||||||
|
<Label htmlFor="default-language" className="text-text-secondary font-medium">
|
||||||
|
Default Target Language
|
||||||
|
</Label>
|
||||||
|
<Select value={defaultLanguage} onValueChange={setDefaultLanguage}>
|
||||||
|
<SelectTrigger className="bg-surface border-border-subtle text-white focus:border-primary focus:ring-primary/20">
|
||||||
|
<SelectValue placeholder="Select default language" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="bg-surface-elevated border-border-subtle max-h-[300px]">
|
||||||
|
{languages.map((lang) => (
|
||||||
|
<SelectItem
|
||||||
|
key={lang.code}
|
||||||
|
value={lang.code}
|
||||||
|
className="text-white hover:bg-surface-hover focus:bg-primary/20 focus:text-primary"
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<span>{lang.flag}</span>
|
||||||
|
<span>{lang.name}</span>
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-sm text-text-tertiary">
|
||||||
|
This language will be pre-selected when translating documents
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Supported Formats */}
|
||||||
|
<Card variant="elevated" className="mb-8 animate-fade-in-up animation-delay-400">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 rounded-lg bg-accent/20">
|
||||||
|
<Globe className="h-5 w-5 text-accent" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-white">Supported Formats</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Document types that can be translated
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
<Card variant="glass" className="group hover:scale-105 transition-all duration-300">
|
||||||
|
<CardContent className="p-6 text-center">
|
||||||
|
<div className="text-4xl mb-3 group-hover:scale-110 transition-transform duration-300">📊</div>
|
||||||
|
<h3 className="font-semibold text-white mb-2">Excel</h3>
|
||||||
|
<p className="text-sm text-text-tertiary mb-4">.xlsx, .xls</p>
|
||||||
|
<div className="flex flex-wrap gap-2 justify-center">
|
||||||
|
<Badge variant="outline" className="border-success/50 text-success bg-success/10 text-xs">
|
||||||
|
Formulas
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="outline" className="border-primary/50 text-primary bg-primary/10 text-xs">
|
||||||
|
Styles
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="outline" className="border-accent/50 text-accent bg-accent/10 text-xs">
|
||||||
|
Images
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card variant="glass" className="group hover:scale-105 transition-all duration-300">
|
||||||
|
<CardContent className="p-6 text-center">
|
||||||
|
<div className="text-4xl mb-3 group-hover:scale-110 transition-transform duration-300">📝</div>
|
||||||
|
<h3 className="font-semibold text-white mb-2">Word</h3>
|
||||||
|
<p className="text-sm text-text-tertiary mb-4">.docx, .doc</p>
|
||||||
|
<div className="flex flex-wrap gap-2 justify-center">
|
||||||
|
<Badge variant="outline" className="border-success/50 text-success bg-success/10 text-xs">
|
||||||
|
Headers
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="outline" className="border-primary/50 text-primary bg-primary/10 text-xs">
|
||||||
|
Tables
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="outline" className="border-accent/50 text-accent bg-accent/10 text-xs">
|
||||||
|
Images
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card variant="glass" className="group hover:scale-105 transition-all duration-300">
|
||||||
|
<CardContent className="p-6 text-center">
|
||||||
|
<div className="text-4xl mb-3 group-hover:scale-110 transition-transform duration-300">📽️</div>
|
||||||
|
<h3 className="font-semibold text-white mb-2">PowerPoint</h3>
|
||||||
|
<p className="text-sm text-text-tertiary mb-4">.pptx, .ppt</p>
|
||||||
|
<div className="flex flex-wrap gap-2 justify-center">
|
||||||
|
<Badge variant="outline" className="border-success/50 text-success bg-success/10 text-xs">
|
||||||
|
Slides
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="outline" className="border-primary/50 text-primary bg-primary/10 text-xs">
|
||||||
|
Notes
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="outline" className="border-accent/50 text-accent bg-accent/10 text-xs">
|
||||||
|
Images
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* System Information */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
||||||
|
<Card variant="elevated" className="animate-fade-in-up animation-delay-500">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 rounded-lg bg-success/20">
|
||||||
|
<Database className="h-5 w-5 text-success" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-white">API Information</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Backend server connection details
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between p-3 rounded-lg bg-surface/50 hover:bg-surface transition-colors duration-200">
|
||||||
|
<span className="text-text-tertiary">API Endpoint</span>
|
||||||
|
<code className="text-primary text-sm font-mono bg-surface px-2 py-1 rounded">http://localhost:8000</code>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between p-3 rounded-lg bg-surface/50 hover:bg-surface transition-colors duration-200">
|
||||||
|
<span className="text-text-tertiary">Health Check</span>
|
||||||
|
<code className="text-primary text-sm font-mono bg-surface px-2 py-1 rounded">/health</code>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between p-3 rounded-lg bg-surface/50 hover:bg-surface transition-colors duration-200">
|
||||||
|
<span className="text-text-tertiary">Translate Endpoint</span>
|
||||||
|
<code className="text-primary text-sm font-mono bg-surface px-2 py-1 rounded">/translate</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card variant="elevated" className="animate-fade-in-up animation-delay-600">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 rounded-lg bg-warning/20">
|
||||||
|
<Shield className="h-5 w-5 text-warning" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-white">System Status</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Application health and performance
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between p-3 rounded-lg bg-surface/50">
|
||||||
|
<span className="text-text-tertiary">Connection Status</span>
|
||||||
|
<Badge variant="outline" className="border-success/50 text-success bg-success/10">
|
||||||
|
<div className="w-2 h-2 bg-success rounded-full mr-2 animate-pulse"></div>
|
||||||
|
Connected
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between p-3 rounded-lg bg-surface/50">
|
||||||
|
<span className="text-text-tertiary">Last Sync</span>
|
||||||
|
<span className="text-sm text-text-secondary">Just now</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between p-3 rounded-lg bg-surface/50">
|
||||||
|
<span className="text-text-tertiary">Version</span>
|
||||||
|
<span className="text-sm text-text-secondary">v2.0.0</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="flex flex-col sm:flex-row gap-4 justify-between items-start sm:items-center animate-fade-in-up animation-delay-700">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="default-language" className="text-zinc-300">
|
<p className="text-sm text-text-tertiary">
|
||||||
Default Target Language
|
Need help with settings? Check our documentation.
|
||||||
</Label>
|
|
||||||
<Select value={defaultLanguage} onValueChange={setDefaultLanguage}>
|
|
||||||
<SelectTrigger className="bg-zinc-800 border-zinc-700 text-white">
|
|
||||||
<SelectValue placeholder="Select default language" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent className="bg-zinc-800 border-zinc-700 max-h-[300px]">
|
|
||||||
{languages.map((lang) => (
|
|
||||||
<SelectItem
|
|
||||||
key={lang.code}
|
|
||||||
value={lang.code}
|
|
||||||
className="text-white hover:bg-zinc-700"
|
|
||||||
>
|
|
||||||
<span className="flex items-center gap-2">
|
|
||||||
<span>{lang.flag}</span>
|
|
||||||
<span>{lang.name}</span>
|
|
||||||
</span>
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<p className="text-xs text-zinc-500">
|
|
||||||
This language will be pre-selected when translating documents
|
|
||||||
</p>
|
</p>
|
||||||
|
<Button variant="glass" size="sm" className="group">
|
||||||
|
<Settings className="h-4 w-4 mr-2 transition-transform duration-200 group-hover:rotate-90" />
|
||||||
|
View Documentation
|
||||||
|
<ArrowRight className="h-4 w-4 ml-2 transition-transform duration-200 group-hover:translate-x-1" />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
<div className="flex gap-3">
|
||||||
|
<Button
|
||||||
{/* Supported Formats */}
|
onClick={handleClearCache}
|
||||||
<Card className="border-zinc-800 bg-zinc-900/50">
|
disabled={isClearing}
|
||||||
<CardHeader>
|
variant="outline"
|
||||||
<div className="flex items-center gap-3">
|
size="lg"
|
||||||
<Globe className="h-6 w-6 text-teal-400" />
|
className="border-destructive/50 text-destructive hover:bg-destructive/10 hover:border-destructive group"
|
||||||
<div>
|
>
|
||||||
<CardTitle className="text-white">Supported Formats</CardTitle>
|
{isClearing ? (
|
||||||
<CardDescription>
|
<>
|
||||||
Document types that can be translated
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
</CardDescription>
|
Clearing...
|
||||||
</div>
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Trash2 className="mr-2 h-4 w-4 transition-transform duration-200 group-hover:scale-110" />
|
||||||
|
Clear Cache
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={isSaving}
|
||||||
|
size="lg"
|
||||||
|
className="bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90 text-white group"
|
||||||
|
>
|
||||||
|
{isSaving ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Saving...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Save className="mr-2 h-4 w-4 transition-transform duration-200 group-hover:scale-110" />
|
||||||
|
Save Settings
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</div>
|
||||||
<CardContent>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
||||||
<div className="p-4 rounded-lg border border-zinc-800 bg-zinc-800/30">
|
|
||||||
<div className="text-2xl mb-2">📊</div>
|
|
||||||
<h3 className="font-medium text-white">Excel</h3>
|
|
||||||
<p className="text-xs text-zinc-500 mt-1">.xlsx, .xls</p>
|
|
||||||
<div className="flex flex-wrap gap-1 mt-2">
|
|
||||||
<Badge variant="outline" className="border-zinc-700 text-zinc-400 text-xs">
|
|
||||||
Formulas
|
|
||||||
</Badge>
|
|
||||||
<Badge variant="outline" className="border-zinc-700 text-zinc-400 text-xs">
|
|
||||||
Styles
|
|
||||||
</Badge>
|
|
||||||
<Badge variant="outline" className="border-zinc-700 text-zinc-400 text-xs">
|
|
||||||
Images
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-4 rounded-lg border border-zinc-800 bg-zinc-800/30">
|
|
||||||
<div className="text-2xl mb-2">📝</div>
|
|
||||||
<h3 className="font-medium text-white">Word</h3>
|
|
||||||
<p className="text-xs text-zinc-500 mt-1">.docx, .doc</p>
|
|
||||||
<div className="flex flex-wrap gap-1 mt-2">
|
|
||||||
<Badge variant="outline" className="border-zinc-700 text-zinc-400 text-xs">
|
|
||||||
Headers
|
|
||||||
</Badge>
|
|
||||||
<Badge variant="outline" className="border-zinc-700 text-zinc-400 text-xs">
|
|
||||||
Tables
|
|
||||||
</Badge>
|
|
||||||
<Badge variant="outline" className="border-zinc-700 text-zinc-400 text-xs">
|
|
||||||
Images
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-4 rounded-lg border border-zinc-800 bg-zinc-800/30">
|
|
||||||
<div className="text-2xl mb-2">📽️</div>
|
|
||||||
<h3 className="font-medium text-white">PowerPoint</h3>
|
|
||||||
<p className="text-xs text-zinc-500 mt-1">.pptx, .ppt</p>
|
|
||||||
<div className="flex flex-wrap gap-1 mt-2">
|
|
||||||
<Badge variant="outline" className="border-zinc-700 text-zinc-400 text-xs">
|
|
||||||
Slides
|
|
||||||
</Badge>
|
|
||||||
<Badge variant="outline" className="border-zinc-700 text-zinc-400 text-xs">
|
|
||||||
Notes
|
|
||||||
</Badge>
|
|
||||||
<Badge variant="outline" className="border-zinc-700 text-zinc-400 text-xs">
|
|
||||||
Images
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* API Status */}
|
|
||||||
<Card className="border-zinc-800 bg-zinc-900/50">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-white">API Information</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Backend server connection details
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="flex items-center justify-between p-3 rounded-lg bg-zinc-800/50">
|
|
||||||
<span className="text-zinc-400">API Endpoint</span>
|
|
||||||
<code className="text-teal-400 text-sm">http://localhost:8000</code>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between p-3 rounded-lg bg-zinc-800/50">
|
|
||||||
<span className="text-zinc-400">Health Check</span>
|
|
||||||
<code className="text-teal-400 text-sm">/health</code>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between p-3 rounded-lg bg-zinc-800/50">
|
|
||||||
<span className="text-zinc-400">Translate Endpoint</span>
|
|
||||||
<code className="text-teal-400 text-sm">/translate</code>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Save Button */}
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<Button
|
|
||||||
onClick={handleClearCache}
|
|
||||||
disabled={isClearing}
|
|
||||||
variant="destructive"
|
|
||||||
className="bg-red-600 hover:bg-red-700 text-white px-6"
|
|
||||||
>
|
|
||||||
{isClearing ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
||||||
Clearing...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Trash2 className="mr-2 h-4 w-4" />
|
|
||||||
Clear Cache
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={handleSave}
|
|
||||||
disabled={isSaving}
|
|
||||||
className="bg-teal-600 hover:bg-teal-700 text-white px-8"
|
|
||||||
>
|
|
||||||
{isSaving ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
||||||
Saving...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Save className="mr-2 h-4 w-4" />
|
|
||||||
Save Settings
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -1,8 +1,27 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useCallback, useEffect } from "react";
|
import { useState, useCallback, useEffect, useRef } from "react";
|
||||||
import { useDropzone } from "react-dropzone";
|
import { useDropzone } from "react-dropzone";
|
||||||
import { Upload, FileText, FileSpreadsheet, Presentation, X, Download, Loader2, Cpu, AlertTriangle, Brain } from "lucide-react";
|
import {
|
||||||
|
Upload,
|
||||||
|
FileText,
|
||||||
|
FileSpreadsheet,
|
||||||
|
Presentation,
|
||||||
|
X,
|
||||||
|
Download,
|
||||||
|
Loader2,
|
||||||
|
Cpu,
|
||||||
|
AlertTriangle,
|
||||||
|
Brain,
|
||||||
|
CheckCircle,
|
||||||
|
File,
|
||||||
|
Zap,
|
||||||
|
Shield,
|
||||||
|
Eye,
|
||||||
|
Trash2,
|
||||||
|
Copy,
|
||||||
|
ExternalLink
|
||||||
|
} from "lucide-react";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
@ -11,6 +30,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
|||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
import { useTranslationStore, openaiModels, openrouterModels } from "@/lib/store";
|
import { useTranslationStore, openaiModels, openrouterModels } from "@/lib/store";
|
||||||
import { translateDocument, languages, providers, extractTextsFromDocument, reconstructDocument, TranslatedText } from "@/lib/api";
|
import { translateDocument, languages, providers, extractTextsFromDocument, reconstructDocument, TranslatedText } from "@/lib/api";
|
||||||
import { useWebLLM } from "@/lib/webllm";
|
import { useWebLLM } from "@/lib/webllm";
|
||||||
@ -27,6 +47,141 @@ const fileIcons: Record<string, React.ElementType> = {
|
|||||||
|
|
||||||
type ProviderType = "google" | "ollama" | "deepl" | "libre" | "webllm" | "openai" | "openrouter";
|
type ProviderType = "google" | "ollama" | "deepl" | "libre" | "webllm" | "openai" | "openrouter";
|
||||||
|
|
||||||
|
interface FilePreviewProps {
|
||||||
|
file: File;
|
||||||
|
onRemove: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FilePreview = ({ file, onRemove }: FilePreviewProps) => {
|
||||||
|
const [preview, setPreview] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const generatePreview = async () => {
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
if (file.type.startsWith('image/')) {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (e) => {
|
||||||
|
setPreview(e.target?.result as string);
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
} else if (file.type === 'application/pdf') {
|
||||||
|
setPreview('/pdf-preview.png'); // Placeholder
|
||||||
|
} else {
|
||||||
|
// Generate text preview for documents
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (e) => {
|
||||||
|
const text = e.target?.result as string;
|
||||||
|
setPreview(text.substring(0, 200) + (text.length > 200 ? '...' : ''));
|
||||||
|
};
|
||||||
|
reader.readAsText(file);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Preview generation failed:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
generatePreview();
|
||||||
|
}, [file]);
|
||||||
|
|
||||||
|
const getFileExtension = (filename: string) => {
|
||||||
|
return filename.split(".").pop()?.toLowerCase() || "";
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatFileSize = (bytes: number) => {
|
||||||
|
if (bytes < 1024) return bytes + " B";
|
||||||
|
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB";
|
||||||
|
return (bytes / (1024 * 1024)).toFixed(1) + " MB";
|
||||||
|
};
|
||||||
|
|
||||||
|
const FileIcon = fileIcons[getFileExtension(file.name)] || FileText;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card variant="elevated" className="overflow-hidden group">
|
||||||
|
<CardContent className="p-0">
|
||||||
|
{/* File Header */}
|
||||||
|
<div className="flex items-center justify-between p-4 border-b border-border-subtle bg-surface/50">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-12 h-12 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||||
|
<FileIcon className="w-6 h-6 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-foreground truncate max-w-xs">
|
||||||
|
{file.name}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-text-tertiary">
|
||||||
|
{formatFileSize(file.size)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant="outline" size="sm">
|
||||||
|
{getFileExtension(file.name).toUpperCase()}
|
||||||
|
</Badge>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
onClick={onRemove}
|
||||||
|
className="text-text-tertiary hover:text-destructive"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* File Preview */}
|
||||||
|
<div className="relative h-48 bg-surface/30">
|
||||||
|
{loading ? (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||||
|
</div>
|
||||||
|
) : preview ? (
|
||||||
|
<div className="p-4 h-full overflow-hidden">
|
||||||
|
{file.type.startsWith('image/') ? (
|
||||||
|
<img
|
||||||
|
src={preview}
|
||||||
|
alt="Preview"
|
||||||
|
className="w-full h-full object-contain rounded"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="text-sm text-text-secondary font-mono whitespace-pre-wrap break-all">
|
||||||
|
{preview}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<File className="h-12 w-12 text-border" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* File Actions */}
|
||||||
|
<div className="flex items-center justify-between p-4 border-t border-border-subtle">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-text-tertiary">
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
Preview
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="ghost" size="icon-sm">
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="icon-sm">
|
||||||
|
<ExternalLink className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export function FileUploader() {
|
export function FileUploader() {
|
||||||
const { settings, isTranslating, progress, setTranslating, setProgress } = useTranslationStore();
|
const { settings, isTranslating, progress, setTranslating, setProgress } = useTranslationStore();
|
||||||
const webllm = useWebLLM();
|
const webllm = useWebLLM();
|
||||||
@ -38,6 +193,8 @@ export function FileUploader() {
|
|||||||
const [downloadUrl, setDownloadUrl] = useState<string | null>(null);
|
const [downloadUrl, setDownloadUrl] = useState<string | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [translationStatus, setTranslationStatus] = useState<string>("");
|
const [translationStatus, setTranslationStatus] = useState<string>("");
|
||||||
|
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
// Sync with store settings when they change
|
// Sync with store settings when they change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -67,21 +224,6 @@ export function FileUploader() {
|
|||||||
multiple: false,
|
multiple: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const getFileExtension = (filename: string) => {
|
|
||||||
return filename.split(".").pop()?.toLowerCase() || "";
|
|
||||||
};
|
|
||||||
|
|
||||||
const getFileIcon = (filename: string) => {
|
|
||||||
const ext = getFileExtension(filename);
|
|
||||||
return fileIcons[ext] || FileText;
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatFileSize = (bytes: number) => {
|
|
||||||
if (bytes < 1024) return bytes + " B";
|
|
||||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB";
|
|
||||||
return (bytes / (1024 * 1024)).toFixed(1) + " MB";
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTranslate = async () => {
|
const handleTranslate = async () => {
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
|
|
||||||
@ -174,7 +316,7 @@ export function FileUploader() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Update progress (10% for extraction, 80% for translation, 10% for reconstruction)
|
// Update progress (10% for extraction, 80% for translation, 10% for reconstruction)
|
||||||
const translationProgress = 10 + (80 * (i + 1) / totalTexts);
|
const translationProgress = 10 + (80 * (i + 1)) / totalTexts;
|
||||||
setProgress(translationProgress);
|
setProgress(translationProgress);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -249,301 +391,254 @@ export function FileUploader() {
|
|||||||
document.body.removeChild(a);
|
document.body.removeChild(a);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getFileExtension = (filename: string) => {
|
||||||
|
return filename.split(".").pop()?.toLowerCase() || "";
|
||||||
|
};
|
||||||
|
|
||||||
const removeFile = () => {
|
const removeFile = () => {
|
||||||
setFile(null);
|
setFile(null);
|
||||||
setDownloadUrl(null);
|
setDownloadUrl(null);
|
||||||
setError(null);
|
setError(null);
|
||||||
setProgress(0);
|
setProgress(0);
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = '';
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const FileIcon = file ? getFileIcon(file.name) : FileText;
|
const FileIcon = file ? fileIcons[getFileExtension(file.name)] : FileText;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-8">
|
||||||
{/* File Drop Zone */}
|
{/* Enhanced File Drop Zone */}
|
||||||
<Card className="border-zinc-800 bg-zinc-900/50">
|
<Card variant="elevated" className="overflow-hidden">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-white">Upload Document</CardTitle>
|
<CardTitle className="flex items-center gap-3">
|
||||||
|
<Upload className="h-5 w-5 text-primary" />
|
||||||
|
Upload Document
|
||||||
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Drag and drop or click to select a file (Excel, Word, PowerPoint)
|
Drag and drop or click to select a file (Excel, Word, PowerPoint)
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="p-6">
|
||||||
{!file ? (
|
{!file ? (
|
||||||
<div
|
<div
|
||||||
{...getRootProps()}
|
{...getRootProps()}
|
||||||
className={cn(
|
className={cn(
|
||||||
"border-2 border-dashed rounded-xl p-12 text-center cursor-pointer transition-all",
|
"relative border-2 border-dashed rounded-xl p-12 text-center cursor-pointer transition-all duration-300",
|
||||||
isDragActive
|
isDragActive
|
||||||
? "border-teal-500 bg-teal-500/10"
|
? "border-primary bg-primary/5 scale-[1.02]"
|
||||||
: "border-zinc-700 hover:border-zinc-600 hover:bg-zinc-800/50"
|
: "border-border-subtle hover:border-border hover:bg-surface/50"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<input {...getInputProps()} />
|
<input {...getInputProps()} ref={fileInputRef} className="hidden" />
|
||||||
<Upload className="h-12 w-12 mx-auto mb-4 text-zinc-500" />
|
|
||||||
<p className="text-zinc-400 mb-2">
|
{/* Upload Icon with animation */}
|
||||||
{isDragActive
|
<div className={cn(
|
||||||
? "Drop the file here..."
|
"w-16 h-16 mx-auto mb-4 rounded-2xl bg-primary/10 flex items-center justify-center transition-all duration-300",
|
||||||
: "Drag & drop a document here, or click to select"}
|
isDragActive ? "scale-110 bg-primary/20" : ""
|
||||||
</p>
|
)}>
|
||||||
<p className="text-xs text-zinc-600">
|
<Upload className={cn(
|
||||||
Supports: .xlsx, .docx, .pptx
|
"w-8 h-8 text-primary transition-transform duration-300",
|
||||||
</p>
|
isDragActive ? "scale-110" : ""
|
||||||
</div>
|
)} />
|
||||||
) : (
|
|
||||||
<div className="flex items-center gap-4 p-4 bg-zinc-800/50 rounded-lg">
|
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-zinc-700">
|
|
||||||
<FileIcon className="h-6 w-6 text-teal-400" />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<p className="text-sm font-medium text-white truncate">
|
|
||||||
{file.name}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-zinc-500">
|
|
||||||
{formatFileSize(file.size)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Badge variant="outline" className="border-zinc-700 text-zinc-400">
|
|
||||||
{getFileExtension(file.name).toUpperCase()}
|
|
||||||
</Badge>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={removeFile}
|
|
||||||
className="text-zinc-500 hover:text-red-400"
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Translation Options */}
|
|
||||||
<Card className="border-zinc-800 bg-zinc-900/50">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-white">Translation Options</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Configure your translation settings
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-6">
|
|
||||||
{/* Target Language */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="language" className="text-zinc-300">Target Language</Label>
|
|
||||||
<Select value={targetLanguage} onValueChange={setTargetLanguage}>
|
|
||||||
<SelectTrigger id="language" className="bg-zinc-800 border-zinc-700 text-white">
|
|
||||||
<SelectValue placeholder="Select language" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent className="bg-zinc-800 border-zinc-700 max-h-80">
|
|
||||||
{languages.map((lang) => (
|
|
||||||
<SelectItem
|
|
||||||
key={lang.code}
|
|
||||||
value={lang.code}
|
|
||||||
className="text-white hover:bg-zinc-700"
|
|
||||||
>
|
|
||||||
<span className="flex items-center gap-2">
|
|
||||||
<span>{lang.flag}</span>
|
|
||||||
<span>{lang.name}</span>
|
|
||||||
</span>
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Provider Selection */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="provider" className="text-zinc-300">Translation Provider</Label>
|
|
||||||
<Select value={provider} onValueChange={(v) => setProvider(v as ProviderType)}>
|
|
||||||
<SelectTrigger id="provider" className="bg-zinc-800 border-zinc-700 text-white">
|
|
||||||
<SelectValue placeholder="Select provider" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent className="bg-zinc-800 border-zinc-700">
|
|
||||||
{providers.map((p) => (
|
|
||||||
<SelectItem
|
|
||||||
key={p.id}
|
|
||||||
value={p.id}
|
|
||||||
className="text-white hover:bg-zinc-700"
|
|
||||||
>
|
|
||||||
<span className="flex items-center gap-2">
|
|
||||||
<span>{p.icon}</span>
|
|
||||||
<span>{p.name}</span>
|
|
||||||
</span>
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* OpenAI Model Selection */}
|
|
||||||
{provider === "openai" && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="openai-model" className="text-zinc-300">OpenAI Model</Label>
|
|
||||||
<Select
|
|
||||||
value={settings.openaiModel}
|
|
||||||
onValueChange={(v) => useTranslationStore.getState().updateSettings({ openaiModel: v })}
|
|
||||||
>
|
|
||||||
<SelectTrigger id="openai-model" className="bg-zinc-800 border-zinc-700 text-white">
|
|
||||||
<SelectValue placeholder="Select model" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent className="bg-zinc-800 border-zinc-700">
|
|
||||||
{openaiModels.map((m) => (
|
|
||||||
<SelectItem
|
|
||||||
key={m.id}
|
|
||||||
value={m.id}
|
|
||||||
className="text-white hover:bg-zinc-700"
|
|
||||||
>
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span>{m.name}</span>
|
|
||||||
<span className="text-xs text-zinc-500">{m.description}</span>
|
|
||||||
</div>
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
{!settings.openaiApiKey && (
|
|
||||||
<p className="text-xs text-amber-400">⚠️ API key required in Settings</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* OpenRouter Model Selection */}
|
|
||||||
{provider === "openrouter" && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="openrouter-model" className="text-zinc-300">OpenRouter Model</Label>
|
|
||||||
<Select
|
|
||||||
value={settings.openrouterModel}
|
|
||||||
onValueChange={(v) => useTranslationStore.getState().updateSettings({ openrouterModel: v })}
|
|
||||||
>
|
|
||||||
<SelectTrigger id="openrouter-model" className="bg-zinc-800 border-zinc-700 text-white">
|
|
||||||
<SelectValue placeholder="Select model" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent className="bg-zinc-800 border-zinc-700">
|
|
||||||
{openrouterModels.map((m) => (
|
|
||||||
<SelectItem
|
|
||||||
key={m.id}
|
|
||||||
value={m.id}
|
|
||||||
className="text-white hover:bg-zinc-700"
|
|
||||||
>
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span>{m.name}</span>
|
|
||||||
<span className="text-xs text-zinc-500">{m.description}</span>
|
|
||||||
</div>
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
{!settings.openrouterApiKey && (
|
|
||||||
<p className="text-xs text-amber-400">⚠️ API key required in Settings</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Context Section - Only for LLM providers */}
|
|
||||||
{(provider === "openai" || provider === "openrouter" || provider === "ollama") && (
|
|
||||||
<div className="space-y-4 p-4 bg-zinc-800/50 rounded-lg border border-zinc-700">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<Label className="text-zinc-300 flex items-center gap-2">
|
|
||||||
<Brain className="h-4 w-4 text-teal-400" />
|
|
||||||
Translation Context
|
|
||||||
</Label>
|
|
||||||
<Badge variant="outline" className="border-teal-500/50 text-teal-400 text-xs">
|
|
||||||
LLM Only
|
|
||||||
</Badge>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* System Prompt */}
|
<p className="text-lg font-medium text-foreground mb-2">
|
||||||
<div className="space-y-2">
|
{isDragActive
|
||||||
<Label htmlFor="system-prompt" className="text-zinc-400 text-sm">Instructions for the AI</Label>
|
? "Drop your file here..."
|
||||||
<Textarea
|
: "Drag & drop your document here"}
|
||||||
id="system-prompt"
|
</p>
|
||||||
value={settings.systemPrompt}
|
<p className="text-sm text-text-tertiary mb-6">
|
||||||
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => useTranslationStore.getState().updateSettings({ systemPrompt: e.target.value })}
|
or click to browse
|
||||||
placeholder="E.g., You are translating technical HVAC documentation. Keep unit measurements unchanged. Use formal language..."
|
</p>
|
||||||
className="bg-zinc-900 border-zinc-700 text-white placeholder:text-zinc-600 min-h-[80px] text-sm"
|
|
||||||
/>
|
{/* Supported formats */}
|
||||||
</div>
|
<div className="flex flex-wrap justify-center gap-3">
|
||||||
|
{[
|
||||||
{/* Glossary */}
|
{ ext: "xlsx", name: "Excel", icon: FileSpreadsheet, color: "text-green-400" },
|
||||||
<div className="space-y-2">
|
{ ext: "docx", name: "Word", icon: FileText, color: "text-blue-400" },
|
||||||
<Label htmlFor="glossary" className="text-zinc-400 text-sm">Glossary (term=translation, one per line)</Label>
|
{ ext: "pptx", name: "PowerPoint", icon: Presentation, color: "text-orange-400" },
|
||||||
<Textarea
|
].map((format) => (
|
||||||
id="glossary"
|
<div key={format.ext} className="flex items-center gap-2 px-3 py-2 rounded-lg bg-surface border border-border-subtle">
|
||||||
value={settings.glossary}
|
<format.icon className={cn("w-4 h-4", format.color)} />
|
||||||
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => useTranslationStore.getState().updateSettings({ glossary: e.target.value })}
|
<span className="text-sm text-text-secondary">{format.name}</span>
|
||||||
placeholder="compressor=compresseur evaporator=évaporateur condenser=condenseur"
|
<span className="text-xs text-text-tertiary">.{format.ext}</span>
|
||||||
className="bg-zinc-900 border-zinc-700 text-white placeholder:text-zinc-600 min-h-[80px] text-sm font-mono"
|
</div>
|
||||||
/>
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : (
|
||||||
|
<FilePreview file={file} onRemove={removeFile} />
|
||||||
{/* Translate Button */}
|
|
||||||
<Button
|
|
||||||
onClick={handleTranslate}
|
|
||||||
disabled={!file || isTranslating}
|
|
||||||
className="w-full bg-teal-600 hover:bg-teal-700 text-white h-12"
|
|
||||||
>
|
|
||||||
{isTranslating ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
||||||
Translating...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Upload className="mr-2 h-4 w-4" />
|
|
||||||
Translate Document
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{/* Progress Bar */}
|
|
||||||
{isTranslating && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex justify-between text-sm">
|
|
||||||
<span className="text-zinc-400">
|
|
||||||
{translationStatus || "Processing..."}
|
|
||||||
</span>
|
|
||||||
<span className="text-teal-400">{Math.round(progress)}%</span>
|
|
||||||
</div>
|
|
||||||
<Progress value={progress} className="h-2" />
|
|
||||||
{provider === "webllm" && (
|
|
||||||
<p className="text-xs text-zinc-500 flex items-center gap-1">
|
|
||||||
<Cpu className="h-3 w-3" />
|
|
||||||
Translating locally with WebLLM...
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Error */}
|
|
||||||
{error && (
|
|
||||||
<div className="rounded-lg bg-red-500/10 border border-red-500/30 p-4">
|
|
||||||
<p className="text-sm text-red-400">{error}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Download Section */}
|
{/* Enhanced Translation Options */}
|
||||||
{downloadUrl && (
|
{file && (
|
||||||
<Card className="border-teal-500/30 bg-teal-500/5">
|
<Card variant="elevated">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-teal-400 flex items-center gap-2">
|
<CardTitle className="flex items-center gap-3">
|
||||||
<Download className="h-5 w-5" />
|
<Brain className="h-5 w-5 text-primary" />
|
||||||
Translation Complete
|
Translation Options
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Your document has been translated successfully
|
Configure your translation settings
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="space-y-6">
|
||||||
|
{/* Target Language */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label htmlFor="language" className="text-text-secondary font-medium">Target Language</Label>
|
||||||
|
<Select value={targetLanguage} onValueChange={setTargetLanguage}>
|
||||||
|
<SelectTrigger id="language" className="bg-surface border-border-subtle">
|
||||||
|
<SelectValue placeholder="Select language" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="bg-surface-elevated border-border max-h-80">
|
||||||
|
{languages.map((lang) => (
|
||||||
|
<SelectItem
|
||||||
|
key={lang.code}
|
||||||
|
value={lang.code}
|
||||||
|
className="text-foreground hover:bg-surface hover:text-primary"
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<span>{lang.flag}</span>
|
||||||
|
<span>{lang.name}</span>
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Provider Selection */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label className="text-text-secondary font-medium">Translation Provider</Label>
|
||||||
|
<Select value={provider} onValueChange={(value: ProviderType) => setProvider(value)}>
|
||||||
|
<SelectTrigger className="bg-surface border-border-subtle">
|
||||||
|
<SelectValue placeholder="Select provider" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="bg-surface-elevated border-border">
|
||||||
|
{providers.map((p) => (
|
||||||
|
<SelectItem
|
||||||
|
key={p.id}
|
||||||
|
value={p.id}
|
||||||
|
className="text-foreground hover:bg-surface hover:text-primary"
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<span>{p.icon}</span>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-medium">{p.name}</span>
|
||||||
|
<span className="text-xs text-text-tertiary">{p.description}</span>
|
||||||
|
</div>
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Advanced Options Toggle */}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||||
|
className="w-full justify-between text-primary hover:text-primary/80"
|
||||||
|
>
|
||||||
|
<span>Advanced Options</span>
|
||||||
|
<ChevronRight className={cn(
|
||||||
|
"h-4 w-4 transition-transform duration-200",
|
||||||
|
showAdvanced && "rotate-90"
|
||||||
|
)} />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Advanced Options */}
|
||||||
|
{showAdvanced && (
|
||||||
|
<div className="space-y-4 p-4 rounded-lg bg-surface/50 border border-border-subtle animate-slide-up">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label htmlFor="translate-images" className="text-text-secondary">Translate Images</Label>
|
||||||
|
<Switch
|
||||||
|
id="translate-images"
|
||||||
|
checked={translateImages}
|
||||||
|
onCheckedChange={setTranslateImages}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Translate Button */}
|
||||||
|
<Button
|
||||||
|
onClick={handleTranslate}
|
||||||
|
disabled={isTranslating}
|
||||||
|
variant="premium"
|
||||||
|
size="lg"
|
||||||
|
className="w-full h-12 text-lg group"
|
||||||
|
>
|
||||||
|
{isTranslating ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
|
||||||
|
Translating...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Zap className="mr-2 h-5 w-5 transition-transform group-hover:scale-110" />
|
||||||
|
Translate Document
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Progress Bar */}
|
||||||
|
{isTranslating && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-text-secondary">
|
||||||
|
{translationStatus || "Processing..."}
|
||||||
|
</span>
|
||||||
|
<span className="text-primary font-medium">{Math.round(progress)}%</span>
|
||||||
|
</div>
|
||||||
|
<Progress value={progress} className="h-2" />
|
||||||
|
{provider === "webllm" && (
|
||||||
|
<div className="flex items-center gap-2 text-xs text-text-tertiary p-3 rounded-lg bg-primary/5">
|
||||||
|
<Cpu className="h-3 w-3" />
|
||||||
|
Translating locally with WebLLM...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error Display */}
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-lg bg-destructive/10 border border-destructive/30 p-4 animate-slide-up">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<AlertTriangle className="h-5 w-5 text-destructive flex-shrink-0 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-destructive mb-1">Translation Error</p>
|
||||||
|
<p className="text-sm text-destructive/80">{error}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Enhanced Download Section */}
|
||||||
|
{downloadUrl && (
|
||||||
|
<Card variant="gradient" className="overflow-hidden animate-slide-up">
|
||||||
|
<CardContent className="p-8 text-center">
|
||||||
|
<div className="w-16 h-16 mx-auto mb-4 rounded-2xl bg-white/20 flex items-center justify-center animate-pulse">
|
||||||
|
<CheckCircle className="w-8 h-8 text-white" />
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-2xl mb-2">Translation Complete!</CardTitle>
|
||||||
|
<CardDescription className="mb-6">
|
||||||
|
Your document has been translated successfully while preserving all formatting.
|
||||||
|
</CardDescription>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleDownload}
|
onClick={handleDownload}
|
||||||
className="w-full bg-teal-600 hover:bg-teal-700 text-white h-12"
|
variant="glass"
|
||||||
|
size="lg"
|
||||||
|
className="group px-8"
|
||||||
>
|
>
|
||||||
<Download className="mr-2 h-4 w-4" />
|
<Download className="mr-2 h-5 w-5 transition-transform group-hover:scale-110" />
|
||||||
Download Translated Document
|
Download Translated Document
|
||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@ -12,10 +12,22 @@ import {
|
|||||||
Server,
|
Server,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
FileSpreadsheet,
|
FileSpreadsheet,
|
||||||
Presentation
|
Presentation,
|
||||||
|
Star,
|
||||||
|
TrendingUp,
|
||||||
|
Users,
|
||||||
|
Clock,
|
||||||
|
Award,
|
||||||
|
ChevronRight,
|
||||||
|
Play,
|
||||||
|
BarChart3,
|
||||||
|
Brain,
|
||||||
|
Lock,
|
||||||
|
Zap as ZapIcon
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardFeature } from "@/components/ui/card";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
interface User {
|
interface User {
|
||||||
@ -25,6 +37,7 @@ interface User {
|
|||||||
|
|
||||||
export function LandingHero() {
|
export function LandingHero() {
|
||||||
const [user, setUser] = useState<User | null>(null);
|
const [user, setUser] = useState<User | null>(null);
|
||||||
|
const [isLoaded, setIsLoaded] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const storedUser = localStorage.getItem("user");
|
const storedUser = localStorage.getItem("user");
|
||||||
@ -35,51 +48,102 @@ export function LandingHero() {
|
|||||||
setUser(null);
|
setUser(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Trigger animation after mount
|
||||||
|
setTimeout(() => setIsLoaded(true), 100);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative overflow-hidden">
|
<div className="relative overflow-hidden">
|
||||||
{/* Background gradient */}
|
{/* Enhanced Background with animated gradient */}
|
||||||
<div className="absolute inset-0 bg-gradient-to-br from-teal-500/10 via-transparent to-purple-500/10" />
|
<div className="absolute inset-0">
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-br from-primary/5 via-transparent to-accent/5" />
|
||||||
|
<div className="absolute inset-0 bg-[url('/grid.svg')] opacity-5" />
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-t from-background via-background/90 to-background/50" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Animated floating elements */}
|
||||||
|
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
||||||
|
<div className={cn(
|
||||||
|
"absolute top-20 left-10 w-20 h-20 bg-primary/10 rounded-full blur-xl animate-pulse",
|
||||||
|
isLoaded && "animate-float"
|
||||||
|
)} />
|
||||||
|
<div className={cn(
|
||||||
|
"absolute top-40 right-20 w-32 h-32 bg-accent/10 rounded-full blur-2xl animate-pulse animation-delay-2000",
|
||||||
|
isLoaded && "animate-float-delayed"
|
||||||
|
)} />
|
||||||
|
<div className={cn(
|
||||||
|
"absolute bottom-20 left-1/4 w-16 h-16 bg-success/10 rounded-full blur-lg animate-pulse animation-delay-4000",
|
||||||
|
isLoaded && "animate-float-slow"
|
||||||
|
)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Hero content */}
|
{/* Hero content */}
|
||||||
<div className="relative px-4 py-16 sm:py-24">
|
<div className={cn(
|
||||||
<div className="text-center max-w-4xl mx-auto">
|
"relative px-4 py-24 sm:py-32 transition-all duration-1000 ease-out",
|
||||||
<Badge className="mb-6 bg-teal-500/20 text-teal-400 border-teal-500/30">
|
isLoaded ? "opacity-100 translate-y-0" : "opacity-0 translate-y-4"
|
||||||
<Sparkles className="h-3 w-3 mr-1" />
|
)}>
|
||||||
|
<div className="text-center max-w-5xl mx-auto">
|
||||||
|
{/* Premium Badge */}
|
||||||
|
<Badge
|
||||||
|
variant="premium"
|
||||||
|
size="lg"
|
||||||
|
className="mb-8 animate-slide-up animation-delay-200"
|
||||||
|
>
|
||||||
|
<Sparkles className="w-4 h-4 mr-2" />
|
||||||
AI-Powered Document Translation
|
AI-Powered Document Translation
|
||||||
</Badge>
|
</Badge>
|
||||||
|
|
||||||
<h1 className="text-4xl sm:text-5xl lg:text-6xl font-bold text-white mb-6 leading-tight">
|
{/* Enhanced Headline */}
|
||||||
Translate Documents{" "}
|
<h1 className="text-display text-4xl sm:text-6xl lg:text-7xl font-bold text-white mb-6 leading-tight">
|
||||||
<span className="text-transparent bg-clip-text bg-gradient-to-r from-teal-400 to-cyan-400">
|
<span className={cn(
|
||||||
Instantly
|
"block bg-gradient-to-r from-primary via-accent to-primary bg-clip-text text-transparent bg-size-200 animate-gradient",
|
||||||
|
isLoaded && "animate-gradient-shift"
|
||||||
|
)}>
|
||||||
|
Translate Documents
|
||||||
|
</span>
|
||||||
|
<span className="block text-3xl sm:text-4xl lg:text-5xl mt-2">
|
||||||
|
<span className="relative">
|
||||||
|
Instantly
|
||||||
|
<span className="absolute -bottom-2 left-0 right-0 h-1 bg-gradient-to-r from-primary to-accent rounded-full animate-underline-expand" />
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<p className="text-xl text-zinc-400 mb-8 max-w-2xl mx-auto">
|
{/* Enhanced Description */}
|
||||||
|
<p className={cn(
|
||||||
|
"text-xl text-text-secondary mb-12 max-w-3xl mx-auto leading-relaxed",
|
||||||
|
isLoaded && "animate-slide-up animation-delay-400"
|
||||||
|
)}>
|
||||||
Upload Word, Excel, and PowerPoint files. Get perfect translations while preserving
|
Upload Word, Excel, and PowerPoint files. Get perfect translations while preserving
|
||||||
all formatting, styles, and layouts. Powered by AI.
|
all formatting, styles, and layouts. Powered by advanced AI technology.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="flex flex-col sm:flex-row gap-4 justify-center mb-12">
|
{/* Enhanced CTA Buttons */}
|
||||||
|
<div className={cn(
|
||||||
|
"flex flex-col sm:flex-row gap-6 justify-center mb-16",
|
||||||
|
isLoaded && "animate-slide-up animation-delay-600"
|
||||||
|
)}>
|
||||||
{user ? (
|
{user ? (
|
||||||
<Link href="#upload">
|
<Link href="#upload">
|
||||||
<Button size="lg" className="bg-teal-500 hover:bg-teal-600 text-white px-8">
|
<Button size="lg" variant="premium" className="group px-8 py-4 text-lg">
|
||||||
Start Translating
|
Start Translating
|
||||||
<ArrowRight className="ml-2 h-4 w-4" />
|
<ArrowRight className="ml-2 h-5 w-5 transition-transform group-hover:translate-x-1" />
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Link href="/auth/register">
|
<Link href="/auth/register">
|
||||||
<Button size="lg" className="bg-teal-500 hover:bg-teal-600 text-white px-8">
|
<Button size="lg" variant="premium" className="group px-8 py-4 text-lg">
|
||||||
Get Started Free
|
Get Started Free
|
||||||
<ArrowRight className="ml-2 h-4 w-4" />
|
<ArrowRight className="ml-2 h-5 w-5 transition-transform group-hover:translate-x-1" />
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/pricing">
|
<Link href="/pricing">
|
||||||
<Button size="lg" variant="outline" className="border-zinc-700 text-white hover:bg-zinc-800">
|
<Button
|
||||||
|
size="lg"
|
||||||
|
variant="glass"
|
||||||
|
className="px-8 py-4 text-lg border-2 hover:border-primary/50"
|
||||||
|
>
|
||||||
View Pricing
|
View Pricing
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
@ -87,20 +151,53 @@ export function LandingHero() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Supported formats */}
|
{/* Enhanced Supported formats */}
|
||||||
<div className="flex flex-wrap justify-center gap-4">
|
<div className={cn(
|
||||||
<div className="flex items-center gap-2 px-4 py-2 rounded-full bg-zinc-800/50 border border-zinc-700">
|
"flex flex-wrap justify-center gap-4 mb-16",
|
||||||
<FileText className="h-4 w-4 text-blue-400" />
|
isLoaded && "animate-slide-up animation-delay-800"
|
||||||
<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">
|
{ icon: FileText, name: "Word", ext: ".docx", color: "text-blue-400" },
|
||||||
<FileSpreadsheet className="h-4 w-4 text-green-400" />
|
{ icon: FileSpreadsheet, name: "Excel", ext: ".xlsx", color: "text-green-400" },
|
||||||
<span className="text-sm text-zinc-300">Excel (.xlsx)</span>
|
{ icon: Presentation, name: "PowerPoint", ext: ".pptx", color: "text-orange-400" },
|
||||||
</div>
|
].map((format, idx) => (
|
||||||
<div className="flex items-center gap-2 px-4 py-2 rounded-full bg-zinc-800/50 border border-zinc-700">
|
<Card
|
||||||
<Presentation className="h-4 w-4 text-orange-400" />
|
key={format.name}
|
||||||
<span className="text-sm text-zinc-300">PowerPoint (.pptx)</span>
|
variant="glass"
|
||||||
</div>
|
className="group px-6 py-4 hover:scale-105 transition-all duration-300"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className={cn(
|
||||||
|
"w-12 h-12 rounded-xl flex items-center justify-center transition-all duration-300",
|
||||||
|
format.color
|
||||||
|
)}>
|
||||||
|
<format.icon className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
<div className="text-left">
|
||||||
|
<div className="font-semibold text-white">{format.name}</div>
|
||||||
|
<div className="text-sm text-text-tertiary">{format.ext}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Trust Indicators */}
|
||||||
|
<div className={cn(
|
||||||
|
"flex flex-wrap justify-center gap-8 text-sm text-text-tertiary",
|
||||||
|
isLoaded && "animate-slide-up animation-delay-1000"
|
||||||
|
)}>
|
||||||
|
{[
|
||||||
|
{ icon: Users, text: "10,000+ Users" },
|
||||||
|
{ icon: Star, text: "4.9/5 Rating" },
|
||||||
|
{ icon: Shield, text: "Bank-level Security" },
|
||||||
|
{ icon: ZapIcon, text: "Lightning Fast" },
|
||||||
|
].map((indicator, idx) => (
|
||||||
|
<div key={indicator.text} className="flex items-center gap-2">
|
||||||
|
<indicator.icon className="w-4 h-4 text-primary" />
|
||||||
|
<span>{indicator.text}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -109,78 +206,157 @@ export function LandingHero() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function FeaturesSection() {
|
export function FeaturesSection() {
|
||||||
|
const [ref, setRef] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
([entry]) => {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
setRef(true);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ threshold: 0.1 }
|
||||||
|
);
|
||||||
|
|
||||||
|
const element = document.getElementById('features-section');
|
||||||
|
if (element) {
|
||||||
|
observer.observe(element);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
const features = [
|
const features = [
|
||||||
{
|
{
|
||||||
icon: Globe2,
|
icon: Globe2,
|
||||||
title: "100+ Languages",
|
title: "100+ Languages",
|
||||||
description: "Translate between any language pair with high accuracy using AI models",
|
description: "Translate between any language pair with high accuracy using advanced AI models",
|
||||||
color: "text-blue-400",
|
color: "text-blue-400",
|
||||||
|
stats: "100+",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: FileText,
|
icon: FileText,
|
||||||
title: "Preserve Formatting",
|
title: "Preserve Formatting",
|
||||||
description: "All styles, fonts, colors, tables, and charts remain intact",
|
description: "All styles, fonts, colors, tables, and charts remain intact",
|
||||||
color: "text-green-400",
|
color: "text-green-400",
|
||||||
|
stats: "100%",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: Zap,
|
icon: Zap,
|
||||||
title: "Lightning Fast",
|
title: "Lightning Fast",
|
||||||
description: "Batch processing translates entire documents in seconds",
|
description: "Batch processing translates entire documents in seconds",
|
||||||
color: "text-amber-400",
|
color: "text-amber-400",
|
||||||
|
stats: "2s",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: Shield,
|
icon: Shield,
|
||||||
title: "Secure & Private",
|
title: "Secure & Private",
|
||||||
description: "Your documents are encrypted and never stored permanently",
|
description: "Your documents are encrypted and never stored permanently",
|
||||||
color: "text-purple-400",
|
color: "text-purple-400",
|
||||||
|
stats: "AES-256",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: Sparkles,
|
icon: Brain,
|
||||||
title: "AI-Powered",
|
title: "AI-Powered",
|
||||||
description: "Advanced neural translation for natural, context-aware results",
|
description: "Advanced neural translation for natural, context-aware results",
|
||||||
color: "text-teal-400",
|
color: "text-teal-400",
|
||||||
|
stats: "GPT-4",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: Server,
|
icon: Server,
|
||||||
title: "Enterprise Ready",
|
title: "Enterprise Ready",
|
||||||
description: "API access, team management, and dedicated support for businesses",
|
description: "API access, team management, and dedicated support for businesses",
|
||||||
color: "text-orange-400",
|
color: "text-orange-400",
|
||||||
|
stats: "99.9%",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="py-16 px-4">
|
<div id="features-section" className="py-24 px-4 relative">
|
||||||
<div className="max-w-6xl mx-auto">
|
{/* Background decoration */}
|
||||||
<div className="text-center mb-12">
|
<div className="absolute inset-0 bg-gradient-to-b from-transparent to-surface/50 pointer-events-none" />
|
||||||
|
|
||||||
|
<div className="max-w-7xl mx-auto">
|
||||||
|
<div className="text-center mb-16">
|
||||||
|
<Badge variant="glass" className="mb-4">
|
||||||
|
Features
|
||||||
|
</Badge>
|
||||||
<h2 className="text-3xl font-bold text-white mb-4">
|
<h2 className="text-3xl font-bold text-white mb-4">
|
||||||
Everything You Need for Document Translation
|
Everything You Need for Document Translation
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-zinc-400 max-w-2xl mx-auto">
|
<p className="text-xl text-text-secondary max-w-3xl mx-auto">
|
||||||
Professional-grade translation with enterprise features, available to everyone.
|
Professional-grade translation with enterprise features, available to everyone.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||||
{features.map((feature) => {
|
{features.map((feature, idx) => {
|
||||||
const Icon = feature.icon;
|
const Icon = feature.icon;
|
||||||
return (
|
return (
|
||||||
<div
|
<CardFeature
|
||||||
key={feature.title}
|
key={feature.title}
|
||||||
className="p-6 rounded-xl border border-zinc-800 bg-zinc-900/50 hover:border-zinc-700 transition-colors"
|
icon={<Icon className="w-6 h-6" />}
|
||||||
>
|
title={feature.title}
|
||||||
<Icon className={cn("h-8 w-8 mb-4", feature.color)} />
|
description={feature.description}
|
||||||
<h3 className="text-lg font-semibold text-white mb-2">{feature.title}</h3>
|
color="primary"
|
||||||
<p className="text-zinc-400 text-sm">{feature.description}</p>
|
className={cn(
|
||||||
</div>
|
"group",
|
||||||
|
ref && "animate-fade-in-up",
|
||||||
|
`animation-delay-${idx * 100}`
|
||||||
|
)}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Enhanced Stats Row */}
|
||||||
|
<div className="mt-20 grid grid-cols-2 md:grid-cols-4 gap-8">
|
||||||
|
{[
|
||||||
|
{ value: "10M+", label: "Documents Translated", icon: FileText },
|
||||||
|
{ value: "150+", label: "Countries", icon: Globe2 },
|
||||||
|
{ value: "99.9%", label: "Uptime", icon: Shield },
|
||||||
|
{ value: "24/7", label: "Support", icon: Clock },
|
||||||
|
].map((stat, idx) => (
|
||||||
|
<div
|
||||||
|
key={stat.label}
|
||||||
|
className={cn(
|
||||||
|
"text-center p-6 rounded-xl surface-elevated border border-border-subtle",
|
||||||
|
ref && "animate-fade-in-up",
|
||||||
|
`animation-delay-${idx * 100 + 600}`
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<stat.icon className="w-8 h-8 mx-auto mb-3 text-primary" />
|
||||||
|
<div className="text-2xl font-bold text-white mb-1">{stat.value}</div>
|
||||||
|
<div className="text-sm text-text-secondary">{stat.label}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PricingPreview() {
|
export function PricingPreview() {
|
||||||
|
const [ref, setRef] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
([entry]) => {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
setRef(true);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ threshold: 0.1 }
|
||||||
|
);
|
||||||
|
|
||||||
|
const element = document.getElementById('pricing-preview');
|
||||||
|
if (element) {
|
||||||
|
observer.observe(element);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
const plans = [
|
const plans = [
|
||||||
{
|
{
|
||||||
name: "Free",
|
name: "Free",
|
||||||
@ -189,6 +365,7 @@ export function PricingPreview() {
|
|||||||
features: ["5 documents/day", "10 pages/doc", "Basic support"],
|
features: ["5 documents/day", "10 pages/doc", "Basic support"],
|
||||||
cta: "Get Started",
|
cta: "Get Started",
|
||||||
href: "/auth/register",
|
href: "/auth/register",
|
||||||
|
popular: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Pro",
|
name: "Pro",
|
||||||
@ -208,74 +385,99 @@ export function PricingPreview() {
|
|||||||
features: ["1000 documents/month", "Team management", "Dedicated support", "SLA"],
|
features: ["1000 documents/month", "Team management", "Dedicated support", "SLA"],
|
||||||
cta: "Contact Sales",
|
cta: "Contact Sales",
|
||||||
href: "/pricing",
|
href: "/pricing",
|
||||||
|
popular: false,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="py-16 px-4 bg-zinc-900/50">
|
<div id="pricing-preview" className="py-24 px-4 relative">
|
||||||
|
{/* Background decoration */}
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-b from-surface/50 to-transparent pointer-events-none" />
|
||||||
|
|
||||||
<div className="max-w-5xl mx-auto">
|
<div className="max-w-5xl mx-auto">
|
||||||
<div className="text-center mb-12">
|
<div className="text-center mb-16">
|
||||||
|
<Badge variant="glass" className="mb-4">
|
||||||
|
Pricing
|
||||||
|
</Badge>
|
||||||
<h2 className="text-3xl font-bold text-white mb-4">
|
<h2 className="text-3xl font-bold text-white mb-4">
|
||||||
Simple, Transparent Pricing
|
Simple, Transparent Pricing
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-zinc-400">
|
<p className="text-xl text-text-secondary max-w-3xl mx-auto">
|
||||||
Start free, upgrade when you need more.
|
Start free, upgrade when you need more.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||||
{plans.map((plan) => (
|
{plans.map((plan, idx) => (
|
||||||
<div
|
<Card
|
||||||
key={plan.name}
|
key={plan.name}
|
||||||
|
variant={plan.popular ? "gradient" : "elevated"}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative p-6 rounded-xl border",
|
"relative overflow-hidden group",
|
||||||
plan.popular
|
ref && "animate-fade-in-up",
|
||||||
? "border-teal-500 bg-gradient-to-b from-teal-500/10 to-transparent"
|
`animation-delay-${idx * 100}`
|
||||||
: "border-zinc-800 bg-zinc-900/50"
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{plan.popular && (
|
{plan.popular && (
|
||||||
<Badge className="absolute -top-3 left-1/2 -translate-x-1/2 bg-teal-500 text-white">
|
<div className="absolute -top-4 left-1/2 transform -translate-x-1/2">
|
||||||
Most Popular
|
<Badge variant="premium" className="animate-pulse">
|
||||||
</Badge>
|
Most Popular
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<h3 className="text-xl font-semibold text-white mb-1">{plan.name}</h3>
|
<CardHeader className="text-center pb-4">
|
||||||
<p className="text-sm text-zinc-400 mb-4">{plan.description}</p>
|
<CardTitle className="text-xl mb-2">{plan.name}</CardTitle>
|
||||||
|
<CardDescription>{plan.description}</CardDescription>
|
||||||
<div className="mb-6">
|
|
||||||
<span className="text-3xl font-bold text-white">{plan.price}</span>
|
<div className="my-6">
|
||||||
{plan.period && <span className="text-zinc-500">{plan.period}</span>}
|
<span className="text-4xl font-bold text-white">
|
||||||
</div>
|
{plan.price}
|
||||||
|
</span>
|
||||||
<ul className="space-y-2 mb-6">
|
{plan.period && (
|
||||||
{plan.features.map((feature) => (
|
<span className="text-lg text-text-secondary ml-1">
|
||||||
<li key={feature} className="flex items-center gap-2 text-sm text-zinc-300">
|
{plan.period}
|
||||||
<Check className="h-4 w-4 text-teal-400" />
|
</span>
|
||||||
{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"
|
|
||||||
)}
|
)}
|
||||||
>
|
</div>
|
||||||
{plan.cta}
|
</CardHeader>
|
||||||
</Button>
|
|
||||||
</Link>
|
<CardContent className="pt-0">
|
||||||
</div>
|
<ul className="space-y-3 mb-6">
|
||||||
|
{plan.features.map((feature) => (
|
||||||
|
<li key={feature} className="flex items-center gap-2 text-sm text-text-secondary">
|
||||||
|
<Check className="h-4 w-4 text-success flex-shrink-0" />
|
||||||
|
<span>{feature}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<Link href={plan.href}>
|
||||||
|
<Button
|
||||||
|
variant={plan.popular ? "default" : "outline"}
|
||||||
|
className="w-full group"
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
{plan.cta}
|
||||||
|
<ChevronRight className="ml-2 h-4 w-4 transition-transform group-hover:translate-x-1" />
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</CardContent>
|
||||||
|
|
||||||
|
{/* Hover effect for popular plan */}
|
||||||
|
{plan.popular && (
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-r from-primary/20 to-accent/20 opacity-0 group-hover:opacity-100 transition-opacity duration-300 pointer-events-none" />
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-center mt-8">
|
<div className="text-center mt-12">
|
||||||
<Link href="/pricing" className="text-teal-400 hover:text-teal-300 text-sm">
|
<Link href="/pricing" className="group">
|
||||||
View all plans and features →
|
<Button variant="ghost" className="text-primary hover:text-primary/80">
|
||||||
|
View all plans and features
|
||||||
|
<ArrowRight className="ml-2 h-4 w-4 transition-transform group-hover:translate-x-1" />
|
||||||
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -286,3 +488,107 @@ export function PricingPreview() {
|
|||||||
export function SelfHostCTA() {
|
export function SelfHostCTA() {
|
||||||
return null; // Removed for commercial version
|
return null; // Removed for commercial version
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Custom animations
|
||||||
|
const style = document.createElement('style');
|
||||||
|
style.textContent = `
|
||||||
|
@keyframes float {
|
||||||
|
0%, 100% { transform: translateY(0px) rotate(0deg); }
|
||||||
|
33% { transform: translateY(-20px) rotate(120deg); }
|
||||||
|
66% { transform: translateY(-10px) rotate(240deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes float-delayed {
|
||||||
|
0%, 100% { transform: translateY(0px) rotate(0deg); }
|
||||||
|
33% { transform: translateY(-30px) rotate(90deg); }
|
||||||
|
66% { transform: translateY(-15px) rotate(180deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes float-slow {
|
||||||
|
0%, 100% { transform: translateY(0px) translateX(0px); }
|
||||||
|
25% { transform: translateY(-15px) translateX(10px); }
|
||||||
|
50% { transform: translateY(-25px) translateX(-10px); }
|
||||||
|
75% { transform: translateY(-10px) translateX(5px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes gradient-shift {
|
||||||
|
0%, 100% { background-position: 0% 50%; }
|
||||||
|
50% { background-position: 100% 50%; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes underline-expand {
|
||||||
|
0% { width: 0%; left: 50%; }
|
||||||
|
100% { width: 100%; left: 0%; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fade-in-up {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(30px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slide-up {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(20px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-float {
|
||||||
|
animation: float 6s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-float-delayed {
|
||||||
|
animation: float-delayed 8s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-float-slow {
|
||||||
|
animation: float-slow 10s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-gradient {
|
||||||
|
background-size: 200% 200%;
|
||||||
|
animation: gradient-shift 3s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-gradient-shift {
|
||||||
|
animation: gradient-shift 4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-underline-expand {
|
||||||
|
animation: underline-expand 1s ease-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-fade-in-up {
|
||||||
|
animation: fade-in-up 0.6s ease-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-slide-up {
|
||||||
|
animation: slide-up 0.6s ease-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animation-delay-200 { animation-delay: 200ms; }
|
||||||
|
.animation-delay-400 { animation-delay: 400ms; }
|
||||||
|
.animation-delay-600 { animation-delay: 600ms; }
|
||||||
|
.animation-delay-800 { animation-delay: 800ms; }
|
||||||
|
.animation-delay-1000 { animation-delay: 1000ms; }
|
||||||
|
.animation-delay-2000 { animation-delay: 2000ms; }
|
||||||
|
.animation-delay-4000 { animation-delay: 4000ms; }
|
||||||
|
|
||||||
|
.bg-size-200 {
|
||||||
|
background-size: 200% 200%;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (typeof document !== 'undefined') {
|
||||||
|
document.head.appendChild(style);
|
||||||
|
}
|
||||||
|
|||||||
@ -10,7 +10,6 @@ import {
|
|||||||
LogIn,
|
LogIn,
|
||||||
Crown,
|
Crown,
|
||||||
LogOut,
|
LogOut,
|
||||||
Settings,
|
|
||||||
BookOpen,
|
BookOpen,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
@ -35,6 +34,12 @@ const navigation = [
|
|||||||
icon: Upload,
|
icon: Upload,
|
||||||
description: "Translate documents",
|
description: "Translate documents",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "Context",
|
||||||
|
href: "/settings/context",
|
||||||
|
icon: BookOpen,
|
||||||
|
description: "Configure AI instructions & glossary",
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const planColors: Record<string, string> = {
|
const planColors: Record<string, string> = {
|
||||||
|
|||||||
@ -1,46 +1,295 @@
|
|||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { Slot } from "@radix-ui/react-slot"
|
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
const badgeVariants = cva(
|
const badgeVariants = cva(
|
||||||
"inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-all duration-200 ease-out focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default:
|
default:
|
||||||
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
"border-transparent bg-primary text-primary-foreground shadow-sm hover:shadow-md hover:bg-primary/80",
|
||||||
secondary:
|
secondary:
|
||||||
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
"border-transparent bg-surface text-secondary-foreground hover:bg-surface-hover",
|
||||||
destructive:
|
destructive:
|
||||||
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
"border-transparent bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/80",
|
||||||
outline:
|
outline: "text-foreground border-border hover:bg-surface hover:border-border-strong",
|
||||||
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
success:
|
||||||
|
"border-transparent bg-success text-success-foreground shadow-sm hover:bg-success/80",
|
||||||
|
warning:
|
||||||
|
"border-transparent bg-warning text-warning-foreground shadow-sm hover:bg-warning/80",
|
||||||
|
info:
|
||||||
|
"border-transparent bg-primary/20 text-primary border-primary/30 hover:bg-primary/30",
|
||||||
|
accent:
|
||||||
|
"border-transparent bg-accent text-accent-foreground shadow-sm hover:bg-accent/80",
|
||||||
|
glass:
|
||||||
|
"glass text-foreground border-border/20 hover:bg-surface/50 hover:border-border/40",
|
||||||
|
gradient:
|
||||||
|
"border-transparent bg-gradient-to-r from-primary to-accent text-white shadow-lg hover:shadow-xl hover:shadow-primary/25",
|
||||||
|
neon:
|
||||||
|
"border-transparent bg-primary/10 text-primary border-primary/20 shadow-lg shadow-primary/20 hover:bg-primary/20 hover:border-primary/30 hover:shadow-primary/30",
|
||||||
|
pulse:
|
||||||
|
"border-transparent bg-primary text-primary-foreground shadow-lg shadow-primary/25 animate-pulse hover:animate-none",
|
||||||
|
dot:
|
||||||
|
"border-transparent bg-primary text-primary-foreground w-2 h-2 p-0 rounded-full",
|
||||||
|
premium:
|
||||||
|
"border-transparent bg-gradient-to-r from-primary via-accent to-primary text-white shadow-lg hover:shadow-xl hover:shadow-primary/25 relative overflow-hidden",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "px-2.5 py-0.5 text-xs",
|
||||||
|
sm: "px-2 py-0.5 text-xs",
|
||||||
|
lg: "px-3 py-1 text-sm",
|
||||||
|
xl: "px-4 py-1.5 text-base",
|
||||||
|
icon: "w-6 h-6 p-0 rounded-lg flex items-center justify-center",
|
||||||
|
dot: "w-2 h-2 p-0 rounded-full",
|
||||||
|
},
|
||||||
|
interactive: {
|
||||||
|
true: "cursor-pointer hover:scale-105 active:scale-95",
|
||||||
|
false: "",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
variant: "default",
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
interactive: false,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
function Badge({
|
export interface BadgeProps
|
||||||
className,
|
extends React.HTMLAttributes<HTMLDivElement>,
|
||||||
variant,
|
VariantProps<typeof badgeVariants> {
|
||||||
asChild = false,
|
icon?: React.ReactNode
|
||||||
...props
|
removable?: boolean
|
||||||
}: React.ComponentProps<"span"> &
|
onRemove?: () => void
|
||||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
pulse?: boolean
|
||||||
const Comp = asChild ? Slot : "span"
|
count?: number
|
||||||
|
maxCount?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const Badge = React.forwardRef<HTMLDivElement, BadgeProps>(
|
||||||
|
({
|
||||||
|
className,
|
||||||
|
variant,
|
||||||
|
size,
|
||||||
|
interactive = false,
|
||||||
|
icon,
|
||||||
|
removable = false,
|
||||||
|
onRemove,
|
||||||
|
pulse = false,
|
||||||
|
count,
|
||||||
|
maxCount = 99,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}, ref) => {
|
||||||
|
const [visible, setVisible] = React.useState(true)
|
||||||
|
const [removing, setRemoving] = React.useState(false)
|
||||||
|
|
||||||
|
const handleRemove = () => {
|
||||||
|
setRemoving(true)
|
||||||
|
setTimeout(() => {
|
||||||
|
setVisible(false)
|
||||||
|
onRemove?.()
|
||||||
|
}, 200)
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayCount = count && count > maxCount ? `${maxCount}+` : count
|
||||||
|
|
||||||
|
if (!visible) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
badgeVariants({ variant, size, interactive }),
|
||||||
|
pulse && "animate-pulse",
|
||||||
|
removing && "scale-0 opacity-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{/* Gradient Shine Effect */}
|
||||||
|
{(variant === "premium" || variant === "gradient") && (
|
||||||
|
<span className="absolute inset-0 bg-gradient-to-r from-transparent via-white/20 to-transparent -skew-x-12 -translate-x-full group-hover:translate-x-full transition-transform duration-1000 ease-out pointer-events-none" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Icon */}
|
||||||
|
{icon && (
|
||||||
|
<span className="mr-1 flex-shrink-0">
|
||||||
|
{icon}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
{children}
|
||||||
|
{displayCount && (
|
||||||
|
<span className="ml-1 bg-white/20 px-1.5 py-0.5 rounded text-xs font-bold">
|
||||||
|
{displayCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Remove Button */}
|
||||||
|
{removable && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleRemove}
|
||||||
|
className="ml-1 flex-shrink-0 rounded-full bg-white/20 hover:bg-white/30 transition-colors p-0.5"
|
||||||
|
aria-label="Remove badge"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="h-3 w-3"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M6 18L18 6M6 6l12 12"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Pulse Ring for Neon Variant */}
|
||||||
|
{variant === "neon" && (
|
||||||
|
<>
|
||||||
|
<span className="absolute inset-0 rounded-full bg-primary/20 animate-ping" />
|
||||||
|
<span className="absolute inset-0 rounded-full bg-primary/10 animate-ping animation-delay-200" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Badge.displayName = "Badge"
|
||||||
|
|
||||||
|
// Status Badge Component
|
||||||
|
export const StatusBadge = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
Omit<BadgeProps, 'variant'> & {
|
||||||
|
status: "online" | "offline" | "busy" | "away" | "success" | "error" | "warning"
|
||||||
|
showLabel?: boolean
|
||||||
|
}
|
||||||
|
>(({ className, status, showLabel = true, children, ...props }, ref) => {
|
||||||
|
const statusConfig = {
|
||||||
|
online: { variant: "success" as const, label: "Online", icon: "●" },
|
||||||
|
offline: { variant: "secondary" as const, label: "Offline", icon: "○" },
|
||||||
|
busy: { variant: "destructive" as const, label: "Busy", icon: "◐" },
|
||||||
|
away: { variant: "warning" as const, label: "Away", icon: "◐" },
|
||||||
|
success: { variant: "success" as const, label: "Success", icon: "✓" },
|
||||||
|
error: { variant: "destructive" as const, label: "Error", icon: "✕" },
|
||||||
|
warning: { variant: "warning" as const, label: "Warning", icon: "!" },
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = statusConfig[status]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Comp
|
<Badge
|
||||||
data-slot="badge"
|
ref={ref}
|
||||||
className={cn(badgeVariants({ variant }), className)}
|
variant={config.variant}
|
||||||
|
className={cn("gap-1.5", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
>
|
||||||
|
<span className="relative">
|
||||||
|
<span className="text-xs">{config.icon}</span>
|
||||||
|
{status === "online" && (
|
||||||
|
<span className="absolute inset-0 rounded-full bg-success animate-ping" />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
{showLabel && (
|
||||||
|
<span>{children || config.label}</span>
|
||||||
|
)}
|
||||||
|
</Badge>
|
||||||
)
|
)
|
||||||
}
|
})
|
||||||
|
StatusBadge.displayName = "StatusBadge"
|
||||||
|
|
||||||
export { Badge, badgeVariants }
|
// Counter Badge Component
|
||||||
|
export const CounterBadge = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
Omit<BadgeProps, 'children'> & {
|
||||||
|
value: number
|
||||||
|
showZero?: boolean
|
||||||
|
position?: "top-right" | "top-left" | "bottom-right" | "bottom-left"
|
||||||
|
}
|
||||||
|
>(({
|
||||||
|
className,
|
||||||
|
value,
|
||||||
|
showZero = false,
|
||||||
|
position = "top-right",
|
||||||
|
size = "icon",
|
||||||
|
...props
|
||||||
|
}, ref) => {
|
||||||
|
if (value === 0 && !showZero) return null
|
||||||
|
|
||||||
|
const positionClasses = {
|
||||||
|
"top-right": "-top-2 -right-2",
|
||||||
|
"top-left": "-top-2 -left-2",
|
||||||
|
"bottom-right": "-bottom-2 -right-2",
|
||||||
|
"bottom-left": "-bottom-2 -left-2",
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
ref={ref}
|
||||||
|
variant="destructive"
|
||||||
|
size={size}
|
||||||
|
className={cn(
|
||||||
|
"absolute min-w-[20px] h-5 flex items-center justify-center text-xs font-bold",
|
||||||
|
positionClasses[position],
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{value > 99 ? "99+" : value}
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
CounterBadge.displayName = "CounterBadge"
|
||||||
|
|
||||||
|
// Progress Badge Component
|
||||||
|
export const ProgressBadge = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
Omit<BadgeProps, 'children'> & {
|
||||||
|
value: number
|
||||||
|
max?: number
|
||||||
|
showPercentage?: boolean
|
||||||
|
}
|
||||||
|
>(({
|
||||||
|
className,
|
||||||
|
value,
|
||||||
|
max = 100,
|
||||||
|
showPercentage = true,
|
||||||
|
...props
|
||||||
|
}, ref) => {
|
||||||
|
const percentage = Math.min(100, Math.max(0, (value / max) * 100))
|
||||||
|
const displayValue = showPercentage ? `${Math.round(percentage)}%` : `${value}/${max}`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
ref={ref}
|
||||||
|
variant="outline"
|
||||||
|
className={cn(
|
||||||
|
"relative overflow-hidden",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{/* Progress Background */}
|
||||||
|
<span
|
||||||
|
className="absolute inset-0 bg-primary/20"
|
||||||
|
style={{ width: `${percentage}%` }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Text */}
|
||||||
|
<span className="relative z-10">{displayValue}</span>
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
ProgressBadge.displayName = "ProgressBadge"
|
||||||
|
|
||||||
|
export { Badge, badgeVariants, StatusBadge, CounterBadge, ProgressBadge }
|
||||||
|
|||||||
@ -1,32 +1,33 @@
|
|||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { Slot } from "@radix-ui/react-slot"
|
import { Slot } from "@radix-ui/react-slot"
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
const buttonVariants = cva(
|
const buttonVariants = cva(
|
||||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg text-sm font-medium transition-all duration-200 ease-out disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive relative overflow-hidden group",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
default: "bg-primary text-primary-foreground hover:bg-primary/90 shadow-lg hover:shadow-xl hover:shadow-primary/25 transform hover:-translate-y-0.5 active:translate-y-0",
|
||||||
destructive:
|
destructive:
|
||||||
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60 shadow-lg hover:shadow-xl hover:shadow-destructive/25 transform hover:-translate-y-0.5 active:translate-y-0",
|
||||||
outline:
|
outline:
|
||||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
"border border-border bg-transparent shadow-sm hover:bg-surface hover:text-accent-foreground hover:border-accent hover:shadow-md transform hover:-translate-y-0.5 active:translate-y-0",
|
||||||
secondary:
|
secondary:
|
||||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
"bg-surface text-secondary-foreground hover:bg-surface-hover hover:text-secondary-foreground/80 border border-border-subtle shadow-sm hover:shadow-md transform hover:-translate-y-0.5 active:translate-y-0",
|
||||||
ghost:
|
ghost:
|
||||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
"hover:bg-surface-hover hover:text-accent-foreground dark:hover:bg-surface/50 transform hover:-translate-y-0.5 active:translate-y-0",
|
||||||
link: "text-primary underline-offset-4 hover:underline",
|
link: "text-primary underline-offset-4 hover:underline decoration-2 decoration-primary/60 hover:decoration-primary transition-all duration-200",
|
||||||
|
premium: "bg-gradient-to-r from-primary to-accent text-white hover:from-primary/90 hover:to-accent/90 shadow-lg hover:shadow-xl hover:shadow-primary/25 transform hover:-translate-y-0.5 active:translate-y-0 relative overflow-hidden",
|
||||||
|
glass: "glass text-foreground hover:bg-surface/50 border border-border-subtle hover:border-border shadow-md hover:shadow-lg transform hover:-translate-y-0.5 active:translate-y-0",
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
default: "h-10 px-4 py-2 has-[>svg]:px-3",
|
||||||
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5 text-xs",
|
||||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
lg: "h-12 rounded-xl px-6 has-[>svg]:px-4 text-base",
|
||||||
icon: "size-9",
|
icon: "size-10 rounded-lg",
|
||||||
"icon-sm": "size-8",
|
"icon-sm": "size-8 rounded-md",
|
||||||
"icon-lg": "size-10",
|
"icon-lg": "size-12 rounded-xl",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
@ -36,25 +37,107 @@ const buttonVariants = cva(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
function Button({
|
interface ButtonProps
|
||||||
className,
|
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
variant,
|
VariantProps<typeof buttonVariants> {
|
||||||
size,
|
asChild?: boolean
|
||||||
asChild = false,
|
loading?: boolean
|
||||||
...props
|
ripple?: boolean
|
||||||
}: React.ComponentProps<"button"> &
|
|
||||||
VariantProps<typeof buttonVariants> & {
|
|
||||||
asChild?: boolean
|
|
||||||
}) {
|
|
||||||
const Comp = asChild ? Slot : "button"
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Comp
|
|
||||||
data-slot="button"
|
|
||||||
className={cn(buttonVariants({ variant, size, className }))}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
({ className, variant, size, asChild = false, loading = false, ripple = true, children, disabled, ...props }, ref) => {
|
||||||
|
const Comp = asChild ? Slot : "button"
|
||||||
|
const [ripples, setRipples] = React.useState<Array<{ id: number; x: number; y: number; size: number }>>([])
|
||||||
|
|
||||||
|
const createRipple = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
|
if (!ripple || disabled || loading) return
|
||||||
|
|
||||||
|
const button = event.currentTarget
|
||||||
|
const rect = button.getBoundingClientRect()
|
||||||
|
const size = Math.max(rect.width, rect.height)
|
||||||
|
const x = event.clientX - rect.left - size / 2
|
||||||
|
const y = event.clientY - rect.top - size / 2
|
||||||
|
|
||||||
|
const newRipple = {
|
||||||
|
id: Date.now(),
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
size,
|
||||||
|
}
|
||||||
|
|
||||||
|
setRipples(prev => [...prev, newRipple])
|
||||||
|
|
||||||
|
// Remove ripple after animation
|
||||||
|
setTimeout(() => {
|
||||||
|
setRipples(prev => prev.filter(r => r.id !== newRipple.id))
|
||||||
|
}, 600)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
ref={ref}
|
||||||
|
disabled={disabled || loading}
|
||||||
|
onClick={createRipple}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{/* Ripple Effect */}
|
||||||
|
{ripple && (
|
||||||
|
<span className="absolute inset-0 overflow-hidden rounded-[inherit]">
|
||||||
|
{ripples.map(ripple => (
|
||||||
|
<span
|
||||||
|
key={ripple.id}
|
||||||
|
className="absolute bg-white/20 rounded-full animate-ping"
|
||||||
|
style={{
|
||||||
|
left: ripple.x,
|
||||||
|
top: ripple.y,
|
||||||
|
width: ripple.size,
|
||||||
|
height: ripple.size,
|
||||||
|
animation: 'ripple 0.6s ease-out',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Loading State */}
|
||||||
|
{loading && (
|
||||||
|
<svg
|
||||||
|
className="animate-spin -ml-1 mr-2 h-4 w-4"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
className="opacity-25"
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="10"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="4"
|
||||||
|
></circle>
|
||||||
|
<path
|
||||||
|
className="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Button Content */}
|
||||||
|
<span className={cn("flex items-center gap-2", loading && "opacity-70")}>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Shine Effect for Premium Variant */}
|
||||||
|
{variant === 'premium' && (
|
||||||
|
<span className="absolute inset-0 bg-gradient-to-r from-transparent via-white/10 to-transparent -skew-x-12 -translate-x-full group-hover:translate-x-full transition-transform duration-1000 ease-out pointer-events-none" />
|
||||||
|
)}
|
||||||
|
</Comp>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Button.displayName = "Button"
|
||||||
|
|
||||||
export { Button, buttonVariants }
|
export { Button, buttonVariants }
|
||||||
|
|||||||
@ -1,92 +1,312 @@
|
|||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
interface CardProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
return (
|
variant?: "default" | "elevated" | "glass" | "gradient"
|
||||||
<div
|
hover?: boolean
|
||||||
data-slot="card"
|
interactive?: boolean
|
||||||
className={cn(
|
|
||||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
const Card = React.forwardRef<HTMLDivElement, CardProps>(
|
||||||
|
({ className, variant = "default", hover = true, interactive = false, ...props }, ref) => {
|
||||||
|
const variants = {
|
||||||
|
default: "bg-card text-card-foreground border border-border shadow-sm",
|
||||||
|
elevated: "bg-surface-elevated text-card-foreground border border-border shadow-lg",
|
||||||
|
glass: "glass text-card-foreground border border-border/20 shadow-lg backdrop-blur-xl",
|
||||||
|
gradient: "bg-gradient-to-br from-surface to-surface-elevated text-card-foreground border border-border/50 shadow-xl"
|
||||||
|
}
|
||||||
|
|
||||||
|
const hoverClasses = hover ? `
|
||||||
|
transition-all duration-300 ease-out
|
||||||
|
${interactive ? 'cursor-pointer' : ''}
|
||||||
|
${hover ? 'hover:shadow-xl hover:shadow-primary/10 hover:-translate-y-1' : ''}
|
||||||
|
${interactive ? 'active:translate-y-0 active:shadow-lg' : ''}
|
||||||
|
` : ''
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
data-slot="card"
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col gap-6 rounded-xl p-6 relative overflow-hidden",
|
||||||
|
variants[variant],
|
||||||
|
hoverClasses,
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Card.displayName = "Card"
|
||||||
|
|
||||||
|
const CardHeader = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement> & {
|
||||||
|
title?: string
|
||||||
|
description?: string
|
||||||
|
action?: React.ReactNode
|
||||||
|
}
|
||||||
|
>(({ className, title, description, action, children, ...props }, ref) => {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
ref={ref}
|
||||||
data-slot="card-header"
|
data-slot="card-header"
|
||||||
className={cn(
|
className={cn(
|
||||||
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
"grid auto-rows-min grid-rows-[auto_auto] items-start gap-3 relative",
|
||||||
|
action && "grid-cols-[1fr_auto]",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
>
|
||||||
|
{children || (
|
||||||
|
<>
|
||||||
|
{title && (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<h3 className="text-lg font-semibold leading-none text-card-foreground">
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{description && (
|
||||||
|
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{action && (
|
||||||
|
<div className="col-start-2 row-span-2 row-start-1 self-start justify-self-end">
|
||||||
|
{action}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
})
|
||||||
|
CardHeader.displayName = "CardHeader"
|
||||||
|
|
||||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
const CardTitle = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement> & {
|
||||||
|
as?: "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "div"
|
||||||
|
icon?: React.ReactNode
|
||||||
|
badge?: React.ReactNode
|
||||||
|
}
|
||||||
|
>(({ className, as: Component = "h3", icon, badge, children, ...props }, ref) => {
|
||||||
|
const Comp = Component
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<Comp
|
||||||
|
ref={ref}
|
||||||
data-slot="card-title"
|
data-slot="card-title"
|
||||||
className={cn("leading-none font-semibold", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
data-slot="card-description"
|
|
||||||
className={cn("text-muted-foreground text-sm", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
data-slot="card-action"
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
"leading-none font-semibold text-xl flex items-center gap-3",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
>
|
||||||
|
{icon && (
|
||||||
|
<span className="flex-shrink-0 w-8 h-8 rounded-lg bg-primary/10 flex items-center justify-center text-primary">
|
||||||
|
{icon}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="flex-1">{children}</span>
|
||||||
|
{badge && (
|
||||||
|
<span className="flex-shrink-0">
|
||||||
|
{badge}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Comp>
|
||||||
)
|
)
|
||||||
}
|
})
|
||||||
|
CardTitle.displayName = "CardTitle"
|
||||||
|
|
||||||
|
const CardDescription = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLParagraphElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<p
|
||||||
|
ref={ref}
|
||||||
|
data-slot="card-description"
|
||||||
|
className={cn("text-sm text-muted-foreground leading-relaxed", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardDescription.displayName = "CardDescription"
|
||||||
|
|
||||||
|
const CardAction = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
data-slot="card-action"
|
||||||
|
className={cn(
|
||||||
|
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardAction.displayName = "CardAction"
|
||||||
|
|
||||||
|
const CardContent = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement> & {
|
||||||
|
noPadding?: boolean
|
||||||
|
}
|
||||||
|
>(({ className, noPadding = false, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
data-slot="card-content"
|
||||||
|
className={cn(
|
||||||
|
noPadding ? "" : "px-6",
|
||||||
|
"relative",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardContent.displayName = "CardContent"
|
||||||
|
|
||||||
|
const CardFooter = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement> & {
|
||||||
|
sticky?: boolean
|
||||||
|
}
|
||||||
|
>(({ className, sticky = false, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
data-slot="card-footer"
|
||||||
|
className={cn(
|
||||||
|
"flex items-center px-6 py-4",
|
||||||
|
sticky && "sticky bottom-0 bg-card/95 backdrop-blur-sm border-t border-border/50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardFooter.displayName = "CardFooter"
|
||||||
|
|
||||||
|
// Enhanced Card Components
|
||||||
|
const CardStats = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement> & {
|
||||||
|
title: string
|
||||||
|
value: string | number
|
||||||
|
change?: {
|
||||||
|
value: number
|
||||||
|
type: "increase" | "decrease"
|
||||||
|
period?: string
|
||||||
|
}
|
||||||
|
icon?: React.ReactNode
|
||||||
|
trend?: "up" | "down" | "stable"
|
||||||
|
}
|
||||||
|
>(({ className, title, value, change, icon, trend, ...props }, ref) => {
|
||||||
|
const trendColors = {
|
||||||
|
up: "text-success",
|
||||||
|
down: "text-destructive",
|
||||||
|
stable: "text-muted-foreground"
|
||||||
|
}
|
||||||
|
|
||||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot="card-content"
|
ref={ref}
|
||||||
className={cn("px-6", className)}
|
className={cn("p-6 space-y-4", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium text-muted-foreground">{title}</span>
|
||||||
|
{icon && (
|
||||||
|
<span className="w-8 h-8 rounded-lg bg-primary/10 flex items-center justify-center text-primary">
|
||||||
|
{icon}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-2xl font-bold text-card-foreground">
|
||||||
|
{value}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{change && (
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<span className={cn(
|
||||||
|
"flex items-center gap-1",
|
||||||
|
change.type === "increase" ? "text-success" : "text-destructive"
|
||||||
|
)}>
|
||||||
|
{change.type === "increase" ? "↑" : "↓"} {Math.abs(change.value)}%
|
||||||
|
</span>
|
||||||
|
{change.period && (
|
||||||
|
<span className="text-muted-foreground">{change.period}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{trend && (
|
||||||
|
<div className={cn("text-sm", trendColors[trend])}>
|
||||||
|
{trend === "up" && "↗ Trending up"}
|
||||||
|
{trend === "down" && "↘ Trending down"}
|
||||||
|
{trend === "stable" && "→ Stable"}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
})
|
||||||
|
CardStats.displayName = "CardStats"
|
||||||
|
|
||||||
|
const CardFeature = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement> & {
|
||||||
|
icon: React.ReactNode
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
color?: "primary" | "accent" | "success" | "warning" | "destructive"
|
||||||
|
}
|
||||||
|
>(({ className, icon, title, description, color = "primary", ...props }, ref) => {
|
||||||
|
const colorClasses = {
|
||||||
|
primary: "bg-primary/10 text-primary",
|
||||||
|
accent: "bg-accent/10 text-accent",
|
||||||
|
success: "bg-success/10 text-success",
|
||||||
|
warning: "bg-warning/10 text-warning",
|
||||||
|
destructive: "bg-destructive/10 text-destructive"
|
||||||
|
}
|
||||||
|
|
||||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot="card-footer"
|
ref={ref}
|
||||||
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
className={cn("p-6 space-y-4 text-center group", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
>
|
||||||
|
<div className={cn(
|
||||||
|
"w-16 h-16 rounded-2xl flex items-center justify-center mx-auto transition-all duration-300 group-hover:scale-110 group-hover:shadow-lg",
|
||||||
|
colorClasses[color]
|
||||||
|
)}>
|
||||||
|
<span className="text-2xl">{icon}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h3 className="text-lg font-semibold text-card-foreground">
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
})
|
||||||
|
CardFeature.displayName = "CardFeature"
|
||||||
|
|
||||||
export {
|
export {
|
||||||
Card,
|
Card,
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardFooter,
|
CardFooter,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
CardAction,
|
|
||||||
CardDescription,
|
CardDescription,
|
||||||
|
CardAction,
|
||||||
CardContent,
|
CardContent,
|
||||||
|
CardStats,
|
||||||
|
CardFeature,
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,21 +1,354 @@
|
|||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
export interface InputProps
|
||||||
return (
|
extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||||
<input
|
label?: string
|
||||||
type={type}
|
error?: string
|
||||||
data-slot="input"
|
helper?: string
|
||||||
className={cn(
|
leftIcon?: React.ReactNode
|
||||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
rightIcon?: React.ReactNode
|
||||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
loading?: boolean
|
||||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
variant?: "default" | "filled" | "outlined" | "ghost"
|
||||||
className
|
}
|
||||||
)}
|
|
||||||
{...props}
|
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||||
/>
|
({
|
||||||
)
|
className,
|
||||||
}
|
type,
|
||||||
|
label,
|
||||||
|
error,
|
||||||
|
helper,
|
||||||
|
leftIcon,
|
||||||
|
rightIcon,
|
||||||
|
loading = false,
|
||||||
|
variant = "default",
|
||||||
|
...props
|
||||||
|
}, ref) => {
|
||||||
|
const [focused, setFocused] = React.useState(false)
|
||||||
|
const [filled, setFilled] = React.useState(false)
|
||||||
|
|
||||||
|
const handleFocus = (e: React.FocusEvent<HTMLInputElement>) => {
|
||||||
|
setFocused(true)
|
||||||
|
props.onFocus?.(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBlur = (e: React.FocusEvent<HTMLInputElement>) => {
|
||||||
|
setFocused(false)
|
||||||
|
setFilled(e.target.value.length > 0)
|
||||||
|
props.onBlur?.(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setFilled(e.target.value.length > 0)
|
||||||
|
props.onChange?.(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
const variants = {
|
||||||
|
default: `
|
||||||
|
bg-surface border border-border-subtle
|
||||||
|
focus:border-primary focus:ring-2 focus:ring-primary/20
|
||||||
|
hover:border-border
|
||||||
|
${error ? 'border-destructive focus:border-destructive focus:ring-destructive/20' : ''}
|
||||||
|
`,
|
||||||
|
filled: `
|
||||||
|
bg-surface-elevated border-0 border-b-2 border-border-subtle
|
||||||
|
focus:border-primary focus:ring-0
|
||||||
|
hover:border-border
|
||||||
|
${error ? 'border-b-destructive focus:border-destructive' : ''}
|
||||||
|
`,
|
||||||
|
outlined: `
|
||||||
|
bg-transparent border-2 border-border-subtle
|
||||||
|
focus:border-primary focus:ring-2 focus:ring-primary/20
|
||||||
|
hover:border-border
|
||||||
|
${error ? 'border-destructive focus:border-destructive focus:ring-destructive/20' : ''}
|
||||||
|
`,
|
||||||
|
ghost: `
|
||||||
|
bg-transparent border-0 border-b border-border-subtle
|
||||||
|
focus:border-primary focus:ring-0
|
||||||
|
hover:border-border
|
||||||
|
${error ? 'border-b-destructive focus:border-destructive' : ''}
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputClasses = cn(
|
||||||
|
"flex h-10 w-full rounded-lg px-3 py-2 text-sm transition-all duration-200 ease-out",
|
||||||
|
"file:text-foreground placeholder:text-muted-foreground",
|
||||||
|
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
"outline-none focus:outline-none",
|
||||||
|
"read-only:cursor-default read-only:bg-surface/50",
|
||||||
|
variants[variant],
|
||||||
|
leftIcon && "pl-10",
|
||||||
|
rightIcon && "pr-10",
|
||||||
|
(leftIcon && rightIcon) && "px-10",
|
||||||
|
error && "text-destructive",
|
||||||
|
className
|
||||||
|
)
|
||||||
|
|
||||||
|
const containerClasses = cn(
|
||||||
|
"relative w-full",
|
||||||
|
label && "space-y-2"
|
||||||
|
)
|
||||||
|
|
||||||
|
const labelClasses = cn(
|
||||||
|
"text-sm font-medium transition-all duration-200",
|
||||||
|
error ? "text-destructive" : "text-foreground",
|
||||||
|
focused && "text-primary",
|
||||||
|
filled && "text-muted-foreground"
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={containerClasses}>
|
||||||
|
{label && (
|
||||||
|
<label className={labelClasses}>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
{/* Left Icon */}
|
||||||
|
{leftIcon && (
|
||||||
|
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground pointer-events-none">
|
||||||
|
{leftIcon}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Input Field */}
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
className={inputClasses}
|
||||||
|
ref={ref}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
onChange={handleChange}
|
||||||
|
disabled={loading}
|
||||||
|
aria-invalid={!!error}
|
||||||
|
aria-describedby={error ? `${props.id}-error` : helper ? `${props.id}-helper` : undefined}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Right Icon or Loading */}
|
||||||
|
{(rightIcon || loading) && (
|
||||||
|
<div className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground pointer-events-none">
|
||||||
|
{loading ? (
|
||||||
|
<svg
|
||||||
|
className="animate-spin h-4 w-4"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
className="opacity-25"
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="10"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="4"
|
||||||
|
></circle>
|
||||||
|
<path
|
||||||
|
className="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
rightIcon
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Focus Ring */}
|
||||||
|
{focused && (
|
||||||
|
<div className="absolute inset-0 rounded-lg pointer-events-none">
|
||||||
|
<div className="absolute inset-0 rounded-lg border-2 border-primary/20 animate-pulse" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Helper Text */}
|
||||||
|
{helper && !error && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-1" id={`${props.id}-helper`}>
|
||||||
|
{helper}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error Message */}
|
||||||
|
{error && (
|
||||||
|
<p className="text-xs text-destructive mt-1 flex items-center gap-1" id={`${props.id}-error`}>
|
||||||
|
<svg
|
||||||
|
className="h-3 w-3 flex-shrink-0"
|
||||||
|
fill="currentColor"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z"
|
||||||
|
clipRule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Input.displayName = "Input"
|
||||||
|
|
||||||
export { Input }
|
export { Input }
|
||||||
|
|
||||||
|
// Enhanced Search Input Component
|
||||||
|
export const SearchInput = React.forwardRef<
|
||||||
|
HTMLInputElement,
|
||||||
|
Omit<InputProps, 'leftIcon'> & {
|
||||||
|
onClear?: () => void
|
||||||
|
showClear?: boolean
|
||||||
|
}
|
||||||
|
>(({ className, showClear = true, onClear, value, ...props }, ref) => {
|
||||||
|
const [focused, setFocused] = React.useState(false)
|
||||||
|
|
||||||
|
const handleClear = () => {
|
||||||
|
onClear?.()
|
||||||
|
// Trigger change event
|
||||||
|
const event = new Event('input', { bubbles: true })
|
||||||
|
const input = ref as React.RefObject<HTMLInputElement>
|
||||||
|
if (input?.current) {
|
||||||
|
input.current.value = ''
|
||||||
|
input.current.dispatchEvent(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
ref={ref}
|
||||||
|
type="search"
|
||||||
|
leftIcon={
|
||||||
|
<svg
|
||||||
|
className="h-4 w-4"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
rightIcon={
|
||||||
|
showClear && value && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleClear}
|
||||||
|
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="h-4 w-4"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M6 18L18 6M6 6l12 12"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className={cn(
|
||||||
|
"pl-10 pr-10",
|
||||||
|
focused && "shadow-lg shadow-primary/10",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
onFocus={(e) => {
|
||||||
|
setFocused(true)
|
||||||
|
props.onFocus?.(e)
|
||||||
|
}}
|
||||||
|
onBlur={(e) => {
|
||||||
|
setFocused(false)
|
||||||
|
props.onBlur?.(e)
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
SearchInput.displayName = "SearchInput"
|
||||||
|
|
||||||
|
// File Input Component
|
||||||
|
export const FileInput = React.forwardRef<
|
||||||
|
HTMLInputElement,
|
||||||
|
Omit<InputProps, 'type'> & {
|
||||||
|
accept?: string
|
||||||
|
multiple?: boolean
|
||||||
|
onFileSelect?: (files: FileList | null) => void
|
||||||
|
}
|
||||||
|
>(({ className, onFileSelect, accept, multiple = false, ...props }, ref) => {
|
||||||
|
const [dragActive, setDragActive] = React.useState(false)
|
||||||
|
const [fileName, setFileName] = React.useState<string>("")
|
||||||
|
|
||||||
|
const handleDrag = (e: React.DragEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
if (e.type === "dragenter" || e.type === "dragover") {
|
||||||
|
setDragActive(true)
|
||||||
|
} else if (e.type === "dragleave") {
|
||||||
|
setDragActive(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDrop = (e: React.DragEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
setDragActive(false)
|
||||||
|
|
||||||
|
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
|
||||||
|
setFileName(e.dataTransfer.files[0].name)
|
||||||
|
onFileSelect?.(e.dataTransfer.files)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
if (e.target.files && e.target.files[0]) {
|
||||||
|
setFileName(e.target.files[0].name)
|
||||||
|
onFileSelect?.(e.target.files)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
ref={ref}
|
||||||
|
type="file"
|
||||||
|
accept={accept}
|
||||||
|
multiple={multiple}
|
||||||
|
className={cn(
|
||||||
|
"file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-medium file:bg-primary file:text-primary-foreground hover:file:bg-primary/90 cursor-pointer",
|
||||||
|
dragActive && "border-primary bg-primary/5",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
onDragEnter={handleDrag}
|
||||||
|
onDragLeave={handleDrag}
|
||||||
|
onDragOver={handleDrag}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
onChange={handleChange}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{fileName && (
|
||||||
|
<div className="absolute inset-y-0 right-0 flex items-center pr-3">
|
||||||
|
<span className="text-xs text-muted-foreground truncate max-w-32">
|
||||||
|
{fileName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
FileInput.displayName = "FileInput"
|
||||||
|
|||||||
325
frontend/src/components/ui/notification.tsx
Normal file
325
frontend/src/components/ui/notification.tsx
Normal file
@ -0,0 +1,325 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
import { X, CheckCircle, AlertCircle, AlertTriangle, Info, Loader2 } from "lucide-react"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const notificationVariants = cva(
|
||||||
|
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-lg border p-4 pr-8 shadow-lg transition-all duration-300 ease-out",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "border-border bg-card text-foreground",
|
||||||
|
destructive: "border-destructive bg-destructive text-destructive-foreground",
|
||||||
|
success: "border-success bg-success text-success-foreground",
|
||||||
|
warning: "border-warning bg-warning text-warning-foreground",
|
||||||
|
info: "border-primary bg-primary text-primary-foreground",
|
||||||
|
glass: "glass text-foreground border-border/20",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "max-w-md",
|
||||||
|
sm: "max-w-sm",
|
||||||
|
lg: "max-w-lg",
|
||||||
|
xl: "max-w-xl",
|
||||||
|
full: "max-w-full",
|
||||||
|
},
|
||||||
|
position: {
|
||||||
|
"top-right": "fixed top-4 right-4 z-50",
|
||||||
|
"top-left": "fixed top-4 left-4 z-50",
|
||||||
|
"bottom-right": "fixed bottom-4 right-4 z-50",
|
||||||
|
"bottom-left": "fixed bottom-4 left-4 z-50",
|
||||||
|
"top-center": "fixed top-4 left-1/2 transform -translate-x-1/2 z-50",
|
||||||
|
"bottom-center": "fixed bottom-4 left-1/2 transform -translate-x-1/2 z-50",
|
||||||
|
"center": "fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 z-50",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
position: "top-right",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export interface NotificationProps
|
||||||
|
extends React.HTMLAttributes<HTMLDivElement>,
|
||||||
|
VariantProps<typeof notificationVariants> {
|
||||||
|
title?: string
|
||||||
|
description?: string
|
||||||
|
action?: React.ReactNode
|
||||||
|
icon?: React.ReactNode
|
||||||
|
loading?: boolean
|
||||||
|
closable?: boolean
|
||||||
|
autoClose?: boolean
|
||||||
|
duration?: number
|
||||||
|
onClose?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const Notification = React.forwardRef<HTMLDivElement, NotificationProps>(
|
||||||
|
({
|
||||||
|
className,
|
||||||
|
variant,
|
||||||
|
size,
|
||||||
|
position,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
action,
|
||||||
|
icon,
|
||||||
|
loading = false,
|
||||||
|
closable = true,
|
||||||
|
autoClose = true,
|
||||||
|
duration = 5000,
|
||||||
|
onClose,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}, ref) => {
|
||||||
|
const [visible, setVisible] = React.useState(true)
|
||||||
|
const [progress, setProgress] = React.useState(100)
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (autoClose && !loading) {
|
||||||
|
const startTime = Date.now()
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
const elapsed = Date.now() - startTime
|
||||||
|
const remaining = Math.max(0, 100 - (elapsed / duration) * 100)
|
||||||
|
setProgress(remaining)
|
||||||
|
|
||||||
|
if (remaining === 0) {
|
||||||
|
clearInterval(interval)
|
||||||
|
setVisible(false)
|
||||||
|
onClose?.()
|
||||||
|
}
|
||||||
|
}, 50)
|
||||||
|
|
||||||
|
return () => clearInterval(interval)
|
||||||
|
}
|
||||||
|
}, [autoClose, loading, duration, onClose])
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setVisible(false)
|
||||||
|
onClose?.()
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultIcons = {
|
||||||
|
default: <Info className="h-5 w-5" />,
|
||||||
|
destructive: <AlertCircle className="h-5 w-5" />,
|
||||||
|
success: <CheckCircle className="h-5 w-5" />,
|
||||||
|
warning: <AlertTriangle className="h-5 w-5" />,
|
||||||
|
info: <Info className="h-5 w-5" />,
|
||||||
|
glass: <Info className="h-5 w-5" />,
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayIcon = icon || defaultIcons[variant as keyof typeof defaultIcons] || defaultIcons.default
|
||||||
|
|
||||||
|
if (!visible) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(notificationVariants({ variant, size, position }), className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{/* Progress Bar for Auto-close */}
|
||||||
|
{autoClose && !loading && (
|
||||||
|
<div className="absolute top-0 left-0 h-1 bg-white/20">
|
||||||
|
<div
|
||||||
|
className="h-full bg-white/40 transition-all duration-100 ease-linear"
|
||||||
|
style={{ width: `${progress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid gap-3">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
{/* Icon */}
|
||||||
|
<div className={cn(
|
||||||
|
"flex-shrink-0 w-10 h-10 rounded-lg flex items-center justify-center",
|
||||||
|
variant === "success" && "bg-success/20 text-success",
|
||||||
|
variant === "destructive" && "bg-destructive/20 text-destructive",
|
||||||
|
variant === "warning" && "bg-warning/20 text-warning",
|
||||||
|
variant === "info" && "bg-primary/20 text-primary",
|
||||||
|
variant === "default" && "bg-muted text-muted-foreground",
|
||||||
|
variant === "glass" && "bg-surface/50 text-foreground"
|
||||||
|
)}>
|
||||||
|
{loading ? (
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
displayIcon
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="grid gap-1 flex-1 min-w-0">
|
||||||
|
{title && (
|
||||||
|
<div className="text-sm font-semibold leading-none">
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{description && (
|
||||||
|
<div className="text-sm opacity-90 leading-relaxed">
|
||||||
|
{description}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action */}
|
||||||
|
{action && (
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
{action}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Close Button */}
|
||||||
|
{closable && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleClose}
|
||||||
|
className="absolute right-2 top-2 flex-shrink-0 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
||||||
|
aria-label="Close notification"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Notification.displayName = "Notification"
|
||||||
|
|
||||||
|
// Notification Context
|
||||||
|
interface NotificationContextType {
|
||||||
|
notifications: Array<{
|
||||||
|
id: string
|
||||||
|
title?: string
|
||||||
|
description?: string
|
||||||
|
variant?: VariantProps<typeof notificationVariants>["variant"]
|
||||||
|
duration?: number
|
||||||
|
action?: React.ReactNode
|
||||||
|
icon?: React.ReactNode
|
||||||
|
closable?: boolean
|
||||||
|
autoClose?: boolean
|
||||||
|
}>
|
||||||
|
notify: (notification: Omit<NotificationContextType["notifications"][0], "id">) => void
|
||||||
|
success: (notification: Omit<NotificationContextType["notifications"][0], "variant">) => void
|
||||||
|
error: (notification: Omit<NotificationContextType["notifications"][0], "variant">) => void
|
||||||
|
warning: (notification: Omit<NotificationContextType["notifications"][0], "variant">) => void
|
||||||
|
info: (notification: Omit<NotificationContextType["notifications"][0], "variant">) => void
|
||||||
|
dismiss: (id: string) => void
|
||||||
|
dismissAll: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const NotificationContext = React.createContext<NotificationContextType | undefined>(undefined)
|
||||||
|
|
||||||
|
export function NotificationProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [notifications, setNotifications] = React.useState<NotificationContextType["notifications"]>([])
|
||||||
|
|
||||||
|
const notify = React.useCallback(
|
||||||
|
(notification: Omit<NotificationContextType["notifications"][0], "id">) => {
|
||||||
|
const id = Math.random().toString(36).substr(2, 9)
|
||||||
|
setNotifications(prev => [...prev, { ...notification, id }])
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
const success = React.useCallback(
|
||||||
|
(notification: Omit<NotificationContextType["notifications"][0], "variant">) =>
|
||||||
|
notify({ ...notification, variant: "success" }),
|
||||||
|
[notify]
|
||||||
|
)
|
||||||
|
|
||||||
|
const error = React.useCallback(
|
||||||
|
(notification: Omit<NotificationContextType["notifications"][0], "variant">) =>
|
||||||
|
notify({ ...notification, variant: "destructive" }),
|
||||||
|
[notify]
|
||||||
|
)
|
||||||
|
|
||||||
|
const warning = React.useCallback(
|
||||||
|
(notification: Omit<NotificationContextType["notifications"][0], "variant">) =>
|
||||||
|
notify({ ...notification, variant: "warning" }),
|
||||||
|
[notify]
|
||||||
|
)
|
||||||
|
|
||||||
|
const info = React.useCallback(
|
||||||
|
(notification: Omit<NotificationContextType["notifications"][0], "variant">) =>
|
||||||
|
notify({ ...notification, variant: "info" }),
|
||||||
|
[notify]
|
||||||
|
)
|
||||||
|
|
||||||
|
const dismiss = React.useCallback((id: string) => {
|
||||||
|
setNotifications(prev => prev.filter(n => n.id !== id))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const dismissAll = React.useCallback(() => {
|
||||||
|
setNotifications([])
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const value = React.useMemo(() => ({
|
||||||
|
notifications,
|
||||||
|
notify,
|
||||||
|
success,
|
||||||
|
error,
|
||||||
|
warning,
|
||||||
|
info,
|
||||||
|
dismiss,
|
||||||
|
dismissAll,
|
||||||
|
}), [notifications, notify, success, error, warning, info, dismiss, dismissAll])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NotificationContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
<NotificationContainer />
|
||||||
|
</NotificationContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useNotification() {
|
||||||
|
const context = React.useContext(NotificationContext)
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error("useNotification must be used within a NotificationProvider")
|
||||||
|
}
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notification Container
|
||||||
|
function NotificationContainer() {
|
||||||
|
const { notifications, dismiss } = useNotification()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed top-0 right-0 z-50 flex flex-col-reverse p-4 space-y-2 pointer-events-none">
|
||||||
|
{notifications.map((notification) => (
|
||||||
|
<Notification
|
||||||
|
key={notification.id}
|
||||||
|
position="top-right"
|
||||||
|
variant={notification.variant}
|
||||||
|
title={notification.title}
|
||||||
|
description={notification.description}
|
||||||
|
action={notification.action}
|
||||||
|
icon={notification.icon}
|
||||||
|
closable={notification.closable}
|
||||||
|
autoClose={notification.autoClose}
|
||||||
|
duration={notification.duration}
|
||||||
|
onClose={() => dismiss(notification.id)}
|
||||||
|
className="pointer-events-auto animate-in slide-in-from-top-2"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skeleton Notification for Loading States
|
||||||
|
export const NotificationSkeleton = () => (
|
||||||
|
<div className="w-full max-w-md rounded-lg border border-border bg-card p-4 shadow-lg">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-border skeleton" />
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
<div className="h-4 w-3/4 rounded bg-border skeleton" />
|
||||||
|
<div className="h-3 w-1/2 rounded bg-border skeleton" />
|
||||||
|
</div>
|
||||||
|
<div className="w-6 h-6 rounded bg-border skeleton" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
export { Notification, notificationVariants }
|
||||||
313
frontend/src/components/ui/toast.tsx
Normal file
313
frontend/src/components/ui/toast.tsx
Normal file
@ -0,0 +1,313 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as ToastPrimitives from "@radix-ui/react-toast"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
import { X, CheckCircle, AlertCircle, AlertTriangle, Info } from "lucide-react"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const toastVariants = cva(
|
||||||
|
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-lg border p-6 pr-8 shadow-lg transition-all data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=cancel]:translate-x-0 data-[swipe=end]:animate-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:animate-out",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "border-border bg-card text-foreground",
|
||||||
|
destructive: "border-destructive bg-destructive text-destructive-foreground",
|
||||||
|
success: "border-success bg-success text-success-foreground",
|
||||||
|
warning: "border-warning bg-warning text-warning-foreground",
|
||||||
|
info: "border-primary bg-primary text-primary-foreground",
|
||||||
|
glass: "glass text-foreground border-border/20",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "max-w-md",
|
||||||
|
sm: "max-w-sm",
|
||||||
|
lg: "max-w-lg",
|
||||||
|
xl: "max-w-xl",
|
||||||
|
full: "max-w-full",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const ToastProvider = ToastPrimitives.Provider
|
||||||
|
|
||||||
|
const ToastViewport = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToastPrimitives.Viewport>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ToastPrimitives.Viewport
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
|
||||||
|
|
||||||
|
const Toast = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToastPrimitives.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
|
||||||
|
VariantProps<typeof toastVariants> & {
|
||||||
|
icon?: React.ReactNode
|
||||||
|
title?: string
|
||||||
|
description?: string
|
||||||
|
action?: React.ReactNode
|
||||||
|
duration?: number
|
||||||
|
}
|
||||||
|
>(({ className, variant, size, icon, title, description, action, duration = 5000, ...props }, ref) => {
|
||||||
|
const [open, setOpen] = React.useState(true)
|
||||||
|
|
||||||
|
const defaultIcons = {
|
||||||
|
default: <Info className="h-5 w-5" />,
|
||||||
|
destructive: <AlertCircle className="h-5 w-5" />,
|
||||||
|
success: <CheckCircle className="h-5 w-5" />,
|
||||||
|
warning: <AlertTriangle className="h-5 w-5" />,
|
||||||
|
info: <Info className="h-5 w-5" />,
|
||||||
|
glass: <Info className="h-5 w-5" />,
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayIcon = icon || defaultIcons[variant as keyof typeof defaultIcons] || defaultIcons.default
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToastPrimitives.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(toastVariants({ variant, size }), className)}
|
||||||
|
duration={duration}
|
||||||
|
open={open}
|
||||||
|
onOpenChange={setOpen}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div className="grid gap-1">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{displayIcon && (
|
||||||
|
<div className={cn(
|
||||||
|
"flex-shrink-0 w-10 h-10 rounded-lg flex items-center justify-center",
|
||||||
|
variant === "success" && "bg-success/20 text-success",
|
||||||
|
variant === "destructive" && "bg-destructive/20 text-destructive",
|
||||||
|
variant === "warning" && "bg-warning/20 text-warning",
|
||||||
|
variant === "info" && "bg-primary/20 text-primary",
|
||||||
|
variant === "default" && "bg-muted text-muted-foreground",
|
||||||
|
variant === "glass" && "bg-surface/50 text-foreground"
|
||||||
|
)}>
|
||||||
|
{displayIcon}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid gap-1 flex-1">
|
||||||
|
{title && (
|
||||||
|
<ToastPrimitives.Title className="text-sm font-semibold leading-none">
|
||||||
|
{title}
|
||||||
|
</ToastPrimitives.Title>
|
||||||
|
)}
|
||||||
|
{description && (
|
||||||
|
<ToastPrimitives.Description className="text-sm opacity-90 leading-relaxed">
|
||||||
|
{description}
|
||||||
|
</ToastPrimitives.Description>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{action && (
|
||||||
|
<ToastPrimitives.Action
|
||||||
|
className={cn(
|
||||||
|
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive"
|
||||||
|
)}
|
||||||
|
alt={typeof action === 'string' ? action : undefined}
|
||||||
|
>
|
||||||
|
{action}
|
||||||
|
</ToastPrimitives.Action>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ToastPrimitives.Close className="absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600">
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</ToastPrimitives.Close>
|
||||||
|
</ToastPrimitives.Root>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
Toast.displayName = ToastPrimitives.Root.displayName
|
||||||
|
|
||||||
|
const ToastAction = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToastPrimitives.Action>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ToastPrimitives.Action
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
ToastAction.displayName = ToastPrimitives.Action.displayName
|
||||||
|
|
||||||
|
const ToastTitle = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToastPrimitives.Title>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ToastPrimitives.Title
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
ToastTitle.displayName = ToastPrimitives.Title.displayName
|
||||||
|
|
||||||
|
const ToastDescription = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToastPrimitives.Description>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ToastPrimitives.Description
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm opacity-90", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
ToastDescription.displayName = ToastPrimitives.Description.displayName
|
||||||
|
|
||||||
|
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
|
||||||
|
|
||||||
|
type ToastActionElement = React.ReactElement<{
|
||||||
|
altText: string
|
||||||
|
onClick: () => void
|
||||||
|
}>
|
||||||
|
|
||||||
|
// Enhanced Toast Hook
|
||||||
|
export function useToast() {
|
||||||
|
const [toasts, setToasts] = React.useState<Array<{
|
||||||
|
id: string
|
||||||
|
title?: string
|
||||||
|
description?: string
|
||||||
|
variant?: VariantProps<typeof toastVariants>["variant"]
|
||||||
|
duration?: number
|
||||||
|
action?: ToastActionElement
|
||||||
|
icon?: React.ReactNode
|
||||||
|
}>>([])
|
||||||
|
|
||||||
|
const toast = React.useCallback(
|
||||||
|
({ title, description, variant = "default", duration = 5000, action, icon }: Omit<ToastProps, "id">) => {
|
||||||
|
const id = Math.random().toString(36).substr(2, 9)
|
||||||
|
|
||||||
|
setToasts(prev => [...prev, {
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
variant,
|
||||||
|
duration,
|
||||||
|
action,
|
||||||
|
icon,
|
||||||
|
}])
|
||||||
|
|
||||||
|
// Auto remove after duration
|
||||||
|
setTimeout(() => {
|
||||||
|
setToasts(prev => prev.filter(toast => toast.id !== id))
|
||||||
|
}, duration)
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
const success = React.useCallback(
|
||||||
|
(props: Omit<ToastProps, "variant">) =>
|
||||||
|
toast({ ...props, variant: "success" }),
|
||||||
|
[toast]
|
||||||
|
)
|
||||||
|
|
||||||
|
const error = React.useCallback(
|
||||||
|
(props: Omit<ToastProps, "variant">) =>
|
||||||
|
toast({ ...props, variant: "destructive" }),
|
||||||
|
[toast]
|
||||||
|
)
|
||||||
|
|
||||||
|
const warning = React.useCallback(
|
||||||
|
(props: Omit<ToastProps, "variant">) =>
|
||||||
|
toast({ ...props, variant: "warning" }),
|
||||||
|
[toast]
|
||||||
|
)
|
||||||
|
|
||||||
|
const info = React.useCallback(
|
||||||
|
(props: Omit<ToastProps, "variant">) =>
|
||||||
|
toast({ ...props, variant: "info" }),
|
||||||
|
[toast]
|
||||||
|
)
|
||||||
|
|
||||||
|
const dismiss = React.useCallback((id: string) => {
|
||||||
|
setToasts(prev => prev.filter(toast => toast.id !== id))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const dismissAll = React.useCallback(() => {
|
||||||
|
setToasts([])
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return {
|
||||||
|
toast,
|
||||||
|
success,
|
||||||
|
error,
|
||||||
|
warning,
|
||||||
|
info,
|
||||||
|
dismiss,
|
||||||
|
dismissAll,
|
||||||
|
toasts,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toast Container Component
|
||||||
|
export const ToastContainer = ({ children }: { children: React.ReactNode }) => {
|
||||||
|
return (
|
||||||
|
<ToastProvider>
|
||||||
|
{children}
|
||||||
|
<ToastViewport />
|
||||||
|
</ToastProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Individual Toast Component for use in ToastContainer
|
||||||
|
export const ToastItem = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
{
|
||||||
|
toast: {
|
||||||
|
id: string
|
||||||
|
title?: string
|
||||||
|
description?: string
|
||||||
|
variant?: VariantProps<typeof toastVariants>["variant"]
|
||||||
|
duration?: number
|
||||||
|
action?: ToastActionElement
|
||||||
|
icon?: React.ReactNode
|
||||||
|
}
|
||||||
|
onDismiss: (id: string) => void
|
||||||
|
}
|
||||||
|
>(({ toast, onDismiss, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<Toast
|
||||||
|
ref={ref}
|
||||||
|
variant={toast.variant}
|
||||||
|
title={toast.title}
|
||||||
|
description={toast.description}
|
||||||
|
duration={toast.duration}
|
||||||
|
icon={toast.icon}
|
||||||
|
action={toast.action}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
onDismiss(toast.id)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
ToastItem.displayName = "ToastItem"
|
||||||
|
|
||||||
|
export {
|
||||||
|
type ToastProps,
|
||||||
|
ToastProvider,
|
||||||
|
ToastViewport,
|
||||||
|
Toast,
|
||||||
|
ToastTitle,
|
||||||
|
ToastDescription,
|
||||||
|
ToastClose,
|
||||||
|
ToastAction,
|
||||||
|
}
|
||||||
134
frontend/tailwind.config.js
Normal file
134
frontend/tailwind.config.js
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
module.exports = {
|
||||||
|
content: [
|
||||||
|
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
|
||||||
|
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
|
||||||
|
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
// Enhanced Dark Theme Colors
|
||||||
|
background: '#0a0a0a',
|
||||||
|
surface: '#141414',
|
||||||
|
'surface-elevated': '#1a1a1a',
|
||||||
|
'surface-hover': '#1f1f1f',
|
||||||
|
foreground: '#fafafa',
|
||||||
|
'text-primary': '#fafafa',
|
||||||
|
'text-secondary': '#a1a1aa',
|
||||||
|
'text-tertiary': '#71717a',
|
||||||
|
'text-inverse': '#0a0a0a',
|
||||||
|
'text-muted': '#71717a',
|
||||||
|
|
||||||
|
card: '#141414',
|
||||||
|
'card-foreground': '#fafafa',
|
||||||
|
popover: '#141414',
|
||||||
|
'popover-foreground': '#fafafa',
|
||||||
|
|
||||||
|
primary: '#3b82f6',
|
||||||
|
'primary-hover': '#2563eb',
|
||||||
|
'primary-light': '#60a5fa',
|
||||||
|
'primary-foreground': '#ffffff',
|
||||||
|
|
||||||
|
accent: '#8b5cf6',
|
||||||
|
'accent-hover': '#7c3aed',
|
||||||
|
'accent-foreground': '#ffffff',
|
||||||
|
|
||||||
|
secondary: '#272727',
|
||||||
|
'secondary-foreground': '#fafafa',
|
||||||
|
|
||||||
|
muted: '#1f1f1f',
|
||||||
|
'muted-foreground': '#71717a',
|
||||||
|
|
||||||
|
border: '#272727',
|
||||||
|
'border-subtle': '#1f1f1f',
|
||||||
|
'border-strong': '#2f2f2f',
|
||||||
|
input: '#272727',
|
||||||
|
|
||||||
|
destructive: '#ef4444',
|
||||||
|
'destructive-foreground': '#ffffff',
|
||||||
|
success: '#10b981',
|
||||||
|
'success-foreground': '#ffffff',
|
||||||
|
warning: '#f59e0b',
|
||||||
|
'warning-foreground': '#000000',
|
||||||
|
|
||||||
|
ring: '#3b82f6',
|
||||||
|
'ring-offset': '#0a0a0a',
|
||||||
|
|
||||||
|
sidebar: '#0f0f0f',
|
||||||
|
'sidebar-foreground': '#fafafa',
|
||||||
|
'sidebar-primary': '#3b82f6',
|
||||||
|
'sidebar-primary-foreground': '#ffffff',
|
||||||
|
'sidebar-accent': '#272727',
|
||||||
|
'sidebar-accent-foreground': '#fafafa',
|
||||||
|
'sidebar-border': '#1f1f1f',
|
||||||
|
'sidebar-ring': '#3b82f6',
|
||||||
|
},
|
||||||
|
fontFamily: {
|
||||||
|
sans: ['Inter', 'system-ui', '-apple-system', 'sans-serif'],
|
||||||
|
mono: ['JetBrains Mono', 'SF Mono', 'Monaco', 'Cascadia Code', 'monospace'],
|
||||||
|
},
|
||||||
|
fontSize: {
|
||||||
|
xs: '0.75rem', // 12px
|
||||||
|
sm: '0.875rem', // 14px
|
||||||
|
base: '1rem', // 16px
|
||||||
|
lg: '1.125rem', // 18px
|
||||||
|
xl: '1.25rem', // 20px
|
||||||
|
'2xl': '1.5rem', // 24px
|
||||||
|
'3xl': '1.875rem', // 30px
|
||||||
|
'4xl': '2.25rem', // 36px
|
||||||
|
'5xl': '3rem', // 48px
|
||||||
|
'6xl': '3.75rem', // 60px
|
||||||
|
},
|
||||||
|
lineHeight: {
|
||||||
|
tight: '1.25',
|
||||||
|
normal: '1.5',
|
||||||
|
relaxed: '1.625',
|
||||||
|
},
|
||||||
|
spacing: {
|
||||||
|
xs: '0.25rem', // 4px
|
||||||
|
sm: '0.5rem', // 8px
|
||||||
|
md: '0.75rem', // 12px
|
||||||
|
lg: '1rem', // 16px
|
||||||
|
xl: '1.5rem', // 24px
|
||||||
|
'2xl': '2rem', // 32px
|
||||||
|
'3xl': '3rem', // 48px
|
||||||
|
'4xl': '4rem', // 64px
|
||||||
|
},
|
||||||
|
borderRadius: {
|
||||||
|
sm: 'calc(var(--radius) - 4px)',
|
||||||
|
md: 'calc(var(--radius) - 2px)',
|
||||||
|
lg: 'var(--radius)',
|
||||||
|
xl: 'calc(var(--radius) + 4px)',
|
||||||
|
},
|
||||||
|
boxShadow: {
|
||||||
|
sm: '0 1px 2px 0 rgb(0 0 0 / 0.05)',
|
||||||
|
md: '0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)',
|
||||||
|
lg: '0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)',
|
||||||
|
xl: '0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1)',
|
||||||
|
glow: '0 0 20px rgb(59 130 246 / 0.15)',
|
||||||
|
},
|
||||||
|
animationDuration: {
|
||||||
|
fast: '150ms',
|
||||||
|
normal: '200ms',
|
||||||
|
slow: '300ms',
|
||||||
|
slower: '500ms',
|
||||||
|
},
|
||||||
|
animationTimingFunction: {
|
||||||
|
easeOut: 'cubic-bezier(0.25, 0.46, 0.45, 0.94)',
|
||||||
|
easeIn: 'cubic-bezier(0.55, 0.055, 0.675, 0.19)',
|
||||||
|
easeInOut: 'cubic-bezier(0.645, 0.045, 0.355, 1)',
|
||||||
|
},
|
||||||
|
screens: {
|
||||||
|
xs: '640px',
|
||||||
|
sm: '768px',
|
||||||
|
md: '1024px',
|
||||||
|
lg: '1280px',
|
||||||
|
xl: '1440px',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [
|
||||||
|
require('@tailwindcss/postcss'),
|
||||||
|
],
|
||||||
|
}
|
||||||
105
main.py
105
main.py
@ -41,13 +41,37 @@ logging.basicConfig(
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# ============== Admin Authentication ==============
|
# ============== Admin Authentication ==============
|
||||||
ADMIN_USERNAME = os.getenv("ADMIN_USERNAME", "admin")
|
ADMIN_USERNAME = os.getenv("ADMIN_USERNAME")
|
||||||
ADMIN_PASSWORD_HASH = os.getenv("ADMIN_PASSWORD_HASH", "") # SHA256 hash of password
|
ADMIN_PASSWORD_HASH = os.getenv("ADMIN_PASSWORD_HASH") # SHA256 hash of password (preferred)
|
||||||
ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD", "changeme123") # Default password (change in production!)
|
ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD") # Plain password (use hash in production!)
|
||||||
ADMIN_TOKEN_SECRET = os.getenv("ADMIN_TOKEN_SECRET", secrets.token_hex(32))
|
ADMIN_TOKEN_SECRET = os.getenv("ADMIN_TOKEN_SECRET", secrets.token_hex(32))
|
||||||
|
|
||||||
# Store active admin sessions (token -> expiry timestamp)
|
# Validate admin credentials are configured
|
||||||
admin_sessions: dict = {}
|
if not ADMIN_USERNAME:
|
||||||
|
logger.warning("⚠️ ADMIN_USERNAME not set - admin endpoints will be disabled")
|
||||||
|
if not ADMIN_PASSWORD_HASH and not ADMIN_PASSWORD:
|
||||||
|
logger.warning("⚠️ ADMIN_PASSWORD/ADMIN_PASSWORD_HASH not set - admin endpoints will be disabled")
|
||||||
|
|
||||||
|
# Redis connection for sessions (fallback to in-memory if not available)
|
||||||
|
REDIS_URL = os.getenv("REDIS_URL", "")
|
||||||
|
_redis_client = None
|
||||||
|
|
||||||
|
def get_redis_client():
|
||||||
|
"""Get Redis client for session storage"""
|
||||||
|
global _redis_client
|
||||||
|
if _redis_client is None and REDIS_URL:
|
||||||
|
try:
|
||||||
|
import redis
|
||||||
|
_redis_client = redis.from_url(REDIS_URL, decode_responses=True)
|
||||||
|
_redis_client.ping()
|
||||||
|
logger.info("✅ Connected to Redis for session storage")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"⚠️ Redis connection failed: {e}. Using in-memory sessions.")
|
||||||
|
_redis_client = False # Mark as failed
|
||||||
|
return _redis_client if _redis_client else None
|
||||||
|
|
||||||
|
# In-memory fallback for sessions (not recommended for production)
|
||||||
|
_memory_sessions: dict = {}
|
||||||
|
|
||||||
def hash_password(password: str) -> str:
|
def hash_password(password: str) -> str:
|
||||||
"""Hash password with SHA256"""
|
"""Hash password with SHA256"""
|
||||||
@ -55,28 +79,70 @@ def hash_password(password: str) -> str:
|
|||||||
|
|
||||||
def verify_admin_password(password: str) -> bool:
|
def verify_admin_password(password: str) -> bool:
|
||||||
"""Verify admin password"""
|
"""Verify admin password"""
|
||||||
|
if not ADMIN_PASSWORD_HASH and not ADMIN_PASSWORD:
|
||||||
|
return False # No credentials configured
|
||||||
if ADMIN_PASSWORD_HASH:
|
if ADMIN_PASSWORD_HASH:
|
||||||
return hash_password(password) == ADMIN_PASSWORD_HASH
|
return hash_password(password) == ADMIN_PASSWORD_HASH
|
||||||
return password == ADMIN_PASSWORD
|
return password == ADMIN_PASSWORD
|
||||||
|
|
||||||
|
def _get_session_key(token: str) -> str:
|
||||||
|
"""Get Redis key for session token"""
|
||||||
|
return f"admin_session:{token}"
|
||||||
|
|
||||||
def create_admin_token() -> str:
|
def create_admin_token() -> str:
|
||||||
"""Create a new admin session token"""
|
"""Create a new admin session token with Redis or memory fallback"""
|
||||||
token = secrets.token_urlsafe(32)
|
token = secrets.token_urlsafe(32)
|
||||||
# Token expires in 24 hours
|
expiry = int(time.time()) + (24 * 60 * 60) # 24 hours
|
||||||
admin_sessions[token] = time.time() + (24 * 60 * 60)
|
|
||||||
|
redis_client = get_redis_client()
|
||||||
|
if redis_client:
|
||||||
|
try:
|
||||||
|
redis_client.setex(_get_session_key(token), 24 * 60 * 60, str(expiry))
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Redis session save failed: {e}")
|
||||||
|
_memory_sessions[token] = expiry
|
||||||
|
else:
|
||||||
|
_memory_sessions[token] = expiry
|
||||||
|
|
||||||
return token
|
return token
|
||||||
|
|
||||||
def verify_admin_token(token: str) -> bool:
|
def verify_admin_token(token: str) -> bool:
|
||||||
"""Verify admin token is valid and not expired"""
|
"""Verify admin token is valid and not expired"""
|
||||||
if token not in admin_sessions:
|
redis_client = get_redis_client()
|
||||||
|
|
||||||
|
if redis_client:
|
||||||
|
try:
|
||||||
|
expiry = redis_client.get(_get_session_key(token))
|
||||||
|
if expiry and int(expiry) > time.time():
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Redis session check failed: {e}")
|
||||||
|
|
||||||
|
# Fallback to memory
|
||||||
|
if token not in _memory_sessions:
|
||||||
return False
|
return False
|
||||||
if time.time() > admin_sessions[token]:
|
if time.time() > _memory_sessions[token]:
|
||||||
del admin_sessions[token]
|
del _memory_sessions[token]
|
||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
def delete_admin_token(token: str):
|
||||||
|
"""Delete an admin session token"""
|
||||||
|
redis_client = get_redis_client()
|
||||||
|
if redis_client:
|
||||||
|
try:
|
||||||
|
redis_client.delete(_get_session_key(token))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if token in _memory_sessions:
|
||||||
|
del _memory_sessions[token]
|
||||||
|
|
||||||
async def require_admin(authorization: Optional[str] = Header(None)) -> bool:
|
async def require_admin(authorization: Optional[str] = Header(None)) -> bool:
|
||||||
"""Dependency to require admin authentication"""
|
"""Dependency to require admin authentication"""
|
||||||
|
if not ADMIN_USERNAME or (not ADMIN_PASSWORD_HASH and not ADMIN_PASSWORD):
|
||||||
|
raise HTTPException(status_code=503, detail="Admin authentication not configured")
|
||||||
|
|
||||||
if not authorization:
|
if not authorization:
|
||||||
raise HTTPException(status_code=401, detail="Authorization header required")
|
raise HTTPException(status_code=401, detail="Authorization header required")
|
||||||
|
|
||||||
@ -164,11 +230,19 @@ app.add_middleware(SecurityHeadersMiddleware, config={"enable_hsts": os.getenv("
|
|||||||
app.add_middleware(RateLimitMiddleware, rate_limit_manager=rate_limit_manager)
|
app.add_middleware(RateLimitMiddleware, rate_limit_manager=rate_limit_manager)
|
||||||
|
|
||||||
# CORS - configure for production
|
# CORS - configure for production
|
||||||
allowed_origins = os.getenv("CORS_ORIGINS", "*").split(",")
|
# WARNING: Do not use "*" in production! Set CORS_ORIGINS to your actual frontend domains
|
||||||
|
_cors_env = os.getenv("CORS_ORIGINS", "")
|
||||||
|
if _cors_env == "*" or not _cors_env:
|
||||||
|
logger.warning("⚠️ CORS_ORIGINS not properly configured. Using permissive settings for development only!")
|
||||||
|
allowed_origins = ["*"]
|
||||||
|
else:
|
||||||
|
allowed_origins = [origin.strip() for origin in _cors_env.split(",") if origin.strip()]
|
||||||
|
logger.info(f"✅ CORS configured for origins: {allowed_origins}")
|
||||||
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=allowed_origins,
|
allow_origins=allowed_origins,
|
||||||
allow_credentials=True,
|
allow_credentials=True if allowed_origins != ["*"] else False, # Can't use credentials with wildcard
|
||||||
allow_methods=["GET", "POST", "DELETE", "OPTIONS"],
|
allow_methods=["GET", "POST", "DELETE", "OPTIONS"],
|
||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
expose_headers=["X-Request-ID", "X-Original-Filename", "X-File-Size-MB", "X-Target-Language"]
|
expose_headers=["X-Request-ID", "X-Original-Filename", "X-File-Size-MB", "X-Target-Language"]
|
||||||
@ -891,9 +965,8 @@ async def admin_logout(authorization: Optional[str] = Header(None)):
|
|||||||
parts = authorization.split(" ")
|
parts = authorization.split(" ")
|
||||||
if len(parts) == 2 and parts[0].lower() == "bearer":
|
if len(parts) == 2 and parts[0].lower() == "bearer":
|
||||||
token = parts[1]
|
token = parts[1]
|
||||||
if token in admin_sessions:
|
delete_admin_token(token)
|
||||||
del admin_sessions[token]
|
logger.info("Admin logout successful")
|
||||||
logger.info("Admin logout successful")
|
|
||||||
|
|
||||||
return {"status": "success", "message": "Logged out"}
|
return {"status": "success", "message": "Logged out"}
|
||||||
|
|
||||||
|
|||||||
@ -22,8 +22,11 @@ class SubscriptionStatus(str, Enum):
|
|||||||
TRIALING = "trialing"
|
TRIALING = "trialing"
|
||||||
PAUSED = "paused"
|
PAUSED = "paused"
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
# Plan definitions with limits
|
# Plan definitions with limits
|
||||||
|
# NOTE: Stripe price IDs should be set via environment variables in production
|
||||||
|
# Create products and prices in Stripe Dashboard: https://dashboard.stripe.com/products
|
||||||
PLANS = {
|
PLANS = {
|
||||||
PlanType.FREE: {
|
PlanType.FREE: {
|
||||||
"name": "Free",
|
"name": "Free",
|
||||||
@ -46,8 +49,8 @@ PLANS = {
|
|||||||
},
|
},
|
||||||
PlanType.STARTER: {
|
PlanType.STARTER: {
|
||||||
"name": "Starter",
|
"name": "Starter",
|
||||||
"price_monthly": 9,
|
"price_monthly": 12, # Updated pricing
|
||||||
"price_yearly": 90, # 2 months free
|
"price_yearly": 120, # 2 months free
|
||||||
"docs_per_month": 50,
|
"docs_per_month": 50,
|
||||||
"max_pages_per_doc": 50,
|
"max_pages_per_doc": 50,
|
||||||
"max_file_size_mb": 25,
|
"max_file_size_mb": 25,
|
||||||
@ -61,17 +64,17 @@ PLANS = {
|
|||||||
],
|
],
|
||||||
"api_access": False,
|
"api_access": False,
|
||||||
"priority_processing": False,
|
"priority_processing": False,
|
||||||
"stripe_price_id_monthly": "price_starter_monthly",
|
"stripe_price_id_monthly": os.getenv("STRIPE_PRICE_STARTER_MONTHLY", ""),
|
||||||
"stripe_price_id_yearly": "price_starter_yearly",
|
"stripe_price_id_yearly": os.getenv("STRIPE_PRICE_STARTER_YEARLY", ""),
|
||||||
},
|
},
|
||||||
PlanType.PRO: {
|
PlanType.PRO: {
|
||||||
"name": "Pro",
|
"name": "Pro",
|
||||||
"price_monthly": 29,
|
"price_monthly": 39, # Updated pricing
|
||||||
"price_yearly": 290, # 2 months free
|
"price_yearly": 390, # 2 months free
|
||||||
"docs_per_month": 200,
|
"docs_per_month": 200,
|
||||||
"max_pages_per_doc": 200,
|
"max_pages_per_doc": 200,
|
||||||
"max_file_size_mb": 100,
|
"max_file_size_mb": 100,
|
||||||
"providers": ["ollama", "google", "deepl", "openai", "libre"],
|
"providers": ["ollama", "google", "deepl", "openai", "libre", "openrouter"],
|
||||||
"features": [
|
"features": [
|
||||||
"200 documents per month",
|
"200 documents per month",
|
||||||
"Up to 200 pages per document",
|
"Up to 200 pages per document",
|
||||||
@ -83,17 +86,17 @@ PLANS = {
|
|||||||
"api_access": True,
|
"api_access": True,
|
||||||
"api_calls_per_month": 1000,
|
"api_calls_per_month": 1000,
|
||||||
"priority_processing": True,
|
"priority_processing": True,
|
||||||
"stripe_price_id_monthly": "price_pro_monthly",
|
"stripe_price_id_monthly": os.getenv("STRIPE_PRICE_PRO_MONTHLY", ""),
|
||||||
"stripe_price_id_yearly": "price_pro_yearly",
|
"stripe_price_id_yearly": os.getenv("STRIPE_PRICE_PRO_YEARLY", ""),
|
||||||
},
|
},
|
||||||
PlanType.BUSINESS: {
|
PlanType.BUSINESS: {
|
||||||
"name": "Business",
|
"name": "Business",
|
||||||
"price_monthly": 79,
|
"price_monthly": 99, # Updated pricing
|
||||||
"price_yearly": 790, # 2 months free
|
"price_yearly": 990, # 2 months free
|
||||||
"docs_per_month": 1000,
|
"docs_per_month": 1000,
|
||||||
"max_pages_per_doc": 500,
|
"max_pages_per_doc": 500,
|
||||||
"max_file_size_mb": 250,
|
"max_file_size_mb": 250,
|
||||||
"providers": ["ollama", "google", "deepl", "openai", "libre", "azure"],
|
"providers": ["ollama", "google", "deepl", "openai", "libre", "openrouter", "azure"],
|
||||||
"features": [
|
"features": [
|
||||||
"1000 documents per month",
|
"1000 documents per month",
|
||||||
"Up to 500 pages per document",
|
"Up to 500 pages per document",
|
||||||
@ -108,8 +111,8 @@ PLANS = {
|
|||||||
"api_calls_per_month": -1, # Unlimited
|
"api_calls_per_month": -1, # Unlimited
|
||||||
"priority_processing": True,
|
"priority_processing": True,
|
||||||
"team_seats": 5,
|
"team_seats": 5,
|
||||||
"stripe_price_id_monthly": "price_business_monthly",
|
"stripe_price_id_monthly": os.getenv("STRIPE_PRICE_BUSINESS_MONTHLY", ""),
|
||||||
"stripe_price_id_yearly": "price_business_yearly",
|
"stripe_price_id_yearly": os.getenv("STRIPE_PRICE_BUSINESS_YEARLY", ""),
|
||||||
},
|
},
|
||||||
PlanType.ENTERPRISE: {
|
PlanType.ENTERPRISE: {
|
||||||
"name": "Enterprise",
|
"name": "Enterprise",
|
||||||
@ -118,7 +121,7 @@ PLANS = {
|
|||||||
"docs_per_month": -1, # Unlimited
|
"docs_per_month": -1, # Unlimited
|
||||||
"max_pages_per_doc": -1,
|
"max_pages_per_doc": -1,
|
||||||
"max_file_size_mb": -1,
|
"max_file_size_mb": -1,
|
||||||
"providers": ["ollama", "google", "deepl", "openai", "libre", "azure", "custom"],
|
"providers": ["ollama", "google", "deepl", "openai", "libre", "openrouter", "azure", "custom"],
|
||||||
"features": [
|
"features": [
|
||||||
"Unlimited documents",
|
"Unlimited documents",
|
||||||
"Unlimited pages",
|
"Unlimited pages",
|
||||||
|
|||||||
@ -25,3 +25,11 @@ PyJWT==2.8.0
|
|||||||
passlib[bcrypt]==1.7.4
|
passlib[bcrypt]==1.7.4
|
||||||
stripe==7.0.0
|
stripe==7.0.0
|
||||||
|
|
||||||
|
# Session storage & caching (optional but recommended for production)
|
||||||
|
redis==5.0.1
|
||||||
|
|
||||||
|
# Database (optional but recommended for production)
|
||||||
|
# sqlalchemy==2.0.25
|
||||||
|
# asyncpg==0.29.0 # PostgreSQL async driver
|
||||||
|
# alembic==1.13.1 # Database migrations
|
||||||
|
|
||||||
|
|||||||
@ -1 +0,0 @@
|
|||||||
,ramez,simorgh,30.11.2025 09:24,C:/Users/ramez/AppData/Local/onlyoffice;
|
|
||||||
@ -11,16 +11,45 @@ from config import config
|
|||||||
import concurrent.futures
|
import concurrent.futures
|
||||||
import threading
|
import threading
|
||||||
import asyncio
|
import asyncio
|
||||||
from functools import lru_cache
|
from functools import lru_cache, wraps
|
||||||
import time
|
import time
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import random
|
||||||
|
import logging
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Global thread pool for parallel translations
|
# Global thread pool for parallel translations
|
||||||
_executor = concurrent.futures.ThreadPoolExecutor(max_workers=8)
|
_executor = concurrent.futures.ThreadPoolExecutor(max_workers=8)
|
||||||
|
|
||||||
|
|
||||||
|
def retry_with_backoff(max_retries: int = 3, base_delay: float = 1.0, max_delay: float = 30.0):
|
||||||
|
"""
|
||||||
|
Decorator for retry logic with exponential backoff and jitter.
|
||||||
|
Used for API calls that may fail due to rate limiting or transient errors.
|
||||||
|
"""
|
||||||
|
def decorator(func):
|
||||||
|
@wraps(func)
|
||||||
|
def wrapper(*args, **kwargs):
|
||||||
|
last_exception = None
|
||||||
|
for attempt in range(max_retries):
|
||||||
|
try:
|
||||||
|
return func(*args, **kwargs)
|
||||||
|
except Exception as e:
|
||||||
|
last_exception = e
|
||||||
|
if attempt < max_retries - 1:
|
||||||
|
# Exponential backoff with jitter
|
||||||
|
delay = min(base_delay * (2 ** attempt) + random.uniform(0, 1), max_delay)
|
||||||
|
logger.warning(f"Retry {attempt + 1}/{max_retries} for {func.__name__} after {delay:.2f}s: {e}")
|
||||||
|
time.sleep(delay)
|
||||||
|
# All retries exhausted
|
||||||
|
logger.error(f"All {max_retries} retries failed for {func.__name__}: {last_exception}")
|
||||||
|
raise last_exception
|
||||||
|
return wrapper
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
class TranslationCache:
|
class TranslationCache:
|
||||||
"""Thread-safe LRU cache for translations to avoid redundant API calls"""
|
"""Thread-safe LRU cache for translations to avoid redundant API calls"""
|
||||||
|
|
||||||
@ -143,6 +172,11 @@ class GoogleTranslationProvider(TranslationProvider):
|
|||||||
self._local.translators[key] = GoogleTranslator(source=source_language, target=target_language)
|
self._local.translators[key] = GoogleTranslator(source=source_language, target=target_language)
|
||||||
return self._local.translators[key]
|
return self._local.translators[key]
|
||||||
|
|
||||||
|
@retry_with_backoff(max_retries=3, base_delay=1.0)
|
||||||
|
def _do_translate(self, translator: GoogleTranslator, text: str) -> str:
|
||||||
|
"""Perform translation with retry logic"""
|
||||||
|
return translator.translate(text)
|
||||||
|
|
||||||
def translate(self, text: str, target_language: str, source_language: str = 'auto') -> str:
|
def translate(self, text: str, target_language: str, source_language: str = 'auto') -> str:
|
||||||
if not text or not text.strip():
|
if not text or not text.strip():
|
||||||
return text
|
return text
|
||||||
@ -154,12 +188,12 @@ class GoogleTranslationProvider(TranslationProvider):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
translator = self._get_translator(source_language, target_language)
|
translator = self._get_translator(source_language, target_language)
|
||||||
result = translator.translate(text)
|
result = self._do_translate(translator, text)
|
||||||
# Cache the result
|
# Cache the result
|
||||||
_translation_cache.set(text, target_language, source_language, self.provider_name, result)
|
_translation_cache.set(text, target_language, source_language, self.provider_name, result)
|
||||||
return result
|
return result
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Translation error: {e}")
|
logger.error(f"Translation error: {e}")
|
||||||
return text
|
return text
|
||||||
|
|
||||||
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]:
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user