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:
Your Name
2026-03-29 11:39:08 +02:00
parent bc8a5dc290
commit 03c451abe5
13 changed files with 2443 additions and 402 deletions

View File

@@ -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():

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

View File

@@ -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():

View File

@@ -117,16 +117,14 @@ services:
- VITE_SITE_DOMAIN=${VITE_SITE_DOMAIN:-}
- VITE_SENTRY_DSN=${VITE_SENTRY_DSN:-}
# --- Nginx Reverse Proxy ---
# --- Nginx Reverse Proxy ---
nginx:
image: nginx:alpine
ports:
- "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

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

View File

@@ -1,40 +1,137 @@
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="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" />
<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 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', { defaultValue: 'Processing...' })}
</p>
{progress !== undefined && (
<p className="text-xs text-slate-500 dark:text-slate-400 mt-1">
{progress}%
</p>
)}
</div>
</div>
{/* 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>
)}
<div className="flex-1">
<p className="text-sm font-medium text-slate-700 dark:text-slate-300">
{message || t('common.processing')}
</p>
</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>
{/* Animated progress bar for active states */}
{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" />
{/* 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>

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

View File

@@ -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,94 +25,35 @@ 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' && (
<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 className="grid grid-cols-3 gap-3">
{qualityOptions.map((opt) => (
<button
key={opt.value}
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'
}`}
>
<p className="text-sm font-medium">{opt.label}</p>
<p className="text-xs text-slate-400 mt-0.5">{opt.desc}</p>
</button>
))}
</div>
<button onClick={handleUpload} className="btn-primary w-full">
{t('tools.compressPdf.shortDesc')}
<ToolTemplate config={toolConfig} onGetExtraData={getExtraData}>
{(props: ToolTemplateProps) => (
<div className="space-y-4">
<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
key={opt.value}
onClick={() => setQuality(opt.value)}
className={`rounded-xl p-3 text-center ring-1 transition-all ${
quality === opt.value
? '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-500 dark:text-slate-400 mt-0.5">{opt.desc}</p>
</button>
</>
)}
</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>
</>
</div>
)}
</ToolTemplate>
);
}

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

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

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