feat: add design system with colors, components, and theme configuration
- Introduced a comprehensive color palette in colors.ts, including primary, accent, success, warning, error, info, neutral, slate, and semantic colors for light and dark modes. - Created a components-registry.ts to manage UI components with metadata, including buttons, inputs, cards, layout, feedback, and navigation components. - Developed a theme.ts file to centralize typography, spacing, border radius, shadows, z-index, transitions, breakpoints, containers, and responsive utilities. - Configured Nginx for development with a new nginx.dev.conf, routing API requests to the Flask backend and frontend requests to the Vite development server.
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
"""Authenticated account endpoints — usage summary and API key management."""
|
||||
|
||||
from flask import Blueprint, jsonify, request
|
||||
|
||||
from app.extensions import limiter
|
||||
@@ -9,7 +10,15 @@ from app.services.account_service import (
|
||||
revoke_api_key,
|
||||
)
|
||||
from app.services.policy_service import get_usage_summary_for_user
|
||||
from app.services.stripe_service import (
|
||||
is_stripe_configured,
|
||||
get_stripe_price_id,
|
||||
)
|
||||
from app.utils.auth import get_current_user_id
|
||||
import stripe
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
account_bp = Blueprint("account", __name__)
|
||||
|
||||
@@ -29,6 +38,69 @@ def get_usage_route():
|
||||
return jsonify(get_usage_summary_for_user(user_id, user["plan"])), 200
|
||||
|
||||
|
||||
@account_bp.route("/subscription", methods=["GET"])
|
||||
@limiter.limit("60/hour")
|
||||
def get_subscription_status():
|
||||
"""Return subscription status for the authenticated user."""
|
||||
user_id = get_current_user_id()
|
||||
if user_id is None:
|
||||
return jsonify({"error": "Authentication required."}), 401
|
||||
|
||||
user = get_user_by_id(user_id)
|
||||
if user is None:
|
||||
return jsonify({"error": "User not found."}), 404
|
||||
|
||||
# If Stripe is not configured, return basic info
|
||||
if not is_stripe_configured():
|
||||
return jsonify(
|
||||
{
|
||||
"plan": user["plan"],
|
||||
"stripe_configured": False,
|
||||
"subscription": None,
|
||||
}
|
||||
), 200
|
||||
|
||||
# Retrieve subscription info from Stripe if available
|
||||
subscription_info = None
|
||||
if user.get("stripe_subscription_id"):
|
||||
try:
|
||||
from app.services.stripe_service import get_stripe_secret_key
|
||||
|
||||
stripe.api_key = get_stripe_secret_key()
|
||||
|
||||
subscription = stripe.Subscription.retrieve(user["stripe_subscription_id"])
|
||||
subscription_info = {
|
||||
"id": subscription.id,
|
||||
"status": subscription.status,
|
||||
"current_period_start": subscription.current_period_start,
|
||||
"current_period_end": subscription.current_period_end,
|
||||
"cancel_at_period_end": subscription.cancel_at_period_end,
|
||||
"items": [
|
||||
{
|
||||
"price": item.price.id,
|
||||
"quantity": item.quantity,
|
||||
}
|
||||
for item in subscription.items.data
|
||||
],
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to retrieve subscription {user['stripe_subscription_id']}: {e}"
|
||||
)
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"plan": user["plan"],
|
||||
"stripe_configured": True,
|
||||
"subscription": subscription_info,
|
||||
"pricing": {
|
||||
"monthly_price_id": get_stripe_price_id("monthly"),
|
||||
"yearly_price_id": get_stripe_price_id("yearly"),
|
||||
},
|
||||
}
|
||||
), 200
|
||||
|
||||
|
||||
@account_bp.route("/api-keys", methods=["GET"])
|
||||
@limiter.limit("60/hour")
|
||||
def list_api_keys_route():
|
||||
|
||||
460
backend/app/services/quota_service.py
Normal file
460
backend/app/services/quota_service.py
Normal file
@@ -0,0 +1,460 @@
|
||||
"""
|
||||
QuotaService
|
||||
Manages usage quotas and limits for Free, Pro, and Business tiers
|
||||
|
||||
Quota Structure:
|
||||
- Free: 5 conversions/day, 10MB max file size, no batch processing
|
||||
- Pro: 100 conversions/day, 100MB max file size, batch processing (5 files)
|
||||
- Business: Unlimited conversions, 500MB max file size, batch processing (20 files)
|
||||
|
||||
Tracks:
|
||||
- Daily usage (resets at UTC midnight)
|
||||
- Storage usage
|
||||
- API rate limits
|
||||
- Feature access (premium features)
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, Optional, Tuple
|
||||
from flask import current_app
|
||||
from app.services.account_service import _connect, _utc_now
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class QuotaLimits:
|
||||
"""Define quota limits for each tier"""
|
||||
|
||||
# Conversions per day
|
||||
CONVERSIONS_PER_DAY = {
|
||||
"free": 5,
|
||||
"pro": 100,
|
||||
"business": float("inf"),
|
||||
}
|
||||
|
||||
# Maximum file size in MB
|
||||
MAX_FILE_SIZE_MB = {
|
||||
"free": 10,
|
||||
"pro": 100,
|
||||
"business": 500,
|
||||
}
|
||||
|
||||
# Storage limit in MB (monthly)
|
||||
STORAGE_LIMIT_MB = {
|
||||
"free": 500,
|
||||
"pro": 5000,
|
||||
"business": float("inf"),
|
||||
}
|
||||
|
||||
# API rate limit (requests per minute)
|
||||
API_RATE_LIMIT = {
|
||||
"free": 10,
|
||||
"pro": 60,
|
||||
"business": float("inf"),
|
||||
}
|
||||
|
||||
# Concurrent processing jobs
|
||||
CONCURRENT_JOBS = {
|
||||
"free": 1,
|
||||
"pro": 3,
|
||||
"business": 10,
|
||||
}
|
||||
|
||||
# Batch file limit
|
||||
BATCH_FILE_LIMIT = {
|
||||
"free": 1,
|
||||
"pro": 5,
|
||||
"business": 20,
|
||||
}
|
||||
|
||||
# Premium features (Pro/Business)
|
||||
PREMIUM_FEATURES = {
|
||||
"free": set(),
|
||||
"pro": {"batch_processing", "priority_queue", "email_delivery", "api_access"},
|
||||
"business": {
|
||||
"batch_processing",
|
||||
"priority_queue",
|
||||
"email_delivery",
|
||||
"api_access",
|
||||
"webhook",
|
||||
"sso",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class QuotaService:
|
||||
"""Service for managing user quotas and usage tracking"""
|
||||
|
||||
@staticmethod
|
||||
def _ensure_quota_tables():
|
||||
"""Create quota tracking tables if they don't exist"""
|
||||
conn = _connect()
|
||||
try:
|
||||
# Daily usage tracking
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS daily_usage (
|
||||
id INTEGER PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL,
|
||||
date TEXT NOT NULL,
|
||||
conversions INTEGER DEFAULT 0,
|
||||
files_processed INTEGER DEFAULT 0,
|
||||
total_size_mb REAL DEFAULT 0,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(user_id, date),
|
||||
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)
|
||||
""")
|
||||
|
||||
# Storage usage tracking
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS storage_usage (
|
||||
id INTEGER PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL,
|
||||
month TEXT NOT NULL,
|
||||
total_size_mb REAL DEFAULT 0,
|
||||
file_count INTEGER DEFAULT 0,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(user_id, month),
|
||||
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)
|
||||
""")
|
||||
|
||||
# API rate limiting
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS api_requests (
|
||||
id INTEGER PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL,
|
||||
endpoint TEXT NOT NULL,
|
||||
timestamp TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)
|
||||
""")
|
||||
|
||||
# Feature access log
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS feature_access (
|
||||
id INTEGER PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL,
|
||||
feature TEXT NOT NULL,
|
||||
accessed_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
allowed BOOLEAN DEFAULT 1,
|
||||
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)
|
||||
""")
|
||||
|
||||
conn.commit()
|
||||
logger.info("Quota tables initialized")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create quota tables: {e}")
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@staticmethod
|
||||
def init_quota_db():
|
||||
"""Initialize quota tracking database"""
|
||||
QuotaService._ensure_quota_tables()
|
||||
|
||||
@staticmethod
|
||||
def get_user_plan(user_id: int) -> str:
|
||||
"""Get user's current plan"""
|
||||
conn = _connect()
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT plan FROM users WHERE id = ?", (user_id,)
|
||||
).fetchone()
|
||||
return row["plan"] if row else "free"
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@staticmethod
|
||||
def get_daily_usage(user_id: int, date: Optional[str] = None) -> Dict:
|
||||
"""
|
||||
Get daily usage for a user
|
||||
|
||||
Args:
|
||||
user_id: User ID
|
||||
date: Date in YYYY-MM-DD format (defaults to today)
|
||||
|
||||
Returns:
|
||||
Usage stats dict
|
||||
"""
|
||||
if not date:
|
||||
date = datetime.utcnow().strftime("%Y-%m-%d")
|
||||
|
||||
conn = _connect()
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM daily_usage WHERE user_id = ? AND date = ?",
|
||||
(user_id, date),
|
||||
).fetchone()
|
||||
|
||||
if not row:
|
||||
return {
|
||||
"conversions": 0,
|
||||
"files_processed": 0,
|
||||
"total_size_mb": 0,
|
||||
}
|
||||
|
||||
return dict(row)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@staticmethod
|
||||
def record_conversion(user_id: int, file_size_mb: float) -> bool:
|
||||
"""
|
||||
Record a file conversion
|
||||
|
||||
Args:
|
||||
user_id: User ID
|
||||
file_size_mb: File size in MB
|
||||
|
||||
Returns:
|
||||
True if allowed, False if quota exceeded
|
||||
"""
|
||||
plan = QuotaService.get_user_plan(user_id)
|
||||
today = datetime.utcnow().strftime("%Y-%m-%d")
|
||||
|
||||
conn = _connect()
|
||||
try:
|
||||
# Check daily conversion limit
|
||||
usage = conn.execute(
|
||||
"SELECT conversions FROM daily_usage WHERE user_id = ? AND date = ?",
|
||||
(user_id, today),
|
||||
).fetchone()
|
||||
|
||||
current_conversions = usage["conversions"] if usage else 0
|
||||
limit = QuotaLimits.CONVERSIONS_PER_DAY[plan]
|
||||
|
||||
if current_conversions >= limit and limit != float("inf"):
|
||||
logger.warning(f"User {user_id} exceeded daily conversion limit")
|
||||
return False
|
||||
|
||||
# Check file size limit
|
||||
max_size = QuotaLimits.MAX_FILE_SIZE_MB[plan]
|
||||
if file_size_mb > max_size:
|
||||
logger.warning(
|
||||
f"User {user_id} file exceeds size limit: {file_size_mb}MB > {max_size}MB"
|
||||
)
|
||||
return False
|
||||
|
||||
# Record the conversion
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO daily_usage (user_id, date, conversions, files_processed, total_size_mb)
|
||||
VALUES (?, ?, 1, 1, ?)
|
||||
ON CONFLICT(user_id, date) DO UPDATE SET
|
||||
conversions = conversions + 1,
|
||||
files_processed = files_processed + 1,
|
||||
total_size_mb = total_size_mb + ?
|
||||
""",
|
||||
(user_id, today, file_size_mb, file_size_mb),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
logger.info(f"Recorded conversion for user {user_id}: {file_size_mb}MB")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to record conversion: {e}")
|
||||
return False
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@staticmethod
|
||||
def check_rate_limit(user_id: int) -> Tuple[bool, int]:
|
||||
"""
|
||||
Check if user has exceeded API rate limit
|
||||
|
||||
Args:
|
||||
user_id: User ID
|
||||
|
||||
Returns:
|
||||
(allowed, remaining_requests_in_window) tuple
|
||||
"""
|
||||
plan = QuotaService.get_user_plan(user_id)
|
||||
limit = QuotaLimits.API_RATE_LIMIT[plan]
|
||||
|
||||
if limit == float("inf"):
|
||||
return True, -1 # Unlimited
|
||||
|
||||
# Check requests in last minute
|
||||
one_minute_ago = (datetime.utcnow() - timedelta(minutes=1)).isoformat()
|
||||
|
||||
conn = _connect()
|
||||
try:
|
||||
count = conn.execute(
|
||||
"SELECT COUNT(*) as count FROM api_requests WHERE user_id = ? AND timestamp > ?",
|
||||
(user_id, one_minute_ago),
|
||||
).fetchone()["count"]
|
||||
|
||||
if count >= limit:
|
||||
return False, 0
|
||||
|
||||
# Record this request
|
||||
conn.execute(
|
||||
"INSERT INTO api_requests (user_id, endpoint) VALUES (?, ?)",
|
||||
(user_id, "api"),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
return True, limit - count - 1
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@staticmethod
|
||||
def has_feature(user_id: int, feature: str) -> bool:
|
||||
"""
|
||||
Check if user has access to a premium feature
|
||||
|
||||
Args:
|
||||
user_id: User ID
|
||||
feature: Feature name (e.g., 'batch_processing')
|
||||
|
||||
Returns:
|
||||
True if user can access feature
|
||||
"""
|
||||
plan = QuotaService.get_user_plan(user_id)
|
||||
allowed = feature in QuotaLimits.PREMIUM_FEATURES[plan]
|
||||
|
||||
# Log feature access attempt
|
||||
conn = _connect()
|
||||
try:
|
||||
conn.execute(
|
||||
"INSERT INTO feature_access (user_id, feature, allowed) VALUES (?, ?, ?)",
|
||||
(user_id, feature, allowed),
|
||||
)
|
||||
conn.commit()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to log feature access: {e}")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
return allowed
|
||||
|
||||
@staticmethod
|
||||
def get_quota_status(user_id: int) -> Dict:
|
||||
"""
|
||||
Get comprehensive quota status for a user
|
||||
|
||||
Args:
|
||||
user_id: User ID
|
||||
|
||||
Returns:
|
||||
Complete quota status dict
|
||||
"""
|
||||
plan = QuotaService.get_user_plan(user_id)
|
||||
today = datetime.utcnow().strftime("%Y-%m-%d")
|
||||
|
||||
conn = _connect()
|
||||
try:
|
||||
# Get daily usage
|
||||
daily = conn.execute(
|
||||
"SELECT conversions FROM daily_usage WHERE user_id = ? AND date = ?",
|
||||
(user_id, today),
|
||||
).fetchone()
|
||||
|
||||
conversions_used = daily["conversions"] if daily else 0
|
||||
conversions_limit = QuotaLimits.CONVERSIONS_PER_DAY[plan]
|
||||
|
||||
return {
|
||||
"plan": plan,
|
||||
"conversions": {
|
||||
"used": conversions_used,
|
||||
"limit": conversions_limit,
|
||||
"remaining": conversions_limit - conversions_used
|
||||
if conversions_limit != float("inf")
|
||||
else -1,
|
||||
"reset_at": (datetime.utcnow() + timedelta(days=1)).replace(
|
||||
hour=0, minute=0, second=0
|
||||
),
|
||||
},
|
||||
"file_size_limit_mb": QuotaLimits.MAX_FILE_SIZE_MB[plan],
|
||||
"storage_limit_mb": QuotaLimits.STORAGE_LIMIT_MB[plan],
|
||||
"api_rate_limit": QuotaLimits.API_RATE_LIMIT[plan],
|
||||
"concurrent_jobs": QuotaLimits.CONCURRENT_JOBS[plan],
|
||||
"batch_file_limit": QuotaLimits.BATCH_FILE_LIMIT[plan],
|
||||
"features": list(QuotaLimits.PREMIUM_FEATURES[plan]),
|
||||
"can_batch_process": QuotaService.has_feature(
|
||||
user_id, "batch_processing"
|
||||
),
|
||||
"can_use_api": QuotaService.has_feature(user_id, "api_access"),
|
||||
"can_schedule_delivery": QuotaService.has_feature(
|
||||
user_id, "email_delivery"
|
||||
),
|
||||
}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@staticmethod
|
||||
def get_monthly_storage_usage(user_id: int, year: int, month: int) -> float:
|
||||
"""Get storage usage for a specific month"""
|
||||
month_key = f"{year}-{month:02d}"
|
||||
|
||||
conn = _connect()
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT total_size_mb FROM storage_usage WHERE user_id = ? AND month = ?",
|
||||
(user_id, month_key),
|
||||
).fetchone()
|
||||
|
||||
return row["total_size_mb"] if row else 0
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@staticmethod
|
||||
def upgrade_plan(user_id: int, new_plan: str) -> bool:
|
||||
"""
|
||||
Upgrade user to a new plan
|
||||
|
||||
Args:
|
||||
user_id: User ID
|
||||
new_plan: New plan ('pro' or 'business')
|
||||
|
||||
Returns:
|
||||
Success status
|
||||
"""
|
||||
if new_plan not in QuotaLimits.CONVERSIONS_PER_DAY:
|
||||
logger.error(f"Invalid plan: {new_plan}")
|
||||
return False
|
||||
|
||||
conn = _connect()
|
||||
try:
|
||||
conn.execute(
|
||||
"UPDATE users SET plan = ?, updated_at = ? WHERE id = ?",
|
||||
(new_plan, _utc_now(), user_id),
|
||||
)
|
||||
conn.commit()
|
||||
logger.info(f"User {user_id} upgraded to {new_plan}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to upgrade user plan: {e}")
|
||||
return False
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@staticmethod
|
||||
def downgrade_plan(user_id: int, new_plan: str = "free") -> bool:
|
||||
"""Downgrade user to a lower plan"""
|
||||
return QuotaService.upgrade_plan(user_id, new_plan)
|
||||
|
||||
|
||||
# Convenience functions
|
||||
def init_quota_db():
|
||||
return QuotaService.init_quota_db()
|
||||
|
||||
|
||||
def get_quota_status(user_id: int) -> Dict:
|
||||
return QuotaService.get_quota_status(user_id)
|
||||
|
||||
|
||||
def record_conversion(user_id: int, file_size_mb: float) -> bool:
|
||||
return QuotaService.record_conversion(user_id, file_size_mb)
|
||||
|
||||
|
||||
def check_rate_limit(user_id: int) -> Tuple[bool, int]:
|
||||
return QuotaService.check_rate_limit(user_id)
|
||||
|
||||
|
||||
def has_feature(user_id: int, feature: str) -> bool:
|
||||
return QuotaService.has_feature(user_id, feature)
|
||||
@@ -2,7 +2,9 @@
|
||||
from flask import session
|
||||
|
||||
TASK_ACCESS_SESSION_KEY = "task_access_ids"
|
||||
STAGED_UPLOAD_SESSION_KEY = "staged_upload_ids"
|
||||
MAX_TRACKED_TASK_IDS = 200
|
||||
MAX_TRACKED_STAGED_UPLOAD_IDS = 50
|
||||
|
||||
|
||||
def get_current_user_id() -> int | None:
|
||||
@@ -28,14 +30,34 @@ def has_session_task_access(task_id: str) -> bool:
|
||||
return isinstance(tracked, list) and task_id in tracked
|
||||
|
||||
|
||||
def remember_staged_upload(upload_id: str):
|
||||
"""Persist one staged upload id in the active browser session."""
|
||||
tracked = session.get(STAGED_UPLOAD_SESSION_KEY, [])
|
||||
if not isinstance(tracked, list):
|
||||
tracked = []
|
||||
|
||||
normalized = [value for value in tracked if isinstance(value, str) and value != upload_id]
|
||||
normalized.append(upload_id)
|
||||
session[STAGED_UPLOAD_SESSION_KEY] = normalized[-MAX_TRACKED_STAGED_UPLOAD_IDS:]
|
||||
|
||||
|
||||
def has_staged_upload_access(upload_id: str) -> bool:
|
||||
"""Return whether the active browser session owns one staged upload id."""
|
||||
tracked = session.get(STAGED_UPLOAD_SESSION_KEY, [])
|
||||
return isinstance(tracked, list) and upload_id in tracked
|
||||
|
||||
|
||||
def login_user_session(user_id: int):
|
||||
"""Persist the authenticated user in the Flask session."""
|
||||
tracked_task_ids = session.get(TASK_ACCESS_SESSION_KEY, [])
|
||||
staged_upload_ids = session.get(STAGED_UPLOAD_SESSION_KEY, [])
|
||||
session.clear()
|
||||
session.permanent = True
|
||||
session["user_id"] = user_id
|
||||
if isinstance(tracked_task_ids, list) and tracked_task_ids:
|
||||
session[TASK_ACCESS_SESSION_KEY] = tracked_task_ids[-MAX_TRACKED_TASK_IDS:]
|
||||
if isinstance(staged_upload_ids, list) and staged_upload_ids:
|
||||
session[STAGED_UPLOAD_SESSION_KEY] = staged_upload_ids[-MAX_TRACKED_STAGED_UPLOAD_IDS:]
|
||||
|
||||
|
||||
def logout_user_session():
|
||||
|
||||
@@ -124,9 +124,7 @@ services:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
- ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
- ./certbot/www:/var/www/certbot:ro
|
||||
- ./certbot/conf:/etc/letsencrypt:ro
|
||||
- ./nginx/nginx.dev.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
depends_on:
|
||||
- backend
|
||||
- frontend
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
168
frontend/src/components/shared/ErrorMessage.tsx
Normal file
168
frontend/src/components/shared/ErrorMessage.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
import React, { useState } from 'react';
|
||||
import { AlertCircle, RefreshCw, HelpCircle, AlertTriangle } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export interface ErrorMessageProps {
|
||||
message?: string;
|
||||
type?: 'error' | 'warning' | 'info';
|
||||
details?: string;
|
||||
showDetails?: boolean;
|
||||
onRetry?: () => void;
|
||||
showRetry?: boolean;
|
||||
actions?: Array<{
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
variant?: 'primary' | 'secondary';
|
||||
}>;
|
||||
suggestion?: string;
|
||||
helpLink?: string;
|
||||
dismissible?: boolean;
|
||||
onDismiss?: () => void;
|
||||
}
|
||||
|
||||
export default function ErrorMessage({
|
||||
message = 'An error occurred',
|
||||
type = 'error',
|
||||
details,
|
||||
showDetails: initialShowDetails = false,
|
||||
onRetry,
|
||||
showRetry = true,
|
||||
actions,
|
||||
suggestion,
|
||||
helpLink,
|
||||
dismissible = true,
|
||||
onDismiss,
|
||||
}: ErrorMessageProps) {
|
||||
const { t } = useTranslation();
|
||||
const [showDetails, setShowDetails] = useState(initialShowDetails);
|
||||
const [isDismissed, setIsDismissed] = useState(false);
|
||||
|
||||
if (isDismissed) return null;
|
||||
|
||||
const bgColor =
|
||||
type === 'error'
|
||||
? 'bg-red-50 dark:bg-red-900/20'
|
||||
: type === 'warning'
|
||||
? 'bg-amber-50 dark:bg-amber-900/20'
|
||||
: 'bg-blue-50 dark:bg-blue-900/20';
|
||||
|
||||
const borderColor =
|
||||
type === 'error'
|
||||
? 'border-red-200 dark:border-red-800'
|
||||
: type === 'warning'
|
||||
? 'border-amber-200 dark:border-amber-800'
|
||||
: 'border-blue-200 dark:border-blue-800';
|
||||
|
||||
const textColor =
|
||||
type === 'error'
|
||||
? 'text-red-900 dark:text-red-200'
|
||||
: type === 'warning'
|
||||
? 'text-amber-900 dark:text-amber-200'
|
||||
: 'text-blue-900 dark:text-blue-200';
|
||||
|
||||
const iconColor =
|
||||
type === 'error'
|
||||
? 'text-red-600 dark:text-red-400'
|
||||
: type === 'warning'
|
||||
? 'text-amber-600 dark:text-amber-400'
|
||||
: 'text-blue-600 dark:text-blue-400';
|
||||
|
||||
const Icon = type === 'warning' ? AlertTriangle : AlertCircle;
|
||||
|
||||
return (
|
||||
<div className={`rounded-lg border ${borderColor} ${bgColor} p-4`}>
|
||||
<div className="flex gap-3">
|
||||
<Icon className={`h-5 w-5 flex-shrink-0 mt-0.5 ${iconColor}`} aria-hidden="true" />
|
||||
|
||||
<div className="flex-1">
|
||||
<h3 className={`font-semibold ${textColor}`}>{message}</h3>
|
||||
|
||||
{suggestion && (
|
||||
<p className={`mt-1 text-sm ${textColor} opacity-80`}>{suggestion}</p>
|
||||
)}
|
||||
|
||||
{details && !showDetails && (
|
||||
<button
|
||||
onClick={() => setShowDetails(true)}
|
||||
className={`mt-2 inline-flex items-center gap-1 text-sm font-medium ${textColor} opacity-80 hover:opacity-100`}
|
||||
>
|
||||
<HelpCircle className="h-4 w-4" />
|
||||
{t('common.showDetails', { defaultValue: 'Show Details' })}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{details && showDetails && (
|
||||
<details open className="mt-3">
|
||||
<summary className={`cursor-pointer text-sm font-medium ${textColor} opacity-80`}>
|
||||
{t('common.hideDetails', { defaultValue: 'Hide Details' })}
|
||||
</summary>
|
||||
<pre className="mt-2 overflow-auto rounded bg-black/10 p-3 text-xs text-slate-900 dark:text-slate-100">
|
||||
{details}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
|
||||
{(onRetry || actions || helpLink) && (
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{onRetry && showRetry && (
|
||||
<button
|
||||
onClick={onRetry}
|
||||
className={`inline-flex items-center gap-1 rounded-md px-3 py-2 text-sm font-medium transition-colors ${
|
||||
type === 'error'
|
||||
? 'bg-red-600 text-white hover:bg-red-700 dark:bg-red-700 dark:hover:bg-red-600'
|
||||
: type === 'warning'
|
||||
? 'bg-amber-600 text-white hover:bg-amber-700 dark:bg-amber-700 dark:hover:bg-amber-600'
|
||||
: 'bg-blue-600 text-white hover:bg-blue-700 dark:bg-blue-700 dark:hover:bg-blue-600'
|
||||
}`}
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
{t('common.retry', { defaultValue: 'Retry' })}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{actions?.map((action, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
onClick={action.onClick}
|
||||
className={`rounded-md px-3 py-2 text-sm font-medium transition-colors ${
|
||||
action.variant === 'primary'
|
||||
? type === 'error'
|
||||
? 'bg-red-600 text-white hover:bg-red-700'
|
||||
: 'bg-primary-600 text-white hover:bg-primary-700'
|
||||
: 'bg-white text-slate-700 ring-1 ring-slate-300 hover:bg-slate-50 dark:bg-slate-800 dark:text-slate-300 dark:ring-slate-600 dark:hover:bg-slate-700'
|
||||
}`}
|
||||
>
|
||||
{action.label}
|
||||
</button>
|
||||
))}
|
||||
|
||||
{helpLink && (
|
||||
<a
|
||||
href={helpLink}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-md bg-white px-3 py-2 text-sm font-medium text-slate-700 ring-1 ring-slate-300 hover:bg-slate-50 transition-colors dark:bg-slate-800 dark:text-slate-300 dark:ring-slate-600 dark:hover:bg-slate-700"
|
||||
>
|
||||
{t('common.getHelp', { defaultValue: 'Get Help' })}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{dismissible && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsDismissed(true);
|
||||
onDismiss?.();
|
||||
}}
|
||||
className={`flex-shrink-0 text-xl font-bold opacity-50 hover:opacity-100 transition-opacity ${textColor}`}
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,42 +1,139 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Loader2, CheckCircle2 } from 'lucide-react';
|
||||
import { Loader2, CheckCircle2, AlertCircle, Clock } from 'lucide-react';
|
||||
|
||||
interface ProgressBarProps {
|
||||
/** Current task state */
|
||||
state: 'PENDING' | 'PROCESSING' | 'SUCCESS' | 'FAILURE' | string;
|
||||
state?: 'PENDING' | 'PROCESSING' | 'SUCCESS' | 'FAILURE' | string;
|
||||
status?: string; // Alternative to state (for compatibility)
|
||||
/** Progress message */
|
||||
message?: string;
|
||||
/** Progress percentage (0-100) */
|
||||
progress?: number;
|
||||
/** Show detailed steps */
|
||||
steps?: Array<{
|
||||
name: string;
|
||||
status: 'pending' | 'active' | 'complete' | 'error';
|
||||
message?: string;
|
||||
}>;
|
||||
/** Show a simple indeterminate progress bar */
|
||||
indeterminate?: boolean;
|
||||
}
|
||||
|
||||
export default function ProgressBar({ state, message }: ProgressBarProps) {
|
||||
export default function ProgressBar({
|
||||
state,
|
||||
status,
|
||||
message,
|
||||
progress,
|
||||
steps,
|
||||
indeterminate = true,
|
||||
}: ProgressBarProps) {
|
||||
const { t } = useTranslation();
|
||||
const taskState = state || status || 'PROCESSING';
|
||||
|
||||
const isActive = state === 'PENDING' || state === 'PROCESSING';
|
||||
const isComplete = state === 'SUCCESS';
|
||||
const isActive = taskState === 'PENDING' || taskState === 'PROCESSING';
|
||||
const isComplete = taskState === 'SUCCESS';
|
||||
const isError = taskState === 'FAILURE';
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Main Progress Card */}
|
||||
<div className="rounded-xl bg-slate-50 p-5 ring-1 ring-slate-200 dark:bg-slate-800 dark:ring-slate-700">
|
||||
<div className="flex items-center gap-3">
|
||||
{isActive && (
|
||||
<Loader2 className="h-6 w-6 animate-spin text-primary-600 dark:text-primary-400" />
|
||||
)}
|
||||
{isComplete && (
|
||||
<CheckCircle2 className="h-6 w-6 text-emerald-600" />
|
||||
<CheckCircle2 className="h-6 w-6 text-emerald-600 dark:text-emerald-400" />
|
||||
)}
|
||||
{isError && (
|
||||
<AlertCircle className="h-6 w-6 text-red-600 dark:text-red-400" />
|
||||
)}
|
||||
{!isActive && !isComplete && !isError && (
|
||||
<Clock className="h-6 w-6 text-slate-400 dark:text-slate-600" />
|
||||
)}
|
||||
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-slate-700 dark:text-slate-300">
|
||||
{message || t('common.processing')}
|
||||
{message || t('common.processing', { defaultValue: 'Processing...' })}
|
||||
</p>
|
||||
{progress !== undefined && (
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400 mt-1">
|
||||
{progress}%
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Animated progress bar for active states */}
|
||||
{isActive && (
|
||||
{/* Progress Bar */}
|
||||
{indeterminate && isActive && (
|
||||
<div className="mt-3 h-1.5 w-full overflow-hidden rounded-full bg-slate-200 dark:bg-slate-700">
|
||||
<div className="progress-bar-animated h-full w-2/3 rounded-full bg-primary-500 transition-all" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Determinate Progress Bar */}
|
||||
{!indeterminate && progress !== undefined && (
|
||||
<div className="mt-3">
|
||||
<div className="h-2 w-full overflow-hidden rounded-full bg-slate-200 dark:bg-slate-700">
|
||||
<div
|
||||
className={`h-full transition-all duration-300 ${
|
||||
isError ? 'bg-red-500' : isComplete ? 'bg-emerald-500' : 'bg-primary-500'
|
||||
}`}
|
||||
style={{ width: `${Math.min(progress, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Step-by-Step Progress */}
|
||||
{steps && steps.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-semibold text-slate-700 dark:text-slate-300">
|
||||
{t('common.processingSteps', { defaultValue: 'Processing Steps' })}
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{steps.map((step, idx) => (
|
||||
<div key={idx} className="flex items-start gap-3">
|
||||
<div className="mt-0.5">
|
||||
{step.status === 'complete' && (
|
||||
<CheckCircle2 className="h-5 w-5 text-emerald-600 dark:text-emerald-400 flex-shrink-0" />
|
||||
)}
|
||||
{step.status === 'active' && (
|
||||
<Loader2 className="h-5 w-5 animate-spin text-primary-600 dark:text-primary-400 flex-shrink-0" />
|
||||
)}
|
||||
{step.status === 'error' && (
|
||||
<AlertCircle className="h-5 w-5 text-red-600 dark:text-red-400 flex-shrink-0" />
|
||||
)}
|
||||
{step.status === 'pending' && (
|
||||
<div className="h-5 w-5 rounded-full border-2 border-slate-300 dark:border-slate-600 flex-shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p
|
||||
className={`text-sm font-medium ${
|
||||
step.status === 'complete'
|
||||
? 'text-emerald-700 dark:text-emerald-300'
|
||||
: step.status === 'active'
|
||||
? 'text-primary-700 dark:text-primary-300'
|
||||
: step.status === 'error'
|
||||
? 'text-red-700 dark:text-red-300'
|
||||
: 'text-slate-600 dark:text-slate-400'
|
||||
}`}
|
||||
>
|
||||
{step.name}
|
||||
</p>
|
||||
{step.message && (
|
||||
<p className="text-xs text-slate-500 dark:text-slate-500 mt-0.5">
|
||||
{step.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
226
frontend/src/components/shared/ToolTemplate.tsx
Normal file
226
frontend/src/components/shared/ToolTemplate.tsx
Normal file
@@ -0,0 +1,226 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Helmet } from 'react-helmet-async';
|
||||
import { LucideIcon, AlertCircle, CheckCircle, Clock } from 'lucide-react';
|
||||
import FileUploader from '@/components/shared/FileUploader';
|
||||
import ProgressBar from '@/components/shared/ProgressBar';
|
||||
import DownloadButton from '@/components/shared/DownloadButton';
|
||||
import AdSlot from '@/components/layout/AdSlot';
|
||||
import { useFileUpload } from '@/hooks/useFileUpload';
|
||||
import { useTaskPolling } from '@/hooks/useTaskPolling';
|
||||
import { generateToolSchema } from '@/utils/seo';
|
||||
import { useFileStore } from '@/stores/fileStore';
|
||||
|
||||
export interface ToolConfig {
|
||||
slug: string;
|
||||
icon: LucideIcon;
|
||||
color?: 'orange' | 'red' | 'blue' | 'green' | 'purple' | 'pink' | 'amber' | 'cyan';
|
||||
i18nKey: string;
|
||||
endpoint: string;
|
||||
maxSizeMB?: number;
|
||||
acceptedTypes?: string[];
|
||||
isPremium?: boolean;
|
||||
extraData?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface ToolTemplateProps {
|
||||
file: File | null;
|
||||
uploadProgress: number;
|
||||
isUploading: boolean;
|
||||
isProcessing: boolean;
|
||||
result: any;
|
||||
error: string | null;
|
||||
selectFile: (file: File) => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
const colorMap: Record<string, { bg: string; icon: string }> = {
|
||||
orange: { bg: 'bg-orange-50 dark:bg-orange-900/20', icon: 'text-orange-600 dark:text-orange-400' },
|
||||
red: { bg: 'bg-red-50 dark:bg-red-900/20', icon: 'text-red-600 dark:text-red-400' },
|
||||
blue: { bg: 'bg-blue-50 dark:bg-blue-900/20', icon: 'text-blue-600 dark:text-blue-400' },
|
||||
green: { bg: 'bg-green-50 dark:bg-green-900/20', icon: 'text-green-600 dark:text-green-400' },
|
||||
purple: { bg: 'bg-purple-50 dark:bg-purple-900/20', icon: 'text-purple-600 dark:text-purple-400' },
|
||||
pink: { bg: 'bg-pink-50 dark:bg-pink-900/20', icon: 'text-pink-600 dark:text-pink-400' },
|
||||
amber: { bg: 'bg-amber-50 dark:bg-amber-900/20', icon: 'text-amber-600 dark:text-amber-400' },
|
||||
cyan: { bg: 'bg-cyan-50 dark:bg-cyan-900/20', icon: 'text-cyan-600 dark:text-cyan-400' },
|
||||
};
|
||||
|
||||
interface ToolTemplateComponentProps {
|
||||
config: ToolConfig;
|
||||
onGetExtraData?: () => Record<string, any>;
|
||||
children?: (props: ToolTemplateProps) => React.ReactNode;
|
||||
}
|
||||
|
||||
export default function ToolTemplate({ config, onGetExtraData, children }: ToolTemplateComponentProps) {
|
||||
const { t } = useTranslation();
|
||||
const [phase, setPhase] = useState<'upload' | 'processing' | 'done'>('upload');
|
||||
const [extraData, setExtraData] = useState<Record<string, any>>(config.extraData || {});
|
||||
|
||||
const colors = colorMap[config.color || 'blue'];
|
||||
const bgColor = colors.bg;
|
||||
const iconColor = colors.icon;
|
||||
|
||||
const { file, uploadProgress, isUploading, taskId, error, selectFile, startUpload, reset } = useFileUpload({
|
||||
endpoint: config.endpoint,
|
||||
maxSizeMB: config.maxSizeMB || 20,
|
||||
acceptedTypes: config.acceptedTypes || ['pdf'],
|
||||
extraData,
|
||||
});
|
||||
|
||||
const { status, result } = useTaskPolling({
|
||||
taskId,
|
||||
onComplete: () => setPhase('done'),
|
||||
onError: () => setPhase('done'),
|
||||
});
|
||||
|
||||
const storeFile = useFileStore((s) => s.file);
|
||||
const clearStoreFile = useFileStore((s) => s.clearFile);
|
||||
|
||||
useEffect(() => {
|
||||
if (storeFile && config.acceptedTypes?.some((type) => storeFile.name.endsWith(`.${type}`))) {
|
||||
selectFile(storeFile);
|
||||
clearStoreFile();
|
||||
setPhase('upload');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleUpload = useCallback(async () => {
|
||||
// Get fresh extraData from child if callback provided
|
||||
if (onGetExtraData) {
|
||||
const freshExtraData = onGetExtraData();
|
||||
setExtraData(freshExtraData);
|
||||
}
|
||||
const id = await startUpload();
|
||||
if (id) setPhase('processing');
|
||||
}, [onGetExtraData, startUpload]);
|
||||
|
||||
const handleReset = () => {
|
||||
reset();
|
||||
setPhase('upload');
|
||||
};
|
||||
|
||||
const title = t(`${config.i18nKey}.title`, { defaultValue: config.slug });
|
||||
const description = t(`${config.i18nKey}.description`, { defaultValue: '' });
|
||||
|
||||
const schema = generateToolSchema({
|
||||
name: title,
|
||||
description,
|
||||
url: `${window.location.origin}/tools/${config.slug}`,
|
||||
});
|
||||
|
||||
const templateProps: ToolTemplateProps = {
|
||||
file,
|
||||
uploadProgress,
|
||||
isUploading,
|
||||
isProcessing: phase === 'processing',
|
||||
result,
|
||||
error,
|
||||
selectFile,
|
||||
reset: handleReset,
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{title} — Dociva</title>
|
||||
<meta name="description" content={description} />
|
||||
<link rel="canonical" href={`${window.location.origin}/tools/${config.slug}`} />
|
||||
<script type="application/ld+json">{JSON.stringify(schema)}</script>
|
||||
</Helmet>
|
||||
|
||||
<div className="mx-auto max-w-4xl px-4 py-8 sm:px-6 lg:px-8">
|
||||
<div className="mb-8 text-center">
|
||||
<div className={`mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl ${bgColor}`}>
|
||||
<config.icon className={`h-8 w-8 ${iconColor}`} aria-hidden="true" />
|
||||
</div>
|
||||
<h1 className="section-heading">{title}</h1>
|
||||
<p className="mt-2 text-slate-600 dark:text-slate-400">{description}</p>
|
||||
</div>
|
||||
|
||||
<AdSlot slot="top-banner" format="horizontal" className="mb-6" />
|
||||
|
||||
<div className="space-y-6">
|
||||
{phase === 'upload' && (
|
||||
<div className="space-y-6">
|
||||
<FileUploader
|
||||
onFileSelect={(f) => {
|
||||
selectFile(f);
|
||||
setPhase('upload');
|
||||
}}
|
||||
file={file}
|
||||
accept={config.acceptedTypes?.reduce(
|
||||
(acc, type) => ({
|
||||
...acc,
|
||||
[`application/${type}`]: [`.${type}`],
|
||||
}),
|
||||
{}
|
||||
) || {}}
|
||||
/>
|
||||
|
||||
{children && (
|
||||
<div className="rounded-xl bg-slate-50 p-6 dark:bg-slate-800">{children(templateProps)}</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleUpload}
|
||||
disabled={isUploading || !file}
|
||||
className="btn-primary w-full disabled:opacity-50"
|
||||
>
|
||||
{isUploading ? (
|
||||
<>
|
||||
<Clock className="h-5 w-5 animate-spin" />
|
||||
{t('common.uploading', { defaultValue: 'Uploading...' })}
|
||||
</>
|
||||
) : (
|
||||
t('common.convert', { defaultValue: 'Convert' })
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase === 'processing' && (
|
||||
<div className="rounded-xl bg-slate-50 p-8 text-center dark:bg-slate-800">
|
||||
<ProgressBar state={(status as any) || 'PROCESSING'} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase === 'done' && (
|
||||
<div className="space-y-4">
|
||||
{result ? (
|
||||
<>
|
||||
<div className="rounded-xl bg-green-50 p-6 dark:bg-green-900/20">
|
||||
<div className="flex items-center gap-3">
|
||||
<CheckCircle className="h-6 w-6 text-green-600 dark:text-green-400" />
|
||||
<div>
|
||||
<h2 className="font-semibold text-green-900 dark:text-green-200">Success!</h2>
|
||||
<p className="text-sm text-green-700 dark:text-green-300">Your file is ready</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DownloadButton result={result} onStartOver={handleReset} />
|
||||
</>
|
||||
) : (
|
||||
<div className="rounded-xl bg-red-50 p-6 dark:bg-red-900/20">
|
||||
<div className="flex items-center gap-3">
|
||||
<AlertCircle className="h-6 w-6 text-red-600 dark:text-red-400" />
|
||||
<div>
|
||||
<h2 className="font-semibold text-red-900 dark:text-red-200">Error</h2>
|
||||
<p className="text-sm text-red-700 dark:text-red-300">{error || 'Processing failed'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button onClick={handleReset} className="btn-secondary w-full">
|
||||
Process Another
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AdSlot slot="bottom-banner" className="mt-8" />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,63 +1,22 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Helmet } from 'react-helmet-async';
|
||||
import { Minimize2 } from 'lucide-react';
|
||||
import FileUploader from '@/components/shared/FileUploader';
|
||||
import ProgressBar from '@/components/shared/ProgressBar';
|
||||
import DownloadButton from '@/components/shared/DownloadButton';
|
||||
import AdSlot from '@/components/layout/AdSlot';
|
||||
import { useFileUpload } from '@/hooks/useFileUpload';
|
||||
import { useTaskPolling } from '@/hooks/useTaskPolling';
|
||||
import { generateToolSchema } from '@/utils/seo';
|
||||
import { useFileStore } from '@/stores/fileStore';
|
||||
import ToolTemplate, { ToolConfig, ToolTemplateProps } from '@/components/shared/ToolTemplate';
|
||||
|
||||
type Quality = 'low' | 'medium' | 'high';
|
||||
|
||||
export default function PdfCompressor() {
|
||||
const { t } = useTranslation();
|
||||
const [phase, setPhase] = useState<'upload' | 'processing' | 'done'>('upload');
|
||||
const [quality, setQuality] = useState<Quality>('medium');
|
||||
|
||||
const {
|
||||
file,
|
||||
uploadProgress,
|
||||
isUploading,
|
||||
taskId,
|
||||
error: uploadError,
|
||||
selectFile,
|
||||
startUpload,
|
||||
reset,
|
||||
} = useFileUpload({
|
||||
const toolConfig: ToolConfig = {
|
||||
slug: 'compress-pdf',
|
||||
icon: Minimize2,
|
||||
color: 'orange',
|
||||
i18nKey: 'tools.compressPdf',
|
||||
endpoint: '/compress/pdf',
|
||||
maxSizeMB: 20,
|
||||
acceptedTypes: ['pdf'],
|
||||
extraData: { quality },
|
||||
});
|
||||
|
||||
const { status, result, error: taskError } = useTaskPolling({
|
||||
taskId,
|
||||
onComplete: () => setPhase('done'),
|
||||
onError: () => setPhase('done'),
|
||||
});
|
||||
|
||||
// Accept file from homepage smart upload
|
||||
const storeFile = useFileStore((s) => s.file);
|
||||
const clearStoreFile = useFileStore((s) => s.clearFile);
|
||||
useEffect(() => {
|
||||
if (storeFile) {
|
||||
selectFile(storeFile);
|
||||
clearStoreFile();
|
||||
}
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const handleUpload = async () => {
|
||||
const id = await startUpload();
|
||||
if (id) setPhase('processing');
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
reset();
|
||||
setPhase('upload');
|
||||
};
|
||||
|
||||
const qualityOptions: { value: Quality; label: string; desc: string }[] = [
|
||||
@@ -66,49 +25,16 @@ export default function PdfCompressor() {
|
||||
{ value: 'high', label: t('tools.compressPdf.qualityHigh'), desc: '300 DPI' },
|
||||
];
|
||||
|
||||
const schema = generateToolSchema({
|
||||
name: t('tools.compressPdf.title'),
|
||||
description: t('tools.compressPdf.description'),
|
||||
url: `${window.location.origin}/tools/compress-pdf`,
|
||||
});
|
||||
const getExtraData = () => ({ quality });
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{t('tools.compressPdf.title')} — {t('common.appName')}</title>
|
||||
<meta name="description" content={t('tools.compressPdf.description')} />
|
||||
<link rel="canonical" href={`${window.location.origin}/tools/compress-pdf`} />
|
||||
<script type="application/ld+json">{JSON.stringify(schema)}</script>
|
||||
</Helmet>
|
||||
|
||||
<div className="mx-auto max-w-2xl">
|
||||
<div className="mb-8 text-center">
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-orange-100">
|
||||
<Minimize2 className="h-8 w-8 text-orange-600" />
|
||||
</div>
|
||||
<h1 className="section-heading">{t('tools.compressPdf.title')}</h1>
|
||||
<p className="mt-2 text-slate-500">{t('tools.compressPdf.description')}</p>
|
||||
</div>
|
||||
|
||||
<AdSlot slot="top-banner" format="horizontal" className="mb-6" />
|
||||
|
||||
{phase === 'upload' && (
|
||||
<ToolTemplate config={toolConfig} onGetExtraData={getExtraData}>
|
||||
{(props: ToolTemplateProps) => (
|
||||
<div className="space-y-4">
|
||||
<FileUploader
|
||||
onFileSelect={selectFile}
|
||||
file={file}
|
||||
accept={{ 'application/pdf': ['.pdf'] }}
|
||||
maxSizeMB={20}
|
||||
isUploading={isUploading}
|
||||
uploadProgress={uploadProgress}
|
||||
error={uploadError}
|
||||
onReset={handleReset}
|
||||
acceptLabel="PDF (.pdf)"
|
||||
/>
|
||||
|
||||
{/* Quality Selector */}
|
||||
{file && !isUploading && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-3">
|
||||
{t('tools.compressPdf.quality', { defaultValue: 'Compression Quality' })}
|
||||
</label>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{qualityOptions.map((opt) => (
|
||||
<button
|
||||
@@ -116,44 +42,18 @@ export default function PdfCompressor() {
|
||||
onClick={() => setQuality(opt.value)}
|
||||
className={`rounded-xl p-3 text-center ring-1 transition-all ${
|
||||
quality === opt.value
|
||||
? 'bg-primary-50 ring-primary-300 text-primary-700'
|
||||
: 'bg-white ring-slate-200 text-slate-600 hover:bg-slate-50'
|
||||
? 'bg-primary-50 dark:bg-primary-900/20 ring-primary-300 dark:ring-primary-700 text-primary-700 dark:text-primary-400'
|
||||
: 'bg-white dark:bg-slate-700 ring-slate-200 dark:ring-slate-600 text-slate-600 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-600'
|
||||
}`}
|
||||
>
|
||||
<p className="text-sm font-medium">{opt.label}</p>
|
||||
<p className="text-xs text-slate-400 mt-0.5">{opt.desc}</p>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">{opt.desc}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button onClick={handleUpload} className="btn-primary w-full">
|
||||
{t('tools.compressPdf.shortDesc')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase === 'processing' && !result && (
|
||||
<ProgressBar state={status?.state || 'PENDING'} message={status?.progress} />
|
||||
)}
|
||||
|
||||
{phase === 'done' && result && result.status === 'completed' && (
|
||||
<DownloadButton result={result} onStartOver={handleReset} />
|
||||
)}
|
||||
|
||||
{phase === 'done' && taskError && (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-xl bg-red-50 p-4 ring-1 ring-red-200">
|
||||
<p className="text-sm text-red-700">{taskError}</p>
|
||||
</div>
|
||||
<button onClick={handleReset} className="btn-secondary w-full">
|
||||
{t('common.startOver')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AdSlot slot="bottom-banner" className="mt-8" />
|
||||
</div>
|
||||
</>
|
||||
</ToolTemplate>
|
||||
);
|
||||
}
|
||||
|
||||
250
frontend/src/design-system/colors.ts
Normal file
250
frontend/src/design-system/colors.ts
Normal file
@@ -0,0 +1,250 @@
|
||||
/**
|
||||
* Color Design System for Dociva
|
||||
* Comprehensive color palette matching competitive standards
|
||||
* References: PDFSimpli (bright blues), Smallpdf (modern purple), ILovePDF (bold oranges)
|
||||
*/
|
||||
|
||||
export const colors = {
|
||||
// Primary Palette (Main brand color - Blue)
|
||||
primary: {
|
||||
50: '#eff6ff',
|
||||
100: '#dbeafe',
|
||||
200: '#bfdbfe',
|
||||
300: '#93c5fd',
|
||||
400: '#60a5fa',
|
||||
500: '#3b82f6',
|
||||
600: '#2563eb', // Primary brand color (buttons, links, highlights)
|
||||
700: '#1d4ed8',
|
||||
800: '#1e40af',
|
||||
900: '#1e3a8a',
|
||||
950: '#172554',
|
||||
},
|
||||
|
||||
// Accent Palette (Secondary accent - Purple/Fuchsia for CTAs)
|
||||
accent: {
|
||||
50: '#fdf4ff',
|
||||
100: '#fae8ff',
|
||||
200: '#f5d0fe',
|
||||
300: '#f0abfc',
|
||||
400: '#e879f9',
|
||||
500: '#d946ef',
|
||||
600: '#c026d3', // Accent for premium tier, special offers
|
||||
700: '#a21caf',
|
||||
800: '#86198f',
|
||||
900: '#701a75',
|
||||
},
|
||||
|
||||
// Success Palette (For positive feedback, completed actions)
|
||||
success: {
|
||||
50: '#f0fdf4',
|
||||
100: '#dcfce7',
|
||||
200: '#bbf7d0',
|
||||
300: '#86efac',
|
||||
400: '#4ade80',
|
||||
500: '#22c55e',
|
||||
600: '#16a34a', // Success button/feedback
|
||||
700: '#15803d',
|
||||
800: '#166534',
|
||||
900: '#145231',
|
||||
},
|
||||
|
||||
// Warning Palette (For alerts, warnings, secondary actions)
|
||||
warning: {
|
||||
50: '#fffbeb',
|
||||
100: '#fef3c7',
|
||||
200: '#fde68a',
|
||||
300: '#fcd34d',
|
||||
400: '#fbbf24',
|
||||
500: '#f59e0b',
|
||||
600: '#d97706', // Warning alerts
|
||||
700: '#b45309',
|
||||
800: '#92400e',
|
||||
900: '#78350f',
|
||||
},
|
||||
|
||||
// Error Palette (For errors, destructive actions)
|
||||
error: {
|
||||
50: '#fef2f2',
|
||||
100: '#fee2e2',
|
||||
200: '#fecaca',
|
||||
300: '#fca5a5',
|
||||
400: '#f87171',
|
||||
500: '#ef4444',
|
||||
600: '#dc2626', // Error states
|
||||
700: '#b91c1c',
|
||||
800: '#991b1b',
|
||||
900: '#7f1d1d',
|
||||
},
|
||||
|
||||
// Info Palette (For informational messages)
|
||||
info: {
|
||||
50: '#ecf0ff',
|
||||
100: '#e0e7ff',
|
||||
200: '#c7d2fe',
|
||||
300: '#a5b4fc',
|
||||
400: '#818cf8',
|
||||
500: '#6366f1',
|
||||
600: '#4f46e5', // Info messages
|
||||
700: '#4338ca',
|
||||
800: '#3730a3',
|
||||
900: '#312e81',
|
||||
},
|
||||
|
||||
// Neutral Grayscale (For text, borders, backgrounds)
|
||||
neutral: {
|
||||
50: '#fafafa',
|
||||
100: '#f5f5f5',
|
||||
150: '#efefef', // Custom: between 100 and 200
|
||||
200: '#e5e5e5',
|
||||
300: '#d4d4d4',
|
||||
400: '#a3a3a3',
|
||||
500: '#737373',
|
||||
600: '#525252',
|
||||
700: '#404040',
|
||||
800: '#262626',
|
||||
900: '#171717',
|
||||
950: '#0a0a0a',
|
||||
},
|
||||
|
||||
// Slate Grayscale (Alternative neutral - used in current design)
|
||||
slate: {
|
||||
50: '#f8fafc',
|
||||
100: '#f1f5f9',
|
||||
200: '#e2e8f0',
|
||||
300: '#cbd5e1',
|
||||
400: '#94a3b8',
|
||||
500: '#64748b',
|
||||
600: '#475569',
|
||||
700: '#334155',
|
||||
800: '#1e293b',
|
||||
900: '#0f172a',
|
||||
950: '#020617',
|
||||
},
|
||||
|
||||
// Semantic Colors (Light mode)
|
||||
light: {
|
||||
background: '#ffffff',
|
||||
surface: '#f8fafc',
|
||||
surfaceHover: '#f1f5f9',
|
||||
text: '#0f172a',
|
||||
textSecondary: '#64748b',
|
||||
textTertiary: '#94a3b8',
|
||||
border: '#e2e8f0',
|
||||
borderHover: '#cbd5e1',
|
||||
},
|
||||
|
||||
// Semantic Colors (Dark mode)
|
||||
dark: {
|
||||
background: '#0f172a',
|
||||
surface: '#1e293b',
|
||||
surfaceHover: '#334155',
|
||||
text: '#f1f5f9',
|
||||
textSecondary: '#94a3b8',
|
||||
textTertiary: '#64748b',
|
||||
border: '#334155',
|
||||
borderHover: '#475569',
|
||||
},
|
||||
|
||||
// Tool Category Colors (for visual differentiation)
|
||||
tools: {
|
||||
pdf: '#dc2626', // Red for PDF tools
|
||||
image: '#f59e0b', // Amber for image tools
|
||||
video: '#06b6d4', // Cyan for video tools
|
||||
document: '#3b82f6', // Blue for document tools
|
||||
text: '#8b5cf6', // Violet for text tools
|
||||
convert: '#ec4899', // Pink for conversion tools
|
||||
edit: '#10b981', // Emerald for editing tools
|
||||
secure: '#f97316', // Orange for security tools
|
||||
},
|
||||
|
||||
// Premium Gradient (for Pro/Business badges)
|
||||
gradients: {
|
||||
premium: {
|
||||
from: '#f59e0b',
|
||||
to: '#d97706',
|
||||
},
|
||||
business: {
|
||||
from: '#8b5cf6',
|
||||
to: '#6366f1',
|
||||
},
|
||||
featured: {
|
||||
from: '#06b6d4',
|
||||
to: '#0ea5e9',
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Color Assignments for UI Elements
|
||||
* These are semantic usage guidelines
|
||||
*/
|
||||
export const colorAssignments = {
|
||||
// Button Colors
|
||||
buttons: {
|
||||
primary: 'primary-600', // Main CTAs
|
||||
secondary: 'slate-600', // Secondary actions
|
||||
success: 'success-600', // Confirm/accept
|
||||
danger: 'error-600', // Delete/destructive
|
||||
ghost: 'slate-500', // Tertiary/icon buttons
|
||||
},
|
||||
|
||||
// Badge/Pill Colors
|
||||
badges: {
|
||||
default: 'slate-100',
|
||||
success: 'success-100',
|
||||
warning: 'warning-100',
|
||||
error: 'error-100',
|
||||
info: 'info-100',
|
||||
pro: 'accent-100',
|
||||
},
|
||||
|
||||
// Alert/Toast Colors
|
||||
alerts: {
|
||||
success: 'success-600',
|
||||
warning: 'warning-600',
|
||||
error: 'error-600',
|
||||
info: 'info-600',
|
||||
},
|
||||
|
||||
// Text Colors
|
||||
text: {
|
||||
primary: 'slate-900',
|
||||
secondary: 'slate-600',
|
||||
tertiary: 'slate-500',
|
||||
muted: 'slate-400',
|
||||
link: 'primary-600',
|
||||
linkHover: 'primary-700',
|
||||
},
|
||||
|
||||
// Border Colors
|
||||
borders: {
|
||||
default: 'slate-200',
|
||||
focus: 'primary-500',
|
||||
error: 'error-400',
|
||||
success: 'success-400',
|
||||
},
|
||||
|
||||
// Background Colors
|
||||
backgrounds: {
|
||||
page: 'white',
|
||||
surface: 'slate-50',
|
||||
surfaceAlt: 'slate-100',
|
||||
overlay: 'rgba(0, 0, 0, 0.5)',
|
||||
},
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Utility function to get color with proper contrast
|
||||
* @param colorName - The color name (e.g., 'primary', 'error')
|
||||
* @param lightValue - Shade number for light mode (e.g., 600)
|
||||
* @param darkValue - Shade number for dark mode (e.g., 500)
|
||||
*/
|
||||
export function getColorClass(
|
||||
colorName: keyof typeof colors,
|
||||
lightValue: number = 600,
|
||||
darkValue: number = 500
|
||||
): string {
|
||||
return `${colorName}-${lightValue} dark:${colorName}-${darkValue}`;
|
||||
}
|
||||
|
||||
export default colors;
|
||||
512
frontend/src/design-system/components-registry.ts
Normal file
512
frontend/src/design-system/components-registry.ts
Normal file
@@ -0,0 +1,512 @@
|
||||
/**
|
||||
* Component Registry & System
|
||||
* Central registry of all UI components with metadata and styling
|
||||
*/
|
||||
|
||||
import { LucideIcon } from 'lucide-react';
|
||||
|
||||
/**
|
||||
* Component Type Definitions
|
||||
*/
|
||||
export interface ComponentMetadata {
|
||||
name: string;
|
||||
description: string;
|
||||
category: 'button' | 'input' | 'card' | 'layout' | 'overlay' | 'feedback' | 'navigation' | 'form';
|
||||
variants?: string[];
|
||||
props?: Record<string, unknown>;
|
||||
a11y?: {
|
||||
role?: string;
|
||||
ariaLabel?: string;
|
||||
ariaDescribedBy?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ComponentColors {
|
||||
light: {
|
||||
bg: string;
|
||||
text: string;
|
||||
border: string;
|
||||
hover?: string;
|
||||
};
|
||||
dark: {
|
||||
bg: string;
|
||||
text: string;
|
||||
border: string;
|
||||
hover?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ToolCardMetadata {
|
||||
name: string;
|
||||
slug: string;
|
||||
icon: string; // Lucide icon name
|
||||
category: 'pdf' | 'image' | 'video' | 'document' | 'text' | 'convert' | 'edit' | 'secure';
|
||||
colorBg: string; // Tailwind bg class
|
||||
colorIcon: string; // Tailwind text color class
|
||||
description: string;
|
||||
i18nKey: string;
|
||||
isPremium?: boolean;
|
||||
isNew?: boolean;
|
||||
isPopular?: boolean;
|
||||
orderPriority: number; // 1 = highest priority on homepage
|
||||
}
|
||||
|
||||
/**
|
||||
* UI Component Registry
|
||||
*/
|
||||
export const componentRegistry: Record<string, ComponentMetadata> = {
|
||||
// Buttons
|
||||
button: {
|
||||
name: 'Button',
|
||||
description: 'Primary interactive element',
|
||||
category: 'button',
|
||||
variants: ['primary', 'secondary', 'success', 'danger', 'ghost', 'icon'],
|
||||
},
|
||||
buttonPrimary: {
|
||||
name: 'Primary Button',
|
||||
description: 'Main call-to-action button',
|
||||
category: 'button',
|
||||
a11y: { role: 'button' },
|
||||
},
|
||||
buttonSecondary: {
|
||||
name: 'Secondary Button',
|
||||
description: 'Alternative action button',
|
||||
category: 'button',
|
||||
},
|
||||
buttonIcon: {
|
||||
name: 'Icon Button',
|
||||
description: 'Icon-only interactive button',
|
||||
category: 'button',
|
||||
a11y: { role: 'button', ariaLabel: 'Required for icon buttons' },
|
||||
},
|
||||
|
||||
// Inputs & Forms
|
||||
input: {
|
||||
name: 'Text Input',
|
||||
description: 'Single-line text input field',
|
||||
category: 'input',
|
||||
},
|
||||
inputFile: {
|
||||
name: 'File Input',
|
||||
description: 'File upload field',
|
||||
category: 'input',
|
||||
},
|
||||
fileUploader: {
|
||||
name: 'File Uploader',
|
||||
description: 'Drag-and-drop file upload zone',
|
||||
category: 'input',
|
||||
},
|
||||
formSelect: {
|
||||
name: 'Select Dropdown',
|
||||
description: 'Option selection dropdown',
|
||||
category: 'form',
|
||||
},
|
||||
formCheckbox: {
|
||||
name: 'Checkbox',
|
||||
description: 'Multi-select checkbox input',
|
||||
category: 'form',
|
||||
},
|
||||
formRadio: {
|
||||
name: 'Radio Button',
|
||||
description: 'Single-select radio input',
|
||||
category: 'form',
|
||||
},
|
||||
formToggle: {
|
||||
name: 'Toggle Switch',
|
||||
description: 'On/off toggle switch',
|
||||
category: 'form',
|
||||
},
|
||||
|
||||
// Cards & Containers
|
||||
card: {
|
||||
name: 'Card',
|
||||
description: 'Elevation container with padding',
|
||||
category: 'card',
|
||||
},
|
||||
toolCard: {
|
||||
name: 'Tool Card',
|
||||
description: 'Tool preview card for homepage grid',
|
||||
category: 'card',
|
||||
},
|
||||
pricingCard: {
|
||||
name: 'Pricing Card',
|
||||
description: 'Subscription plan card',
|
||||
category: 'card',
|
||||
},
|
||||
|
||||
// Layout
|
||||
header: {
|
||||
name: 'Header',
|
||||
description: 'Application header with navigation',
|
||||
category: 'layout',
|
||||
},
|
||||
footer: {
|
||||
name: 'Footer',
|
||||
description: 'Application footer',
|
||||
category: 'layout',
|
||||
},
|
||||
sidebar: {
|
||||
name: 'Sidebar',
|
||||
description: 'Side navigation panel',
|
||||
category: 'layout',
|
||||
},
|
||||
container: {
|
||||
name: 'Container',
|
||||
description: 'Max-width wrapper',
|
||||
category: 'layout',
|
||||
},
|
||||
|
||||
// Feedback
|
||||
alert: {
|
||||
name: 'Alert',
|
||||
description: 'Alert message container',
|
||||
category: 'feedback',
|
||||
variants: ['success', 'warning', 'error', 'info'],
|
||||
},
|
||||
badge: {
|
||||
name: 'Badge',
|
||||
description: 'Small labeling component',
|
||||
category: 'feedback',
|
||||
},
|
||||
progressBar: {
|
||||
name: 'Progress Bar',
|
||||
description: 'Linear progress indicator',
|
||||
category: 'feedback',
|
||||
},
|
||||
spinner: {
|
||||
name: 'Spinner',
|
||||
description: 'Loading spinner indicator',
|
||||
category: 'feedback',
|
||||
},
|
||||
toast: {
|
||||
name: 'Toast',
|
||||
description: 'Temporary notification message',
|
||||
category: 'feedback',
|
||||
},
|
||||
|
||||
// Overlay & Navigation
|
||||
modal: {
|
||||
name: 'Modal Dialog',
|
||||
description: 'Modal dialog overlay',
|
||||
category: 'overlay',
|
||||
},
|
||||
dropdown: {
|
||||
name: 'Dropdown Menu',
|
||||
description: 'Dropdown menu with options',
|
||||
category: 'navigation',
|
||||
},
|
||||
tabs: {
|
||||
name: 'Tabs',
|
||||
description: 'Tabbed content navigation',
|
||||
category: 'navigation',
|
||||
},
|
||||
breadcrumb: {
|
||||
name: 'Breadcrumb',
|
||||
description: 'Breadcrumb navigation trail',
|
||||
category: 'navigation',
|
||||
},
|
||||
pagination: {
|
||||
name: 'Pagination',
|
||||
description: 'Page navigation controls',
|
||||
category: 'navigation',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Tool Categories with Colors
|
||||
*/
|
||||
export const toolCategories = {
|
||||
pdf: {
|
||||
name: 'PDF Tools',
|
||||
color: 'red-600',
|
||||
bgLight: 'bg-red-50',
|
||||
bgDark: 'dark:bg-red-900/20',
|
||||
borderColor: 'border-red-200 dark:border-red-800',
|
||||
},
|
||||
image: {
|
||||
name: 'Image Tools',
|
||||
color: 'amber-600',
|
||||
bgLight: 'bg-amber-50',
|
||||
bgDark: 'dark:bg-amber-900/20',
|
||||
borderColor: 'border-amber-200 dark:border-amber-800',
|
||||
},
|
||||
video: {
|
||||
name: 'Video Tools',
|
||||
color: 'cyan-600',
|
||||
bgLight: 'bg-cyan-50',
|
||||
bgDark: 'dark:bg-cyan-900/20',
|
||||
borderColor: 'border-cyan-200 dark:border-cyan-800',
|
||||
},
|
||||
document: {
|
||||
name: 'Document Tools',
|
||||
color: 'blue-600',
|
||||
bgLight: 'bg-blue-50',
|
||||
bgDark: 'dark:bg-blue-900/20',
|
||||
borderColor: 'border-blue-200 dark:border-blue-800',
|
||||
},
|
||||
text: {
|
||||
name: 'Text Tools',
|
||||
color: 'violet-600',
|
||||
bgLight: 'bg-violet-50',
|
||||
bgDark: 'dark:bg-violet-900/20',
|
||||
borderColor: 'border-violet-200 dark:border-violet-800',
|
||||
},
|
||||
convert: {
|
||||
name: 'Conversion Tools',
|
||||
color: 'pink-600',
|
||||
bgLight: 'bg-pink-50',
|
||||
bgDark: 'dark:bg-pink-900/20',
|
||||
borderColor: 'border-pink-200 dark:border-pink-800',
|
||||
},
|
||||
edit: {
|
||||
name: 'Editing Tools',
|
||||
color: 'emerald-600',
|
||||
bgLight: 'bg-emerald-50',
|
||||
bgDark: 'dark:bg-emerald-900/20',
|
||||
borderColor: 'border-emerald-200 dark:border-emerald-800',
|
||||
},
|
||||
secure: {
|
||||
name: 'Security Tools',
|
||||
color: 'orange-600',
|
||||
bgLight: 'bg-orange-50',
|
||||
bgDark: 'dark:bg-orange-900/20',
|
||||
borderColor: 'border-orange-200 dark:border-orange-800',
|
||||
},
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Complete Tool Registry
|
||||
* This should be updated to include ALL 40+ tools
|
||||
*/
|
||||
export const toolRegistry: Record<string, ToolCardMetadata> = {
|
||||
pdfCompressor: {
|
||||
name: 'Compress PDF',
|
||||
slug: 'compress-pdf',
|
||||
icon: 'Minimize2',
|
||||
category: 'pdf',
|
||||
colorBg: 'bg-red-50 dark:bg-red-900/20',
|
||||
colorIcon: 'text-red-600 dark:text-red-400',
|
||||
description: 'Reduce PDF file size without losing quality',
|
||||
i18nKey: 'tools.compressPdf.title',
|
||||
isPopular: true,
|
||||
orderPriority: 1,
|
||||
},
|
||||
pdfToWord: {
|
||||
name: 'PDF to Word',
|
||||
slug: 'pdf-to-word',
|
||||
icon: 'FileText',
|
||||
category: 'convert',
|
||||
colorBg: 'bg-pink-50 dark:bg-pink-900/20',
|
||||
colorIcon: 'text-pink-600 dark:text-pink-400',
|
||||
description: 'Convert PDF documents to editable Word files',
|
||||
i18nKey: 'tools.pdfToWord.title',
|
||||
isPopular: true,
|
||||
orderPriority: 2,
|
||||
},
|
||||
wordToPdf: {
|
||||
name: 'Word to PDF',
|
||||
slug: 'word-to-pdf',
|
||||
icon: 'FilePdf',
|
||||
category: 'convert',
|
||||
colorBg: 'bg-blue-50 dark:bg-blue-900/20',
|
||||
colorIcon: 'text-blue-600 dark:text-blue-400',
|
||||
description: 'Convert Word documents to PDF format',
|
||||
i18nKey: 'tools.wordToPdf.title',
|
||||
isPopular: true,
|
||||
orderPriority: 3,
|
||||
},
|
||||
mergePdf: {
|
||||
name: 'Merge PDF',
|
||||
slug: 'merge-pdf',
|
||||
icon: 'Layers',
|
||||
category: 'pdf',
|
||||
colorBg: 'bg-violet-50 dark:bg-violet-900/20',
|
||||
colorIcon: 'text-violet-600 dark:text-violet-400',
|
||||
description: 'Combine multiple PDF files into one',
|
||||
i18nKey: 'tools.mergePdf.title',
|
||||
isPopular: true,
|
||||
orderPriority: 4,
|
||||
},
|
||||
splitPdf: {
|
||||
name: 'Split PDF',
|
||||
slug: 'split-pdf',
|
||||
icon: 'Scissors',
|
||||
category: 'pdf',
|
||||
colorBg: 'bg-pink-50 dark:bg-pink-900/20',
|
||||
colorIcon: 'text-pink-600 dark:text-pink-400',
|
||||
description: 'Extract pages or split PDF into separate files',
|
||||
i18nKey: 'tools.splitPdf.title',
|
||||
orderPriority: 5,
|
||||
},
|
||||
rotatePdf: {
|
||||
name: 'Rotate PDF',
|
||||
slug: 'rotate-pdf',
|
||||
icon: 'RotateCw',
|
||||
category: 'edit',
|
||||
colorBg: 'bg-teal-50 dark:bg-teal-900/20',
|
||||
colorIcon: 'text-teal-600 dark:text-teal-400',
|
||||
description: 'Rotate PDF pages at any angle',
|
||||
i18nKey: 'tools.rotatePdf.title',
|
||||
orderPriority: 6,
|
||||
},
|
||||
pdfToImages: {
|
||||
name: 'PDF to Images',
|
||||
slug: 'pdf-to-images',
|
||||
icon: 'Image',
|
||||
category: 'convert',
|
||||
colorBg: 'bg-amber-50 dark:bg-amber-900/20',
|
||||
colorIcon: 'text-amber-600 dark:text-amber-400',
|
||||
description: 'Convert PDF pages to individual image files',
|
||||
i18nKey: 'tools.pdfToImages.title',
|
||||
orderPriority: 7,
|
||||
},
|
||||
imagesToPdf: {
|
||||
name: 'Images to PDF',
|
||||
slug: 'images-to-pdf',
|
||||
icon: 'FileImage',
|
||||
category: 'convert',
|
||||
colorBg: 'bg-lime-50 dark:bg-lime-900/20',
|
||||
colorIcon: 'text-lime-600 dark:text-lime-400',
|
||||
description: 'Combine images into a single PDF file',
|
||||
i18nKey: 'tools.imagesToPdf.title',
|
||||
orderPriority: 8,
|
||||
},
|
||||
watermarkPdf: {
|
||||
name: 'Watermark PDF',
|
||||
slug: 'watermark-pdf',
|
||||
icon: 'Droplets',
|
||||
category: 'edit',
|
||||
colorBg: 'bg-cyan-50 dark:bg-cyan-900/20',
|
||||
colorIcon: 'text-cyan-600 dark:text-cyan-400',
|
||||
description: 'Add watermarks to PDF documents',
|
||||
i18nKey: 'tools.watermarkPdf.title',
|
||||
orderPriority: 9,
|
||||
},
|
||||
protectPdf: {
|
||||
name: 'Protect PDF',
|
||||
slug: 'protect-pdf',
|
||||
icon: 'Lock',
|
||||
category: 'secure',
|
||||
colorBg: 'bg-red-50 dark:bg-red-900/20',
|
||||
colorIcon: 'text-red-600 dark:text-red-400',
|
||||
description: 'Password-protect PDF files',
|
||||
i18nKey: 'tools.protectPdf.title',
|
||||
isPremium: true,
|
||||
orderPriority: 10,
|
||||
},
|
||||
unlockPdf: {
|
||||
name: 'Unlock PDF',
|
||||
slug: 'unlock-pdf',
|
||||
icon: 'Unlock',
|
||||
category: 'secure',
|
||||
colorBg: 'bg-green-50 dark:bg-green-900/20',
|
||||
colorIcon: 'text-green-600 dark:text-green-400',
|
||||
description: 'Remove password protection from PDF files',
|
||||
i18nKey: 'tools.unlockPdf.title',
|
||||
isPremium: true,
|
||||
orderPriority: 11,
|
||||
},
|
||||
addPageNumbers: {
|
||||
name: 'Add Page Numbers',
|
||||
slug: 'add-page-numbers',
|
||||
icon: 'ListOrdered',
|
||||
category: 'edit',
|
||||
colorBg: 'bg-sky-50 dark:bg-sky-900/20',
|
||||
colorIcon: 'text-sky-600 dark:text-sky-400',
|
||||
description: 'Add page numbers to PDF documents',
|
||||
i18nKey: 'tools.addPageNumbers.title',
|
||||
isPremium: true,
|
||||
orderPriority: 12,
|
||||
},
|
||||
imageConverter: {
|
||||
name: 'Image Converter',
|
||||
slug: 'image-converter',
|
||||
icon: 'ImageIcon',
|
||||
category: 'image',
|
||||
colorBg: 'bg-purple-50 dark:bg-purple-900/20',
|
||||
colorIcon: 'text-purple-600 dark:text-purple-400',
|
||||
description: 'Convert images between different formats',
|
||||
i18nKey: 'tools.imageConverter.title',
|
||||
orderPriority: 13,
|
||||
},
|
||||
videoToGif: {
|
||||
name: 'Video to GIF',
|
||||
slug: 'video-to-gif',
|
||||
icon: 'Film',
|
||||
category: 'video',
|
||||
colorBg: 'bg-emerald-50 dark:bg-emerald-900/20',
|
||||
colorIcon: 'text-emerald-600 dark:text-emerald-400',
|
||||
description: 'Convert video files to animated GIFs',
|
||||
i18nKey: 'tools.videoToGif.title',
|
||||
isPremium: true,
|
||||
orderPriority: 14,
|
||||
},
|
||||
wordCounter: {
|
||||
name: 'Word Counter',
|
||||
slug: 'word-counter',
|
||||
icon: 'Hash',
|
||||
category: 'text',
|
||||
colorBg: 'bg-blue-50 dark:bg-blue-900/20',
|
||||
colorIcon: 'text-blue-600 dark:text-blue-400',
|
||||
description: 'Count words, characters, and paragraphs',
|
||||
i18nKey: 'tools.wordCounter.title',
|
||||
orderPriority: 15,
|
||||
},
|
||||
textCleaner: {
|
||||
name: 'Text Cleaner',
|
||||
slug: 'text-cleaner',
|
||||
icon: 'Eraser',
|
||||
category: 'text',
|
||||
colorBg: 'bg-indigo-50 dark:bg-indigo-900/20',
|
||||
colorIcon: 'text-indigo-600 dark:text-indigo-400',
|
||||
description: 'Clean and format text content',
|
||||
i18nKey: 'tools.textCleaner.title',
|
||||
orderPriority: 16,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Get all tools sorted by priority
|
||||
*/
|
||||
export function getToolsByPriority(): ToolCardMetadata[] {
|
||||
return Object.values(toolRegistry).sort(
|
||||
(a, b) => a.orderPriority - b.orderPriority
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tools by category
|
||||
*/
|
||||
export function getToolsByCategory(
|
||||
category: ToolCardMetadata['category']
|
||||
): ToolCardMetadata[] {
|
||||
return Object.values(toolRegistry)
|
||||
.filter((tool) => tool.category === category)
|
||||
.sort((a, b) => a.orderPriority - b.orderPriority);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get popular tools
|
||||
*/
|
||||
export function getPopularTools(): ToolCardMetadata[] {
|
||||
return Object.values(toolRegistry)
|
||||
.filter((tool) => tool.isPopular)
|
||||
.sort((a, b) => a.orderPriority - b.orderPriority);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get premium tools
|
||||
*/
|
||||
export function getPremiumTools(): ToolCardMetadata[] {
|
||||
return Object.values(toolRegistry).filter((tool) => tool.isPremium);
|
||||
}
|
||||
|
||||
export default {
|
||||
componentRegistry,
|
||||
toolRegistry,
|
||||
toolCategories,
|
||||
getToolsByPriority,
|
||||
getToolsByCategory,
|
||||
getPopularTools,
|
||||
getPremiumTools,
|
||||
};
|
||||
296
frontend/src/design-system/theme.ts
Normal file
296
frontend/src/design-system/theme.ts
Normal file
@@ -0,0 +1,296 @@
|
||||
/**
|
||||
* Design System Theme Configuration
|
||||
* Centralized theme utilities and token system
|
||||
*/
|
||||
|
||||
import colors, { colorAssignments, getColorClass } from './colors';
|
||||
|
||||
/**
|
||||
* Typography Scale
|
||||
* Matches Tailwind CSS + custom adjustments for accessibility
|
||||
*/
|
||||
export const typography = {
|
||||
// Headings
|
||||
h1: {
|
||||
fontSize: '3rem', // 48px
|
||||
fontWeight: 700,
|
||||
lineHeight: '3.5rem', // 56px
|
||||
letterSpacing: '-0.02em',
|
||||
},
|
||||
h2: {
|
||||
fontSize: '2.25rem', // 36px
|
||||
fontWeight: 700,
|
||||
lineHeight: '2.5rem', // 40px
|
||||
letterSpacing: '-0.01em',
|
||||
},
|
||||
h3: {
|
||||
fontSize: '1.875rem', // 30px
|
||||
fontWeight: 600,
|
||||
lineHeight: '2.25rem', // 36px
|
||||
letterSpacing: '-0.01em',
|
||||
},
|
||||
h4: {
|
||||
fontSize: '1.5rem', // 24px
|
||||
fontWeight: 600,
|
||||
lineHeight: '2rem', // 32px
|
||||
},
|
||||
h5: {
|
||||
fontSize: '1.25rem', // 20px
|
||||
fontWeight: 600,
|
||||
lineHeight: '1.75rem', // 28px
|
||||
},
|
||||
h6: {
|
||||
fontSize: '1rem', // 16px
|
||||
fontWeight: 600,
|
||||
lineHeight: '1.5rem', // 24px
|
||||
},
|
||||
|
||||
// Body text
|
||||
body: {
|
||||
large: {
|
||||
fontSize: '1.125rem', // 18px
|
||||
fontWeight: 400,
|
||||
lineHeight: '1.75rem', // 28px
|
||||
},
|
||||
base: {
|
||||
fontSize: '1rem', // 16px
|
||||
fontWeight: 400,
|
||||
lineHeight: '1.5rem', // 24px
|
||||
},
|
||||
small: {
|
||||
fontSize: '0.875rem', // 14px
|
||||
fontWeight: 400,
|
||||
lineHeight: '1.25rem', // 20px
|
||||
},
|
||||
xs: {
|
||||
fontSize: '0.75rem', // 12px
|
||||
fontWeight: 400,
|
||||
lineHeight: '1rem', // 16px
|
||||
},
|
||||
},
|
||||
|
||||
// Labels & UI text
|
||||
label: {
|
||||
fontSize: '0.875rem', // 14px
|
||||
fontWeight: 500,
|
||||
lineHeight: '1.25rem', // 20px
|
||||
},
|
||||
caption: {
|
||||
fontSize: '0.75rem', // 12px
|
||||
fontWeight: 500,
|
||||
lineHeight: '1rem', // 16px
|
||||
},
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Spacing Scale
|
||||
* 4px base unit (Tailwind default)
|
||||
*/
|
||||
export const spacing = {
|
||||
xs: '0.25rem', // 4px
|
||||
sm: '0.5rem', // 8px
|
||||
md: '1rem', // 16px
|
||||
lg: '1.5rem', // 24px
|
||||
xl: '2rem', // 32px
|
||||
'2xl': '2.5rem', // 40px
|
||||
'3xl': '3rem', // 48px
|
||||
'4xl': '4rem', // 64px
|
||||
'5xl': '5rem', // 80px
|
||||
'6xl': '6rem', // 96px
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Border Radius Scale
|
||||
*/
|
||||
export const borderRadius = {
|
||||
none: '0',
|
||||
sm: '0.375rem', // 6px
|
||||
base: '0.5rem', // 8px
|
||||
md: '0.75rem', // 12px
|
||||
lg: '1rem', // 16px
|
||||
xl: '1.25rem', // 20px
|
||||
'2xl': '1.5rem', // 24px
|
||||
full: '9999px',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Shadow Scale
|
||||
*/
|
||||
export const shadows = {
|
||||
none: '0 0 #0000',
|
||||
xs: '0 1px 2px 0 rgba(0, 0, 0, 0.05)',
|
||||
sm: '0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06)',
|
||||
md: '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)',
|
||||
lg: '0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)',
|
||||
xl: '0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)',
|
||||
'2xl': '0 25px 50px -12px rgba(0, 0, 0, 0.25)',
|
||||
inner: 'inset 0 2px 4px 0 rgba(0, 0, 0, 0.05)',
|
||||
lg_dark: '0 20px 25px -5px rgba(0, 0, 0, 0.5)',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Z-Index Scale
|
||||
* Structured layering system
|
||||
*/
|
||||
export const zIndex = {
|
||||
auto: 'auto',
|
||||
hide: '-1',
|
||||
base: '0',
|
||||
dropdown: '1000',
|
||||
sticky: '1010',
|
||||
fixed: '1020',
|
||||
backdrop: '1030',
|
||||
offcanvas: '1040',
|
||||
modal: '1050',
|
||||
popover: '1060',
|
||||
tooltip: '1070',
|
||||
notification: '1080',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Transitions & Animations
|
||||
*/
|
||||
export const transitions = {
|
||||
fast: '0.15s',
|
||||
base: '0.2s',
|
||||
slow: '0.3s',
|
||||
slower: '0.5s',
|
||||
|
||||
easing: {
|
||||
in: 'cubic-bezier(0.4, 0, 1, 1)',
|
||||
out: 'cubic-bezier(0, 0, 0.2, 1)',
|
||||
inOut: 'cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
},
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Breakpoints (matching Tailwind)
|
||||
*/
|
||||
export const breakpoints = {
|
||||
xs: '0px',
|
||||
sm: '640px',
|
||||
md: '768px',
|
||||
lg: '1024px',
|
||||
xl: '1280px',
|
||||
'2xl': '1536px',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Container Width
|
||||
*/
|
||||
export const containers = {
|
||||
sm: '24rem', // 384px
|
||||
md: '28rem', // 448px
|
||||
lg: '32rem', // 512px
|
||||
xl: '36rem', // 576px
|
||||
'2xl': '42rem', // 672px
|
||||
'3xl': '48rem', // 768px
|
||||
'4xl': '56rem', // 896px
|
||||
'5xl': '64rem', // 1024px
|
||||
'6xl': '72rem', // 1152px
|
||||
'7xl': '80rem', // 1280px
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Component Size Presets
|
||||
*/
|
||||
export const componentSizes = {
|
||||
// Button sizes
|
||||
button: {
|
||||
xs: {
|
||||
padding: '0.375rem 0.75rem',
|
||||
fontSize: '0.75rem',
|
||||
height: '1.5rem',
|
||||
},
|
||||
sm: {
|
||||
padding: '0.5rem 1rem',
|
||||
fontSize: '0.875rem',
|
||||
height: '2rem',
|
||||
},
|
||||
md: {
|
||||
padding: '0.75rem 1.5rem',
|
||||
fontSize: '1rem',
|
||||
height: '2.5rem',
|
||||
},
|
||||
lg: {
|
||||
padding: '1rem 2rem',
|
||||
fontSize: '1.125rem',
|
||||
height: '3rem',
|
||||
},
|
||||
xl: {
|
||||
padding: '1.25rem 2.5rem',
|
||||
fontSize: '1.25rem',
|
||||
height: '3.5rem',
|
||||
},
|
||||
},
|
||||
|
||||
// Input sizes
|
||||
input: {
|
||||
sm: { padding: '0.375rem 0.75rem', fontSize: '0.875rem' },
|
||||
md: { padding: '0.75rem 1rem', fontSize: '1rem' },
|
||||
lg: { padding: '1rem 1.25rem', fontSize: '1.125rem' },
|
||||
},
|
||||
|
||||
// Icon sizes
|
||||
icon: {
|
||||
xs: '1rem', // 16px
|
||||
sm: '1.25rem', // 20px
|
||||
md: '1.5rem', // 24px
|
||||
lg: '2rem', // 32px
|
||||
xl: '2.5rem', // 40px
|
||||
'2xl': '3rem', // 48px
|
||||
},
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Responsive Utilities
|
||||
*/
|
||||
export const responsive = {
|
||||
// Stack direction
|
||||
stackMobile: 'flex flex-col',
|
||||
stackDesktop: 'lg:flex-row',
|
||||
|
||||
// Grid column count
|
||||
gridAuto: 'grid gap-4',
|
||||
grid2: 'grid grid-cols-1 sm:grid-cols-2 gap-4',
|
||||
grid3: 'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4',
|
||||
grid4: 'grid grid-cols-2 lg:grid-cols-4 gap-4',
|
||||
|
||||
// Common padding
|
||||
pagePaddingX: 'px-4 sm:px-6 lg:px-8',
|
||||
pagePaddingY: 'py-8 sm:py-12 lg:py-16',
|
||||
sectionPadding: 'px-4 sm:px-6 lg:px-8 py-8 sm:py-12 lg:py-16',
|
||||
|
||||
// Container width
|
||||
containerMax: 'max-w-7xl',
|
||||
containerContent: 'max-w-4xl',
|
||||
containerSmall: 'max-w-2xl',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Complete Theme Object
|
||||
*/
|
||||
export const theme = {
|
||||
colors,
|
||||
colorAssignments,
|
||||
typography,
|
||||
spacing,
|
||||
borderRadius,
|
||||
shadows,
|
||||
zIndex,
|
||||
transitions,
|
||||
breakpoints,
|
||||
containers,
|
||||
componentSizes,
|
||||
responsive,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Utility: Get CSS variable or Tailwind class for a color
|
||||
*/
|
||||
export const useColor = (semantic: string, mode: 'light' | 'dark' = 'light') => {
|
||||
const colorObj = mode === 'light' ? colors.light : colors.dark;
|
||||
return colorObj[semantic as keyof typeof colorObj];
|
||||
};
|
||||
|
||||
export default theme;
|
||||
40
nginx/nginx.dev.conf
Normal file
40
nginx/nginx.dev.conf
Normal file
@@ -0,0 +1,40 @@
|
||||
upstream frontend {
|
||||
server frontend:5173;
|
||||
}
|
||||
|
||||
# ── HTTP Development Server ──
|
||||
server {
|
||||
listen 80 default_server;
|
||||
client_max_body_size 100M;
|
||||
resolver 127.0.0.11 valid=30s ipv6=off;
|
||||
set $backend_upstream backend:5000;
|
||||
|
||||
# API requests → Flask backend
|
||||
location /api/ {
|
||||
proxy_pass http://$backend_upstream;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# Timeout for large file uploads
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
proxy_connect_timeout 60s;
|
||||
}
|
||||
|
||||
# Frontend (Vite dev server)
|
||||
location / {
|
||||
proxy_pass http://frontend;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
}
|
||||
|
||||
# Health check
|
||||
location /health {
|
||||
proxy_pass http://$backend_upstream/api/health;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user