feat: revue de code, doc CODE_REVIEW, forfaits 2026, traduction LLM, providers avec modèle

Made-with: Cursor
This commit is contained in:
Sepehr Ramezani
2026-03-07 11:42:58 +01:00
parent 3d37ce4582
commit 473b3e26c7
181 changed files with 30617 additions and 7170 deletions

View File

@@ -2,36 +2,38 @@
Configuration module for the Document Translation API
SaaS-ready with comprehensive settings for production deployment
"""
import os
from pathlib import Path
from dotenv import load_dotenv
load_dotenv()
class Config:
# ============== Translation Service ==============
TRANSLATION_SERVICE = os.getenv("TRANSLATION_SERVICE", "google")
DEEPL_API_KEY = os.getenv("DEEPL_API_KEY", "")
# Ollama Configuration
OLLAMA_BASE_URL = os.getenv("OLLAMA_BASE_URL", "http://localhost:11434")
OLLAMA_MODEL = os.getenv("OLLAMA_MODEL", "llama3")
OLLAMA_VISION_MODEL = os.getenv("OLLAMA_VISION_MODEL", "llava")
# ============== File Upload Configuration ==============
MAX_FILE_SIZE_MB = int(os.getenv("MAX_FILE_SIZE_MB", "50"))
MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024
# Directories
BASE_DIR = Path(__file__).parent
UPLOAD_DIR = BASE_DIR / "uploads"
OUTPUT_DIR = BASE_DIR / "outputs"
TEMP_DIR = BASE_DIR / "temp"
LOGS_DIR = BASE_DIR / "logs"
# Supported file types
SUPPORTED_EXTENSIONS = {".xlsx", ".docx", ".pptx"}
# ============== Rate Limiting (SaaS) ==============
RATE_LIMIT_ENABLED = os.getenv("RATE_LIMIT_ENABLED", "true").lower() == "true"
RATE_LIMIT_PER_MINUTE = int(os.getenv("RATE_LIMIT_PER_MINUTE", "30"))
@@ -39,47 +41,53 @@ class Config:
TRANSLATIONS_PER_MINUTE = int(os.getenv("TRANSLATIONS_PER_MINUTE", "10"))
TRANSLATIONS_PER_HOUR = int(os.getenv("TRANSLATIONS_PER_HOUR", "50"))
MAX_CONCURRENT_TRANSLATIONS = int(os.getenv("MAX_CONCURRENT_TRANSLATIONS", "5"))
# ============== Cleanup Service ==============
CLEANUP_ENABLED = os.getenv("CLEANUP_ENABLED", "true").lower() == "true"
CLEANUP_INTERVAL_MINUTES = int(os.getenv("CLEANUP_INTERVAL_MINUTES", "15"))
CLEANUP_INTERVAL_MINUTES = int(os.getenv("CLEANUP_INTERVAL_MINUTES", "5"))
FILE_TTL_MINUTES = int(os.getenv("FILE_TTL_MINUTES", "60"))
INPUT_FILE_TTL_MINUTES = int(os.getenv("INPUT_FILE_TTL_MINUTES", "30"))
OUTPUT_FILE_TTL_MINUTES = int(os.getenv("OUTPUT_FILE_TTL_MINUTES", "120"))
# Disk space thresholds
DISK_WARNING_THRESHOLD_GB = float(os.getenv("DISK_WARNING_THRESHOLD_GB", "5.0"))
DISK_CRITICAL_THRESHOLD_GB = float(os.getenv("DISK_CRITICAL_THRESHOLD_GB", "1.0"))
MAX_TOTAL_SIZE_GB = float(os.getenv("MAX_TOTAL_SIZE_GB", "10.0"))
# ============== Security ==============
ENABLE_HSTS = os.getenv("ENABLE_HSTS", "false").lower() == "true"
CORS_ORIGINS = os.getenv("CORS_ORIGINS", "*").split(",")
CORS_ORIGINS = [o.strip() for o in os.getenv("CORS_ORIGINS", "").split(",") if o.strip()]
MAX_REQUEST_SIZE_MB = int(os.getenv("MAX_REQUEST_SIZE_MB", "100"))
REQUEST_TIMEOUT_SECONDS = int(os.getenv("REQUEST_TIMEOUT_SECONDS", "300"))
# ============== Monitoring ==============
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO")
ENABLE_REQUEST_LOGGING = os.getenv("ENABLE_REQUEST_LOGGING", "true").lower() == "true"
ENABLE_REQUEST_LOGGING = (
os.getenv("ENABLE_REQUEST_LOGGING", "true").lower() == "true"
)
MAX_MEMORY_PERCENT = float(os.getenv("MAX_MEMORY_PERCENT", "80"))
# ============== API Configuration ==============
API_TITLE = "Document Translation API"
API_VERSION = "1.0.0"
API_DESCRIPTION = """
Advanced Document Translation API with strict formatting preservation.
Supports:
- Excel (.xlsx) - Preserves cell formatting, formulas, merged cells, images
- Word (.docx) - Preserves styles, tables, images, headers/footers
- PowerPoint (.pptx) - Preserves layouts, animations, embedded media
SaaS Features:
- Rate limiting per client IP
- Automatic file cleanup
- Health monitoring
- Request logging
"""
Advanced Document Translation API with strict formatting preservation.
## Supported Formats
- Excel (.xlsx) - Preserves cell formatting, formulas, merged cells, images
- Word (.docx) - Preserves styles, tables, images, headers/footers
- PowerPoint (.pptx) - Preserves layouts, animations, embedded media
## SaaS Features
- Rate limiting per client IP
- Automatic file cleanup
- Health monitoring
- Request logging
## API Versioning
All API endpoints are versioned under /api/v1/ prefix for backward compatibility.
"""
@classmethod
def ensure_directories(cls):
"""Create necessary directories if they don't exist"""

View File

@@ -1,17 +1,46 @@
"""
Database module for the Document Translation API
Provides PostgreSQL support with async SQLAlchemy
Provides PostgreSQL/SQLite support with async SQLAlchemy 2.0
"""
from database.connection import get_db, engine, SessionLocal, init_db
from database.models import User, Subscription, Translation, ApiKey
from database.connection import (
get_db,
get_db_session,
get_async_session,
engine,
AsyncSessionLocal,
init_db,
get_engine,
)
from database.models import (
Base,
User,
Translation,
ApiKey,
UsageLog,
PaymentHistory,
PlanType,
SubscriptionStatus,
Glossary,
GlossaryTerm,
)
__all__ = [
"get_db",
"engine",
"SessionLocal",
"get_db_session",
"get_async_session",
"engine",
"AsyncSessionLocal",
"init_db",
"get_engine",
"Base",
"User",
"Subscription",
"Translation",
"ApiKey"
"ApiKey",
"UsageLog",
"PaymentHistory",
"PlanType",
"SubscriptionStatus",
"Glossary",
"GlossaryTerm",
]

View File

@@ -1,135 +1,174 @@
"""
Database connection and session management
Supports both PostgreSQL (production) and SQLite (development/testing)
Async SQLAlchemy 2.0 implementation
"""
import os
import logging
from typing import Generator, Optional
from typing import AsyncGenerator, Optional
from contextlib import asynccontextmanager
from sqlalchemy import text, create_engine
from sqlalchemy.orm import sessionmaker, Session
from sqlalchemy.ext.asyncio import (
create_async_engine,
AsyncSession,
async_sessionmaker,
AsyncEngine,
)
from sqlalchemy.pool import QueuePool, StaticPool
from contextlib import contextmanager
from sqlalchemy import create_engine, event
from sqlalchemy.orm import sessionmaker, Session
from sqlalchemy.pool import QueuePool, StaticPool
from database.utils import convert_to_async_url
logger = logging.getLogger(__name__)
# Database URL from environment
# PostgreSQL: postgresql://user:password@host:port/database
# SQLite: sqlite:///./data/translate.db
DATABASE_URL = os.getenv("DATABASE_URL", "")
# Determine if we're using SQLite or PostgreSQL
_is_sqlite = DATABASE_URL.startswith("sqlite") if DATABASE_URL else True
_is_postgres = DATABASE_URL.startswith("postgres") if DATABASE_URL else False
# Create engine based on database type
if DATABASE_URL and not _is_sqlite:
# PostgreSQL configuration
engine = create_engine(
DATABASE_URL,
if DATABASE_URL and _is_postgres:
async_database_url = convert_to_async_url(DATABASE_URL)
engine: AsyncEngine = create_async_engine(
async_database_url,
poolclass=QueuePool,
pool_size=5,
max_overflow=10,
pool_timeout=30,
pool_recycle=1800, # Recycle connections after 30 minutes
pool_pre_ping=True, # Check connection health before use
pool_recycle=1800,
pool_pre_ping=True,
echo=os.getenv("DATABASE_ECHO", "false").lower() == "true",
)
logger.info("✅ Database configured with PostgreSQL")
logger.info("✅ Database configured with PostgreSQL (async)")
else:
# SQLite configuration (for development/testing or when no DATABASE_URL)
sqlite_path = os.getenv("SQLITE_PATH", "data/translate.db")
os.makedirs(os.path.dirname(sqlite_path), exist_ok=True)
sqlite_url = f"sqlite:///./{sqlite_path}"
engine = create_engine(
sqlite_url,
os.makedirs(
os.path.dirname(sqlite_path) if os.path.dirname(sqlite_path) else ".",
exist_ok=True,
)
async_database_url = f"sqlite+aiosqlite:///./{sqlite_path}"
engine: AsyncEngine = create_async_engine(
async_database_url,
connect_args={"check_same_thread": False},
poolclass=StaticPool,
echo=os.getenv("DATABASE_ECHO", "false").lower() == "true",
)
# Enable foreign keys for SQLite
@event.listens_for(engine, "connect")
def set_sqlite_pragma(dbapi_connection, connection_record):
cursor = dbapi_connection.cursor()
cursor.execute("PRAGMA foreign_keys=ON")
cursor.close()
if not DATABASE_URL:
logger.warning("⚠️ DATABASE_URL not set, using SQLite for development")
else:
logger.info(f"✅ Database configured with SQLite: {sqlite_path}")
# Session factory
SessionLocal = sessionmaker(
if not DATABASE_URL:
logger.warning("⚠️ DATABASE_URL not set, using SQLite for development (async)")
else:
logger.info(f"✅ Database configured with SQLite: {sqlite_path} (async)")
# Sync engine and session for repositories (auth, translation log).
# Kept for backward compatibility until all callers use async; see story 1-1.
# Prefer get_db() / AsyncSessionLocal for new code.
if DATABASE_URL and _is_postgres:
sync_engine = create_engine(
DATABASE_URL,
poolclass=QueuePool,
pool_size=5,
max_overflow=10,
pool_pre_ping=True,
echo=os.getenv("DATABASE_ECHO", "false").lower() == "true",
)
else:
_sqlite_path = os.getenv("SQLITE_PATH", "data/translate.db")
_sync_sqlite_url = f"sqlite:///./{_sqlite_path}"
sync_engine = create_engine(
_sync_sqlite_url,
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
SyncSessionLocal = sessionmaker(bind=sync_engine, autocommit=False, autoflush=False, expire_on_commit=False)
@contextmanager
def get_sync_session():
"""Sync session context manager for use with sync repositories (auth_service, translation log)."""
session = SyncSessionLocal()
try:
yield session
session.commit()
except Exception:
session.rollback()
raise
finally:
session.close()
AsyncSessionLocal = async_sessionmaker(
bind=engine,
class_=AsyncSession,
autocommit=False,
autoflush=False,
bind=engine,
expire_on_commit=False,
)
def get_db() -> Generator[Session, None, None]:
async def get_db() -> AsyncGenerator[AsyncSession, None]:
"""
Dependency for FastAPI to get database session.
Usage: db: Session = Depends(get_db)
Async dependency for FastAPI to get database session.
Usage: db: AsyncSession = Depends(get_db)
"""
db = SessionLocal()
try:
yield db
finally:
db.close()
async with AsyncSessionLocal() as session:
try:
yield session
finally:
await session.close()
@contextmanager
def get_db_session() -> Generator[Session, None, None]:
@asynccontextmanager
async def get_db_session() -> AsyncGenerator[AsyncSession, None]:
"""
Context manager for database session.
Usage: with get_db_session() as db: ...
Async context manager for database session.
Usage: async with get_db_session() as db: ...
"""
db = SessionLocal()
try:
yield db
db.commit()
except Exception:
db.rollback()
raise
finally:
db.close()
async with AsyncSessionLocal() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
# Alias for backward compatibility
get_sync_session = get_db_session
get_async_session = get_db_session
def init_db():
async def init_db():
"""
Initialize database tables.
Initialize database tables asynchronously.
Call this on application startup.
"""
from database.models import Base
Base.metadata.create_all(bind=engine)
logger.info("✅ Database tables initialized")
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
logger.info("✅ Database tables initialized (async)")
def check_db_connection() -> bool:
async def check_db_connection() -> bool:
"""
Check if database connection is healthy.
Returns True if connection works, False otherwise.
"""
try:
with engine.connect() as conn:
conn.execute("SELECT 1")
async with engine.connect() as conn:
await conn.execute(text("SELECT 1"))
return True
except Exception as e:
logger.error(f"Database connection check failed: {e}")
return False
# Connection pool stats (for monitoring)
def get_pool_stats() -> dict:
"""Get database connection pool statistics"""
if hasattr(engine.pool, 'status'):
if hasattr(engine.pool, "status"):
return {
"pool_size": engine.pool.size(),
"checked_in": engine.pool.checkedin(),
@@ -137,3 +176,8 @@ def get_pool_stats() -> dict:
"overflow": engine.pool.overflow(),
}
return {"status": "pool stats not available"}
def get_engine() -> AsyncEngine:
"""Get the async engine instance"""
return engine

View File

@@ -1,19 +1,34 @@
"""
SQLAlchemy models for the Document Translation API
"""
import os
import uuid
from datetime import datetime
from typing import Optional, List
from datetime import datetime, timezone
import warnings
from sqlalchemy import (
Column, String, Integer, Float, Boolean, DateTime, Text,
ForeignKey, Enum, Index, JSON, BigInteger
Column,
String,
Integer,
Float,
Boolean,
DateTime,
Text,
ForeignKey,
Enum,
Index,
JSON,
BigInteger,
CheckConstraint,
)
from sqlalchemy.orm import relationship, declarative_base
from sqlalchemy.dialects.postgresql import UUID as PG_UUID
import enum
def _utcnow() -> datetime:
return datetime.now(timezone.utc)
Base = declarative_base()
@@ -22,6 +37,11 @@ def generate_uuid():
return str(uuid.uuid4())
def generate_uuid_value():
"""Generate a new UUID value for PostgreSQL UUID column"""
return uuid.uuid4()
class PlanType(str, enum.Enum):
FREE = "free"
STARTER = "starter"
@@ -40,57 +60,78 @@ class SubscriptionStatus(str, enum.Enum):
class User(Base):
"""User model for authentication and billing"""
__tablename__ = "users"
id = Column(String(36), primary_key=True, default=generate_uuid)
email = Column(String(255), unique=True, nullable=False, index=True)
name = Column(String(255), nullable=False)
password_hash = Column(String(255), nullable=False)
# Account status
hashed_password = Column(String(255), nullable=False)
tier = Column(String(10), default="free", nullable=False)
daily_translation_count = Column(Integer, default=0, nullable=False)
email_verified = Column(Boolean, default=False)
is_active = Column(Boolean, default=True)
avatar_url = Column(String(500), nullable=True)
# Subscription info
plan = Column(Enum(PlanType), default=PlanType.FREE)
subscription_status = Column(Enum(SubscriptionStatus), default=SubscriptionStatus.ACTIVE)
# Stripe integration
subscription_status = Column(
Enum(SubscriptionStatus), default=SubscriptionStatus.ACTIVE
)
stripe_customer_id = Column(String(255), nullable=True, index=True)
stripe_subscription_id = Column(String(255), nullable=True)
# Usage tracking (reset monthly)
docs_translated_this_month = Column(Integer, default=0)
pages_translated_this_month = Column(Integer, default=0)
api_calls_this_month = Column(Integer, default=0)
extra_credits = Column(Integer, default=0) # Purchased credits
usage_reset_date = Column(DateTime, default=datetime.utcnow)
# Timestamps
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
extra_credits = Column(Integer, default=0)
usage_reset_date = Column(DateTime, default=_utcnow)
created_at = Column(DateTime, default=_utcnow)
updated_at = Column(DateTime, default=_utcnow, onupdate=_utcnow)
last_login_at = Column(DateTime, nullable=True)
# Relationships
translations = relationship("Translation", back_populates="user", lazy="dynamic")
api_keys = relationship("ApiKey", back_populates="user", lazy="dynamic")
# Indexes
translations = relationship("Translation", back_populates="user", lazy="select")
api_keys = relationship("ApiKey", back_populates="user", lazy="select")
__table_args__ = (
Index('ix_users_email_active', 'email', 'is_active'),
Index('ix_users_stripe_customer', 'stripe_customer_id'),
CheckConstraint("tier IN ('free', 'pro')", name="ck_users_tier"),
Index("ix_users_email_active", "email", "is_active"),
Index("ix_users_stripe_customer", "stripe_customer_id"),
)
@property
def password_hash(self) -> str:
warnings.warn(
"password_hash is deprecated, use hashed_password instead",
DeprecationWarning,
stacklevel=2,
)
return self.hashed_password
@password_hash.setter
def password_hash(self, value: str) -> None:
warnings.warn(
"password_hash is deprecated, use hashed_password instead",
DeprecationWarning,
stacklevel=2,
)
self.hashed_password = value
def to_dict(self) -> dict:
"""Convert user to dictionary for API response"""
return {
"id": self.id,
"email": self.email,
"name": self.name,
"avatar_url": self.avatar_url,
"tier": self.tier,
"plan": self.plan.value if self.plan else "free",
"subscription_status": self.subscription_status.value if self.subscription_status else "active",
"subscription_status": self.subscription_status.value
if self.subscription_status
else "active",
"daily_translation_count": self.daily_translation_count,
"docs_translated_this_month": self.docs_translated_this_month,
"pages_translated_this_month": self.pages_translated_this_month,
"api_calls_this_month": self.api_calls_this_month,
@@ -102,44 +143,49 @@ class User(Base):
class Translation(Base):
"""Translation history for analytics and billing"""
__tablename__ = "translations"
id = Column(String(36), primary_key=True, default=generate_uuid)
user_id = Column(String(36), ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
user_id = Column(
String(36), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
# File info
original_filename = Column(String(255), nullable=False)
file_type = Column(String(10), nullable=False) # xlsx, docx, pptx
file_type = Column(String(20), nullable=False) # xlsx, docx, pptx
file_size_bytes = Column(BigInteger, default=0)
page_count = Column(Integer, default=0)
# Translation details
source_language = Column(String(10), default="auto")
target_language = Column(String(10), nullable=False)
provider = Column(String(50), nullable=False) # google, deepl, ollama, etc.
# Processing info
status = Column(String(20), default="pending") # pending, processing, completed, failed
status = Column(
String(20), default="pending"
) # pending, processing, completed, failed
error_message = Column(Text, nullable=True)
processing_time_ms = Column(Integer, nullable=True)
# Cost tracking (for paid providers)
characters_translated = Column(Integer, default=0)
estimated_cost_usd = Column(Float, default=0.0)
# Timestamps
created_at = Column(DateTime, default=datetime.utcnow)
created_at = Column(DateTime, default=_utcnow)
completed_at = Column(DateTime, nullable=True)
# Relationship
user = relationship("User", back_populates="translations")
# Indexes
__table_args__ = (
Index('ix_translations_user_date', 'user_id', 'created_at'),
Index('ix_translations_status', 'status'),
Index("ix_translations_user_date", "user_id", "created_at"),
Index("ix_translations_status", "status"),
)
def to_dict(self) -> dict:
return {
"id": self.id,
@@ -154,43 +200,49 @@ class Translation(Base):
"processing_time_ms": self.processing_time_ms,
"characters_translated": self.characters_translated,
"created_at": self.created_at.isoformat() if self.created_at else None,
"completed_at": self.completed_at.isoformat() if self.completed_at else None,
"completed_at": self.completed_at.isoformat()
if self.completed_at
else None,
}
class ApiKey(Base):
"""API keys for programmatic access"""
__tablename__ = "api_keys"
id = Column(String(36), primary_key=True, default=generate_uuid)
user_id = Column(String(36), ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
user_id = Column(
String(36), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
# Key info
name = Column(String(100), nullable=False) # User-friendly name
key_hash = Column(String(255), nullable=False) # SHA256 of the key
key_prefix = Column(String(10), nullable=False) # First 8 chars for identification
# Permissions
is_active = Column(Boolean, default=True)
scopes = Column(JSON, default=list) # ["translate", "read", "write"]
# Usage tracking
last_used_at = Column(DateTime, nullable=True)
usage_count = Column(Integer, default=0)
# Timestamps
created_at = Column(DateTime, default=datetime.utcnow)
created_at = Column(DateTime, default=_utcnow)
expires_at = Column(DateTime, nullable=True)
revoked_at = Column(DateTime, nullable=True) # Set when is_active=False
# Relationship
user = relationship("User", back_populates="api_keys")
# Indexes
__table_args__ = (
Index('ix_api_keys_prefix', 'key_prefix'),
Index('ix_api_keys_hash', 'key_hash'),
Index("ix_api_keys_prefix", "key_prefix"),
Index("ix_api_keys_hash", "key_hash"),
)
def to_dict(self) -> dict:
return {
"id": self.id,
@@ -198,7 +250,9 @@ class ApiKey(Base):
"key_prefix": self.key_prefix,
"is_active": self.is_active,
"scopes": self.scopes,
"last_used_at": self.last_used_at.isoformat() if self.last_used_at else None,
"last_used_at": self.last_used_at.isoformat()
if self.last_used_at
else None,
"usage_count": self.usage_count,
"created_at": self.created_at.isoformat() if self.created_at else None,
"expires_at": self.expires_at.isoformat() if self.expires_at else None,
@@ -207,53 +261,149 @@ class ApiKey(Base):
class UsageLog(Base):
"""Daily usage aggregation for billing and analytics"""
__tablename__ = "usage_logs"
id = Column(String(36), primary_key=True, default=generate_uuid)
user_id = Column(String(36), ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
user_id = Column(
String(36), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
# Date (for daily aggregation)
date = Column(DateTime, nullable=False, index=True)
# Aggregated counts
documents_count = Column(Integer, default=0)
pages_count = Column(Integer, default=0)
characters_count = Column(BigInteger, default=0)
api_calls_count = Column(Integer, default=0)
# By provider breakdown (JSON)
provider_breakdown = Column(JSON, default=dict)
# Indexes
__table_args__ = (
Index('ix_usage_logs_user_date', 'user_id', 'date', unique=True),
)
__table_args__ = (Index("ix_usage_logs_user_date", "user_id", "date", unique=True),)
class PaymentHistory(Base):
"""Payment and invoice history"""
__tablename__ = "payment_history"
id = Column(String(36), primary_key=True, default=generate_uuid)
user_id = Column(String(36), ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
user_id = Column(
String(36), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
# Stripe info
stripe_payment_intent_id = Column(String(255), nullable=True)
stripe_invoice_id = Column(String(255), nullable=True)
# Payment details
amount_cents = Column(Integer, nullable=False)
currency = Column(String(3), default="usd")
payment_type = Column(String(50), nullable=False) # subscription, credits, one_time
status = Column(String(20), nullable=False) # succeeded, failed, pending, refunded
# Description
description = Column(String(255), nullable=True)
# Timestamps
created_at = Column(DateTime, default=datetime.utcnow)
created_at = Column(DateTime, default=_utcnow)
# Indexes
__table_args__ = (
Index('ix_payment_history_user', 'user_id', 'created_at'),
__table_args__ = (Index("ix_payment_history_user", "user_id", "created_at"),)
class Glossary(Base):
"""User's glossary containing source->target term pairs.
Story 3.9: Glossaires - Endpoint CRUD
"""
__tablename__ = "glossaries"
id = Column(String(36), primary_key=True, default=generate_uuid)
user_id = Column(
String(36), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
name = Column(String(255), nullable=False)
created_at = Column(DateTime, default=_utcnow)
updated_at = Column(DateTime, default=_utcnow, onupdate=_utcnow)
# Relationship
terms = relationship(
"GlossaryTerm", back_populates="glossary", cascade="all, delete-orphan"
)
# Indexes
__table_args__ = (Index("ix_glossaries_user_id", "user_id"),)
def to_dict(self) -> dict:
return {
"id": self.id,
"user_id": self.user_id,
"name": self.name,
"terms": [term.to_dict() for term in self.terms] if self.terms else [],
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
}
class GlossaryTerm(Base):
"""Single term pair in a glossary.
Story 3.9: Glossaires - Endpoint CRUD
"""
__tablename__ = "glossary_terms"
id = Column(String(36), primary_key=True, default=generate_uuid)
glossary_id = Column(
String(36), ForeignKey("glossaries.id", ondelete="CASCADE"), nullable=False
)
source = Column(String(500), nullable=False)
target = Column(String(500), nullable=False)
created_at = Column(DateTime, default=_utcnow)
# Relationship
glossary = relationship("Glossary", back_populates="terms")
# Indexes
__table_args__ = (Index("ix_glossary_terms_glossary_id", "glossary_id"),)
def to_dict(self) -> dict:
return {
"id": self.id,
"source": self.source,
"target": self.target,
"created_at": self.created_at.isoformat() if self.created_at else None,
}
class CustomPrompt(Base):
"""User's custom prompts for LLM translation context.
Story 3.11: Custom Prompts - Endpoint CRUD
"""
__tablename__ = "custom_prompts"
id = Column(String(36), primary_key=True, default=generate_uuid)
user_id = Column(
String(36), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
name = Column(String(255), nullable=False)
content = Column(Text, nullable=False)
created_at = Column(DateTime, default=_utcnow)
updated_at = Column(DateTime, default=_utcnow, onupdate=_utcnow)
# Indexes
__table_args__ = (Index("ix_custom_prompts_user_id", "user_id"),)
def to_dict(self) -> dict:
return {
"id": self.id,
"user_id": self.user_id,
"name": self.name,
"content": self.content,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
}

View File

@@ -2,54 +2,64 @@
Repository layer for database operations
Provides clean interface for CRUD operations
"""
import hashlib
import secrets
from datetime import datetime, timedelta
from datetime import datetime, timedelta, timezone
from typing import Optional, List, Dict, Any
from sqlalchemy.orm import Session
from sqlalchemy import and_, func, or_
from database.models import (
User, Translation, ApiKey, UsageLog, PaymentHistory,
PlanType, SubscriptionStatus
User,
Translation,
ApiKey,
UsageLog,
PaymentHistory,
PlanType,
SubscriptionStatus,
)
class UserRepository:
"""Repository for User database operations"""
def __init__(self, db: Session):
self.db = db
def get_by_id(self, user_id: str) -> Optional[User]:
"""Get user by ID"""
return self.db.query(User).filter(User.id == user_id).first()
def get_by_email(self, email: str) -> Optional[User]:
"""Get user by email (case-insensitive)"""
return self.db.query(User).filter(
func.lower(User.email) == email.lower()
).first()
return (
self.db.query(User).filter(func.lower(User.email) == email.lower()).first()
)
def get_by_stripe_customer(self, stripe_customer_id: str) -> Optional[User]:
"""Get user by Stripe customer ID"""
return self.db.query(User).filter(
User.stripe_customer_id == stripe_customer_id
).first()
return (
self.db.query(User)
.filter(User.stripe_customer_id == stripe_customer_id)
.first()
)
def create(
self,
email: str,
name: str,
password_hash: str,
plan: PlanType = PlanType.FREE
self,
email: str,
name: str,
hashed_password: str,
tier: str = "free",
) -> User:
"""Create a new user"""
"""Create a new user. Uses hashed_password and tier (story 1-1 refactor)."""
plan = PlanType.PRO if tier == "pro" else PlanType.FREE
user = User(
email=email.lower(),
name=name,
password_hash=password_hash,
hashed_password=hashed_password,
tier=tier,
plan=plan,
subscription_status=SubscriptionStatus.ACTIVE,
)
@@ -57,94 +67,90 @@ class UserRepository:
self.db.commit()
self.db.refresh(user)
return user
def update(self, user_id: str, **kwargs) -> Optional[User]:
"""Update user fields"""
user = self.get_by_id(user_id)
if not user:
return None
for key, value in kwargs.items():
if hasattr(user, key):
setattr(user, key, value)
user.updated_at = datetime.utcnow()
user.updated_at = datetime.now(timezone.utc)
self.db.commit()
self.db.refresh(user)
return user
def delete(self, user_id: str) -> bool:
"""Delete a user"""
user = self.get_by_id(user_id)
if not user:
return False
self.db.delete(user)
self.db.commit()
return True
def increment_usage(
self,
user_id: str,
docs: int = 0,
pages: int = 0,
api_calls: int = 0
self, user_id: str, docs: int = 0, pages: int = 0, api_calls: int = 0
) -> Optional[User]:
"""Increment usage counters"""
user = self.get_by_id(user_id)
if not user:
return None
# Check if usage needs to be reset (monthly)
if user.usage_reset_date:
now = datetime.utcnow()
if now.month != user.usage_reset_date.month or now.year != user.usage_reset_date.year:
now = datetime.now(timezone.utc)
if (
now.month != user.usage_reset_date.month
or now.year != user.usage_reset_date.year
):
user.docs_translated_this_month = 0
user.pages_translated_this_month = 0
user.api_calls_this_month = 0
user.usage_reset_date = now
user.docs_translated_this_month += docs
user.pages_translated_this_month += pages
user.api_calls_this_month += api_calls
self.db.commit()
self.db.refresh(user)
return user
def add_credits(self, user_id: str, credits: int) -> Optional[User]:
"""Add extra credits to user"""
user = self.get_by_id(user_id)
if not user:
return None
user.extra_credits += credits
self.db.commit()
self.db.refresh(user)
return user
def use_credits(self, user_id: str, credits: int) -> bool:
"""Use credits from user balance"""
user = self.get_by_id(user_id)
if not user or user.extra_credits < credits:
return False
user.extra_credits -= credits
self.db.commit()
return True
def get_all_users(
self,
skip: int = 0,
limit: int = 100,
plan: Optional[PlanType] = None
self, skip: int = 0, limit: int = 100, plan: Optional[PlanType] = None
) -> List[User]:
"""Get all users with pagination"""
query = self.db.query(User)
if plan:
query = query.filter(User.plan == plan)
return query.offset(skip).limit(limit).all()
def count_users(self, plan: Optional[PlanType] = None) -> int:
"""Count total users"""
query = self.db.query(func.count(User.id))
@@ -155,10 +161,10 @@ class UserRepository:
class TranslationRepository:
"""Repository for Translation database operations"""
def __init__(self, db: Session):
self.db = db
def create(
self,
user_id: str,
@@ -186,7 +192,36 @@ class TranslationRepository:
self.db.commit()
self.db.refresh(translation)
return translation
def create_completed(
self,
user_id: str,
original_filename: str,
file_type: str,
target_language: str,
provider: str,
source_language: str = "auto",
file_size_bytes: int = 0,
page_count: int = 0,
) -> Translation:
"""Create a translation record directly in completed status (Story 1.8 - billing log)."""
translation = Translation(
user_id=user_id,
original_filename=original_filename,
file_type=file_type,
file_size_bytes=file_size_bytes,
page_count=page_count,
source_language=source_language,
target_language=target_language,
provider=provider,
status="completed",
completed_at=datetime.now(timezone.utc),
)
self.db.add(translation)
self.db.commit()
self.db.refresh(translation)
return translation
def update_status(
self,
translation_id: str,
@@ -196,13 +231,13 @@ class TranslationRepository:
characters_translated: Optional[int] = None,
) -> Optional[Translation]:
"""Update translation status"""
translation = self.db.query(Translation).filter(
Translation.id == translation_id
).first()
translation = (
self.db.query(Translation).filter(Translation.id == translation_id).first()
)
if not translation:
return None
translation.status = status
if error_message:
translation.error_message = error_message
@@ -211,12 +246,12 @@ class TranslationRepository:
if characters_translated:
translation.characters_translated = characters_translated
if status == "completed":
translation.completed_at = datetime.utcnow()
translation.completed_at = datetime.now(timezone.utc)
self.db.commit()
self.db.refresh(translation)
return translation
def get_user_translations(
self,
user_id: str,
@@ -228,24 +263,33 @@ class TranslationRepository:
query = self.db.query(Translation).filter(Translation.user_id == user_id)
if status:
query = query.filter(Translation.status == status)
return query.order_by(Translation.created_at.desc()).offset(skip).limit(limit).all()
return (
query.order_by(Translation.created_at.desc())
.offset(skip)
.limit(limit)
.all()
)
def get_user_stats(self, user_id: str, days: int = 30) -> Dict[str, Any]:
"""Get user's translation statistics"""
since = datetime.utcnow() - timedelta(days=days)
result = self.db.query(
func.count(Translation.id).label("total_translations"),
func.sum(Translation.page_count).label("total_pages"),
func.sum(Translation.characters_translated).label("total_characters"),
).filter(
and_(
Translation.user_id == user_id,
Translation.created_at >= since,
Translation.status == "completed",
since = datetime.now(timezone.utc) - timedelta(days=days)
result = (
self.db.query(
func.count(Translation.id).label("total_translations"),
func.sum(Translation.page_count).label("total_pages"),
func.sum(Translation.characters_translated).label("total_characters"),
)
).first()
.filter(
and_(
Translation.user_id == user_id,
Translation.created_at >= since,
Translation.status == "completed",
)
)
.first()
)
return {
"total_translations": result.total_translations or 0,
"total_pages": result.total_pages or 0,
@@ -256,15 +300,15 @@ class TranslationRepository:
class ApiKeyRepository:
"""Repository for API Key database operations"""
def __init__(self, db: Session):
self.db = db
@staticmethod
def hash_key(key: str) -> str:
"""Hash an API key"""
return hashlib.sha256(key.encode()).hexdigest()
def create(
self,
user_id: str,
@@ -277,11 +321,11 @@ class ApiKeyRepository:
raw_key = f"tr_{secrets.token_urlsafe(32)}"
key_hash = self.hash_key(raw_key)
key_prefix = raw_key[:10]
expires_at = None
if expires_in_days:
expires_at = datetime.utcnow() + timedelta(days=expires_in_days)
expires_at = datetime.now(timezone.utc) + timedelta(days=expires_in_days)
api_key = ApiKey(
user_id=user_id,
name=name,
@@ -293,49 +337,60 @@ class ApiKeyRepository:
self.db.add(api_key)
self.db.commit()
self.db.refresh(api_key)
return api_key, raw_key
def get_by_key(self, raw_key: str) -> Optional[ApiKey]:
"""Get API key by raw key value"""
key_hash = self.hash_key(raw_key)
api_key = self.db.query(ApiKey).filter(
and_(
ApiKey.key_hash == key_hash,
ApiKey.is_active == True,
api_key = (
self.db.query(ApiKey)
.filter(
and_(
ApiKey.key_hash == key_hash,
ApiKey.is_active == True,
)
)
).first()
.first()
)
if api_key:
# Check expiration
if api_key.expires_at and api_key.expires_at < datetime.utcnow():
if api_key.expires_at and api_key.expires_at < datetime.now(timezone.utc):
return None
# Update last used
api_key.last_used_at = datetime.utcnow()
api_key.last_used_at = datetime.now(timezone.utc)
api_key.usage_count += 1
self.db.commit()
return api_key
def get_user_keys(self, user_id: str) -> List[ApiKey]:
"""Get all API keys for a user"""
return self.db.query(ApiKey).filter(
ApiKey.user_id == user_id
).order_by(ApiKey.created_at.desc()).all()
return (
self.db.query(ApiKey)
.filter(ApiKey.user_id == user_id)
.order_by(ApiKey.created_at.desc())
.all()
)
def revoke(self, key_id: str, user_id: str) -> bool:
"""Revoke an API key"""
api_key = self.db.query(ApiKey).filter(
and_(
ApiKey.id == key_id,
ApiKey.user_id == user_id,
api_key = (
self.db.query(ApiKey)
.filter(
and_(
ApiKey.id == key_id,
ApiKey.user_id == user_id,
)
)
).first()
.first()
)
if not api_key:
return False
api_key.is_active = False
self.db.commit()
return True

14
database/utils.py Normal file
View File

@@ -0,0 +1,14 @@
"""
Shared database utilities
"""
def convert_to_async_url(url: str) -> str:
"""Convert a sync database URL to its async driver equivalent."""
if url.startswith("postgresql://"):
return url.replace("postgresql://", "postgresql+asyncpg://", 1)
elif url.startswith("postgres://"):
return url.replace("postgres://", "postgresql+asyncpg://", 1)
elif url.startswith("sqlite:///"):
return url.replace("sqlite:///", "sqlite+aiosqlite:///", 1)
return url

187
docs/CODE_REVIEW.md Normal file
View File

@@ -0,0 +1,187 @@
# Revue de code complète — Office Translator
**Date** : Mars 2026
**Périmètre** : Backend FastAPI (Python) + Frontend Next.js (TypeScript/React)
**Méthode** : Exploration du codebase, recherche bonnes pratiques 2025, audits pip-audit / npm audit, analyse ciblée frontend.
---
## 1. Synthèse exécutive
| Priorité | Thème | Impact |
|----------|--------|--------|
| **Critique** | Path traversal sur `/download/{filename}` et `/cleanup/{filename}` | Lecture/suppression de fichiers hors répertoire |
| **Critique** | Vulnérabilités connues (pip + npm) | RCE, DoS, fuites (Next.js, Starlette, Pillow, etc.) |
| **Haute** | Pas de normalisation `session_id` / chemins dans legacy extract/reconstruct | Risque daccès à des fichiers non prévus |
| **Haute** | Token JWT en `localStorage` sans refresh | Expiration = déconnexion brute ; risque XSS |
| **Haute** | CORS `*` si `CORS_ORIGINS` vide | En prod, exposition à toutes origines |
| **Moyenne** | Config dispersée (`os.getenv` partout) | Erreurs de config, pas de validation au démarrage |
| **Moyenne** | Jobs de traduction en mémoire | Pas de partage entre workers (scale horizontal) |
| **Moyenne** | Duplication API_BASE / fetch+token côté frontend | Maintenance et incohérences |
---
## 2. Architecture (rapport agent)
- **Backend** : `main.py` → CORS, middlewares (rate limit, security, error, cleanup), router unique `/api/v1` (translate, auth, admin, api-keys, legacy, glossary, prompt).
- **Auth** : JWT (access 15 min, refresh 7 j), blocklist JTI (Redis/mémoire), admin par token opaque (Redis/mémoire), API Key (X-API-Key, Pro).
- **Traduction** : POST `/translate` → validation fichier/URL → job `tr_*``asyncio.to_thread` + excel/word/pptx_translator + provider (OpenRouter, etc.) ; clés/modèles depuis admin settings + env.
- **Fichiers** : Upload avec nom unique ; download v1 par `job_id` + ownership ; legacy `/download/{filename}` et `/cleanup/{filename}` **sans normalisation du chemin**.
---
## 3. Sécurité
### 3.1 Path traversal (critique)
**Fichiers** : `routes/legacy_routes.py`
- **`GET /api/v1/download/{filename}`** (L.299311) : `file_path = config.OUTPUT_DIR / filename`. Si `filename = "../../../etc/passwd"`, le chemin peut sortir de `OUTPUT_DIR`.
- **`DELETE /api/v1/cleanup/{filename}`** (L.314325) : même construction.
**Recommandation** :
- Normaliser : `safe_name = Path(filename).name` (pas de `..`).
- Résoudre : `resolved = (config.OUTPUT_DIR / safe_name).resolve()`.
- Vérifier : `resolved.is_relative_to(config.OUTPUT_DIR.resolve())` (Python 3.9+) ou équivalent.
- Sinon 400/404.
### 3.2 Session / extract-texts / reconstruct-document
- **`session_id`** (UUID généré côté serveur) : utilisé pour `session_{session_id}.json` et chemins. Vérifier que tout `session_id` reçu en form est bien un UUID strict.
- **`session_data["input_path"]`** : sassurer que le chemin résolu reste sous `UPLOAD_DIR` avant toute lecture.
### 3.3 Authentification
- **JWT** : si `JWT_SECRET_KEY` absent, clé éphémère au démarrage → tous les tokens invalidés au redémarrage (log CRITICAL). À corriger en prod.
- **Admin** : `ADMIN_PASSWORD` en clair à éviter ; utiliser uniquement `ADMIN_PASSWORD_HASH` (bcrypt) en production.
- **Frontend** : token et refresh en `localStorage` → exposés au XSS. Bonnes pratiques 2025 : cookies HTTP-only pour le token (ou au minimum documenter le risque et privilégier des pages à faible exposition XSS).
### 3.4 CORS
- **`main.py`** : si `CORS_ORIGINS` vide ou `*`, `allowed_origins = ["*"]` avec warning. En production, définir des origines explicites.
### 3.5 Stripe webhook
- **`services/payment_service.py`** : `stripe.Webhook.construct_event(payload, sig_header, STRIPE_WEBHOOK_SECRET)`**signature bien vérifiée**. Rien à changer côté vérification.
### 3.6 SSRF / Webhook URL
- **Translate** : `download_from_url` et validation webhook limitent schéma, DNS et IP privées/loopback. Cohérent avec les bonnes pratiques.
---
## 4. Vulnérabilités connues (dépendances)
### 4.1 Backend (pip-audit)
| Package | Version | Problème | Correction |
|---------|---------|----------|------------|
| deep-translator | 1.11.4 | PYSEC-2022-252 | Mettre à jour |
| fastapi | 0.109.0 | PYSEC-2024-38 | 0.109.1+ |
| pillow | 10.2.0 | CVE-2024-28219 | 10.3.0+ |
| python-multipart | 0.0.9 | CVE-2024-53981, CVE-2026-24486 | 0.0.18+ / 0.0.22+ |
| starlette | 0.35.1 | CVE-2024-47874, CVE-2025-54121 | 0.40.0+ / 0.47.2+ |
**Action** : Exécuter `pip-audit` puis mettre à jour les paquets (tests de non-régression après mise à jour).
### 4.2 Frontend (npm audit)
| Package | Problème | Action |
|---------|----------|--------|
| next | RCE (flight), DoS, exposition Server Actions, etc. | `npm audit fix` ou mise à jour ciblée (ex. next@16.1.6) |
| minimatch | ReDoS | `npm audit fix` |
| ajv | ReDoS ($data) | `npm audit fix` |
**Action** : `npm audit` puis `npm audit fix` (ou `--force` avec prudence et tests).
---
## 5. Fiabilité et bonnes pratiques
### 5.1 Gestion derreurs
- Middleware global + handlers dédiés (TranslationError, ValidationError, etc.) → correct.
- OpenRouter : retry 429 + levée dexception en cas déchec (plus de retour silencieux du texte original) → bon.
### 5.2 Configuration
- **Config** : tout en `os.getenv()` dans `config.py` et ailleurs. Risque : valeurs manquantes ou incohérentes au démarrage.
- **Recommandation** : centraliser dans un module type **Pydantic BaseSettings** (validation, typage, valeurs par défaut) et charger une seule fois au démarrage.
### 5.3 Jobs de traduction
- **`_translation_jobs`** en mémoire : en multi-workers (plusieurs processus uvicorn), les jobs ne sont pas partagés.
- **Recommandation** : pour un scale horizontal, stocker létat des jobs dans Redis (ou autre store partagé).
### 5.4 Legacy
- **`/translate-batch`**, **`/download/{filename}`**, **`/cleanup/{filename}`**, **`/extract-texts`**, **`/reconstruct-document`** : peu ou pas dauth, exposition de chemins/fichiers.
- **Recommandation** : renforcer lauth et la validation des paramètres, ou déprécier au profit des endpoints v1 (translate + download par `job_id`).
---
## 6. Frontend (rapport agent)
### 6.1 Types et erreurs
- **`any`** : `app/(app)/ollama-setup/page.tsx` (models, error), `app/(app)/settings/subscription/page.tsx` (PLAN_ICONS), `app/pricing/page.tsx` (PLAN_ICONS). Remplacer par des types précis.
- **Error boundary** : aucun au niveau racine (`app/layout.tsx`). Ajouter un ErrorBoundary pour éviter des écrans blancs.
### 6.2 Validation et timeout
- **Login / Admin login** : pas de validation explicite (email, mot de passe) avant submit.
- **Translate** : pas de validation centralisée de `file` / `config` (taille, type, champs requis) avant lappel API.
- **Fetch** : plusieurs appels sans timeout (pricing, subscription, admin verify, settings, translate, polling, download). Ajouter `AbortController` + timeout (ex. 830 s selon lendpoint).
### 6.3 Auth et stockage
- **Token** : `localStorage` ("token", "refresh_token", "user"). Risque XSS ; en production privilégier cookies HTTP-only si possible.
- **Refresh** : `refresh_token` stocké mais **jamais utilisé** ; aucun appel `/auth/refresh`. Quand laccess token expire, lutilisateur est déconnecté. Implémenter un refresh automatique (intercepteur ou hook) pour améliorer lUX.
### 6.4 Duplication et centralisation
- **API_BASE** : répété dans plusieurs fichiers (useTranslationSubmit, useTranslationConfig, TranslationComplete, settings/services, lib/api). Utiliser une seule source : `@/lib/config` ou `apiClient`.
- **Pattern fetch + token** : répété partout. Utiliser systématiquement **apiClient** (déjà en place pour auth, glossaries, api-keys) pour tous les appels authentifiés.
- **URL en dur** : `app/(app)/ollama-setup/page.tsx` L.85 `"http://localhost:8000/api/auth/settings"` → remplacer par `API_BASE + "/api/v1/..."` (et vérifier le chemin exact de lAPI).
---
## 7. Plan daction priorisé
### Immédiat (sécurité)
1. **Path traversal** : normaliser `filename` et vérifier `resolved.is_relative_to(OUTPUT_DIR)` dans `legacy_routes.py` pour `/download/{filename}` et `/cleanup/{filename}`.
2. **Dépendances** : mettre à jour FastAPI, Starlette, Pillow, python-multipart, deep-translator ; puis Next.js, minimatch, ajv (avec tests après chaque vague).
### Court terme (sécurité / robustesse)
3. **Session / reconstruct** : valider `session_id` (UUID) et sassurer que tout chemin dérivé reste sous `UPLOAD_DIR`.
4. **CORS** : en prod, définir `CORS_ORIGINS` explicitement (pas `*`).
5. **Admin** : en prod, nutiliser que `ADMIN_PASSWORD_HASH` (bcrypt).
6. **Frontend** : ajouter un ErrorBoundary racine et des timeouts sur les fetch critiques (translate, download, polling, auth verify).
### Moyen terme (qualité / maintenabilité)
7. **Config** : migrer vers Pydantic BaseSettings pour la config backend.
8. **Frontend** : centraliser API_BASE et utiliser apiClient partout ; implémenter le refresh token.
9. **Jobs** : si scale horizontal prévu, stocker létat des jobs dans Redis (ou équivalent).
10. **Legacy** : protéger ou déprécier les routes legacy (auth + validation des paramètres).
### Long terme (bonnes pratiques)
11. **Auth frontend** : étudier le passage à des cookies HTTP-only pour le token (avec adaptation du backend).
12. **Validation** : validation explicite login/admin et validation centralisée des paramètres de traduction avant appel API.
13. **Types** : supprimer les `any` et renforcer les types (discriminated unions, type predicates) côté TypeScript.
---
## 8. Références
- FastAPI & Next.js security (TurboStarter, Next.js Data Security, Mikul Gohil).
- Python code review (DeepSource, Kodus, Medium).
- TypeScript security & quality (Krython, Kodus).
- Exploration et revue frontend par agents (explore + generalPurpose) sur le dépôt.
---
*Document généré dans le cadre dune revue de code complète. Aucune modification na été appliquée automatiquement ; les changements sont à planifier et à tester.*

25
frontend/messages/en.json Normal file
View File

@@ -0,0 +1,25 @@
{
"common": {
"save": "Save",
"cancel": "Cancel",
"delete": "Delete",
"edit": "Edit",
"loading": "Loading...",
"error": "Error",
"success": "Success"
},
"nav": {
"dashboard": "Dashboard",
"translate": "Translate",
"apiKeys": "API Keys",
"settings": "Settings",
"logout": "Logout"
},
"admin": {
"title": "Admin Panel",
"users": "Users",
"system": "System",
"logs": "Logs",
"providers": "Providers"
}
}

25
frontend/messages/fr.json Normal file
View File

@@ -0,0 +1,25 @@
{
"common": {
"save": "Enregistrer",
"cancel": "Annuler",
"delete": "Supprimer",
"edit": "Modifier",
"loading": "Chargement...",
"error": "Erreur",
"success": "Succès"
},
"nav": {
"dashboard": "Tableau de bord",
"translate": "Traduire",
"apiKeys": "Clés API",
"settings": "Paramètres",
"logout": "Déconnexion"
},
"admin": {
"title": "Panneau Admin",
"users": "Utilisateurs",
"system": "Système",
"logs": "Logs",
"providers": "Fournisseurs"
}
}

View File

@@ -1,7 +1,9 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
// Turbopack ne résout pas le require() dynamique de lightningcss → "Module not found".
// Toujours lancer avec Webpack : npm run dev ou next dev --webpack (pas "next dev" seul).
serverExternalPackages: ["lightningcss", "@tailwindcss/postcss", "@tailwindcss/node"],
};
export default nextConfig;

File diff suppressed because it is too large Load Diff

View File

@@ -3,13 +3,18 @@
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"dev": "next dev --webpack",
"dev:turbo": "next dev",
"build": "next build --webpack",
"build:turbo": "next build",
"start": "next start",
"lint": "eslint"
"lint": "eslint",
"test": "vitest",
"test:run": "vitest run"
},
"dependencies": {
"@mlc-ai/web-llm": "^0.2.80",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
@@ -21,14 +26,15 @@
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toast": "^1.2.15",
"@radix-ui/react-tooltip": "^1.2.8",
"axios": "^1.13.2",
"@tanstack/react-query": "^5.90.21",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"framer-motion": "^12.23.24",
"lightningcss-win32-x64-msvc": "^1.30.2",
"lucide-react": "^0.555.0",
"next": "16.0.6",
"next-intl": "^4.8.3",
"react": "19.2.0",
"react-dom": "19.2.0",
"react-dropzone": "^14.3.8",
@@ -37,14 +43,19 @@
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@vitejs/plugin-react": "^5.1.4",
"eslint": "^9",
"eslint-config-next": "16.0.6",
"jsdom": "^28.1.0",
"lightningcss": "^1.30.2",
"tailwindcss": "^4",
"tw-animate-css": "^1.4.0",
"typescript": "^5"
"typescript": "^5",
"vitest": "^4.0.18"
}
}

5
frontend/public/grid.svg Normal file
View File

@@ -0,0 +1,5 @@
<svg width="100" height="100" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0 0h100v1H0zM0 0v100h1V0z" fill="currentColor" fill-opacity="0.1"/>
<path d="M0 20h100v1H0zM0 40h100v1H0zM0 60h100v1H0zM0 80h100v1H0z" fill="currentColor" fill-opacity="0.05"/>
<path d="M20 0v100h1V0zM40 0v100h1V0zM60 0v100h1V0zM80 0v100h1V0z" fill="currentColor" fill-opacity="0.05"/>
</svg>

After

Width:  |  Height:  |  Size: 408 B

View File

@@ -0,0 +1,18 @@
import { Sidebar } from "@/components/sidebar"
export default function AppLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<>
<Sidebar />
<main className="ml-64 min-h-screen p-8">
<div className="max-w-6xl mx-auto">
{children}
</div>
</main>
</>
)
}

View File

@@ -0,0 +1,155 @@
"use client";
import { useEffect, useState } from "react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Zap, CheckCircle2, Lock, Loader2, Globe, Brain } from "lucide-react";
const API_BASE = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000";
const FALLBACK_PROVIDERS = [
{ id: "google", label: "Google Traduction", description: "Traduction rapide, 130+ langues", mode: "classic" as const },
];
interface AvailableProvider {
id: string;
label: string;
description: string;
mode: "classic" | "llm";
model?: string;
}
export default function TranslationServicesPage() {
const [providers, setProviders] = useState<AvailableProvider[]>([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const fetchProviders = async () => {
try {
const token = localStorage.getItem("token");
const headers: Record<string, string> = {};
if (token) headers["Authorization"] = `Bearer ${token}`;
const res = await fetch(`${API_BASE}/api/v1/providers/available`, { headers });
if (res.ok) {
const data = await res.json();
const list = data.providers || [];
setProviders(list.length > 0 ? list : FALLBACK_PROVIDERS);
} else {
setProviders(FALLBACK_PROVIDERS);
}
} catch {
setProviders(FALLBACK_PROVIDERS);
} finally {
setIsLoading(false);
}
};
fetchProviders();
}, []);
const classicProviders = providers.filter((p) => p.mode === "classic");
const llmProviders = providers.filter((p) => p.mode === "llm");
return (
<div className="max-w-2xl mx-auto px-4 py-8 space-y-6">
<div>
<div className="flex items-center gap-2 mb-1">
<Zap className="h-5 w-5 text-primary" />
<h1 className="text-2xl font-bold">Translation Providers</h1>
</div>
<p className="text-sm text-muted-foreground">
Providers are configured by the administrator. You can see which ones are
currently available for your account.
</p>
</div>
{isLoading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
<span>Loading providers</span>
</div>
) : providers.length === 0 ? (
<Card>
<CardContent className="py-8 text-center text-sm text-muted-foreground">
No providers are currently configured. Contact your administrator.
</CardContent>
</Card>
) : (
<div className="space-y-6">
{classicProviders.length > 0 && (
<div className="space-y-3">
<div className="flex items-center gap-2">
<Globe className="size-4 text-muted-foreground" />
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
Classic Translation
</h2>
</div>
<div className="space-y-2">
{classicProviders.map((p) => (
<Card key={p.id}>
<CardContent className="flex items-center justify-between py-4 px-5">
<div>
<p className="font-medium">{p.label}</p>
<p className="text-xs text-muted-foreground">{p.description}</p>
</div>
<Badge variant="outline" className="border-green-500/50 text-green-600 bg-green-50 gap-1">
<CheckCircle2 className="size-3" />
Available
</Badge>
</CardContent>
</Card>
))}
</div>
</div>
)}
{llmProviders.length > 0 && (
<div className="space-y-3">
<div className="flex items-center gap-2">
<Brain className="size-4 text-muted-foreground" />
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
LLM · Context-Aware (Pro)
</h2>
</div>
<div className="space-y-2">
{llmProviders.map((p) => (
<Card key={p.id}>
<CardContent className="flex items-center justify-between py-4 px-5">
<div>
<p className="font-medium">{p.label}</p>
<p className="text-xs text-muted-foreground">{p.description}</p>
{p.model && (
<p className="mt-1 text-[10px] font-mono text-muted-foreground/80" title="Modèle configuré par l'admin">
Modèle : {p.model}
</p>
)}
</div>
<Badge variant="outline" className="border-green-500/50 text-green-600 bg-green-50 gap-1">
<CheckCircle2 className="size-3" />
Available
</Badge>
</CardContent>
</Card>
))}
</div>
</div>
)}
</div>
)}
<Card className="border-amber-200 bg-amber-50">
<CardHeader className="pb-2">
<CardTitle className="text-sm text-amber-800 flex items-center gap-2">
<Lock className="size-4" />
Provider configuration is admin-only
</CardTitle>
</CardHeader>
<CardContent>
<CardDescription className="text-amber-700 text-xs">
API keys, model selection, and provider activation are managed exclusively
by the administrator in the admin panel. You never need to enter an API key.
</CardDescription>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,630 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import {
Crown, Zap, Sparkles, Building2, Rocket, BadgeCheck,
ArrowRight, AlertTriangle, CheckCircle2, XCircle,
BarChart3, FileText, Layers, Brain, CreditCard,
RefreshCw, ExternalLink, ChevronRight, Info,
TrendingUp, Calendar, Gauge
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
import { cn } from "@/lib/utils";
/* ─────────────────────────────────────────────
Types
───────────────────────────────────────────── */
interface UserInfo {
id: string;
email: string;
name: string;
plan: string;
subscription_status: string;
docs_translated_this_month: number;
pages_translated_this_month: number;
api_calls_this_month: number;
extra_credits: number;
subscription_ends_at?: string;
cancel_at_period_end?: boolean;
}
interface UsageLimits {
plan: string;
docs_used: number;
docs_limit: number;
pages_used: number;
pages_limit: number;
api_calls_used: number;
api_calls_limit: number;
can_translate: boolean;
upgrade_required: boolean;
extra_credits: number;
}
interface Plan {
id: string;
name: string;
price_monthly: number;
price_yearly: number;
docs_per_month: number;
max_pages_per_doc: number;
max_file_size_mb: number;
features: string[];
ai_translation: boolean;
ai_tier?: string;
api_access: boolean;
priority_processing: boolean;
team_seats?: number;
popular?: boolean;
badge?: string;
description?: string;
}
/* ─────────────────────────────────────────────
Helpers
───────────────────────────────────────────── */
const PLAN_ICONS: Record<string, any> = {
free: Sparkles, starter: Zap, pro: Crown, business: Building2, enterprise: Rocket,
};
const PLAN_COLORS: Record<string, string> = {
free: "from-slate-600 to-slate-700",
starter: "from-blue-600 to-blue-700",
pro: "from-violet-600 to-violet-700",
business: "from-emerald-600 to-emerald-700",
enterprise: "from-amber-600 to-amber-700",
};
const PLAN_LABELS: Record<string, string> = {
free: "Gratuit", starter: "Starter", pro: "Pro",
business: "Business", enterprise: "Entreprise",
};
function pct(used: number, limit: number) {
if (limit === -1) return 0;
return Math.min(100, Math.round((used / limit) * 100));
}
function fmtLimit(val: number) {
return val === -1 ? "Illimité" : String(val);
}
function UsageBar({
label, used, limit, icon,
}: { label: string; used: number; limit: number; icon: React.ReactNode }) {
const p = pct(used, limit);
const isUnlimited = limit === -1;
return (
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<div className="flex items-center gap-2 text-gray-300">
{icon}
{label}
</div>
<span className={cn(
"font-mono text-xs",
isUnlimited ? "text-emerald-400" :
p >= 90 ? "text-red-400" : p >= 70 ? "text-amber-400" : "text-gray-400"
)}>
{isUnlimited ? "∞" : `${used} / ${limit}`}
</span>
</div>
{!isUnlimited && (
<div className="h-1.5 bg-gray-700/50 rounded-full overflow-hidden">
<div
className={cn(
"h-full rounded-full transition-all duration-700",
p >= 90 ? "bg-red-500" : p >= 70 ? "bg-amber-500" : "bg-violet-500"
)}
style={{ width: `${p}%` }}
/>
</div>
)}
</div>
);
}
/* ─────────────────────────────────────────────
Main component
───────────────────────────────────────────── */
export default function SubscriptionPage() {
const router = useRouter();
const searchParams = useSearchParams();
const targetPlan = searchParams.get("plan");
const [user, setUser] = useState<UserInfo | null>(null);
const [usage, setUsage] = useState<UsageLimits | null>(null);
const [plans, setPlans] = useState<Plan[]>([]);
const [isYearly, setIsYearly] = useState(false);
const [loadingPortal, setLoadingPortal] = useState(false);
const [cancelConfirm, setCancelConfirm] = useState(false);
const [statusMsg, setStatusMsg] = useState<{ type: "ok" | "err"; text: string } | null>(null);
const [loading, setLoading] = useState(true);
const token = typeof window !== "undefined" ? localStorage.getItem("token") : null;
const authHeaders = { Authorization: `Bearer ${token}` };
const fetchData = useCallback(async () => {
if (!token) { router.push("/auth/login?redirect=/settings/subscription"); return; }
try {
const [meRes, usageRes, plansRes] = await Promise.all([
fetch("/api/v1/auth/me", { headers: authHeaders }),
fetch("/api/v1/auth/usage", { headers: authHeaders }),
fetch("/api/v1/auth/plans"),
]);
if (meRes.ok) {
const j = await meRes.json();
setUser(j.data ?? j);
}
if (usageRes.ok) {
const j = await usageRes.json();
setUsage(j.data ?? j);
}
if (plansRes.ok) {
const j = await plansRes.json();
const d = j.data ?? j;
if (Array.isArray(d.plans)) setPlans(d.plans);
}
} catch {
// ignore
} finally {
setLoading(false);
}
}, [token]);
useEffect(() => { fetchData(); }, [fetchData]);
const handleBillingPortal = async () => {
setLoadingPortal(true);
try {
const res = await fetch("/api/v1/auth/billing-portal", { headers: authHeaders });
const j = await res.json();
const url = j.data?.url ?? j.url;
if (url) window.open(url, "_blank");
else setStatusMsg({ type: "err", text: "Portail de facturation non disponible pour le moment." });
} catch {
setStatusMsg({ type: "err", text: "Impossible d'accéder au portail de facturation." });
} finally {
setLoadingPortal(false);
}
};
const handleCancel = async () => {
if (!cancelConfirm) { setCancelConfirm(true); return; }
try {
const res = await fetch("/api/v1/auth/cancel-subscription", {
method: "POST",
headers: authHeaders,
});
if (res.ok) {
setStatusMsg({ type: "ok", text: "Abonnement annulé. Vous conservez l'accès jusqu'à la fin de la période en cours." });
setCancelConfirm(false);
fetchData();
} else {
setStatusMsg({ type: "err", text: "Erreur lors de l'annulation. Réessayez ou contactez le support." });
}
} catch {
setStatusMsg({ type: "err", text: "Erreur réseau." });
}
};
const handleSubscribe = (planId: string) => {
if (planId === "enterprise") {
window.location.href = "mailto:contact@votre-domaine.com?subject=Offre Enterprise";
return;
}
// In a real app, this would initiate a Stripe Checkout session
// For now, redirect to billing portal or show info
setStatusMsg({
type: "ok",
text: `Redirection vers Stripe pour activer le forfait ${PLAN_LABELS[planId] ?? planId}`,
});
setTimeout(() => handleBillingPortal(), 1000);
};
if (loading) {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<RefreshCw className="w-8 h-8 text-violet-400 animate-spin" />
</div>
);
}
const currentPlanId = user?.plan ?? "free";
const currentPlanLabel = PLAN_LABELS[currentPlanId] ?? currentPlanId;
const Icon = PLAN_ICONS[currentPlanId] ?? Sparkles;
const gradient = PLAN_COLORS[currentPlanId] ?? PLAN_COLORS.free;
const currentPlanData = plans.find((p) => p.id === currentPlanId);
const otherPlans = plans.filter((p) => p.id !== currentPlanId);
const upgradePlans = otherPlans.filter((p) => {
const order = ["free", "starter", "pro", "business", "enterprise"];
return order.indexOf(p.id) > order.indexOf(currentPlanId);
});
const downgradePlans = otherPlans.filter((p) => {
const order = ["free", "starter", "pro", "business", "enterprise"];
return order.indexOf(p.id) < order.indexOf(currentPlanId);
});
return (
<div className="max-w-4xl mx-auto px-4 py-10 space-y-8">
<div>
<h1 className="text-3xl font-bold text-white">Mon abonnement</h1>
<p className="text-gray-400 mt-1">Gérez votre forfait, votre usage et votre facturation.</p>
</div>
{/* Status message */}
{statusMsg && (
<div className={cn(
"flex items-start gap-3 p-4 rounded-xl border",
statusMsg.type === "ok"
? "bg-emerald-900/20 border-emerald-600/30 text-emerald-300"
: "bg-red-900/20 border-red-600/30 text-red-300"
)}>
{statusMsg.type === "ok"
? <CheckCircle2 className="w-5 h-5 flex-shrink-0 mt-0.5" />
: <XCircle className="w-5 h-5 flex-shrink-0 mt-0.5" />}
<span className="text-sm">{statusMsg.text}</span>
<button className="ml-auto text-gray-500 hover:text-white" onClick={() => setStatusMsg(null)}></button>
</div>
)}
{/* ── Current plan card ── */}
<div className={cn("rounded-2xl p-1 bg-gradient-to-br", gradient)}>
<div className="bg-gray-900/90 rounded-xl p-6">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div className="flex items-center gap-4">
<div className={cn("p-3 rounded-xl bg-gradient-to-br", gradient)}>
<Icon className="w-6 h-6 text-white" />
</div>
<div>
<div className="flex items-center gap-2">
<h2 className="text-xl font-bold text-white">Forfait {currentPlanLabel}</h2>
{user?.subscription_status && (
<Badge className={cn(
"text-xs",
user.subscription_status === "active" ? "bg-emerald-600/20 text-emerald-300 border-emerald-600/30" :
user.subscription_status === "trialing" ? "bg-blue-600/20 text-blue-300 border-blue-600/30" :
"bg-amber-600/20 text-amber-300 border-amber-600/30"
)}>
{user.subscription_status === "active" ? "Actif" :
user.subscription_status === "trialing" ? "Essai" :
user.subscription_status === "canceled" ? "Annulé" :
user.subscription_status}
</Badge>
)}
</div>
{user?.subscription_ends_at && (
<p className="text-gray-400 text-sm mt-0.5">
<Calendar className="w-3.5 h-3.5 inline mr-1" />
{user.cancel_at_period_end ? "Expire le " : "Renouvellement le "}
{new Date(user.subscription_ends_at).toLocaleDateString("fr-FR", { day: "2-digit", month: "long", year: "numeric" })}
</p>
)}
</div>
</div>
<div className="flex flex-wrap gap-2">
{currentPlanId !== "free" && (
<Button
variant="outline"
className="border-gray-600 text-gray-300 hover:bg-gray-700/30"
onClick={handleBillingPortal}
disabled={loadingPortal}
>
{loadingPortal ? <RefreshCw className="w-4 h-4 animate-spin mr-1" /> : <CreditCard className="w-4 h-4 mr-1" />}
Portail de facturation
<ExternalLink className="w-3.5 h-3.5 ml-1" />
</Button>
)}
{upgradePlans.length > 0 && (
<Button
className="bg-violet-600 hover:bg-violet-500"
onClick={() => router.push("/pricing")}
>
<TrendingUp className="w-4 h-4 mr-1" /> Upgrader
</Button>
)}
</div>
</div>
</div>
</div>
{/* ── Usage this month ── */}
{usage && (
<Card className="bg-gray-900/60 border-gray-700/40">
<CardHeader className="pb-3">
<CardTitle className="text-lg flex items-center gap-2">
<BarChart3 className="w-5 h-5 text-violet-400" />
Utilisation ce mois
<span className="ml-auto text-xs text-gray-500 font-normal">Remise à zéro chaque 1er du mois</span>
</CardTitle>
</CardHeader>
<CardContent className="space-y-5">
<UsageBar
label="Documents traduits"
used={usage.docs_used}
limit={usage.docs_limit}
icon={<FileText className="w-4 h-4 text-gray-400" />}
/>
<UsageBar
label="Pages traduites"
used={usage.pages_used}
limit={usage.pages_limit}
icon={<Layers className="w-4 h-4 text-gray-400" />}
/>
{usage.api_calls_limit !== 0 && (
<UsageBar
label="Appels API"
used={usage.api_calls_used}
limit={usage.api_calls_limit}
icon={<Gauge className="w-4 h-4 text-gray-400" />}
/>
)}
{usage.extra_credits > 0 && (
<div className="flex items-center gap-3 p-3 rounded-xl bg-amber-500/10 border border-amber-500/20 text-sm">
<Info className="w-4 h-4 text-amber-400 flex-shrink-0" />
<span className="text-amber-300">
{usage.extra_credits} crédit{usage.extra_credits > 1 ? "s" : ""} supplémentaire{usage.extra_credits > 1 ? "s" : ""} disponible{usage.extra_credits > 1 ? "s" : ""}
</span>
</div>
)}
{usage.upgrade_required && (
<div className="flex items-center gap-3 p-3 rounded-xl bg-red-500/10 border border-red-500/20 text-sm">
<AlertTriangle className="w-4 h-4 text-red-400 flex-shrink-0" />
<span className="text-red-300">
Quota atteint. Achetez des crédits ou upgradez votre forfait pour continuer.
</span>
<Button size="sm" className="ml-auto bg-red-600 hover:bg-red-500 flex-shrink-0" onClick={() => router.push("/pricing")}>
Upgrader
</Button>
</div>
)}
</CardContent>
</Card>
)}
{/* ── Plan features recap ── */}
{currentPlanData && (
<Card className="bg-gray-900/60 border-gray-700/40">
<CardHeader className="pb-3">
<CardTitle className="text-lg flex items-center gap-2">
<BadgeCheck className="w-5 h-5 text-emerald-400" />
Inclus dans votre forfait
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid sm:grid-cols-2 gap-2">
{currentPlanData.features.map((f, i) => (
<div key={i} className="flex items-start gap-2 text-sm">
<CheckCircle2 className="w-4 h-4 text-emerald-400 flex-shrink-0 mt-0.5" />
<span className="text-gray-300">{f}</span>
</div>
))}
</div>
</CardContent>
</Card>
)}
{/* ── Upgrade options ── */}
{upgradePlans.length > 0 && (
<div>
<h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
<TrendingUp className="w-5 h-5 text-violet-400" />
Passer à un forfait supérieur
</h3>
{/* Billing toggle */}
<div className="inline-flex items-center gap-2 bg-gray-800/60 border border-gray-700/50 rounded-full p-1 mb-4">
<button
onClick={() => setIsYearly(false)}
className={cn("px-4 py-1.5 rounded-full text-xs font-medium transition-all", !isYearly ? "bg-white text-gray-900" : "text-gray-400")}
>Mensuel</button>
<button
onClick={() => setIsYearly(true)}
className={cn("px-4 py-1.5 rounded-full text-xs font-medium transition-all flex items-center gap-1.5", isYearly ? "bg-white text-gray-900" : "text-gray-400")}
>
Annuel
<span className="bg-emerald-500 text-white text-xs px-1.5 py-0.5 rounded-full">20 %</span>
</button>
</div>
<div className="grid sm:grid-cols-2 gap-4">
{upgradePlans.map((plan) => {
const PIcon = PLAN_ICONS[plan.id] ?? Zap;
const grad = PLAN_COLORS[plan.id] ?? PLAN_COLORS.starter;
const price = plan.price_monthly === -1
? null
: isYearly
? (plan.price_yearly / 12).toFixed(2)
: plan.price_monthly.toFixed(2);
return (
<div
key={plan.id}
className={cn(
"relative rounded-2xl border bg-gray-900/60 overflow-hidden",
plan.popular ? "border-violet-500/50" : "border-gray-700/40"
)}
>
{plan.badge && (
<div className="absolute top-3 right-3">
<Badge className="bg-violet-600 text-white text-xs">{plan.badge}</Badge>
</div>
)}
<div className={cn("p-4 bg-gradient-to-br", grad)}>
<div className="flex items-center gap-2">
<PIcon className="w-5 h-5 text-white" />
<span className="font-bold text-white">{plan.name}</span>
</div>
<div className="mt-2 flex items-end gap-1">
{price === null ? (
<span className="text-2xl font-bold text-white">Sur devis</span>
) : (
<>
<span className="text-2xl font-bold text-white">{price} </span>
<span className="text-white/70 text-sm pb-0.5">/mois</span>
</>
)}
</div>
</div>
<div className="p-4 space-y-2">
{plan.features.slice(0, 4).map((f, i) => (
<div key={i} className="flex items-start gap-2 text-xs text-gray-300">
<CheckCircle2 className="w-3.5 h-3.5 text-emerald-400 flex-shrink-0 mt-0.5" />
{f}
</div>
))}
{plan.features.length > 4 && (
<p className="text-xs text-gray-500">+{plan.features.length - 4} autres avantages</p>
)}
<button
onClick={() => handleSubscribe(plan.id)}
className={cn(
"mt-3 w-full py-2 rounded-xl text-sm font-semibold text-white flex items-center justify-center gap-2 transition-all",
`bg-gradient-to-r ${grad} hover:opacity-90`
)}
>
Passer au forfait {plan.name} <ChevronRight className="w-4 h-4" />
</button>
</div>
</div>
);
})}
</div>
</div>
)}
{/* ── Buy credits ── */}
<Card className="bg-gray-900/60 border-gray-700/40">
<CardHeader className="pb-3">
<CardTitle className="text-lg flex items-center gap-2">
<CreditCard className="w-5 h-5 text-amber-400" />
Crédits supplémentaires
</CardTitle>
<p className="text-gray-400 text-sm">1 crédit = 1 page traduite. Utilisables sans expiration.</p>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
{[
{ credits: 50, price: 5 },
{ credits: 150, price: 12, popular: true },
{ credits: 500, price: 35 },
{ credits: 1000, price: 60 },
].map((pkg, i) => (
<div
key={i}
className={cn(
"relative p-4 rounded-xl border text-center",
pkg.popular
? "border-amber-500/50 bg-amber-500/10"
: "border-gray-700/40 bg-gray-800/30"
)}
>
{pkg.popular && (
<div className="absolute -top-2 left-1/2 -translate-x-1/2 px-2 py-0.5 bg-amber-600 text-white text-xs rounded-full font-bold whitespace-nowrap">
Meilleure valeur
</div>
)}
<div className="text-xl font-bold text-white">{pkg.credits}</div>
<div className="text-gray-400 text-xs mb-2">crédits</div>
<div className="text-lg font-bold text-white">{pkg.price} </div>
<div className="text-gray-500 text-xs mb-3">{((pkg.price / pkg.credits) * 100).toFixed(0)} cts/crédit</div>
<button
onClick={handleBillingPortal}
className="w-full py-1.5 rounded-lg bg-gray-700 hover:bg-gray-600 text-white text-xs transition-all"
>
Acheter
</button>
</div>
))}
</div>
</CardContent>
</Card>
{/* ── Downgrade / Cancel ── */}
{currentPlanId !== "free" && (
<Card className="bg-gray-900/60 border-gray-700/40">
<CardHeader className="pb-3">
<CardTitle className="text-lg text-red-400 flex items-center gap-2">
<AlertTriangle className="w-5 h-5" />
Zone de danger
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{downgradePlans.length > 0 && (
<div>
<p className="text-sm text-gray-400 mb-2">Rétrograder vers un forfait inférieur :</p>
<div className="flex flex-wrap gap-2">
{downgradePlans.map((p) => (
<button
key={p.id}
onClick={() => handleSubscribe(p.id)}
className="px-4 py-2 rounded-lg bg-gray-800 hover:bg-gray-700 text-gray-300 text-sm border border-gray-700/50 transition-all"
>
Passer à {p.name}
</button>
))}
</div>
</div>
)}
<div className="border-t border-gray-700/30 pt-4">
{!cancelConfirm ? (
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-white">Annuler mon abonnement</p>
<p className="text-xs text-gray-500 mt-0.5">Vous conservez l'accès jusqu'à la fin de la période payée.</p>
</div>
<Button
variant="outline"
className="border-red-700/50 text-red-400 hover:bg-red-900/20"
onClick={() => setCancelConfirm(true)}
>
Annuler l'abonnement
</Button>
</div>
) : (
<div className="p-4 rounded-xl bg-red-900/20 border border-red-600/30 space-y-3">
<p className="text-sm text-red-300 font-medium">
⚠️ Confirmer l'annulation ?
</p>
<p className="text-xs text-gray-400">
Votre abonnement sera annulé et vous reviendrez au forfait Gratuit à la fin de la période en cours.
Vos documents traduits resteront accessibles pendant 30 jours.
</p>
<div className="flex gap-2">
<Button
className="bg-red-600 hover:bg-red-500 text-white"
onClick={handleCancel}
>
Oui, annuler mon abonnement
</Button>
<Button
variant="outline"
className="border-gray-600 text-gray-300"
onClick={() => setCancelConfirm(false)}
>
Non, conserver
</Button>
</div>
</div>
)}
</div>
</CardContent>
</Card>
)}
{/* Link to full pricing */}
<div className="text-center">
<button
onClick={() => router.push("/pricing")}
className="text-violet-400 hover:text-violet-300 text-sm flex items-center gap-1 mx-auto"
>
Voir tous les forfaits en détail <ChevronRight className="w-4 h-4" />
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,110 @@
"use client";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Languages, Menu, X, ChevronLeft, Shield, LogOut } from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useState } from "react";
import { cn } from "@/lib/utils";
import { useAdminLogin } from "./login/useAdminLogin";
import { adminNavItems } from "./constants";
export function AdminHeader() {
const [mobileOpen, setMobileOpen] = useState(false);
const pathname = usePathname();
const { logout } = useAdminLogin();
return (
<>
<header className="flex h-12 shrink-0 items-center justify-between border-b border-border bg-card px-3 lg:px-4">
<Button
variant="ghost"
size="icon"
className="lg:hidden h-8 w-8"
onClick={() => setMobileOpen(!mobileOpen)}
aria-label="Toggle menu"
>
{mobileOpen ? <X className="size-4" /> : <Menu className="size-4" />}
</Button>
<div className="flex items-center gap-1.5 lg:hidden">
<div className="flex size-5 items-center justify-center rounded bg-foreground">
<Languages className="size-2.5 text-background" />
</div>
<span className="text-xs font-semibold text-foreground">Admin</span>
</div>
<div className="hidden items-center gap-2 lg:flex">
<h1 className="text-xs font-semibold text-foreground">System Administration</h1>
<Separator orientation="vertical" className="h-3" />
<span className="text-xs text-muted-foreground">Monitor infrastructure and manage users</span>
</div>
<div className="flex items-center gap-2">
<Badge
variant="outline"
className="border-destructive/30 bg-destructive/5 text-destructive text-[10px] px-1.5 py-0"
>
<Shield className="mr-1 size-2.5" />
Superadmin
</Badge>
<Avatar className="size-6">
<AvatarFallback className="bg-foreground text-background text-[10px] font-semibold">
SA
</AvatarFallback>
</Avatar>
</div>
</header>
{mobileOpen && (
<div className="border-b border-border bg-card px-3 py-2 lg:hidden">
<nav className="flex flex-col gap-0.5">
{adminNavItems.map((item) => {
const isActive = pathname === item.href;
return (
<Link
key={item.label}
href={item.href}
onClick={() => setMobileOpen(false)}
className={cn(
"flex items-center gap-2.5 rounded-md px-2.5 py-1.5 text-xs font-medium transition-colors",
isActive
? "bg-secondary text-foreground"
: "text-muted-foreground hover:bg-secondary/60 hover:text-foreground"
)}
>
<item.icon className="size-3.5 shrink-0" />
{item.label}
</Link>
);
})}
<Separator className="my-1" />
<Link
href="/dashboard"
onClick={() => setMobileOpen(false)}
className="flex items-center gap-2.5 rounded-md px-2.5 py-1.5 text-xs font-medium text-muted-foreground hover:bg-secondary/60 hover:text-foreground"
>
<ChevronLeft className="size-3.5 shrink-0" />
User Dashboard
</Link>
<Button
variant="ghost"
size="sm"
className="h-7 justify-start gap-2 text-xs text-muted-foreground hover:text-destructive"
onClick={() => {
setMobileOpen(false);
logout();
}}
>
<LogOut className="size-3.5 shrink-0" />
Logout
</Button>
</nav>
</div>
)}
</>
);
}

View File

@@ -0,0 +1,87 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { ChevronLeft, Shield, Languages, LogOut } from "lucide-react";
import { cn } from "@/lib/utils";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import { Button } from "@/components/ui/button";
import { useAdminLogin } from "./login/useAdminLogin";
import { adminNavItems } from "./constants";
export function AdminSidebar() {
const pathname = usePathname();
const { logout } = useAdminLogin();
return (
<aside className="hidden w-56 shrink-0 border-r border-border bg-card lg:flex lg:flex-col">
<div className="flex h-12 items-center gap-2 px-4">
<div className="flex size-6 items-center justify-center rounded-md bg-foreground">
<Languages className="size-3 text-background" />
</div>
<span className="text-xs font-semibold tracking-tight text-foreground">
Office Translator
</span>
<Badge
variant="outline"
className="ml-auto px-1.5 py-0 text-[10px] font-medium text-muted-foreground"
>
Admin
</Badge>
</div>
<Separator />
<nav className="flex flex-1 flex-col gap-0.5 px-2 py-3">
{adminNavItems.map((item) => {
const isActive = pathname === item.href;
return (
<Link
key={item.label}
href={item.href}
className={cn(
"flex items-center gap-2.5 rounded-md px-2.5 py-1.5 text-xs font-medium transition-colors",
isActive
? "bg-secondary text-foreground"
: "text-muted-foreground hover:bg-secondary/60 hover:text-foreground"
)}
>
<item.icon className="size-3.5 shrink-0" />
{item.label}
</Link>
);
})}
</nav>
<Separator />
<div className="flex flex-col gap-1 px-2 py-2">
<div className="flex items-center gap-2 px-2.5 py-1">
<Shield className="size-3 text-muted-foreground" />
<span className="text-[10px] text-muted-foreground">Superadmin access</span>
</div>
<Button
variant="ghost"
size="sm"
className="h-7 justify-start gap-2 text-xs text-muted-foreground"
asChild
>
<Link href="/dashboard">
<ChevronLeft className="size-3" />
User Dashboard
</Link>
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 justify-start gap-2 text-xs text-muted-foreground hover:text-destructive"
onClick={logout}
>
<LogOut className="size-3" />
Logout
</Button>
</div>
</aside>
);
}

View File

@@ -0,0 +1,42 @@
"use client";
import { Calendar } from "lucide-react";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { StatsPeriod } from "./types";
interface DateRangeFilterProps {
value: StatsPeriod;
onChange: (value: StatsPeriod) => void;
}
const periodOptions: { value: StatsPeriod; label: string }[] = [
{ value: "today", label: "Aujourd'hui" },
{ value: "week", label: "7 derniers jours" },
{ value: "month", label: "30 derniers jours" },
];
export function DateRangeFilter({ value, onChange }: DateRangeFilterProps) {
return (
<div className="flex items-center gap-2">
<Calendar className="h-4 w-4 text-muted-foreground" />
<Select value={value} onValueChange={(v) => onChange(v as StatsPeriod)}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Sélectionner une période" />
</SelectTrigger>
<SelectContent>
{periodOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}

View File

@@ -0,0 +1,119 @@
"use client";
import { FileSpreadsheet, FileText, Presentation } from "lucide-react";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import type { TranslationStatsData, FormatBreakdownItem } from "./types";
interface FormatBreakdownChartProps {
data: TranslationStatsData | null;
isLoading: boolean;
}
const formatConfig: Record<string, { label: string; icon: React.ReactNode; color: string }> = {
xlsx: {
label: "Excel (.xlsx)",
icon: <FileSpreadsheet className="h-4 w-4 text-green-500" />,
color: "bg-green-500",
},
docx: {
label: "Word (.docx)",
icon: <FileText className="h-4 w-4 text-blue-500" />,
color: "bg-blue-500",
},
pptx: {
label: "PowerPoint (.pptx)",
icon: <Presentation className="h-4 w-4 text-orange-500" />,
color: "bg-orange-500",
},
};
export function FormatBreakdownChart({ data, isLoading }: FormatBreakdownChartProps) {
if (isLoading) {
return (
<Card>
<CardHeader>
<CardTitle>Répartition par Format</CardTitle>
<CardDescription>Chargement...</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
{[1, 2, 3].map((i) => (
<div key={i} className="space-y-2">
<div className="h-4 w-20 animate-pulse rounded bg-muted" />
<div className="h-2 w-full animate-pulse rounded bg-muted" />
</div>
))}
</div>
</CardContent>
</Card>
);
}
if (!data?.format_breakdown) {
return (
<Card>
<CardHeader>
<CardTitle>Répartition par Format</CardTitle>
<CardDescription>Aucune donnée disponible</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center justify-center py-8 text-muted-foreground">
<p className="text-sm">Aucune donnée de format</p>
</div>
</CardContent>
</Card>
);
}
const formats = Object.entries(data.format_breakdown).filter(
([, value]) => value.count > 0
);
return (
<Card>
<CardHeader>
<CardTitle>Répartition par Format</CardTitle>
<CardDescription>
Distribution des traductions par type de fichier
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
{formats.map(([format, value]) => {
const config = formatConfig[format] || {
label: format.toUpperCase(),
icon: <FileText className="h-4 w-4" />,
color: "bg-gray-500",
};
return (
<div key={format} className="space-y-2">
<div className="flex items-center justify-between text-sm">
<div className="flex items-center gap-2">
{config.icon}
<span className="font-medium">{config.label}</span>
</div>
<span className="text-muted-foreground">
{value.count} ({value.percentage.toFixed(1)}%)
</span>
</div>
<div className="relative h-2 w-full overflow-hidden rounded-full bg-secondary">
<div
className={`h-full ${config.color} transition-all duration-500`}
style={{ width: `${value.percentage}%` }}
/>
</div>
</div>
);
})}
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,115 @@
"use client";
import { Cpu } from "lucide-react";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import type { TranslationStatsData } from "./types";
interface ProviderBreakdownChartProps {
data: TranslationStatsData | null;
isLoading: boolean;
}
const providerLabels: Record<string, string> = {
google: "Google Translate",
deepl: "DeepL",
ollama: "Ollama (Local)",
openai: "OpenAI",
};
const providerColors: Record<string, string> = {
google: "bg-blue-500",
deepl: "bg-indigo-500",
ollama: "bg-green-500",
openai: "bg-purple-500",
};
export function ProviderBreakdownChart({ data, isLoading }: ProviderBreakdownChartProps) {
if (isLoading) {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Cpu className="h-5 w-5" />
Répartition par Provider
</CardTitle>
<CardDescription>Chargement...</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="space-y-2">
<div className="h-4 w-20 animate-pulse rounded bg-muted" />
<div className="h-2 w-full animate-pulse rounded bg-muted" />
</div>
))}
</div>
</CardContent>
</Card>
);
}
if (!data?.provider_breakdown) {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Cpu className="h-5 w-5" />
Répartition par Provider
</CardTitle>
<CardDescription>Aucune donnée disponible</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center justify-center py-8 text-muted-foreground">
<p className="text-sm">Aucune donnée de provider</p>
</div>
</CardContent>
</Card>
);
}
const providers = Object.entries(data.provider_breakdown).filter(
([, value]) => value.count > 0
);
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Cpu className="h-5 w-5" />
Répartition par Provider
</CardTitle>
<CardDescription>
Distribution des traductions par fournisseur de service
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
{providers.map(([provider, value]) => (
<div key={provider} className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="font-medium">
{providerLabels[provider] || provider}
</span>
<span className="text-muted-foreground">
{value.count} ({value.percentage.toFixed(1)}%)
</span>
</div>
<div className="relative h-2 w-full overflow-hidden rounded-full bg-secondary">
<div
className={`h-full ${providerColors[provider] || "bg-primary"} transition-all duration-500`}
style={{ width: `${value.percentage}%` }}
/>
</div>
</div>
))}
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,141 @@
"use client";
import { Badge } from "@/components/ui/badge";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import type { AdminDashboardData, ProviderStatus } from "./types";
interface ProviderStatusProps {
data: AdminDashboardData | null;
isLoading: boolean;
}
const PROVIDER_LABELS: Record<string, string> = {
google: "Google Translate",
deepl: "DeepL",
ollama: "Ollama (Local)",
openai: "OpenAI",
openrouter: "OpenRouter",
};
const STATUS_CONFIG = {
online: {
dotClass: "bg-green-500",
label: "Online",
badgeClass:
"border-green-200/30 bg-green-500/10 text-green-600",
},
degraded: {
dotClass: "bg-yellow-500",
label: "Degraded",
badgeClass:
"border-yellow-200/30 bg-yellow-500/10 text-yellow-600",
},
offline: {
dotClass: "bg-red-500",
label: "Offline",
badgeClass: "border-red-200/30 bg-red-500/10 text-red-500",
},
};
function getProviderStatus(
provider: ProviderStatus
): "online" | "degraded" | "offline" {
if (provider.available) return "online";
if (provider.error) return "offline";
return "degraded";
}
export function ProviderStatus({ data, isLoading }: ProviderStatusProps) {
const providers = Object.entries(data?.providers || {});
if (isLoading && !data) {
return (
<div className="flex flex-col gap-2 rounded-lg border border-border bg-card px-4 py-3">
<div className="flex items-center justify-between">
<div className="h-3 w-32 animate-pulse rounded bg-muted" />
<div className="h-3 w-20 animate-pulse rounded bg-muted" />
</div>
<div className="flex flex-wrap gap-2">
{[1, 2, 3, 4].map((i) => (
<div
key={i}
className="h-6 w-28 animate-pulse rounded bg-muted"
/>
))}
</div>
</div>
);
}
if (providers.length === 0) {
return (
<div className="flex flex-col gap-2 rounded-lg border border-border bg-card px-4 py-3">
<span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
Translation API Providers
</span>
<span className="text-sm text-muted-foreground">
No provider data available
</span>
</div>
);
}
return (
<div className="flex flex-col gap-2 rounded-lg border border-border bg-card px-4 py-3">
<div className="flex items-center justify-between">
<span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
Translation API Providers
</span>
<span className="text-[10px] text-muted-foreground">
{providers.length} provider{providers.length !== 1 ? "s" : ""}
</span>
</div>
<div className="flex flex-wrap items-center gap-2">
{providers.map(([key, provider]) => {
const status = getProviderStatus(provider);
const config = STATUS_CONFIG[status];
const label = PROVIDER_LABELS[key] || provider.name || key;
return (
<Tooltip key={key}>
<TooltipTrigger asChild>
<Badge
variant="outline"
className={`cursor-default gap-1.5 px-2.5 py-1 text-xs font-medium ${config.badgeClass}`}
>
<span
className={`size-1.5 rounded-full ${config.dotClass}`}
/>
{label}
</Badge>
</TooltipTrigger>
<TooltipContent>
<div className="flex flex-col gap-0.5 text-xs">
<span className="font-medium">{config.label}</span>
{provider.latency_ms !== undefined && (
<span className="text-muted-foreground">
Latency: {provider.latency_ms}ms
</span>
)}
{provider.last_check && (
<span className="text-muted-foreground">
Last check:{" "}
{new Date(provider.last_check).toLocaleTimeString()}
</span>
)}
{provider.error && (
<span className="text-red-500">{provider.error}</span>
)}
</div>
</TooltipContent>
</Tooltip>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,116 @@
"use client";
import { TrendingUp, TrendingDown, FileText, AlertCircle } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import type { TranslationStatsData } from "./types";
interface StatsOverviewProps {
data: TranslationStatsData | null;
isLoading: boolean;
}
export function StatsOverview({ data, isLoading }: StatsOverviewProps) {
if (isLoading) {
return (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{[1, 2, 3, 4].map((i) => (
<Card key={i}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<div className="h-4 w-24 animate-pulse rounded bg-muted" />
<div className="h-4 w-4 animate-pulse rounded bg-muted" />
</CardHeader>
<CardContent>
<div className="h-8 w-16 animate-pulse rounded bg-muted" />
<div className="mt-2 h-3 w-20 animate-pulse rounded bg-muted" />
</CardContent>
</Card>
))}
</div>
);
}
if (!data) {
return null;
}
const diff = data.total_translations - data.total_translations_last_period;
const trendUp = diff >= 0;
const trendPercent = data.total_translations_last_period > 0
? Math.abs((diff / data.total_translations_last_period) * 100).toFixed(1)
: "0";
const periodLabels: Record<string, string> = {
today: "Aujourd'hui",
week: "Cette Semaine",
month: "Ce Mois",
};
return (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
Traductions {periodLabels[data.period]}
</CardTitle>
<FileText className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{data.total_translations}</div>
<div className="flex items-center gap-1 text-xs text-muted-foreground">
{trendUp ? (
<TrendingUp className="h-3 w-3 text-green-500" />
) : (
<TrendingDown className="h-3 w-3 text-red-500" />
)}
<span className={trendUp ? "text-green-500" : "text-red-500"}>
{trendUp ? "+" : ""}{diff}
</span>
<span>({trendPercent}%)</span>
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Réussies</CardTitle>
<Badge variant="default" className="bg-green-500/10 text-green-500 hover:bg-green-500/20">
OK
</Badge>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{data.success_count}</div>
<p className="text-xs text-muted-foreground">
{(100 - data.error_rate).toFixed(1)}% de réussite
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Erreurs</CardTitle>
<AlertCircle className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{data.error_count}</div>
<p className="text-xs text-muted-foreground">
Taux d&apos;erreur: {data.error_rate.toFixed(1)}%
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Période Précédente</CardTitle>
<TrendingUp className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{data.total_translations_last_period}</div>
<p className="text-xs text-muted-foreground">
Comparaison
</p>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,163 @@
"use client";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import { HeartPulse, HardDrive, FileWarning, Trash2, Loader2 } from "lucide-react";
import type { AdminDashboardData } from "./types";
interface SystemHealthCardsProps {
data: AdminDashboardData | null;
isLoading: boolean;
isPurging: boolean;
onPurge: () => void;
purgeResult: { files_cleaned: number } | null;
}
export function SystemHealthCards({
data,
isLoading,
isPurging,
onPurge,
purgeResult,
}: SystemHealthCardsProps) {
const diskUsed = data?.system?.disk?.used_percent ?? 0;
const trackedFilesCount = data?.cleanup?.tracked_files_count ?? 0;
const systemStatus = data?.status ?? "unhealthy";
if (isLoading && !data) {
return (
<div className="grid grid-cols-1 gap-3 md:grid-cols-3">
{[1, 2, 3].map((i) => (
<Card key={i} className="py-0">
<CardContent className="flex items-center gap-3 px-4 py-3">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-muted">
<Loader2 className="size-4 animate-spin text-muted-foreground" />
</div>
<div className="flex flex-1 flex-col gap-1">
<div className="h-3 w-20 animate-pulse rounded bg-muted" />
<div className="h-4 w-32 animate-pulse rounded bg-muted" />
</div>
</CardContent>
</Card>
))}
</div>
);
}
return (
<div className="grid grid-cols-1 gap-3 md:grid-cols-3">
<Card className="py-0">
<CardContent className="flex items-center gap-3 px-4 py-3">
<div
className={`flex size-9 shrink-0 items-center justify-center rounded-lg ${
systemStatus === "healthy"
? "bg-green-500/10"
: "bg-red-500/10"
}`}
>
<HeartPulse
className={`size-4 ${
systemStatus === "healthy"
? "text-green-500"
: "text-red-500"
}`}
/>
</div>
<div className="flex flex-1 flex-col gap-0.5">
<span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
Server Health
</span>
<div className="flex items-center gap-1.5">
<span className="relative flex size-2">
{systemStatus === "healthy" && (
<>
<span className="absolute inline-flex size-full animate-ping rounded-full bg-green-500 opacity-75" />
<span className="relative inline-flex size-2 rounded-full bg-green-500" />
</>
)}
{systemStatus !== "healthy" && (
<span className="relative inline-flex size-2 rounded-full bg-red-500" />
)}
</span>
<span className="text-sm font-semibold text-foreground">
{systemStatus === "healthy"
? "All Systems Operational"
: "System Issues Detected"}
</span>
</div>
<span className="text-[10px] text-muted-foreground">
{data?.timestamp
? `Last update: ${new Date(data.timestamp).toLocaleTimeString()}`
: "Waiting for data..."}
</span>
</div>
</CardContent>
</Card>
<Card className="py-0">
<CardContent className="flex items-center gap-3 px-4 py-3">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-blue-500/10">
<HardDrive className="size-4 text-blue-500" />
</div>
<div className="flex flex-1 flex-col gap-1">
<span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
Disk Space
</span>
<div className="flex items-center justify-between">
<span className="text-sm font-semibold tabular-nums text-foreground">
{diskUsed}% used
</span>
<span className="text-[10px] tabular-nums text-muted-foreground">
{data?.system?.disk?.total_gb ?? "--"} GB total
</span>
</div>
<Progress
value={diskUsed}
className="h-1.5 bg-muted [&>[data-slot=progress-indicator]]:bg-blue-500"
/>
</div>
</CardContent>
</Card>
<Card className="py-0">
<CardContent className="flex items-center gap-3 px-4 py-3">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-red-500/10">
<FileWarning className="size-4 text-red-500" />
</div>
<div className="flex flex-1 flex-col gap-0.5">
<span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
Temporary Files
</span>
<span className="text-sm font-semibold tabular-nums text-foreground">
{trackedFilesCount} orphaned files
</span>
{purgeResult && (
<span className="text-[10px] text-green-500">
{purgeResult.files_cleaned} files deleted
</span>
)}
</div>
<Button
variant="outline"
size="sm"
className="h-7 shrink-0 gap-1.5 border-red-200/30 text-red-500 hover:bg-red-500/10 hover:text-red-500 text-xs"
onClick={onPurge}
disabled={isPurging || trackedFilesCount === 0}
>
{isPurging ? (
<Loader2 className="size-3 animate-spin" />
) : (
<Trash2 className="size-3" />
)}
{isPurging
? "Purging..."
: trackedFilesCount === 0
? "Clean"
: "Purge"}
</Button>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,133 @@
"use client";
import { Users, Trophy } from "lucide-react";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
import type { TopUser } from "./types";
interface TopUsersTableProps {
topUsers: TopUser[];
isLoading: boolean;
}
export function TopUsersTable({ topUsers, isLoading }: TopUsersTableProps) {
if (isLoading) {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Trophy className="h-5 w-5" />
Top Utilisateurs
</CardTitle>
<CardDescription>Chargement...</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-2">
{[1, 2, 3].map((i) => (
<div key={i} className="h-10 animate-pulse rounded bg-muted" />
))}
</div>
</CardContent>
</Card>
);
}
if (!topUsers || topUsers.length === 0) {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Trophy className="h-5 w-5" />
Top Utilisateurs
</CardTitle>
<CardDescription>Aucune donnée disponible</CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground">
<Users className="h-12 w-12 mb-2 opacity-50" />
<p className="text-sm">Aucune traduction enregistrée</p>
</div>
</CardContent>
</Card>
);
}
const getRankBadge = (rank: number) => {
if (rank === 1) {
return (
<Badge className="bg-yellow-500/10 text-yellow-600 hover:bg-yellow-500/20">
1er
</Badge>
);
}
if (rank === 2) {
return (
<Badge className="bg-gray-400/10 text-gray-500 hover:bg-gray-400/20">
2e
</Badge>
);
}
if (rank === 3) {
return (
<Badge className="bg-orange-500/10 text-orange-600 hover:bg-orange-500/20">
3e
</Badge>
);
}
return (
<Badge variant="outline" className="text-muted-foreground">
{rank}e
</Badge>
);
};
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Trophy className="h-5 w-5" />
Top Utilisateurs
</CardTitle>
<CardDescription>
Les 10 utilisateurs les plus actifs par volume de traduction
</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-16">Rang</TableHead>
<TableHead>Email</TableHead>
<TableHead className="text-right">Traductions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{topUsers.slice(0, 10).map((user, index) => (
<TableRow key={user.user_id}>
<TableCell>{getRankBadge(index + 1)}</TableCell>
<TableCell className="font-medium">{user.email}</TableCell>
<TableCell className="text-right">
<Badge variant="secondary">{user.translation_count}</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,15 @@
import { LayoutDashboard, Users, Settings, FileText, Key, type LucideIcon } from 'lucide-react';
export interface AdminNavItem {
label: string;
href: string;
icon: LucideIcon;
}
export const adminNavItems: AdminNavItem[] = [
{ label: 'Dashboard', href: '/admin', icon: LayoutDashboard },
{ label: 'Users', href: '/admin/users', icon: Users },
{ label: 'Providers', href: '/admin/settings', icon: Key },
{ label: 'System', href: '/admin/system', icon: Settings },
{ label: 'Logs', href: '/admin/logs', icon: FileText },
];

View File

@@ -0,0 +1,86 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { useRouter, usePathname } from "next/navigation";
import { useTranslationStore } from "@/lib/store";
import { API_BASE } from "@/lib/config";
import { AdminSidebar } from "./AdminSidebar";
import { AdminHeader } from "./AdminHeader";
export default function AdminLayout({
children,
}: {
children: React.ReactNode;
}) {
const router = useRouter();
const pathname = usePathname();
const { settings, setAdminToken } = useTranslationStore();
const [isChecking, setIsChecking] = useState(true);
const [isValid, setIsValid] = useState(false);
const verifyToken = useCallback(async (token: string): Promise<boolean> => {
try {
const response = await fetch(`${API_BASE}/api/v1/admin/verify`, {
method: "GET",
headers: {
"Authorization": `Bearer ${token}`,
},
});
return response.ok;
} catch {
return false;
}
}, []);
useEffect(() => {
if (pathname === "/admin/login") {
setIsChecking(false);
setIsValid(true);
return;
}
const adminToken = settings.adminToken;
if (!adminToken) {
router.push(`/admin/login?redirect=${encodeURIComponent(pathname)}`);
return;
}
verifyToken(adminToken).then((valid) => {
if (!valid) {
setAdminToken(undefined);
router.push(`/admin/login?redirect=${encodeURIComponent(pathname)}`);
return;
}
setIsValid(true);
setIsChecking(false);
});
}, [settings.adminToken, pathname, router, verifyToken, setAdminToken]);
if (isChecking && pathname !== "/admin/login") {
return (
<div className="min-h-screen bg-card flex items-center justify-center">
<div className="text-muted-foreground text-sm">Vérification de l&apos;authentification...</div>
</div>
);
}
if (!isValid && pathname !== "/admin/login") {
return null;
}
if (pathname === "/admin/login") {
return <>{children}</>;
}
return (
<div className="flex min-h-screen bg-background">
<AdminSidebar />
<div className="flex flex-1 flex-col">
<AdminHeader />
<main className="flex-1 p-4 lg:p-6">
{children}
</main>
</div>
</div>
);
}

View File

@@ -2,55 +2,25 @@
import { useState, Suspense } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useTranslationStore } from "@/lib/store";
import { useAdminLogin } from "./useAdminLogin";
import { Shield, Lock, Eye, EyeOff, AlertCircle } from "lucide-react";
function AdminLoginContent() {
const router = useRouter();
const searchParams = useSearchParams();
const { setAdminToken } = useTranslationStore();
const { login, isLoading, error } = useAdminLogin();
const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const API_BASE = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000";
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError(null);
try {
const response = await fetch(`${API_BASE}/admin/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ password }),
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.detail || "Mot de passe incorrect");
}
const data = await response.json();
setAdminToken(data.access_token);
const redirect = searchParams.get("redirect") || "/admin";
router.push(redirect);
} catch (err: any) {
const errorMessage = typeof err.message === 'string' ? err.message : "Erreur de connexion";
setError(errorMessage);
} finally {
setLoading(false);
}
await login(password);
};
return (
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-purple-900 to-slate-900 flex items-center justify-center p-4">
<div className="w-full max-w-md">
{/* Logo */}
<div className="text-center mb-8">
<div className="inline-flex items-center justify-center w-16 h-16 bg-purple-600/20 rounded-2xl mb-4">
<Shield className="w-8 h-8 text-purple-400" />
@@ -59,7 +29,6 @@ function AdminLoginContent() {
<p className="text-gray-400 mt-2">Connexion requise</p>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="bg-black/30 backdrop-blur-xl rounded-2xl border border-white/10 p-8">
{error && (
<div className="flex items-center gap-3 p-4 mb-6 bg-red-500/10 border border-red-500/20 rounded-xl text-red-400">
@@ -80,6 +49,7 @@ function AdminLoginContent() {
placeholder="••••••••"
className="w-full pl-12 pr-12 py-3 bg-black/30 border border-white/10 rounded-xl text-white placeholder:text-gray-500 focus:outline-none focus:border-purple-500 transition-all"
required
disabled={isLoading}
/>
<button
type="button"
@@ -93,10 +63,10 @@ function AdminLoginContent() {
<button
type="submit"
disabled={loading || !password}
disabled={isLoading || !password}
className="w-full py-3 bg-purple-600 hover:bg-purple-700 disabled:bg-purple-600/50 disabled:cursor-not-allowed text-white font-medium rounded-xl transition-all flex items-center justify-center gap-2"
>
{loading ? (
{isLoading ? (
<>
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
Connexion...

View File

@@ -0,0 +1,18 @@
export interface AdminLoginRequest {
password: string;
}
export interface AdminLoginResponse {
status: string;
access_token: string;
token_type: string;
expires_in: number;
message: string;
}
export interface AdminLoginState {
login: (password: string) => Promise<void>;
logout: () => Promise<void>;
isLoading: boolean;
error: string | null;
}

View File

@@ -0,0 +1,89 @@
"use client";
import { useState, useCallback } from "react";
import { useRouter } from "next/navigation";
import { useTranslationStore } from "@/lib/store";
import { API_BASE } from "@/lib/config";
import type { AdminLoginResponse } from "./types";
const TIMEOUT_MS = 15000;
export function useAdminLogin() {
const router = useRouter();
const { setAdminToken } = useTranslationStore();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const login = async (password: string): Promise<void> => {
setIsLoading(true);
setError(null);
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), TIMEOUT_MS);
const response = await fetch(`${API_BASE}/api/v1/admin/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ password }),
signal: controller.signal,
});
clearTimeout(timeoutId);
const contentType = response.headers.get("content-type");
const isJson = contentType?.includes("application/json");
if (!response.ok) {
const message = isJson
? (await response.json()).detail || "Mot de passe incorrect"
: "Erreur de connexion au serveur";
throw new Error(message);
}
const data: AdminLoginResponse = isJson ? await response.json() : {};
const token = data.access_token;
if (!token) {
throw new Error("Réponse invalide du serveur");
}
setAdminToken(token);
router.push("/admin");
} catch (err: unknown) {
if (err instanceof Error) {
if (err.name === "AbortError") {
setError("Délai de connexion dépassé. Veuillez réessayer.");
} else {
setError(err.message);
}
} else {
setError("Erreur de connexion");
}
} finally {
setIsLoading(false);
}
};
const logout = useCallback(async (): Promise<void> => {
const token = useTranslationStore.getState().settings.adminToken;
if (token) {
try {
await fetch(`${API_BASE}/api/v1/admin/logout`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${token}`,
},
});
} catch {
// Ignore logout errors - proceed with local cleanup
}
}
setAdminToken(undefined);
router.push("/admin/login");
}, [router, setAdminToken]);
return { login, logout, isLoading, error };
}

View File

@@ -1,614 +1,113 @@
"use client";
import { useEffect, useState, Suspense } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useTranslationStore } from "@/lib/store";
import { motion } from "framer-motion";
import { Users, Activity, Settings, FileText, TrendingUp, Server, Key, LogOut, RefreshCw, Search, ChevronRight, Shield, Zap, Globe, DollarSign } from "lucide-react";
interface DashboardData {
translations_today: number;
translations_total: number;
active_users: number;
popular_languages: { [key: string]: number };
average_processing_time: number;
cache_hit_rate: number;
openrouter_usage?: {
total_cost: number;
requests_count: number;
models_used: { [key: string]: number };
};
}
interface User {
id: string;
email: string;
username: string;
plan: string;
translations_count: number;
is_active: boolean;
created_at: string;
last_login?: string;
}
interface AdminSettings {
default_provider: string;
openrouter_enabled: boolean;
google_enabled: boolean;
max_file_size_mb: number;
rate_limit_per_minute: number;
cache_enabled: boolean;
}
function AdminContent() {
const router = useRouter();
const searchParams = useSearchParams();
const { adminToken } = useTranslationStore();
const [activeTab, setActiveTab] = useState<"overview" | "users" | "config" | "settings">("overview");
const [dashboardData, setDashboardData] = useState<DashboardData | null>(null);
const [users, setUsers] = useState<User[]>([]);
const [settings, setSettings] = useState<AdminSettings>({
default_provider: "google",
openrouter_enabled: true,
google_enabled: true,
max_file_size_mb: 10,
rate_limit_per_minute: 60,
cache_enabled: true
});
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState("");
const [refreshing, setRefreshing] = useState(false);
const API_BASE = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000";
useEffect(() => {
const tab = searchParams.get("tab");
if (tab && ["overview", "users", "config", "settings"].includes(tab)) {
setActiveTab(tab as any);
}
}, [searchParams]);
useEffect(() => {
if (!adminToken) {
router.push("/admin/login");
return;
}
fetchDashboardData();
}, [adminToken]);
useEffect(() => {
if (activeTab === "users" && users.length === 0) {
fetchUsers();
}
}, [activeTab]);
const fetchDashboardData = async () => {
try {
setLoading(true);
const response = await fetch(`${API_BASE}/admin/dashboard`, {
headers: { Authorization: `Bearer ${adminToken}` }
});
if (!response.ok) throw new Error("Failed to fetch dashboard data");
const data = await response.json();
setDashboardData(data);
} catch (err) {
setError("Erreur de chargement des données");
console.error(err);
} finally {
setLoading(false);
}
};
const fetchUsers = async () => {
try {
const response = await fetch(`${API_BASE}/admin/users`, {
headers: { Authorization: `Bearer ${adminToken}` }
});
if (!response.ok) throw new Error("Failed to fetch users");
const data = await response.json();
setUsers(data.users || []);
} catch (err) {
console.error("Error fetching users:", err);
}
};
const refreshData = async () => {
setRefreshing(true);
await fetchDashboardData();
if (activeTab === "users") {
await fetchUsers();
}
setRefreshing(false);
};
const handleLogout = () => {
useTranslationStore.getState().setAdminToken(null);
router.push("/admin/login");
};
const filteredUsers = users.filter(user =>
user.email?.toLowerCase().includes(searchQuery.toLowerCase()) ||
user.username?.toLowerCase().includes(searchQuery.toLowerCase())
);
if (loading) {
return (
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-purple-900 to-slate-900 flex items-center justify-center">
<div className="text-white text-xl flex items-center gap-3">
<RefreshCw className="animate-spin" />
Chargement...
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-purple-900 to-slate-900">
{/* Header */}
<header className="bg-black/30 backdrop-blur-xl border-b border-white/10">
<div className="max-w-7xl mx-auto px-6 py-4 flex items-center justify-between">
<div className="flex items-center gap-3">
<Shield className="w-8 h-8 text-purple-400" />
<h1 className="text-2xl font-bold text-white">Administration</h1>
</div>
<div className="flex items-center gap-4">
<button
onClick={refreshData}
disabled={refreshing}
className="p-2 rounded-lg bg-white/10 hover:bg-white/20 text-white transition-all"
>
<RefreshCw className={`w-5 h-5 ${refreshing ? 'animate-spin' : ''}`} />
</button>
<button
onClick={handleLogout}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-red-500/20 hover:bg-red-500/30 text-red-400 transition-all"
>
<LogOut className="w-4 h-4" />
Déconnexion
</button>
</div>
</div>
</header>
<div className="max-w-7xl mx-auto px-6 py-8">
{/* Tab Navigation */}
<div className="flex gap-2 mb-8 bg-black/20 p-2 rounded-xl w-fit">
{[
{ id: "overview", label: "Vue d'ensemble", icon: Activity },
{ id: "users", label: "Utilisateurs", icon: Users },
{ id: "config", label: "Configuration", icon: Server },
{ id: "settings", label: "Paramètres", icon: Settings }
].map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id as any)}
className={`flex items-center gap-2 px-4 py-2 rounded-lg transition-all ${
activeTab === tab.id
? "bg-purple-600 text-white shadow-lg"
: "text-gray-400 hover:text-white hover:bg-white/10"
}`}
>
<tab.icon className="w-4 h-4" />
{tab.label}
</button>
))}
</div>
{/* Overview Tab */}
{activeTab === "overview" && dashboardData && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="space-y-6"
>
{/* Stats Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<StatCard
title="Traductions Aujourd'hui"
value={dashboardData.translations_today ?? 0}
icon={FileText}
color="purple"
/>
<StatCard
title="Total Traductions"
value={dashboardData.translations_total ?? 0}
icon={TrendingUp}
color="blue"
/>
<StatCard
title="Utilisateurs Actifs"
value={dashboardData.active_users ?? 0}
icon={Users}
color="green"
/>
<StatCard
title="Taux Cache"
value={`${((dashboardData.cache_hit_rate ?? 0) * 100).toFixed(1)}%`}
icon={Zap}
color="yellow"
/>
</div>
{/* OpenRouter Usage */}
{dashboardData.openrouter_usage && (
<div className="bg-black/20 backdrop-blur-xl rounded-2xl border border-white/10 p-6">
<h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
<Globe className="w-5 h-5 text-purple-400" />
Utilisation OpenRouter
</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="bg-white/5 rounded-xl p-4">
<p className="text-gray-400 text-sm">Coût Total</p>
<p className="text-2xl font-bold text-green-400">
${dashboardData.openrouter_usage.total_cost?.toFixed(4) ?? '0.0000'}
</p>
</div>
<div className="bg-white/5 rounded-xl p-4">
<p className="text-gray-400 text-sm">Requêtes</p>
<p className="text-2xl font-bold text-blue-400">
{dashboardData.openrouter_usage.requests_count ?? 0}
</p>
</div>
<div className="bg-white/5 rounded-xl p-4">
<p className="text-gray-400 text-sm">Temps Moyen</p>
<p className="text-2xl font-bold text-yellow-400">
{(dashboardData.average_processing_time ?? 0).toFixed(2)}s
</p>
</div>
</div>
</div>
)}
{/* Popular Languages */}
<div className="bg-black/20 backdrop-blur-xl rounded-2xl border border-white/10 p-6">
<h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
<Globe className="w-5 h-5 text-purple-400" />
Langues Populaires
</h3>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{Object.entries(dashboardData.popular_languages || {}).slice(0, 8).map(([lang, count]) => (
<div key={lang} className="bg-white/5 rounded-xl p-4 text-center">
<p className="text-2xl font-bold text-white">{count}</p>
<p className="text-gray-400 text-sm uppercase">{lang}</p>
</div>
))}
</div>
</div>
</motion.div>
)}
{/* Users Tab */}
{activeTab === "users" && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="space-y-6"
>
{/* Search */}
<div className="flex items-center gap-4">
<div className="relative flex-1 max-w-md">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
<input
type="text"
placeholder="Rechercher un utilisateur..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-10 pr-4 py-3 bg-black/30 border border-white/10 rounded-xl text-white placeholder:text-gray-500 focus:outline-none focus:border-purple-500"
/>
</div>
<div className="text-gray-400">
{filteredUsers.length} utilisateur(s)
</div>
</div>
{/* Users Table */}
<div className="bg-black/20 backdrop-blur-xl rounded-2xl border border-white/10 overflow-hidden">
<table className="w-full">
<thead>
<tr className="border-b border-white/10">
<th className="text-left p-4 text-gray-400 font-medium">Utilisateur</th>
<th className="text-left p-4 text-gray-400 font-medium">Plan</th>
<th className="text-left p-4 text-gray-400 font-medium">Traductions</th>
<th className="text-left p-4 text-gray-400 font-medium">Statut</th>
<th className="text-left p-4 text-gray-400 font-medium">Inscrit le</th>
<th className="text-left p-4 text-gray-400 font-medium"></th>
</tr>
</thead>
<tbody>
{filteredUsers.map((user) => (
<tr key={user.id} className="border-b border-white/5 hover:bg-white/5 transition-colors">
<td className="p-4">
<div>
<p className="text-white font-medium">{user.username || 'N/A'}</p>
<p className="text-gray-400 text-sm">{user.email}</p>
</div>
</td>
<td className="p-4">
<span className={`px-3 py-1 rounded-full text-xs font-medium ${
user.plan === 'premium'
? 'bg-purple-500/20 text-purple-400'
: user.plan === 'pro'
? 'bg-blue-500/20 text-blue-400'
: 'bg-gray-500/20 text-gray-400'
}`}>
{user.plan || 'free'}
</span>
</td>
<td className="p-4 text-white">{user.translations_count ?? 0}</td>
<td className="p-4">
<span className={`px-3 py-1 rounded-full text-xs font-medium ${
user.is_active
? 'bg-green-500/20 text-green-400'
: 'bg-red-500/20 text-red-400'
}`}>
{user.is_active ? 'Actif' : 'Inactif'}
</span>
</td>
<td className="p-4 text-gray-400 text-sm">
{user.created_at ? new Date(user.created_at).toLocaleDateString('fr-FR') : 'N/A'}
</td>
<td className="p-4">
<button className="p-2 rounded-lg hover:bg-white/10 text-gray-400 hover:text-white transition-all">
<ChevronRight className="w-5 h-5" />
</button>
</td>
</tr>
))}
{filteredUsers.length === 0 && (
<tr>
<td colSpan={6} className="p-8 text-center text-gray-400">
Aucun utilisateur trouvé
</td>
</tr>
)}
</tbody>
</table>
</div>
</motion.div>
)}
{/* Config Tab */}
{activeTab === "config" && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="space-y-6"
>
{/* Translation Providers */}
<div className="bg-black/20 backdrop-blur-xl rounded-2xl border border-white/10 p-6">
<h3 className="text-lg font-semibold text-white mb-6 flex items-center gap-2">
<Server className="w-5 h-5 text-purple-400" />
Fournisseurs de Traduction
</h3>
<div className="space-y-4">
{/* Google Translate */}
<div className="flex items-center justify-between p-4 bg-white/5 rounded-xl">
<div className="flex items-center gap-4">
<div className="w-12 h-12 bg-blue-500/20 rounded-xl flex items-center justify-center">
<Globe className="w-6 h-6 text-blue-400" />
</div>
<div>
<h4 className="text-white font-medium">Google Translate</h4>
<p className="text-gray-400 text-sm">API officielle Google Cloud</p>
</div>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={settings.google_enabled}
onChange={(e) => setSettings({ ...settings, google_enabled: e.target.checked })}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-gray-700 peer-focus:ring-4 peer-focus:ring-purple-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-purple-600"></div>
</label>
</div>
{/* OpenRouter */}
<div className="flex items-center justify-between p-4 bg-white/5 rounded-xl">
<div className="flex items-center gap-4">
<div className="w-12 h-12 bg-purple-500/20 rounded-xl flex items-center justify-center">
<Zap className="w-6 h-6 text-purple-400" />
</div>
<div>
<h4 className="text-white font-medium">OpenRouter</h4>
<p className="text-gray-400 text-sm">Modèles IA avancés (GPT-4, Claude, etc.)</p>
</div>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={settings.openrouter_enabled}
onChange={(e) => setSettings({ ...settings, openrouter_enabled: e.target.checked })}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-gray-700 peer-focus:ring-4 peer-focus:ring-purple-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-purple-600"></div>
</label>
</div>
</div>
</div>
{/* API Keys */}
<div className="bg-black/20 backdrop-blur-xl rounded-2xl border border-white/10 p-6">
<h3 className="text-lg font-semibold text-white mb-6 flex items-center gap-2">
<Key className="w-5 h-5 text-purple-400" />
Clés API
</h3>
<div className="space-y-4">
<div className="p-4 bg-white/5 rounded-xl">
<label className="block text-gray-400 text-sm mb-2">Google Cloud API Key</label>
<div className="flex gap-2">
<input
type="password"
placeholder="••••••••••••••••"
className="flex-1 px-4 py-2 bg-black/30 border border-white/10 rounded-lg text-white placeholder:text-gray-500 focus:outline-none focus:border-purple-500"
/>
<button className="px-4 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded-lg transition-all">
Sauvegarder
</button>
</div>
</div>
<div className="p-4 bg-white/5 rounded-xl">
<label className="block text-gray-400 text-sm mb-2">OpenRouter API Key</label>
<div className="flex gap-2">
<input
type="password"
placeholder="••••••••••••••••"
className="flex-1 px-4 py-2 bg-black/30 border border-white/10 rounded-lg text-white placeholder:text-gray-500 focus:outline-none focus:border-purple-500"
/>
<button className="px-4 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded-lg transition-all">
Sauvegarder
</button>
</div>
</div>
</div>
</div>
{/* Default Provider */}
<div className="bg-black/20 backdrop-blur-xl rounded-2xl border border-white/10 p-6">
<h3 className="text-lg font-semibold text-white mb-6 flex items-center gap-2">
<Settings className="w-5 h-5 text-purple-400" />
Fournisseur par Défaut
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<button
onClick={() => setSettings({ ...settings, default_provider: 'google' })}
className={`p-4 rounded-xl border-2 transition-all ${
settings.default_provider === 'google'
? 'border-purple-500 bg-purple-500/10'
: 'border-white/10 bg-white/5 hover:border-white/20'
}`}
>
<Globe className={`w-8 h-8 mb-2 ${settings.default_provider === 'google' ? 'text-purple-400' : 'text-gray-400'}`} />
<h4 className="text-white font-medium">Google Translate</h4>
<p className="text-gray-400 text-sm">Rapide et fiable</p>
</button>
<button
onClick={() => setSettings({ ...settings, default_provider: 'openrouter' })}
className={`p-4 rounded-xl border-2 transition-all ${
settings.default_provider === 'openrouter'
? 'border-purple-500 bg-purple-500/10'
: 'border-white/10 bg-white/5 hover:border-white/20'
}`}
>
<Zap className={`w-8 h-8 mb-2 ${settings.default_provider === 'openrouter' ? 'text-purple-400' : 'text-gray-400'}`} />
<h4 className="text-white font-medium">OpenRouter</h4>
<p className="text-gray-400 text-sm">IA avancée</p>
</button>
</div>
</div>
</motion.div>
)}
{/* Settings Tab */}
{activeTab === "settings" && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="space-y-6"
>
{/* Limits */}
<div className="bg-black/20 backdrop-blur-xl rounded-2xl border border-white/10 p-6">
<h3 className="text-lg font-semibold text-white mb-6 flex items-center gap-2">
<Settings className="w-5 h-5 text-purple-400" />
Limites
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label className="block text-gray-400 text-sm mb-2">Taille max fichier (MB)</label>
<input
type="number"
value={settings.max_file_size_mb}
onChange={(e) => setSettings({ ...settings, max_file_size_mb: parseInt(e.target.value) || 10 })}
className="w-full px-4 py-3 bg-black/30 border border-white/10 rounded-xl text-white focus:outline-none focus:border-purple-500"
/>
</div>
<div>
<label className="block text-gray-400 text-sm mb-2">Requêtes/minute</label>
<input
type="number"
value={settings.rate_limit_per_minute}
onChange={(e) => setSettings({ ...settings, rate_limit_per_minute: parseInt(e.target.value) || 60 })}
className="w-full px-4 py-3 bg-black/30 border border-white/10 rounded-xl text-white focus:outline-none focus:border-purple-500"
/>
</div>
</div>
</div>
{/* Cache */}
<div className="bg-black/20 backdrop-blur-xl rounded-2xl border border-white/10 p-6">
<h3 className="text-lg font-semibold text-white mb-6 flex items-center gap-2">
<Zap className="w-5 h-5 text-purple-400" />
Cache
</h3>
<div className="flex items-center justify-between p-4 bg-white/5 rounded-xl">
<div>
<h4 className="text-white font-medium">Cache des traductions</h4>
<p className="text-gray-400 text-sm">Améliore les performances et réduit les coûts</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={settings.cache_enabled}
onChange={(e) => setSettings({ ...settings, cache_enabled: e.target.checked })}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-gray-700 peer-focus:ring-4 peer-focus:ring-purple-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-purple-600"></div>
</label>
</div>
</div>
{/* Save Button */}
<div className="flex justify-end">
<button className="px-6 py-3 bg-purple-600 hover:bg-purple-700 text-white rounded-xl font-medium transition-all flex items-center gap-2">
Sauvegarder les paramètres
</button>
</div>
</motion.div>
)}
</div>
</div>
);
}
function StatCard({ title, value, icon: Icon, color }: { title: string; value: string | number; icon: any; color: string }) {
const colorClasses = {
purple: 'bg-purple-500/20 text-purple-400',
blue: 'bg-blue-500/20 text-blue-400',
green: 'bg-green-500/20 text-green-400',
yellow: 'bg-yellow-500/20 text-yellow-400'
};
return (
<div className="bg-black/20 backdrop-blur-xl rounded-2xl border border-white/10 p-6">
<div className="flex items-center justify-between mb-4">
<div className={`p-3 rounded-xl ${colorClasses[color as keyof typeof colorClasses]}`}>
<Icon className="w-6 h-6" />
</div>
</div>
<p className="text-3xl font-bold text-white mb-1">{value}</p>
<p className="text-gray-400 text-sm">{title}</p>
</div>
);
}
import { Shield, RefreshCw, Loader2, AlertCircle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { SystemHealthCards } from "./SystemHealthCards";
import { ProviderStatus } from "./ProviderStatus";
import { useAdminDashboard } from "./useAdminDashboard";
import { useCleanup } from "./useCleanup";
import {
TooltipProvider,
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
export default function AdminPage() {
const { data, isLoading, error, refetch } = useAdminDashboard();
const { isPurging, purgeResult, triggerCleanup } = useCleanup();
const handlePurge = async () => {
await triggerCleanup();
refetch();
};
return (
<Suspense fallback={
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-purple-900 to-slate-900 flex items-center justify-center">
<div className="text-white text-xl">Chargement...</div>
<TooltipProvider>
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex size-10 items-center justify-center rounded-lg bg-purple-500/10">
<Shield className="size-5 text-purple-500" />
</div>
<div>
<h1 className="text-xl font-semibold text-foreground">
Dashboard Admin
</h1>
<p className="text-sm text-muted-foreground">
Panneau de contrôle administrateur
</p>
</div>
</div>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="sm"
className="gap-1.5"
onClick={() => refetch()}
disabled={isLoading}
>
{isLoading ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<RefreshCw className="size-3.5" />
)}
Refresh
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Refresh dashboard data</p>
</TooltipContent>
</Tooltip>
</div>
{error && (
<div className="flex items-center gap-2 rounded-lg border border-red-200/30 bg-red-500/10 px-4 py-3 text-red-500">
<AlertCircle className="size-4" />
<span className="text-sm">{error}</span>
</div>
)}
<SystemHealthCards
data={data}
isLoading={isLoading}
isPurging={isPurging}
onPurge={handlePurge}
purgeResult={purgeResult}
/>
<ProviderStatus data={data} isLoading={isLoading} />
{data?.config && (
<div className="rounded-lg border border-border bg-card px-4 py-3">
<span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
System Configuration
</span>
<div className="mt-2 flex flex-wrap gap-4 text-xs text-muted-foreground">
<span>
Max file size:{" "}
<strong className="text-foreground">
{data.config.max_file_size_mb}MB
</strong>
</span>
<span>
Translation service:{" "}
<strong className="text-foreground">
{data.config.translation_service}
</strong>
</span>
<span>
Formats:{" "}
<strong className="text-foreground">
{data.config.supported_extensions.join(", ")}
</strong>
</span>
</div>
</div>
)}
</div>
}>
<AdminContent />
</Suspense>
</TooltipProvider>
);
}

View File

@@ -0,0 +1,574 @@
"use client";
import { useState, useEffect } from "react";
import { Settings, Save, Loader2, CheckCircle, XCircle, RefreshCw, FlaskConical, KeyRound } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Badge } from "@/components/ui/badge";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useNotification } from "@/components/ui/notification";
import { useTranslationStore } from "@/lib/store";
import { API_BASE } from "@/lib/config";
interface ProviderConfig {
enabled: boolean;
api_key?: string;
base_url?: string;
model?: string;
timeout?: number;
max_retries?: number;
}
interface SettingsConfig {
google: ProviderConfig;
deepl: ProviderConfig;
openai: ProviderConfig;
ollama: ProviderConfig;
openrouter: ProviderConfig;
openrouter_premium: ProviderConfig;
zai: ProviderConfig;
fallback_chain: string;
fallback_chain_classic: string;
fallback_chain_llm: string;
}
interface EnvInfo {
deepl: boolean;
openai: boolean;
openrouter: boolean;
openrouter_premium: boolean;
zai: boolean;
ollama: boolean;
}
interface OllamaModel {
name: string;
size: number;
modified_at: string;
}
const defaultConfig: SettingsConfig = {
google: { enabled: true, timeout: 30, max_retries: 3 },
deepl: { enabled: false, api_key: "", timeout: 30, max_retries: 3 },
openai: { enabled: false, api_key: "", timeout: 60, max_retries: 3 },
ollama: { enabled: false, base_url: "http://localhost:11434", model: "llama3" },
openrouter: { enabled: false, api_key: "", model: "deepseek/deepseek-chat" },
openrouter_premium: { enabled: false, api_key: "", model: "openai/gpt-4o-mini" },
zai: { enabled: false, api_key: "", base_url: "https://api.x.ai/v1", model: "grok-2-1212" },
fallback_chain: "google,deepl,openai,ollama,openrouter,openrouter_premium,zai",
fallback_chain_classic: "google,deepl",
fallback_chain_llm: "ollama,openai,openrouter,zai",
};
const defaultEnvInfo: EnvInfo = {
deepl: false,
openai: false,
openrouter: false,
openrouter_premium: false,
zai: false,
ollama: false,
};
export default function AdminSettingsPage() {
const [config, setConfig] = useState<SettingsConfig>(defaultConfig);
const [envInfo, setEnvInfo] = useState<EnvInfo>(defaultEnvInfo);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [testResults, setTestResults] = useState<Record<string, "ok" | "error" | "testing" | "idle">>({});
const [testMessages, setTestMessages] = useState<Record<string, string>>({});
const [ollamaModels, setOllamaModels] = useState<OllamaModel[]>([]);
const [isLoadingModels, setIsLoadingModels] = useState(false);
const { success, error, info } = useNotification();
useEffect(() => {
loadConfig();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const getToken = () => useTranslationStore.getState().settings.adminToken ?? "";
const loadConfig = async () => {
setIsLoading(true);
try {
const response = await fetch(`${API_BASE}/api/v1/admin/settings`, {
headers: { Authorization: `Bearer ${getToken()}` },
});
if (response.ok) {
const envelope = await response.json();
// API returns { data: {...settings...}, env_info: {...}, meta: {} }
const payload = envelope.data ?? envelope;
setConfig({ ...defaultConfig, ...payload });
if (envelope.env_info) {
setEnvInfo({ ...defaultEnvInfo, ...envelope.env_info });
}
} else {
error({ title: "Erreur de chargement", description: `HTTP ${response.status} — vérifiez votre token admin.` });
}
} catch (e) {
error({ title: "Erreur réseau", description: "Impossible de contacter le backend." });
console.error("Failed to load settings:", e);
} finally {
setIsLoading(false);
}
};
const saveConfig = async () => {
setIsSaving(true);
try {
const response = await fetch(`${API_BASE}/api/v1/admin/settings`, {
method: "PUT",
headers: {
Authorization: `Bearer ${getToken()}`,
"Content-Type": "application/json",
},
body: JSON.stringify(config),
});
if (response.ok) {
success({ title: "✅ Configuration sauvegardée", description: "Les paramètres ont été enregistrés avec succès." });
} else {
const body = await response.json().catch(() => ({}));
error({ title: "Erreur de sauvegarde", description: body.detail || `HTTP ${response.status}` });
}
} catch (e) {
error({ title: "Erreur réseau", description: "Impossible de contacter le backend pour la sauvegarde." });
} finally {
setIsSaving(false);
}
};
const testProvider = async (provider: string) => {
setTestResults((prev) => ({ ...prev, [provider]: "testing" }));
setTestMessages((prev) => ({ ...prev, [provider]: "" }));
try {
const response = await fetch(
`${API_BASE}/api/v1/admin/providers/${provider}/test`,
{
method: "POST",
headers: { Authorization: `Bearer ${getToken()}` },
}
);
const data = await response.json();
if (data.available) {
setTestResults((prev) => ({ ...prev, [provider]: "ok" }));
const detail = data.test_result || data.usage || data.models_count !== undefined
? `Connexion OK${data.models_count !== undefined ? `${data.models_count} modèles` : ""}${data.test_result ? ` — "${data.test_result}"` : ""}`
: "Connexion OK";
setTestMessages((prev) => ({ ...prev, [provider]: detail }));
} else {
setTestResults((prev) => ({ ...prev, [provider]: "error" }));
setTestMessages((prev) => ({ ...prev, [provider]: data.error || "Échec" }));
}
} catch (e) {
setTestResults((prev) => ({ ...prev, [provider]: "error" }));
setTestMessages((prev) => ({ ...prev, [provider]: "Erreur réseau" }));
}
};
const fetchOllamaModels = async () => {
setIsLoadingModels(true);
try {
const response = await fetch(
`${API_BASE}/api/v1/admin/providers/ollama/models`,
{ headers: { Authorization: `Bearer ${getToken()}` } }
);
if (response.ok) {
const data = await response.json();
setOllamaModels(data.data || []);
if (data.data?.length > 0 && !config.ollama.model) {
updateProvider("ollama", { model: data.data[0].name });
}
info({ title: `${data.data?.length || 0} modèles Ollama trouvés` });
} else {
error({ title: "Ollama inaccessible", description: "Vérifiez que Ollama tourne sur l'URL configurée." });
}
} catch (e) {
error({ title: "Erreur Ollama", description: "Impossible de contacter Ollama." });
} finally {
setIsLoadingModels(false);
}
};
type ProviderKey = keyof Omit<SettingsConfig, "fallback_chain" | "fallback_chain_classic" | "fallback_chain_llm">;
const updateProvider = (provider: ProviderKey, updates: Partial<ProviderConfig>) => {
setConfig((prev) => ({
...prev,
[provider]: { ...prev[provider], ...updates } as ProviderConfig,
}));
};
if (isLoading) {
return (
<div className="flex items-center justify-center py-12">
<Loader2 className="size-8 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-purple-600/20 rounded-lg flex items-center justify-center">
<Settings className="w-5 h-5 text-purple-400" />
</div>
<div>
<h1 className="text-xl font-semibold text-foreground">Paramètres des providers</h1>
<p className="text-sm text-muted-foreground">
Configurez les clés API. Les clés peuvent aussi être définies dans le fichier <code>.env</code>.
</p>
</div>
</div>
<div className="grid gap-4">
<ProviderCard
title="Google Translate"
description="Tier gratuit : 500 000 caractères/mois. Aucune clé requise."
enabled={config.google.enabled}
onToggle={(enabled) => updateProvider("google", { enabled })}
onTest={() => testProvider("google")}
testResult={testResults.google ?? "idle"}
testMessage={testMessages.google}
noApiKey
/>
<ProviderCard
title="DeepL"
description="Traduction professionnelle. Obtenez une clé sur deepl.com/pro-api"
enabled={config.deepl.enabled}
onToggle={(enabled) => updateProvider("deepl", { enabled })}
onTest={() => testProvider("deepl")}
testResult={testResults.deepl ?? "idle"}
testMessage={testMessages.deepl}
envKeySet={envInfo.deepl}
>
<div className="space-y-2">
<Label htmlFor="deepl-key">Clé API</Label>
<Input
id="deepl-key"
type="password"
placeholder={envInfo.deepl ? "Clé configurée dans .env (laisser vide pour l'utiliser)" : "Entrez votre clé DeepL"}
value={config.deepl.api_key || ""}
onChange={(e) => updateProvider("deepl", { api_key: e.target.value })}
/>
</div>
</ProviderCard>
<ProviderCard
title="OpenAI"
description="Traductions GPT-4. Obtenez une clé sur platform.openai.com"
enabled={config.openai.enabled}
onToggle={(enabled) => updateProvider("openai", { enabled })}
onTest={() => testProvider("openai")}
testResult={testResults.openai ?? "idle"}
testMessage={testMessages.openai}
envKeySet={envInfo.openai}
>
<div className="space-y-2">
<Label htmlFor="openai-key">Clé API</Label>
<Input
id="openai-key"
type="password"
placeholder={envInfo.openai ? "Clé configurée dans .env (laisser vide pour l'utiliser)" : "sk-..."}
value={config.openai.api_key || ""}
onChange={(e) => updateProvider("openai", { api_key: e.target.value })}
/>
</div>
</ProviderCard>
<ProviderCard
title="Ollama"
description="LLM local. Nécessite Ollama en cours d'exécution."
enabled={config.ollama.enabled}
onToggle={(enabled) => updateProvider("ollama", { enabled })}
onTest={() => testProvider("ollama")}
testResult={testResults.ollama ?? "idle"}
testMessage={testMessages.ollama}
envKeySet={envInfo.ollama}
>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="ollama-url">URL de base</Label>
<Input
id="ollama-url"
placeholder={envInfo.ollama ? "URL configurée dans .env" : "http://localhost:11434"}
value={config.ollama.base_url || ""}
onChange={(e) => updateProvider("ollama", { base_url: e.target.value })}
/>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="ollama-model">Modèle</Label>
<Button
variant="ghost"
size="sm"
onClick={fetchOllamaModels}
disabled={isLoadingModels}
className="h-7 px-2 text-xs"
>
{isLoadingModels ? (
<Loader2 className="size-3 animate-spin" />
) : (
<RefreshCw className="size-3" />
)}
<span className="ml-1">Récupérer les modèles</span>
</Button>
</div>
{ollamaModels.length > 0 ? (
<Select
value={config.ollama.model || ""}
onValueChange={(value) => updateProvider("ollama", { model: value })}
>
<SelectTrigger>
<SelectValue placeholder="Sélectionnez un modèle" />
</SelectTrigger>
<SelectContent>
{ollamaModels.map((model) => (
<SelectItem key={model.name} value={model.name}>
{model.name}
{model.size > 0 && (
<span className="ml-2 text-xs text-muted-foreground">
({(model.size / 1e9).toFixed(1)} GB)
</span>
)}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
id="ollama-model"
placeholder="llama3"
value={config.ollama.model || ""}
onChange={(e) => updateProvider("ollama", { model: e.target.value })}
/>
)}
{ollamaModels.length === 0 && (
<p className="text-xs text-muted-foreground">
Cliquez sur &quot;Récupérer les modèles&quot; pour charger la liste depuis Ollama.
</p>
)}
</div>
</div>
</ProviderCard>
<ProviderCard
title="Traduction IA Essentielle"
description="Affichée aux utilisateurs comme 'Traduction IA Essentielle'. Modèles économiques recommandés : deepseek/deepseek-chat, google/gemini-2.0-flash, meta-llama/llama-3.3-70b-instruct. Clé API : openrouter.ai"
enabled={config.openrouter.enabled}
onToggle={(enabled) => updateProvider("openrouter", { enabled })}
onTest={() => testProvider("openrouter")}
testResult={testResults.openrouter ?? "idle"}
testMessage={testMessages.openrouter}
envKeySet={envInfo.openrouter}
>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="openrouter-key">Clé API OpenRouter</Label>
<Input
id="openrouter-key"
type="password"
placeholder={envInfo.openrouter ? "Clé configurée dans .env (partagée avec Premium)" : "sk-or-..."}
value={config.openrouter.api_key || ""}
onChange={(e) => updateProvider("openrouter", { api_key: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="openrouter-model">Modèle Essentiel</Label>
<Input
id="openrouter-model"
placeholder="deepseek/deepseek-chat"
value={config.openrouter.model || ""}
onChange={(e) => updateProvider("openrouter", { model: e.target.value })}
/>
<p className="text-xs text-muted-foreground">Recommandé : <code>deepseek/deepseek-chat</code> (~0.04/doc)</p>
</div>
</div>
</ProviderCard>
<ProviderCard
title="Traduction IA Premium"
description="Affichée aux utilisateurs comme 'Traduction IA Premium'. Modèles haute qualité : openai/gpt-4o, anthropic/claude-3.5-sonnet, google/gemini-1.5-pro. Partage la même clé OpenRouter."
enabled={config.openrouter_premium.enabled}
onToggle={(enabled) => updateProvider("openrouter_premium", { enabled })}
onTest={() => testProvider("openrouter_premium")}
testResult={testResults.openrouter_premium ?? "idle"}
testMessage={testMessages.openrouter_premium}
envKeySet={envInfo.openrouter_premium}
>
<div className="space-y-2">
<Label htmlFor="openrouter-premium-model">Modèle Premium</Label>
<Input
id="openrouter-premium-model"
placeholder="openai/gpt-4o-mini"
value={config.openrouter_premium.model || ""}
onChange={(e) => updateProvider("openrouter_premium", { model: e.target.value })}
/>
<p className="text-xs text-muted-foreground">
Recommandé : <code>openai/gpt-4o-mini</code> (~0.15/doc) ou <code>anthropic/claude-3.5-haiku</code> (~0.20/doc)
</p>
</div>
</ProviderCard>
<ProviderCard
title="z.AI / xAI Grok"
description="Modèles Grok par xAI. API compatible OpenAI. Obtenez votre clé sur x.ai"
enabled={config.zai.enabled}
onToggle={(enabled) => updateProvider("zai", { enabled })}
onTest={() => testProvider("zai")}
testResult={testResults.zai ?? "idle"}
testMessage={testMessages.zai}
envKeySet={envInfo.zai}
>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="zai-key">Clé API</Label>
<Input
id="zai-key"
type="password"
placeholder={envInfo.zai ? "Clé configurée dans .env (laisser vide pour l'utiliser)" : "xai-..."}
value={config.zai.api_key || ""}
onChange={(e) => updateProvider("zai", { api_key: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="zai-model">Modèle</Label>
<Input
id="zai-model"
placeholder="grok-2-1212"
value={config.zai.model || ""}
onChange={(e) => updateProvider("zai", { model: e.target.value })}
/>
</div>
</div>
<div className="mt-3 space-y-2">
<Label htmlFor="zai-url">URL de base</Label>
<Input
id="zai-url"
placeholder="https://api.x.ai/v1"
value={config.zai.base_url || ""}
onChange={(e) => updateProvider("zai", { base_url: e.target.value })}
/>
<p className="text-xs text-muted-foreground">
Par défaut : <code>https://api.x.ai/v1</code> — à changer uniquement si vous utilisez un proxy.
</p>
</div>
</ProviderCard>
<Card>
<CardHeader>
<CardTitle className="text-base">Chaîne de fallback</CardTitle>
<CardDescription>Ordre de priorité pour la sélection des providers</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>Mode classique (Google/DeepL)</Label>
<Input
value={config.fallback_chain_classic}
onChange={(e) => setConfig((prev) => ({ ...prev, fallback_chain_classic: e.target.value }))}
placeholder="google,deepl"
/>
</div>
<div className="space-y-2">
<Label>Mode LLM (Ollama/OpenAI)</Label>
<Input
value={config.fallback_chain_llm}
onChange={(e) => setConfig((prev) => ({ ...prev, fallback_chain_llm: e.target.value }))}
placeholder="ollama,openai"
/>
</div>
</CardContent>
</Card>
</div>
<div className="flex justify-end">
<Button onClick={saveConfig} disabled={isSaving} size="lg">
{isSaving ? (
<>
<Loader2 className="mr-2 size-4 animate-spin" />
Sauvegarde...
</>
) : (
<>
<Save className="mr-2 size-4" />
Sauvegarder la configuration
</>
)}
</Button>
</div>
</div>
);
}
function ProviderCard({
title,
description,
enabled,
onToggle,
onTest,
testResult,
testMessage,
noApiKey = false,
envKeySet = false,
children,
}: {
title: string;
description: string;
enabled: boolean;
onToggle: (enabled: boolean) => void;
onTest: () => void;
testResult: "ok" | "error" | "testing" | "idle";
testMessage?: string;
noApiKey?: boolean;
envKeySet?: boolean;
children?: React.ReactNode;
}) {
return (
<Card className={enabled ? "border-primary/30" : ""}>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<CardTitle className="text-base">{title}</CardTitle>
<Badge variant={enabled ? "default" : "secondary"} className="text-xs">
{enabled ? "Activé" : "Désactivé"}
</Badge>
{envKeySet && !noApiKey && (
<Badge variant="outline" className="text-xs gap-1 border-green-500/40 text-green-400">
<KeyRound className="size-3" />
Clé dans .env
</Badge>
)}
</div>
<div className="flex items-center gap-3">
<Button
variant="outline"
size="sm"
onClick={onTest}
disabled={testResult === "testing"}
className="h-8"
>
{testResult === "testing" ? (
<><Loader2 className="size-3 animate-spin mr-1" />Test...</>
) : testResult === "ok" ? (
<><CheckCircle className="size-3 text-green-500 mr-1" />OK</>
) : testResult === "error" ? (
<><XCircle className="size-3 text-red-500 mr-1" />Erreur</>
) : (
<><FlaskConical className="size-3 mr-1" />Tester</>
)}
</Button>
<Switch checked={enabled} onCheckedChange={onToggle} />
</div>
</div>
<CardDescription>{description}</CardDescription>
{testMessage && (
<p className={`text-xs mt-1 ${testResult === "ok" ? "text-green-400" : "text-red-400"}`}>
{testMessage}
</p>
)}
</CardHeader>
{!noApiKey && children && <CardContent className="pt-0">{children}</CardContent>}
</Card>
);
}

View File

@@ -0,0 +1,126 @@
"use client";
import { useState } from "react";
import { BarChart3, RefreshCw, Loader2, AlertCircle, Info } from "lucide-react";
import { Button } from "@/components/ui/button";
import { StatsOverview } from "../StatsOverview";
import { TopUsersTable } from "../TopUsersTable";
import { ProviderBreakdownChart } from "../ProviderBreakdownChart";
import { FormatBreakdownChart } from "../FormatBreakdownChart";
import { DateRangeFilter } from "../DateRangeFilter";
import { useTranslationStats } from "../useTranslationStats";
import type { StatsPeriod } from "../types";
import {
TooltipProvider,
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
const ERROR_RATE_WARNING_THRESHOLD = 5;
export default function StatsPage() {
const [period, setPeriod] = useState<StatsPeriod>("today");
const { data, isLoading, error, refetch, isMockData } = useTranslationStats(period);
return (
<TooltipProvider>
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex size-10 items-center justify-center rounded-lg bg-blue-500/10">
<BarChart3 className="size-5 text-blue-500" />
</div>
<div>
<h1 className="text-xl font-semibold text-foreground">
Statistiques de Traduction
</h1>
<p className="text-sm text-muted-foreground">
Analyse des traductions et patterns d&apos;utilisation
</p>
</div>
</div>
<div className="flex items-center gap-3">
<DateRangeFilter value={period} onChange={setPeriod} />
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="sm"
className="gap-1.5"
onClick={() => refetch()}
disabled={isLoading}
>
{isLoading ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<RefreshCw className="size-3.5" />
)}
Actualiser
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Actualiser les statistiques</p>
</TooltipContent>
</Tooltip>
</div>
</div>
{error && (
<div className="flex items-center gap-2 rounded-lg border border-red-200/30 bg-red-500/10 px-4 py-3 text-red-500">
<AlertCircle className="size-4" />
<span className="text-sm">{error}</span>
</div>
)}
<StatsOverview data={data} isLoading={isLoading} />
<div className="grid gap-6 md:grid-cols-2">
<ProviderBreakdownChart data={data} isLoading={isLoading} />
<FormatBreakdownChart data={data} isLoading={isLoading} />
</div>
<TopUsersTable
topUsers={data?.top_users ?? []}
isLoading={isLoading}
/>
{data && (
<div className="rounded-lg border border-border bg-card px-4 py-3">
<span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
Informations
</span>
<div className="mt-2 flex flex-wrap gap-4 text-xs text-muted-foreground">
{isMockData && (
<span className="flex items-center gap-1 text-amber-500">
<Info className="h-3 w-3" />
<strong>Mode Démo</strong> - Données simulées
</span>
)}
<span>
Rafraîchissement auto:{" "}
<strong className="text-foreground">toutes les 30 secondes</strong>
</span>
<span>
Période:{" "}
<strong className="text-foreground">
{period === "today"
? "Aujourd'hui"
: period === "week"
? "7 derniers jours"
: "30 derniers jours"}
</strong>
</span>
{data.error_rate > ERROR_RATE_WARNING_THRESHOLD && (
<span className="text-orange-500">
Taux d&apos;erreur élevé détecté ({data.error_rate.toFixed(1)}%)
</span>
)}
</div>
</div>
)}
</div>
</TooltipProvider>
);
}

View File

@@ -0,0 +1,61 @@
"use client";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Trash2, Loader2 } from "lucide-react";
import type { CleanupResponse } from "../types";
interface CleanupSectionProps {
trackedFilesCount: number;
isPurging: boolean;
purgeResult: CleanupResponse | null;
onCleanup: () => void;
}
export function CleanupSection({
trackedFilesCount,
isPurging,
purgeResult,
onCleanup,
}: CleanupSectionProps) {
return (
<Card className="py-0">
<CardContent className="flex items-center gap-3 px-4 py-3">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-red-500/10">
<Trash2 className="size-4 text-red-500" />
</div>
<div className="flex flex-1 flex-col gap-0.5">
<span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
Fichiers Temporaires
</span>
<span className="text-sm font-semibold tabular-nums text-foreground">
{trackedFilesCount} fichier{trackedFilesCount !== 1 ? "s" : ""} orphelin{trackedFilesCount !== 1 ? "s" : ""}
</span>
{purgeResult && (
<span className="text-[10px] text-green-500">
{purgeResult.files_cleaned} fichier{purgeResult.files_cleaned !== 1 ? "s" : ""} supprimé{purgeResult.files_cleaned !== 1 ? "s" : ""}
</span>
)}
</div>
<Button
variant="outline"
size="sm"
className="h-7 shrink-0 gap-1.5 border-red-200/30 text-red-500 hover:bg-red-500/10 hover:text-red-500 text-xs"
onClick={onCleanup}
disabled={isPurging || trackedFilesCount === 0}
>
{isPurging ? (
<Loader2 className="size-3 animate-spin" />
) : (
<Trash2 className="size-3" />
)}
{isPurging
? "Nettoyage..."
: trackedFilesCount === 0
? "Propre"
: "Nettoyer"}
</Button>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,51 @@
"use client";
import { Card, CardContent } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
import { HardDrive } from "lucide-react";
interface DiskSpaceCardProps {
usedPercent?: number;
totalGb?: number;
freeGb?: number;
}
export function DiskSpaceCard({
usedPercent = 0,
totalGb,
freeGb,
}: DiskSpaceCardProps) {
return (
<Card className="py-0">
<CardContent className="flex items-center gap-3 px-4 py-3">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-blue-500/10">
<HardDrive className="size-4 text-blue-500" />
</div>
<div className="flex flex-1 flex-col gap-1">
<span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
Espace Disque
</span>
<div className="flex items-center justify-between">
<span className="text-sm font-semibold tabular-nums text-foreground">
{usedPercent.toFixed(1)}% utilisé
</span>
{totalGb !== undefined && (
<span className="text-[10px] tabular-nums text-muted-foreground">
{totalGb} GB total
</span>
)}
</div>
<Progress
value={usedPercent}
className="h-1.5 bg-muted [&>[data-slot=progress-indicator]]:bg-blue-500"
/>
{freeGb !== undefined && (
<span className="text-[10px] text-muted-foreground">
{freeGb.toFixed(1)} GB libres
</span>
)}
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,61 @@
"use client";
import { Settings, AlertCircle, Loader2 } from "lucide-react";
import { useSystemPage } from "./useSystemPage";
import { CleanupSection } from "./CleanupSection";
import { DiskSpaceCard } from "./DiskSpaceCard";
import { ProviderStatus } from "../ProviderStatus";
export default function AdminSystemPage() {
const { data, isLoading, error, isPurging, purgeResult, handleCleanup } = useSystemPage();
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-purple-600/20 rounded-lg flex items-center justify-center">
<Settings className="w-5 h-5 text-purple-400" />
</div>
<div>
<h1 className="text-xl font-semibold text-foreground">Système</h1>
<p className="text-sm text-muted-foreground">
Surveiller l'état du système et gérer les ressources
</p>
</div>
</div>
{error && (
<div className="flex items-center gap-2 rounded-lg border border-red-200/30 bg-red-500/10 px-4 py-3 text-red-500">
<AlertCircle className="size-4" />
<span className="text-sm">{error}</span>
</div>
)}
{isLoading && !data ? (
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
{[1, 2].map((i) => (
<div
key={i}
className="h-[88px] animate-pulse rounded-lg border border-border bg-card"
/>
))}
</div>
) : (
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
<DiskSpaceCard
usedPercent={data?.system?.disk?.used_percent}
totalGb={data?.system?.disk?.total_gb}
freeGb={data?.system?.disk?.free_gb}
/>
<CleanupSection
trackedFilesCount={data?.cleanup?.tracked_files_count ?? 0}
isPurging={isPurging}
purgeResult={purgeResult}
onCleanup={handleCleanup}
/>
</div>
)}
<ProviderStatus data={data} isLoading={isLoading} />
</div>
);
}

View File

@@ -0,0 +1,20 @@
"use client";
import { useAdminDashboard } from "../useAdminDashboard";
import { useCleanup } from "../useCleanup";
export function useSystemPage() {
const { data, isLoading, error } = useAdminDashboard();
const { isPurging, purgeResult, error: cleanupError, triggerCleanup } = useCleanup();
const handleCleanup = () => triggerCleanup();
return {
data,
isLoading,
error: error || cleanupError,
isPurging,
purgeResult,
handleCleanup,
};
}

View File

@@ -0,0 +1,76 @@
export interface AdminDashboardData {
timestamp: string;
status: "healthy" | "unhealthy";
system: {
memory: Record<string, unknown>;
disk: {
used_percent?: number;
total_gb?: number;
free_gb?: number;
};
};
providers: Record<string, ProviderStatus>;
cleanup: {
files_cleaned: number;
tracked_files_count: number;
};
rate_limits: {
active_clients: number;
};
config: {
max_file_size_mb: number;
supported_extensions: string[];
translation_service: string;
};
}
export interface ProviderStatus {
name: string;
available: boolean;
last_check: string | null;
latency_ms?: number;
error?: string;
}
export interface CleanupResponse {
status: "success" | "error";
files_cleaned: number;
message: string;
}
export type StatsPeriod = "today" | "week" | "month";
export interface ProviderBreakdownItem {
count: number;
percentage: number;
}
export interface FormatBreakdownItem {
count: number;
percentage: number;
}
export interface TopUser {
user_id: string;
email: string;
translation_count: number;
}
export interface TranslationStatsData {
period: StatsPeriod;
total_translations: number;
total_translations_last_period: number;
error_rate: number;
error_count: number;
success_count: number;
top_users: TopUser[];
provider_breakdown: Record<string, ProviderBreakdownItem>;
format_breakdown: Record<string, FormatBreakdownItem>;
}
export interface TranslationStatsResponse {
data: TranslationStatsData;
meta: {
generated_at: string;
};
}

View File

@@ -0,0 +1,97 @@
"use client";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useTranslationStore } from "@/lib/store";
import { API_BASE } from "@/lib/config";
import type { AdminDashboardData } from "./types";
const TIMEOUT_MS = 15000;
export const REFETCH_INTERVAL_MS = 30000;
export const QUERY_KEY = ["admin", "dashboard"];
async function fetchDashboardData(adminToken: string | null | undefined): Promise<AdminDashboardData> {
if (!adminToken) {
throw new Error("AUTH_REQUIRED");
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), TIMEOUT_MS);
try {
const response = await fetch(`${API_BASE}/api/v1/admin/dashboard`, {
headers: {
Authorization: `Bearer ${adminToken}`,
},
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
if (response.status === 401) {
throw new Error("UNAUTHORIZED");
}
throw new Error(`HTTP_ERROR_${response.status}`);
}
return await response.json();
} catch (err) {
clearTimeout(timeoutId);
throw err;
}
}
export function useAdminDashboard() {
const { settings } = useTranslationStore();
const queryClient = useQueryClient();
const { data, isLoading, error, refetch } = useQuery({
queryKey: QUERY_KEY,
queryFn: () => fetchDashboardData(settings.adminToken),
enabled: !!settings.adminToken,
refetchInterval: REFETCH_INTERVAL_MS,
staleTime: 10000,
retry: 1,
});
const getErrorMessage = (err: Error | null): string | null => {
if (!err) return null;
const errorMap: Record<string, string> = {
AUTH_REQUIRED: "Veuillez vous connecter pour accéder au tableau de bord",
UNAUTHORIZED: "Session expirée. Veuillez vous reconnecter.",
HTTP_ERROR_403: "Accès refusé. Droits administrateur requis.",
HTTP_ERROR_404: "Service indisponible. Veuillez réessayer plus tard.",
HTTP_ERROR_500: "Erreur serveur. Veuillez réessayer plus tard.",
HTTP_ERROR_502: "Service temporairement indisponible.",
HTTP_ERROR_503: "Service en maintenance. Veuillez réessayer plus tard.",
};
const code = err.message;
if (errorMap[code]) {
return errorMap[code];
}
if (err.name === "AbortError") {
return "Le serveur met trop de temps à répondre. Veuillez réessayer.";
}
if (err.message.includes("fetch") || err.message.includes("network")) {
return "Impossible de se connecter au serveur. Vérifiez votre connexion.";
}
return "Une erreur inattendue s'est produite. Veuillez réessayer.";
};
const errorMessage = error ? getErrorMessage(error as Error) : null;
return {
data: data ?? null,
isLoading,
error: errorMessage,
refetch,
queryClient,
queryKey: QUERY_KEY,
};
}

View File

@@ -0,0 +1,87 @@
"use client";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslationStore } from "@/lib/store";
import { API_BASE } from "@/lib/config";
import type { CleanupResponse } from "./types";
import { QUERY_KEY as DASHBOARD_QUERY_KEY } from "./useAdminDashboard";
const TIMEOUT_MS = 15000;
async function triggerCleanupApi(adminToken: string | null | undefined): Promise<CleanupResponse> {
if (!adminToken) {
throw new Error("AUTH_REQUIRED");
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), TIMEOUT_MS);
try {
const response = await fetch(`${API_BASE}/api/v1/admin/cleanup/trigger`, {
method: "POST",
headers: {
Authorization: `Bearer ${adminToken}`,
},
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
if (response.status === 401) {
throw new Error("UNAUTHORIZED");
}
throw new Error(`HTTP_ERROR_${response.status}`);
}
return await response.json();
} catch (err) {
clearTimeout(timeoutId);
throw err;
}
}
export function useCleanup() {
const { settings } = useTranslationStore();
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: () => triggerCleanupApi(settings.adminToken),
onSuccess: () => {
// Invalidate dashboard cache after successful cleanup
queryClient.invalidateQueries({ queryKey: DASHBOARD_QUERY_KEY });
},
});
// Map error codes to user-friendly messages
const getErrorMessage = (err: Error | null): string | null => {
if (!err) return null;
const errorMap: Record<string, string> = {
AUTH_REQUIRED: "Veuillez vous connecter pour effectuer cette action",
UNAUTHORIZED: "Session expirée. Veuillez vous reconnecter.",
HTTP_ERROR_403: "Accès refusé. Droits administrateur requis.",
HTTP_ERROR_500: "Erreur serveur lors du nettoyage. Veuillez réessayer.",
};
const code = err.message;
if (errorMap[code]) {
return errorMap[code];
}
if (err.name === "AbortError") {
return "Le serveur met trop de temps à répondre. Veuillez réessayer.";
}
return "Erreur lors du nettoyage. Veuillez réessayer.";
};
const errorMessage = mutation.error ? getErrorMessage(mutation.error as Error) : null;
return {
isPurging: mutation.isPending,
purgeResult: mutation.data ?? null,
error: errorMessage,
triggerCleanup: mutation.mutateAsync,
};
}

View File

@@ -0,0 +1,159 @@
"use client";
import React from "react";
import { useQuery } from "@tanstack/react-query";
import { useTranslationStore } from "@/lib/store";
import { API_BASE } from "@/lib/config";
import type { StatsPeriod, TranslationStatsResponse } from "./types";
const TIMEOUT_MS = 15000;
export const REFETCH_INTERVAL_MS = 30000;
export const QUERY_KEY = (period: StatsPeriod) => ["admin", "stats", "translations", period];
async function fetchTranslationStats(
adminToken: string | null | undefined,
period: StatsPeriod
): Promise<TranslationStatsResponse> {
if (!adminToken) {
throw new Error("AUTH_REQUIRED");
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), TIMEOUT_MS);
try {
const response = await fetch(
`${API_BASE}/api/v1/admin/stats/translations?period=${period}`,
{
headers: {
Authorization: `Bearer ${adminToken}`,
},
signal: controller.signal,
}
);
clearTimeout(timeoutId);
if (!response.ok) {
if (response.status === 401) {
throw new Error("UNAUTHORIZED");
}
if (response.status === 404) {
throw new Error("ENDPOINT_NOT_FOUND");
}
throw new Error(`HTTP_ERROR_${response.status}`);
}
return await response.json();
} catch (err) {
clearTimeout(timeoutId);
throw err;
}
}
function getMockData(period: StatsPeriod): TranslationStatsResponse {
const baseCount = period === "today" ? 42 : period === "week" ? 287 : 1156;
const lastPeriodCount = period === "today" ? 38 : period === "week" ? 254 : 1023;
return {
data: {
period,
total_translations: baseCount,
total_translations_last_period: lastPeriodCount,
error_rate: 2.3,
error_count: Math.floor(baseCount * 0.023),
success_count: Math.floor(baseCount * 0.977),
top_users: [
{ user_id: "user_1", email: "sarah.chen@acme.com", translation_count: 15 },
{ user_id: "user_2", email: "marc.dubois@example.fr", translation_count: 12 },
{ user_id: "user_3", email: "anna.mueller@corp.de", translation_count: 8 },
{ user_id: "user_4", email: "john.smith@company.uk", translation_count: 6 },
{ user_id: "user_5", email: "lisa.wong@startup.io", translation_count: 5 },
{ user_id: "user_6", email: "pierre.leroux@mail.fr", translation_count: 4 },
{ user_id: "user_7", email: "emma.johnson@tech.us", translation_count: 3 },
{ user_id: "user_8", email: "klaus.weber@firm.de", translation_count: 2 },
{ user_id: "user_9", email: "sofia.garcia@empresa.es", translation_count: 2 },
{ user_id: "user_10", email: "yuki.tanaka@office.jp", translation_count: 1 },
],
provider_breakdown: {
google: { count: Math.floor(baseCount * 0.476), percentage: 47.6 },
deepl: { count: Math.floor(baseCount * 0.357), percentage: 35.7 },
ollama: { count: Math.floor(baseCount * 0.119), percentage: 11.9 },
openai: { count: Math.floor(baseCount * 0.048), percentage: 4.8 },
},
format_breakdown: {
xlsx: { count: Math.floor(baseCount * 0.595), percentage: 59.5 },
docx: { count: Math.floor(baseCount * 0.286), percentage: 28.6 },
pptx: { count: Math.floor(baseCount * 0.119), percentage: 11.9 },
},
},
meta: {
generated_at: new Date().toISOString(),
},
};
}
export function useTranslationStats(period: StatsPeriod = "today") {
const { settings } = useTranslationStore();
const [isMockData, setIsMockData] = React.useState(false);
const { data, isLoading, error, refetch } = useQuery({
queryKey: QUERY_KEY(period),
queryFn: async () => {
try {
const result = await fetchTranslationStats(settings.adminToken, period);
setIsMockData(false);
return result;
} catch (err) {
if ((err as Error).message === "ENDPOINT_NOT_FOUND") {
setIsMockData(true);
return getMockData(period);
}
throw err;
}
},
enabled: !!settings.adminToken,
refetchInterval: REFETCH_INTERVAL_MS,
staleTime: 10000,
retry: 1,
});
const getErrorMessage = (err: Error | null): string | null => {
if (!err) return null;
const errorMap: Record<string, string> = {
AUTH_REQUIRED: "Veuillez vous connecter pour accéder aux statistiques",
UNAUTHORIZED: "Session expirée. Veuillez vous reconnecter.",
HTTP_ERROR_403: "Accès refusé. Droits administrateur requis.",
HTTP_ERROR_500: "Erreur serveur. Veuillez réessayer plus tard.",
};
const code = err.message;
if (errorMap[code]) {
return errorMap[code];
}
if (err.name === "AbortError") {
return "Le serveur met trop de temps à répondre.";
}
if (err.message.includes("fetch") || err.message.includes("network")) {
return "Impossible de se connecter au serveur.";
}
return "Une erreur inattendue s'est produite.";
};
const errorMessage = error ? getErrorMessage(error as Error) : null;
return {
data: data?.data ?? null,
isLoading,
error: errorMessage,
refetch,
queryKey: QUERY_KEY(period),
isMockData,
};
}

View File

@@ -0,0 +1,116 @@
"use client";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Users, UserCheck, Crown, Zap } from "lucide-react";
import type { AdminUser } from "./types";
import { PLAN_LABELS } from "./types";
interface UserStatsProps {
users: AdminUser[];
total: number;
isLoading?: boolean;
}
export function UserStats({ users, total, isLoading }: UserStatsProps) {
const activeUsers = users.filter((u) => u.subscription_status === "active").length;
const proUsers = users.filter((u) => u.plan === "pro" || u.plan === "business" || u.plan === "enterprise").length;
const freeUsers = users.filter((u) => u.plan === "free" || u.plan === "starter").length;
const planDistribution = users.reduce(
(acc, user) => {
acc[user.plan] = (acc[user.plan] || 0) + 1;
return acc;
},
{} as Record<string, number>
);
if (isLoading) {
return (
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
{[1, 2, 3, 4].map((i) => (
<Card key={i} className="animate-pulse">
<CardContent className="flex items-center gap-3 p-4">
<div className="size-9 rounded-lg bg-muted" />
<div className="flex-1 space-y-1">
<div className="h-3 w-16 rounded bg-muted" />
<div className="h-5 w-10 rounded bg-muted" />
</div>
</CardContent>
</Card>
))}
</div>
);
}
return (
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
<Card className="py-0">
<CardContent className="flex items-center gap-3 px-4 py-3">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-secondary">
<Users className="size-4 text-foreground" />
</div>
<div className="flex flex-1 flex-col gap-0.5">
<span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
Total Users
</span>
<span className="text-lg font-semibold text-foreground">{total}</span>
</div>
</CardContent>
</Card>
<Card className="py-0">
<CardContent className="flex items-center gap-3 px-4 py-3">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-[oklch(0.59_0.16_145/0.1)]">
<UserCheck className="size-4 text-[oklch(0.59_0.16_145)]" />
</div>
<div className="flex flex-1 flex-col gap-0.5">
<span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
Active This Month
</span>
<span className="text-lg font-semibold text-foreground">{activeUsers}</span>
</div>
</CardContent>
</Card>
<Card className="py-0">
<CardContent className="flex items-center gap-3 px-4 py-3">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-[oklch(0.70_0.14_255/0.1)]">
<Crown className="size-4 text-[oklch(0.70_0.14_255)]" />
</div>
<div className="flex flex-1 flex-col gap-0.5">
<span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
Pro Users
</span>
<span className="text-lg font-semibold text-foreground">{proUsers}</span>
</div>
</CardContent>
</Card>
<Card className="py-0">
<CardContent className="flex items-center gap-3 px-4 py-3">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-muted">
<Zap className="size-4 text-muted-foreground" />
</div>
<div className="flex flex-1 flex-col gap-0.5">
<span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
Free Users
</span>
<span className="text-lg font-semibold text-foreground">{freeUsers}</span>
</div>
</CardContent>
</Card>
{Object.entries(planDistribution).length > 0 && (
<div className="col-span-2 flex flex-wrap items-center gap-2 md:col-span-4">
<span className="text-xs text-muted-foreground">Distribution:</span>
{Object.entries(planDistribution).map(([plan, count]) => (
<Badge key={plan} variant="outline" className="text-xs">
{PLAN_LABELS[plan as keyof typeof PLAN_LABELS] || plan}: {count}
</Badge>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,383 @@
"use client";
import { useState, useMemo } from "react";
import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
Table,
TableHeader,
TableBody,
TableHead,
TableRow,
TableCell,
} from "@/components/ui/table";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Tooltip,
TooltipTrigger,
TooltipContent,
} from "@/components/ui/tooltip";
import { Progress } from "@/components/ui/progress";
import { Input } from "@/components/ui/input";
import { Search, KeyRound, Loader2, Filter } from "lucide-react";
import type { AdminUser, PlanType } from "./types";
import { PLAN_LABELS, PLAN_TIERS } from "./types";
interface UserTableProps {
users: AdminUser[];
isLoading: boolean;
onTierChange: (userId: string, plan: PlanType) => Promise<void>;
onRevokeKeys: (userId: string, keyIds: string[]) => Promise<void>;
isUpdating: boolean;
isRevoking: boolean;
}
type TierFilter = "all" | "free" | "pro";
const statusConfig: Record<string, { label: string; dotClass: string; textClass: string }> = {
active: {
label: "Actif",
dotClass: "bg-[oklch(0.59_0.16_145)]",
textClass: "text-[oklch(0.45_0.12_145)]",
},
suspended: {
label: "Suspendu",
dotClass: "bg-destructive",
textClass: "text-destructive",
},
pending: {
label: "En attente",
dotClass: "bg-[oklch(0.75_0.18_55)]",
textClass: "text-[oklch(0.55_0.16_55)]",
},
cancelled: {
label: "Annulé",
dotClass: "bg-muted-foreground",
textClass: "text-muted-foreground",
},
};
function formatDate(dateString: string): string {
try {
const date = new Date(dateString);
return date.toLocaleDateString("fr-FR", {
day: "2-digit",
month: "short",
year: "numeric",
});
} catch {
return dateString;
}
}
export function UserTable({
users,
isLoading,
onTierChange,
onRevokeKeys,
isUpdating,
isRevoking,
}: UserTableProps) {
const [searchQuery, setSearchQuery] = useState("");
const [tierFilter, setTierFilter] = useState<TierFilter>("all");
const [revokedUsers, setRevokedUsers] = useState<Set<string>>(new Set());
const [errorUserId, setErrorUserId] = useState<string | null>(null);
const filteredUsers = useMemo(() => {
let result = users;
if (tierFilter !== "all") {
result = result.filter((user) => PLAN_TIERS[user.plan] === tierFilter);
}
if (searchQuery.trim()) {
const query = searchQuery.toLowerCase();
result = result.filter((user) => user.email.toLowerCase().includes(query));
}
return result;
}, [users, searchQuery, tierFilter]);
const handleTierChange = async (userId: string, plan: PlanType) => {
setErrorUserId(null);
try {
await onTierChange(userId, plan);
} catch {
setErrorUserId(userId);
}
};
const handleRevokeKeys = async (userId: string, keyIds: string[]) => {
setErrorUserId(null);
try {
await onRevokeKeys(userId, keyIds);
setRevokedUsers((prev) => {
const next = new Set(prev);
next.add(userId);
return next;
});
setTimeout(() => {
setRevokedUsers((prev) => {
const next = new Set(prev);
next.delete(userId);
return next;
});
}, 2000);
} catch {
setErrorUserId(userId);
}
};
const activeCount = users.filter((u) => u.subscription_status === "active").length;
const proCount = users.filter((u) => PLAN_TIERS[u.plan] === "pro").length;
const freeCount = users.filter((u) => PLAN_TIERS[u.plan] === "free").length;
if (isLoading) {
return (
<Card>
<CardContent className="py-12">
<div className="flex flex-col items-center justify-center gap-3">
<Loader2 className="size-8 animate-spin text-muted-foreground" />
<span className="text-sm text-muted-foreground">Chargement des utilisateurs...</span>
</div>
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader className="pb-3">
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div>
<CardTitle className="text-base">Gestion des Utilisateurs</CardTitle>
<CardDescription className="text-xs mt-1">
{users.length} total
<span className="mx-1.5 text-border">|</span>
{activeCount} actifs
<span className="mx-1.5 text-border">|</span>
{proCount} pro
</CardDescription>
</div>
<div className="flex flex-col gap-2 md:flex-row md:items-center">
<div className="flex items-center gap-2">
<Filter className="size-3.5 text-muted-foreground" />
<Select value={tierFilter} onValueChange={(val: TierFilter) => setTierFilter(val)}>
<SelectTrigger className="h-8 w-[100px] text-xs">
<SelectValue placeholder="Tier" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all" className="text-xs">Tous</SelectItem>
<SelectItem value="free" className="text-xs">Free</SelectItem>
<SelectItem value="pro" className="text-xs">Pro</SelectItem>
</SelectContent>
</Select>
</div>
<div className="relative w-full md:w-64">
<Search className="absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Rechercher par email..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="h-8 pl-8 text-xs"
/>
</div>
</div>
</div>
</CardHeader>
<CardContent className="px-0 pb-0">
<div className="border-t border-border overflow-x-auto">
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="h-8 pl-6 text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
Email
</TableHead>
<TableHead className="h-8 text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
Statut
</TableHead>
<TableHead className="h-8 text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
Plan
</TableHead>
<TableHead className="h-8 text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
Usage
</TableHead>
<TableHead className="h-8 text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
Clés
</TableHead>
<TableHead className="h-8 pr-6 text-right text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
Actions
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredUsers.map((user) => {
const sConfig = statusConfig[user.subscription_status] || statusConfig.pending;
const maxDocs = user.plan_limits?.docs_per_month || 100;
const usagePercent = Math.min((user.docs_translated_this_month / maxDocs) * 100, 100);
const isOverQuota = user.docs_translated_this_month > maxDocs;
const justRevoked = revokedUsers.has(user.id);
const hasError = errorUserId === user.id;
const apiKeyIds = user.api_key_ids || [];
return (
<TableRow key={user.id} className={`group ${hasError ? "bg-destructive/5" : ""}`}>
<TableCell className="pl-6 py-2">
<div className="flex flex-col gap-0.5">
<span className="text-xs font-medium text-foreground">
{user.email}
</span>
<span className="text-[10px] text-muted-foreground">
Créé le {formatDate(user.created_at)}
</span>
</div>
</TableCell>
<TableCell className="py-2">
<div className="flex items-center gap-1.5">
<span className={`size-1.5 rounded-full ${sConfig.dotClass}`} />
<span className={`text-xs font-medium ${sConfig.textClass}`}>
{sConfig.label}
</span>
</div>
</TableCell>
<TableCell className="py-2">
<Select
value={user.plan}
onValueChange={(val: PlanType) => handleTierChange(user.id, val)}
disabled={isUpdating}
>
<SelectTrigger
size="sm"
className={`h-7 w-[90px] text-xs font-semibold uppercase tracking-wider ${
PLAN_TIERS[user.plan] === "pro"
? "border-[oklch(0.59_0.16_145)/30] bg-[oklch(0.59_0.16_145)/10] text-[oklch(0.45_0.12_145)]"
: "border-border text-muted-foreground"
}`}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="free" className="text-xs">Free</SelectItem>
<SelectItem value="starter" className="text-xs">Starter</SelectItem>
<SelectItem value="pro" className="text-xs">Pro</SelectItem>
<SelectItem value="business" className="text-xs">Business</SelectItem>
<SelectItem value="enterprise" className="text-xs">Enterprise</SelectItem>
</SelectContent>
</Select>
</TableCell>
<TableCell className="py-2">
<div className="flex w-28 flex-col gap-1">
<div className="flex items-center justify-between">
<span
className={`text-[10px] font-medium tabular-nums ${
isOverQuota ? "text-destructive" : "text-muted-foreground"
}`}
>
{user.docs_translated_this_month} / {maxDocs}
</span>
{isOverQuota && (
<Badge
variant="outline"
className="h-4 border-destructive/30 bg-destructive/5 px-1 text-[9px] text-destructive"
>
Dépassement
</Badge>
)}
</div>
<Progress
value={usagePercent}
className={`h-1 bg-muted ${
isOverQuota
? "[&>[data-slot=progress-indicator]]:bg-destructive"
: usagePercent > 80
? "[&>[data-slot=progress-indicator]]:bg-[oklch(0.75_0.18_55)]"
: "[&>[data-slot=progress-indicator]]:bg-[oklch(0.59_0.16_145)]"
}`}
/>
</div>
</TableCell>
<TableCell className="py-2">
<span className="text-xs tabular-nums text-muted-foreground">
{user.api_keys_count ?? 0}
</span>
</TableCell>
<TableCell className="pr-6 py-2 text-right">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="sm"
className={`h-7 gap-1 px-2 text-[10px] ${
justRevoked
? "border-[oklch(0.59_0.16_145/0.3)] text-[oklch(0.45_0.12_145)]"
: hasError
? "border-destructive text-destructive"
: "border-destructive/30 text-destructive hover:bg-destructive/10 hover:text-destructive"
}`}
onClick={() => handleRevokeKeys(user.id, apiKeyIds)}
disabled={apiKeyIds.length === 0 || isRevoking || justRevoked}
>
<KeyRound className="size-3" />
{justRevoked ? "Révoquées" : "Révoquer"}
</Button>
</TooltipTrigger>
<TooltipContent className="text-xs">
{apiKeyIds.length === 0
? "Aucune clé active"
: `Révoquer ${apiKeyIds.length} clé${apiKeyIds.length > 1 ? "s" : ""} active${apiKeyIds.length > 1 ? "s" : ""}`}
</TooltipContent>
</Tooltip>
</TableCell>
</TableRow>
);
})}
{filteredUsers.length === 0 && (
<TableRow>
<TableCell
colSpan={6}
className="py-8 text-center text-xs text-muted-foreground"
>
{searchQuery || tierFilter !== "all"
? "Aucun utilisateur ne correspond à vos filtres."
: "Aucun utilisateur trouvé."}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<div className="flex items-center justify-between border-t border-border px-6 py-2">
<span className="text-[10px] text-muted-foreground">
Affichage de {filteredUsers.length} sur {users.length} utilisateurs
</span>
{tierFilter !== "all" && (
<Badge variant="outline" className="text-[10px]">
Filtre: {tierFilter === "pro" ? "Pro" : "Free"} ({tierFilter === "pro" ? proCount : freeCount})
</Badge>
)}
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,114 @@
"use client";
import { Users } from "lucide-react";
import { useAdminUsers } from "./useAdminUsers";
import { useUpdateUserTier } from "./useUpdateUserTier";
import { useRevokeApiKey } from "./useRevokeApiKey";
import { UserStats } from "./UserStats";
import { UserTable } from "./UserTable";
import { useToast } from "@/components/ui/toast";
import type { PlanType } from "./types";
export default function AdminUsersPage() {
const { users, total, isLoading, error, refetch } = useAdminUsers();
const { updateTier, isUpdating } = useUpdateUserTier();
const { revokeKey, isRevoking } = useRevokeApiKey();
const toast = useToast();
const handleTierChange = async (userId: string, plan: PlanType) => {
try {
await updateTier({ userId, plan });
toast.success({
title: "Plan mis à jour",
description: `Le plan a été changé vers "${plan}" avec succès.`,
});
} catch (err) {
const message = err instanceof Error ? err.message : "Erreur inconnue";
toast.error({
title: "Erreur",
description: `Impossible de mettre à jour le plan: ${message}`,
});
throw err;
}
};
const handleRevokeKeys = async (userId: string, keyIds: string[]) => {
if (!keyIds || keyIds.length === 0) {
toast.warning({
title: "Aucune clé",
description: "Cet utilisateur n'a pas de clés API actives.",
});
return;
}
try {
await Promise.all(
keyIds.map((keyId) =>
revokeKey({ keyId, reason: "Admin revocation from user management" })
)
);
toast.success({
title: "Clés révoquées",
description: `${keyIds.length} clé${keyIds.length > 1 ? "s" : ""} API ${keyIds.length > 1 ? "ont été révoquées" : "a été révoquée"} avec succès.`,
});
refetch();
} catch (err) {
const message = err instanceof Error ? err.message : "Erreur inconnue";
toast.error({
title: "Erreur",
description: `Impossible de révoquer les clés: ${message}`,
});
throw err;
}
};
if (error) {
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-blue-600/20 rounded-lg flex items-center justify-center">
<Users className="w-5 h-5 text-blue-400" />
</div>
<div>
<h1 className="text-xl font-semibold text-foreground">Gestion des Utilisateurs</h1>
<p className="text-sm text-muted-foreground">Visualiser et gérer les comptes utilisateurs</p>
</div>
</div>
<div className="bg-destructive/10 border border-destructive/30 rounded-lg p-4">
<p className="text-sm text-destructive">{error}</p>
<button
onClick={() => refetch()}
className="mt-2 text-xs text-destructive hover:underline"
>
Réessayer
</button>
</div>
</div>
);
}
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-blue-600/20 rounded-lg flex items-center justify-center">
<Users className="w-5 h-5 text-blue-400" />
</div>
<div>
<h1 className="text-xl font-semibold text-foreground">Gestion des Utilisateurs</h1>
<p className="text-sm text-muted-foreground">Visualiser et gérer les comptes utilisateurs</p>
</div>
</div>
<UserStats users={users} isLoading={isLoading} total={total} />
<UserTable
users={users}
isLoading={isLoading}
onTierChange={handleTierChange}
onRevokeKeys={handleRevokeKeys}
isUpdating={isUpdating}
isRevoking={isRevoking}
/>
</div>
);
}

View File

@@ -0,0 +1,68 @@
export interface PlanLimits {
docs_per_month: number;
max_pages_per_doc: number;
}
export interface AdminUser {
id: string;
email: string;
name: string;
plan: "free" | "starter" | "pro" | "business" | "enterprise";
subscription_status: "active" | "suspended" | "pending" | "cancelled";
docs_translated_this_month: number;
pages_translated_this_month: number;
extra_credits: number;
created_at: string;
plan_limits: PlanLimits;
api_keys_count?: number;
api_key_ids?: string[];
}
export interface AdminUsersResponse {
total: number;
users: AdminUser[];
}
export interface UpdateTierRequest {
plan: "free" | "starter" | "pro" | "business" | "enterprise";
}
export interface UpdateTierResponse {
data: {
id: string;
email: string;
name: string;
plan: string;
tier: "free" | "pro";
};
meta: Record<string, unknown>;
}
export interface RevokeApiKeyResponse {
data: {
id: string;
revoked: boolean;
revoked_at: string;
owner_user_id: string;
reason?: string;
};
meta: Record<string, unknown>;
}
export type PlanType = "free" | "starter" | "pro" | "business" | "enterprise";
export const PLAN_LABELS: Record<PlanType, string> = {
free: "Free",
starter: "Starter",
pro: "Pro",
business: "Business",
enterprise: "Enterprise",
};
export const PLAN_TIERS: Record<PlanType, "free" | "pro"> = {
free: "free",
starter: "free",
pro: "pro",
business: "pro",
enterprise: "pro",
};

View File

@@ -0,0 +1,92 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useTranslationStore } from "@/lib/store";
import { API_BASE } from "@/lib/config";
import type { AdminUsersResponse } from "./types";
export const ADMIN_TIMEOUT_MS = 15000;
export const QUERY_KEY = ["admin", "users"];
async function fetchUsers(adminToken: string | null | undefined): Promise<AdminUsersResponse> {
if (!adminToken) {
throw new Error("AUTH_REQUIRED");
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), ADMIN_TIMEOUT_MS);
try {
const response = await fetch(`${API_BASE}/api/v1/admin/users`, {
headers: {
Authorization: `Bearer ${adminToken}`,
},
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
if (response.status === 401) {
throw new Error("UNAUTHORIZED");
}
throw new Error(`HTTP_ERROR_${response.status}`);
}
return await response.json();
} catch (err) {
clearTimeout(timeoutId);
throw err;
}
}
export function useAdminUsers() {
const { settings } = useTranslationStore();
const { data, isLoading, error, refetch } = useQuery({
queryKey: QUERY_KEY,
queryFn: () => fetchUsers(settings.adminToken),
enabled: !!settings.adminToken,
staleTime: 30000,
retry: 1,
});
const getErrorMessage = (err: Error | null): string | null => {
if (!err) return null;
const errorMap: Record<string, string> = {
AUTH_REQUIRED: "Veuillez vous connecter pour accéder aux utilisateurs",
UNAUTHORIZED: "Session expirée. Veuillez vous reconnecter.",
HTTP_ERROR_403: "Accès refusé. Droits administrateur requis.",
HTTP_ERROR_404: "Service indisponible. Veuillez réessayer plus tard.",
HTTP_ERROR_500: "Erreur serveur. Veuillez réessayer plus tard.",
};
const code = err.message;
if (errorMap[code]) {
return errorMap[code];
}
if (err.name === "AbortError") {
return "Le serveur met trop de temps à répondre. Veuillez réessayer.";
}
if (err.message.includes("fetch") || err.message.includes("network")) {
return "Impossible de se connecter au serveur. Vérifiez votre connexion.";
}
return "Une erreur inattendue s'est produite. Veuillez réessayer.";
};
const errorMessage = error ? getErrorMessage(error as Error) : null;
return {
data: data ?? null,
users: data?.users ?? [],
total: data?.total ?? 0,
isLoading,
error: errorMessage,
refetch,
queryKey: QUERY_KEY,
};
}

View File

@@ -0,0 +1,100 @@
"use client";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslationStore } from "@/lib/store";
import { API_BASE } from "@/lib/config";
import type { RevokeApiKeyResponse } from "./types";
import { QUERY_KEY, ADMIN_TIMEOUT_MS } from "./useAdminUsers";
async function revokeApiKey(
keyId: string,
reason: string | undefined,
adminToken: string | null | undefined
): Promise<RevokeApiKeyResponse> {
if (!adminToken) {
throw new Error("AUTH_REQUIRED");
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), ADMIN_TIMEOUT_MS);
try {
const response = await fetch(`${API_BASE}/api/v1/admin/api-keys/${keyId}`, {
method: "DELETE",
headers: {
Authorization: `Bearer ${adminToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify(reason ? { reason } : {}),
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
if (response.status === 401) {
throw new Error("UNAUTHORIZED");
}
if (response.status === 404) {
throw new Error("API_KEY_NOT_FOUND");
}
throw new Error(`HTTP_ERROR_${response.status}`);
}
return await response.json();
} catch (err) {
clearTimeout(timeoutId);
throw err;
}
}
export function useRevokeApiKey() {
const { settings } = useTranslationStore();
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: ({
keyId,
reason,
}: {
keyId: string;
reason?: string;
}) => revokeApiKey(keyId, reason, settings.adminToken),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: QUERY_KEY });
},
});
const getErrorMessage = (err: Error | null): string | null => {
if (!err) return null;
const errorMap: Record<string, string> = {
AUTH_REQUIRED: "Veuillez vous connecter pour effectuer cette action",
UNAUTHORIZED: "Session expirée. Veuillez vous reconnecter.",
API_KEY_NOT_FOUND: "Clé API non trouvée ou déjà révoquée.",
HTTP_ERROR_403: "Accès refusé. Droits administrateur requis.",
HTTP_ERROR_500: "Erreur serveur. Veuillez réessayer plus tard.",
};
const code = err.message;
if (errorMap[code]) {
return errorMap[code];
}
if (err.name === "AbortError") {
return "Le serveur met trop de temps à répondre. Veuillez réessayer.";
}
return "Erreur lors de la révocation. Veuillez réessayer.";
};
const errorMessage = mutation.error ? getErrorMessage(mutation.error as Error) : null;
return {
isRevoking: mutation.isPending,
result: mutation.data ?? null,
error: errorMessage,
revokeKey: mutation.mutateAsync,
reset: mutation.reset,
};
}

View File

@@ -0,0 +1,96 @@
"use client";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslationStore } from "@/lib/store";
import { API_BASE } from "@/lib/config";
import type { UpdateTierRequest, UpdateTierResponse, PlanType } from "./types";
import { QUERY_KEY, ADMIN_TIMEOUT_MS } from "./useAdminUsers";
async function updateUserTier(
userId: string,
plan: PlanType,
adminToken: string | null | undefined
): Promise<UpdateTierResponse> {
if (!adminToken) {
throw new Error("AUTH_REQUIRED");
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), ADMIN_TIMEOUT_MS);
try {
const response = await fetch(`${API_BASE}/api/v1/admin/users/${userId}`, {
method: "PATCH",
headers: {
Authorization: `Bearer ${adminToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ plan } as UpdateTierRequest),
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
if (response.status === 401) {
throw new Error("UNAUTHORIZED");
}
if (response.status === 404) {
throw new Error("USER_NOT_FOUND");
}
throw new Error(`HTTP_ERROR_${response.status}`);
}
return await response.json();
} catch (err) {
clearTimeout(timeoutId);
throw err;
}
}
export function useUpdateUserTier() {
const { settings } = useTranslationStore();
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: ({ userId, plan }: { userId: string; plan: PlanType }) =>
updateUserTier(userId, plan, settings.adminToken),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: QUERY_KEY });
},
});
const getErrorMessage = (err: Error | null): string | null => {
if (!err) return null;
const errorMap: Record<string, string> = {
AUTH_REQUIRED: "Veuillez vous connecter pour effectuer cette action",
UNAUTHORIZED: "Session expirée. Veuillez vous reconnecter.",
USER_NOT_FOUND: "Utilisateur non trouvé.",
HTTP_ERROR_403: "Accès refusé. Droits administrateur requis.",
HTTP_ERROR_400: "Plan invalide. Veuillez réessayer.",
HTTP_ERROR_500: "Erreur serveur. Veuillez réessayer plus tard.",
};
const code = err.message;
if (errorMap[code]) {
return errorMap[code];
}
if (err.name === "AbortError") {
return "Le serveur met trop de temps à répondre. Veuillez réessayer.";
}
return "Erreur lors de la mise à jour. Veuillez réessayer.";
};
const errorMessage = mutation.error ? getErrorMessage(mutation.error as Error) : null;
return {
isUpdating: mutation.isPending,
result: mutation.data ?? null,
error: errorMessage,
updateTier: mutation.mutateAsync,
reset: mutation.reset,
};
}

View File

@@ -0,0 +1,126 @@
'use client';
import { useState, useEffect } from 'react';
import Link from 'next/link';
import { Eye, EyeOff, Mail, Lock, ArrowRight, Loader2, Languages } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { useNotification } from '@/components/ui/notification';
import { useLogin } from './useLogin';
export function LoginForm() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const loginMutation = useLogin();
const { notify } = useNotification();
useEffect(() => {
if (loginMutation.isError && loginMutation.error) {
notify({
title: 'Erreur de connexion',
description: loginMutation.error.message,
variant: 'destructive',
});
}
}, [loginMutation.isError, loginMutation.error, notify]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
loginMutation.mutate({ email, password });
};
return (
<Card variant="elevated" className="w-full max-w-md mx-auto" hover={false}>
<CardHeader className="text-center pb-6">
<Link href="/" className="inline-flex items-center gap-3 mb-6 group">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-primary text-primary-foreground shadow-lg">
<Languages className="h-6 w-6" />
</div>
<span className="text-2xl font-semibold text-foreground">
Office Translator
</span>
</Link>
<CardTitle className="text-2xl font-bold">
Welcome back
</CardTitle>
<CardDescription>
Sign in to continue translating
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="you@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
leftIcon={<Mail className="h-4 w-4" />}
required
/>
</div>
<div className="space-y-2">
<div className="flex justify-between items-center">
<Label htmlFor="password">Password</Label>
<Link href="/auth/forgot-password" className="text-sm text-primary hover:underline">
Forgot password?
</Link>
</div>
<div className="relative">
<Input
id="password"
type={showPassword ? 'text' : 'password'}
placeholder="••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
leftIcon={<Lock className="h-4 w-4" />}
required
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
</div>
<Button
type="submit"
disabled={loginMutation.isPending || !email || !password}
className="w-full"
>
{loginMutation.isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Signing in...
</>
) : (
<>
Sign In
<ArrowRight className="ml-2 h-4 w-4" />
</>
)}
</Button>
</form>
<p className="text-center text-sm text-muted-foreground">
Don&apos;t have an account?{' '}
<Link href="/auth/register" className="text-primary hover:underline">
Sign up for free
</Link>
</p>
</CardContent>
</Card>
);
}

View File

@@ -1,381 +1,36 @@
"use client";
import { useState, Suspense } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import Link from "next/link";
import { Eye, EyeOff, Mail, Lock, ArrowRight, Loader2, Shield, CheckCircle, AlertTriangle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
function LoginForm() {
const router = useRouter();
const searchParams = useSearchParams();
const redirect = searchParams.get("redirect") || "/";
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [isValidating, setIsValidating] = useState({
email: false,
password: false,
});
const [isFocused, setIsFocused] = useState({
email: false,
password: false,
});
const [showSuccess, setShowSuccess] = useState(false);
const validateEmail = (email: string) => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
};
const validatePassword = (password: string) => {
return password.length >= 8;
};
const handleEmailChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setEmail(value);
setIsValidating(prev => ({ ...prev, email: value.length > 0 }));
};
const handlePasswordChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setPassword(value);
setIsValidating(prev => ({ ...prev, password: value.length > 0 }));
};
const handleEmailBlur = () => {
setIsValidating(prev => ({ ...prev, email: false }));
setIsFocused(prev => ({ ...prev, email: false }));
};
const handlePasswordBlur = () => {
setIsValidating(prev => ({ ...prev, password: false }));
setIsFocused(prev => ({ ...prev, password: false }));
};
const handleEmailFocus = () => {
setIsFocused(prev => ({ ...prev, email: true }));
};
const handlePasswordFocus = () => {
setIsFocused(prev => ({ ...prev, password: true }));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
setLoading(true);
try {
const res = await fetch("http://localhost:8000/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.detail || "Login failed");
}
// Store tokens
localStorage.setItem("token", data.access_token);
localStorage.setItem("refresh_token", data.refresh_token);
localStorage.setItem("user", JSON.stringify(data.user));
// Show success animation
setShowSuccess(true);
setTimeout(() => {
router.push(redirect);
}, 1000);
} catch (err: any) {
setError(err.message || "Login failed");
setLoading(false);
}
};
const getEmailValidationState = () => {
if (!isValidating.email) return "";
if (email.length === 0) return "";
return validateEmail(email) ? "valid" : "invalid";
};
const getPasswordValidationState = () => {
if (!isValidating.password) return "";
if (password.length === 0) return "";
return validatePassword(password) ? "valid" : "invalid";
};
return (
<>
{/* Enhanced Login Card */}
<Card
variant="elevated"
className="w-full max-w-md mx-auto overflow-hidden animate-fade-in"
>
<CardHeader className="text-center pb-6">
{/* Logo */}
<Link href="/" className="inline-flex items-center gap-3 mb-6 group">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-gradient-to-br from-primary to-accent text-white font-bold text-xl shadow-lg group-hover:shadow-xl group-hover:shadow-primary/25 transition-all duration-300 group-hover:-translate-y-0.5">
A
</div>
<span className="text-2xl font-semibold text-white group-hover:text-primary transition-colors duration-300">
Translate Co.
</span>
</Link>
<CardTitle className="text-2xl font-bold text-white mb-2">
Welcome back
</CardTitle>
<CardDescription className="text-text-secondary">
Sign in to continue translating
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Success Message */}
{showSuccess && (
<div className="rounded-lg bg-success/10 border border-success/30 p-4 animate-slide-up">
<div className="flex items-center gap-3">
<CheckCircle className="h-5 w-5 text-success" />
<span className="text-success font-medium">Login successful! Redirecting...</span>
</div>
</div>
)}
{/* Error Message */}
{error && (
<div className="rounded-lg bg-destructive/10 border border-destructive/30 p-4 animate-slide-up">
<div className="flex items-start gap-3">
<AlertTriangle className="h-5 w-5 text-destructive flex-shrink-0 mt-0.5" />
<div>
<p className="text-destructive font-medium">Authentication Error</p>
<p className="text-destructive/80 text-sm mt-1">{error}</p>
</div>
</div>
</div>
)}
<form onSubmit={handleSubmit} className="space-y-6">
{/* Email Field */}
<div className="space-y-3">
<Label htmlFor="email" className="text-text-secondary font-medium">
Email Address
</Label>
<div className="relative">
<Input
id="email"
type="email"
placeholder="you@example.com"
value={email}
onChange={handleEmailChange}
onBlur={handleEmailBlur}
onFocus={handleEmailFocus}
required
className={cn(
"pl-12 h-12 text-lg",
getEmailValidationState() === "valid" && "border-success focus:border-success",
getEmailValidationState() === "invalid" && "border-destructive focus:border-destructive",
isFocused.email && "ring-2 ring-primary/20"
)}
leftIcon={<Mail className="h-5 w-5" />}
/>
{/* Validation Indicator */}
{isValidating.email && (
<div className="absolute right-3 top-1/2 -translate-y-1/2">
{getEmailValidationState() === "valid" && (
<CheckCircle className="h-5 w-5 text-success animate-fade-in" />
)}
{getEmailValidationState() === "invalid" && (
<AlertTriangle className="h-5 w-5 text-destructive animate-fade-in" />
)}
</div>
)}
</div>
</div>
{/* Password Field */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label htmlFor="password" className="text-text-secondary font-medium">
Password
</Label>
<Link
href="/auth/forgot-password"
className="text-sm text-primary hover:text-primary/80 transition-colors duration-200"
>
Forgot password?
</Link>
</div>
<div className="relative">
<Input
id="password"
type={showPassword ? "text" : "password"}
placeholder="••••••••••"
value={password}
onChange={handlePasswordChange}
onBlur={handlePasswordBlur}
onFocus={handlePasswordFocus}
required
className={cn(
"pl-12 pr-12 h-12 text-lg",
getPasswordValidationState() === "valid" && "border-success focus:border-success",
getPasswordValidationState() === "invalid" && "border-destructive focus:border-destructive",
isFocused.password && "ring-2 ring-primary/20"
)}
leftIcon={<Lock className="h-5 w-5" />}
rightIcon={
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="text-muted-foreground hover:text-foreground transition-colors duration-200"
>
{showPassword ? (
<EyeOff className="h-5 w-5" />
) : (
<Eye className="h-5 w-5" />
)}
</button>
}
/>
{/* Validation Indicator */}
{isValidating.password && (
<div className="absolute right-12 top-1/2 -translate-y-1/2">
{getPasswordValidationState() === "valid" && (
<CheckCircle className="h-5 w-5 text-success animate-fade-in" />
)}
{getPasswordValidationState() === "invalid" && (
<AlertTriangle className="h-5 w-5 text-destructive animate-fade-in" />
)}
</div>
)}
</div>
{/* Password Strength Indicator */}
{isValidating.password && password.length > 0 && (
<div className="mt-2 space-y-1">
<div className="flex justify-between text-xs text-text-tertiary">
<span>Password strength</span>
<span className={cn(
password.length < 8 && "text-destructive",
password.length >= 8 && password.length < 12 && "text-warning",
password.length >= 12 && "text-success"
)}>
{password.length < 8 && "Weak"}
{password.length >= 8 && password.length < 12 && "Fair"}
{password.length >= 12 && "Strong"}
</span>
</div>
<div className="w-full bg-border-subtle rounded-full h-1 overflow-hidden">
<div
className={cn(
"h-full transition-all duration-300 ease-out",
password.length < 8 && "bg-destructive w-1/3",
password.length >= 8 && password.length < 12 && "bg-warning w-2/3",
password.length >= 12 && "bg-success w-full"
)}
/>
</div>
</div>
)}
</div>
{/* Submit Button */}
<Button
type="submit"
disabled={loading || !email || !password}
variant="premium"
size="lg"
className="w-full h-12 text-lg group"
>
{loading ? (
<>
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
Signing in...
</>
) : (
<>
Sign In
<ArrowRight className="ml-2 h-5 w-5 transition-transform duration-200 group-hover:translate-x-1" />
</>
)}
</Button>
</form>
</CardContent>
</Card>
{/* Enhanced Footer */}
<div className="mt-8 text-center">
<p className="text-sm text-text-tertiary mb-6">
Don't have an account?{" "}
<Link
href={`/auth/register${redirect !== "/" ? `?redirect=${redirect}` : ""}`}
className="text-primary hover:text-primary/80 font-medium transition-colors duration-200"
>
Sign up for free
</Link>
</p>
{/* Trust Indicators */}
<div className="flex flex-wrap justify-center gap-6 text-xs text-text-tertiary">
<div className="flex items-center gap-2">
<Shield className="h-4 w-4 text-success" />
<span>Secure login</span>
</div>
<div className="flex items-center gap-2">
<CheckCircle className="h-4 w-4 text-primary" />
<span>SSL encrypted</span>
</div>
</div>
</div>
</>
);
}
import { Suspense } from 'react';
import { LoginForm } from './LoginForm';
import { Loader2, Languages } from 'lucide-react';
function LoadingFallback() {
return (
<Card variant="elevated" className="w-full max-w-md mx-auto">
<CardContent className="flex items-center justify-center py-16">
<div className="text-center space-y-4">
<Loader2 className="h-12 w-12 animate-spin text-primary mx-auto" />
<p className="text-lg font-medium text-foreground">Loading...</p>
<div className="w-16 h-1 bg-border-subtle rounded-full overflow-hidden">
<div className="h-full bg-primary animate-loading-shimmer" />
<div className="w-full max-w-md mx-auto">
<div className="rounded-xl bg-card border border-border shadow-lg p-8">
<div className="flex flex-col items-center justify-center py-8 space-y-4">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-primary text-primary-foreground">
<Languages className="h-6 w-6" />
</div>
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<p className="text-muted-foreground">Loading...</p>
</div>
</CardContent>
</Card>
</div>
</div>
);
}
export default function LoginPage() {
return (
<div className="min-h-screen bg-gradient-to-br from-surface via-surface-elevated to-background flex items-center justify-center p-4 relative overflow-hidden">
{/* Background Effects */}
<div className="absolute inset-0">
<div className="absolute inset-0 bg-gradient-to-br from-primary/5 via-transparent to-accent/5" />
<div className="absolute inset-0 bg-[url('/grid.svg')] opacity-5" />
<div className="absolute inset-0 bg-gradient-to-t from-background via-background/90 to-background/50" />
</div>
{/* Animated Background Elements */}
<div className="absolute inset-0 overflow-hidden pointer-events-none">
<div className="absolute top-20 left-10 w-32 h-32 bg-primary/5 rounded-full blur-3xl animate-float" />
<div className="absolute bottom-20 right-20 w-24 h-24 bg-accent/5 rounded-full blur-2xl animate-float-delayed" />
<div className="absolute top-1/2 right-1/4 w-16 h-16 bg-success/5 rounded-full blur-xl animate-float-slow" />
<div className="absolute top-20 left-10 w-32 h-32 bg-primary/5 rounded-full blur-3xl animate-pulse" />
<div className="absolute bottom-20 right-20 w-24 h-24 bg-accent/5 rounded-full blur-2xl animate-pulse" />
<div className="absolute top-1/2 right-1/4 w-16 h-16 bg-success/5 rounded-full blur-xl animate-pulse" />
</div>
<Suspense fallback={<LoadingFallback />}>

View File

@@ -0,0 +1,16 @@
export interface LoginRequest {
email: string;
password: string;
}
export interface User {
id: string;
email: string;
tier: 'free' | 'pro';
}
export interface LoginResponse {
access_token: string;
refresh_token: string;
token_type: string;
}

View File

@@ -0,0 +1,27 @@
'use client';
import { useMutation } from '@tanstack/react-query';
import { useRouter, useSearchParams } from 'next/navigation';
import { apiClient, ApiClientError } from '@/lib/apiClient';
import type { LoginRequest, LoginResponse } from './types';
export function useLogin() {
const router = useRouter();
const searchParams = useSearchParams();
const redirect = searchParams.get('redirect') || '/dashboard';
return useMutation<LoginResponse, ApiClientError, LoginRequest>({
mutationFn: async (credentials: LoginRequest) => {
const response = await apiClient.post<LoginResponse>(
'/api/v1/auth/login',
credentials
);
return response.data;
},
onSuccess: (data) => {
localStorage.setItem('token', data.access_token);
localStorage.setItem('refresh_token', data.refresh_token);
router.push(redirect);
},
});
}

View File

@@ -0,0 +1,293 @@
'use client';
import { useState } from 'react';
import Link from 'next/link';
import {
Eye,
EyeOff,
Mail,
Lock,
ArrowRight,
Loader2,
CheckCircle,
AlertTriangle,
UserPlus,
Languages,
User,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { useRegister } from './useRegister';
import { cn } from '@/lib/utils';
function validateEmail(email: string) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}
function validatePassword(password: string) {
return password.length >= 8;
}
function getPasswordStrength(password: string) {
if (password.length === 0) return { score: 0, label: '', color: '' };
let score = 0;
if (password.length >= 8) score++;
if (password.length >= 12) score++;
if (/[A-Z]/.test(password)) score++;
if (/[a-z]/.test(password)) score++;
if (/[0-9]/.test(password)) score++;
if (/[^A-Za-z0-9]/.test(password)) score++;
if (score <= 2) return { score, label: 'Faible', color: 'bg-destructive' };
if (score <= 4) return { score, label: 'Moyen', color: 'bg-yellow-500' };
return { score, label: 'Fort', color: 'bg-green-500' };
}
function PasswordToggleIcon({ visible, onToggle, label }: { visible: boolean; onToggle: () => void; label: string }) {
return (
<button
type="button"
onClick={onToggle}
className="text-muted-foreground hover:text-foreground transition-colors duration-200 pointer-events-auto"
tabIndex={-1}
aria-label={label}
>
{visible ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
);
}
export function RegisterForm() {
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [showConfirm, setShowConfirm] = useState(false);
const [touched, setTouched] = useState({ name: false, email: false, password: false, confirmPassword: false });
const registerMutation = useRegister();
const nameError = touched.name && name.length > 0 && name.length < 2
? 'Le nom doit contenir au moins 2 caractères'
: undefined;
const emailError = touched.email && email.length > 0 && !validateEmail(email)
? 'Adresse email invalide'
: undefined;
const passwordError = touched.password && password.length > 0 && !validatePassword(password)
? 'Mot de passe trop court (minimum 8 caractères)'
: undefined;
const confirmError = touched.confirmPassword && confirmPassword.length > 0 && password !== confirmPassword
? 'Les mots de passe ne correspondent pas'
: undefined;
const passwordStrength = getPasswordStrength(password);
const isFormValid =
name.length >= 2 &&
validateEmail(email) &&
validatePassword(password) &&
password === confirmPassword;
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
setTouched({ name: true, email: true, password: true, confirmPassword: true });
if (!isFormValid) return;
registerMutation.mutate({ name, email, password });
};
const getConfirmRightIcon = () => {
if (touched.confirmPassword && confirmPassword.length > 0 && password === confirmPassword) {
return <CheckCircle className="h-4 w-4 text-green-500" />;
}
return (
<PasswordToggleIcon
visible={showConfirm}
onToggle={() => setShowConfirm(!showConfirm)}
label={showConfirm ? 'Masquer' : 'Afficher'}
/>
);
};
return (
<Card variant="elevated" className="w-full max-w-md mx-auto" hover={false}>
<CardHeader className="text-center pb-6">
<Link href="/" className="inline-flex items-center gap-3 mb-6 group">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-primary text-primary-foreground shadow-lg group-hover:shadow-xl group-hover:shadow-primary/25 transition-all duration-300 group-hover:-translate-y-0.5">
<Languages className="h-6 w-6" />
</div>
<span className="text-2xl font-semibold text-foreground group-hover:text-primary transition-colors duration-300">
Office Translator
</span>
</Link>
<CardTitle className="text-2xl font-bold">Créer un compte</CardTitle>
<CardDescription>Commencez à traduire gratuitement</CardDescription>
</CardHeader>
<CardContent className="space-y-5">
{registerMutation.isError && (
<div className="rounded-lg bg-destructive/10 border border-destructive/30 p-4">
<div className="flex items-start gap-3">
<AlertTriangle className="h-5 w-5 text-destructive flex-shrink-0 mt-0.5" />
<p className="text-sm text-destructive">
{registerMutation.error?.message || "L'inscription a échoué"}
</p>
</div>
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Nom</Label>
<Input
id="name"
type="text"
placeholder="Votre nom"
value={name}
onChange={(e) => setName(e.target.value)}
onBlur={() => setTouched((t) => ({ ...t, name: true }))}
leftIcon={<User className="h-4 w-4" />}
rightIcon={
touched.name && name.length > 0
? name.length >= 2
? <CheckCircle className="h-4 w-4 text-green-500" />
: <AlertTriangle className="h-4 w-4 text-destructive" />
: undefined
}
error={nameError}
required
autoComplete="name"
/>
</div>
<div className="space-y-2">
<Label htmlFor="email">Adresse email</Label>
<Input
id="email"
type="email"
placeholder="vous@exemple.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
onBlur={() => setTouched((t) => ({ ...t, email: true }))}
leftIcon={<Mail className="h-4 w-4" />}
rightIcon={
touched.email && email.length > 0
? validateEmail(email)
? <CheckCircle className="h-4 w-4 text-green-500" />
: <AlertTriangle className="h-4 w-4 text-destructive" />
: undefined
}
error={emailError}
required
autoComplete="email"
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Mot de passe</Label>
<Input
id="password"
type={showPassword ? 'text' : 'password'}
placeholder="••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
onBlur={() => setTouched((t) => ({ ...t, password: true }))}
leftIcon={<Lock className="h-4 w-4" />}
rightIcon={
<PasswordToggleIcon
visible={showPassword}
onToggle={() => setShowPassword(!showPassword)}
label={showPassword ? 'Masquer le mot de passe' : 'Afficher le mot de passe'}
/>
}
error={passwordError}
required
minLength={8}
autoComplete="new-password"
/>
{password.length > 0 && (
<div className="space-y-1 pt-1">
<div className="flex gap-1">
{[1, 2, 3, 4].map((level) => (
<div
key={level}
className={cn(
'h-1 flex-1 rounded-full transition-all duration-300',
level <= Math.ceil((passwordStrength.score / 6) * 4)
? passwordStrength.color
: 'bg-border'
)}
/>
))}
</div>
<p className={cn('text-xs', passwordStrength.score <= 2 ? 'text-destructive' : passwordStrength.score <= 4 ? 'text-muted-foreground' : 'text-green-500')}>
Force : {passwordStrength.label}
</p>
</div>
)}
</div>
<div className="space-y-2">
<Label htmlFor="confirmPassword">Confirmer le mot de passe</Label>
<Input
id="confirmPassword"
type={showConfirm ? 'text' : 'password'}
placeholder="••••••••"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
onBlur={() => setTouched((t) => ({ ...t, confirmPassword: true }))}
leftIcon={<Lock className="h-4 w-4" />}
rightIcon={getConfirmRightIcon()}
error={confirmError}
required
autoComplete="new-password"
/>
</div>
<Button
type="submit"
variant="premium"
size="lg"
className="w-full"
disabled={registerMutation.isPending}
loading={registerMutation.isPending}
>
{registerMutation.isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Création du compte...
</>
) : (
<>
<UserPlus className="mr-2 h-4 w-4" />
Créer mon compte
<ArrowRight className="ml-2 h-4 w-4" />
</>
)}
</Button>
</form>
<p className="text-center text-sm text-muted-foreground">
Vous avez déjà un compte ?{' '}
<Link href="/auth/login" className="text-primary hover:underline font-medium">
Se connecter
</Link>
</p>
<p className="text-center text-xs text-muted-foreground">
En créant un compte, vous acceptez notre{' '}
<span className="text-muted-foreground">
utilisation du service
</span>
.
</p>
</CardContent>
</Card>
);
}

View File

@@ -1,601 +1,42 @@
"use client";
import { useState, Suspense } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import Link from "next/link";
import {
Eye,
EyeOff,
Mail,
Lock,
User,
ArrowRight,
Loader2,
Shield,
CheckCircle,
AlertTriangle,
UserPlus,
Info
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
function RegisterForm() {
const router = useRouter();
const searchParams = useSearchParams();
const redirect = searchParams.get("redirect") || "/";
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [step, setStep] = useState(1);
const [showSuccess, setShowSuccess] = useState(false);
const [isValidating, setIsValidating] = useState({
name: false,
email: false,
password: false,
confirmPassword: false,
});
const [isFocused, setIsFocused] = useState({
name: false,
email: false,
password: false,
confirmPassword: false,
});
const validateName = (name: string) => {
return name.trim().length >= 2;
};
const validateEmail = (email: string) => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
};
const validatePassword = (password: string) => {
return password.length >= 8;
};
const validateConfirmPassword = (password: string, confirmPassword: string) => {
return password === confirmPassword && password.length > 0;
};
// Real-time validation
const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setName(value);
setIsValidating(prev => ({ ...prev, name: value.length > 0 }));
};
const handleEmailChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setEmail(value);
setIsValidating(prev => ({ ...prev, email: value.length > 0 }));
};
const handlePasswordChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setPassword(value);
setIsValidating(prev => ({ ...prev, password: value.length > 0 }));
};
const handleConfirmPasswordChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setConfirmPassword(value);
setIsValidating(prev => ({ ...prev, confirmPassword: value.length > 0 }));
};
const handleNameBlur = () => {
setIsValidating(prev => ({ ...prev, name: false }));
setIsFocused(prev => ({ ...prev, name: false }));
};
const handleEmailBlur = () => {
setIsValidating(prev => ({ ...prev, email: false }));
setIsFocused(prev => ({ ...prev, email: false }));
};
const handlePasswordBlur = () => {
setIsValidating(prev => ({ ...prev, password: false }));
setIsFocused(prev => ({ ...prev, password: false }));
};
const handleConfirmPasswordBlur = () => {
setIsValidating(prev => ({ ...prev, confirmPassword: false }));
setIsFocused(prev => ({ ...prev, confirmPassword: false }));
};
const handleNameFocus = () => {
setIsFocused(prev => ({ ...prev, name: true }));
};
const handleEmailFocus = () => {
setIsFocused(prev => ({ ...prev, email: true }));
};
const handlePasswordFocus = () => {
setIsFocused(prev => ({ ...prev, password: true }));
};
const handleConfirmPasswordFocus = () => {
setIsFocused(prev => ({ ...prev, confirmPassword: true }));
};
const getNameValidationState = () => {
if (!isValidating.name) return "";
if (name.length === 0) return "";
return validateName(name) ? "valid" : "invalid";
};
const getEmailValidationState = () => {
if (!isValidating.email) return "";
if (email.length === 0) return "";
return validateEmail(email) ? "valid" : "invalid";
};
const getPasswordValidationState = () => {
if (!isValidating.password) return "";
if (password.length === 0) return "";
return validatePassword(password) ? "valid" : "invalid";
};
const getConfirmPasswordValidationState = () => {
if (!isValidating.confirmPassword) return "";
if (confirmPassword.length === 0) return "";
return validateConfirmPassword(password, confirmPassword) ? "valid" : "invalid";
};
const getPasswordStrength = () => {
if (password.length === 0) return { strength: 0, text: "", color: "" };
let strength = 0;
let text = "";
let color = "";
if (password.length >= 8) strength++;
if (password.length >= 12) strength++;
if (/[A-Z]/.test(password)) strength++;
if (/[a-z]/.test(password)) strength++;
if (/[0-9]/.test(password)) strength++;
if (/[^A-Za-z0-9]/.test(password)) strength++;
if (strength <= 2) {
text = "Weak";
color = "text-destructive";
} else if (strength <= 3) {
text = "Fair";
color = "text-warning";
} else {
text = "Strong";
color = "text-success";
}
return { strength, text, color };
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
// Validate all fields
if (!validateName(name)) {
setError("Name must be at least 2 characters");
return;
}
if (!validateEmail(email)) {
setError("Please enter a valid email address");
return;
}
if (!validatePassword(password)) {
setError("Password must be at least 8 characters");
return;
}
if (!validateConfirmPassword(password, confirmPassword)) {
setError("Passwords do not match");
return;
}
setLoading(true);
try {
const res = await fetch("http://localhost:8000/api/auth/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, email, password }),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.detail || "Registration failed");
}
// Store tokens
localStorage.setItem("token", data.access_token);
localStorage.setItem("refresh_token", data.refresh_token);
localStorage.setItem("user", JSON.stringify(data.user));
// Show success animation
setShowSuccess(true);
setTimeout(() => {
router.push(redirect);
}, 1500);
} catch (err: any) {
setError(err.message || "Registration failed");
setLoading(false);
}
};
const passwordStrength = getPasswordStrength();
return (
<>
{/* Enhanced Registration Card */}
<Card
variant="elevated"
className={cn(
"w-full max-w-md mx-auto overflow-hidden animate-fade-in",
showSuccess && "scale-95 opacity-0"
)}
>
<CardHeader className="text-center pb-6">
{/* Logo */}
<Link href="/" className="inline-flex items-center gap-3 mb-6 group">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-gradient-to-br from-primary to-accent text-white font-bold text-xl shadow-lg group-hover:shadow-xl group-hover:shadow-primary/25 transition-all duration-300 group-hover:-translate-y-0.5">
A
</div>
<span className="text-2xl font-semibold text-white group-hover:text-primary transition-colors duration-300">
Translate Co.
</span>
</Link>
<CardTitle className="text-2xl font-bold text-white mb-2">
Create an account
</CardTitle>
<CardDescription className="text-text-secondary">
Start translating documents for free
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Success Message */}
{showSuccess && (
<div className="rounded-lg bg-success/10 border border-success/30 p-6 mb-6 animate-slide-up">
<div className="flex items-center gap-3">
<CheckCircle className="h-8 w-8 text-success animate-pulse" />
<div>
<p className="text-lg font-medium text-success mb-1">Registration Successful!</p>
<p className="text-sm text-success/80">Redirecting to your dashboard...</p>
</div>
</div>
</div>
)}
{/* Error Message */}
{error && (
<div className="rounded-lg bg-destructive/10 border border-destructive/30 p-4 mb-6 animate-slide-up">
<div className="flex items-start gap-3">
<AlertTriangle className="h-5 w-5 text-destructive flex-shrink-0 mt-0.5" />
<div>
<p className="text-sm font-medium text-destructive mb-1">Registration Error</p>
<p className="text-sm text-destructive/80">{error}</p>
</div>
</div>
</div>
)}
{/* Progress Steps */}
<div className="flex items-center justify-center mb-8">
{[1, 2, 3].map((stepNumber) => (
<div
key={stepNumber}
className={cn(
"flex items-center justify-center w-8 h-8 rounded-full transition-all duration-300",
step === stepNumber
? "bg-primary text-white scale-110"
: "bg-surface text-text-tertiary"
)}
>
<span className="text-sm font-medium">{stepNumber}</span>
</div>
))}
<div className="h-0.5 bg-border-subtle flex-1 mx-2" />
</div>
<form onSubmit={handleSubmit} className="space-y-6">
{/* Name Field */}
<div className="space-y-3">
<Label htmlFor="name" className="text-text-secondary font-medium">
Full Name
</Label>
<div className="relative">
<Input
id="name"
type="text"
placeholder="John Doe"
value={name}
onChange={handleNameChange}
onBlur={handleNameBlur}
onFocus={handleNameFocus}
required
className={cn(
"pl-12 h-12 text-lg",
getNameValidationState() === "valid" && "border-success focus:border-success",
getNameValidationState() === "invalid" && "border-destructive focus:border-destructive",
isFocused.name && "ring-2 ring-primary/20"
)}
leftIcon={<User className="h-5 w-5" />}
/>
{/* Validation Indicator */}
{isValidating.name && (
<div className="absolute right-3 top-1/2 -translate-y-1/2">
{getNameValidationState() === "valid" && (
<CheckCircle className="h-5 w-5 text-success animate-fade-in" />
)}
{getNameValidationState() === "invalid" && (
<AlertTriangle className="h-5 w-5 text-destructive animate-fade-in" />
)}
</div>
)}
</div>
</div>
{/* Email Field */}
<div className="space-y-3">
<Label htmlFor="email" className="text-text-secondary font-medium">
Email Address
</Label>
<div className="relative">
<Input
id="email"
type="email"
placeholder="you@example.com"
value={email}
onChange={handleEmailChange}
onBlur={handleEmailBlur}
onFocus={handleEmailFocus}
required
className={cn(
"pl-12 h-12 text-lg",
getEmailValidationState() === "valid" && "border-success focus:border-success",
getEmailValidationState() === "invalid" && "border-destructive focus:border-destructive",
isFocused.email && "ring-2 ring-primary/20"
)}
leftIcon={<Mail className="h-5 w-5" />}
/>
{/* Validation Indicator */}
{isValidating.email && (
<div className="absolute right-3 top-1/2 -translate-y-1/2">
{getEmailValidationState() === "valid" && (
<CheckCircle className="h-5 w-5 text-success animate-fade-in" />
)}
{getEmailValidationState() === "invalid" && (
<AlertTriangle className="h-5 w-5 text-destructive animate-fade-in" />
)}
</div>
)}
</div>
</div>
{/* Password Field */}
<div className="space-y-3">
<Label htmlFor="password" className="text-text-secondary font-medium">
Password
</Label>
<div className="relative">
<Input
id="password"
type={showPassword ? "text" : "password"}
placeholder="•••••••••••"
value={password}
onChange={handlePasswordChange}
onBlur={handlePasswordBlur}
onFocus={handlePasswordFocus}
required
minLength={8}
className={cn(
"pl-12 pr-12 h-12 text-lg",
getPasswordValidationState() === "valid" && "border-success focus:border-success",
getPasswordValidationState() === "invalid" && "border-destructive focus:border-destructive",
isFocused.password && "ring-2 ring-primary/20"
)}
leftIcon={<Lock className="h-5 w-5" />}
rightIcon={
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="text-muted-foreground hover:text-foreground transition-colors duration-200"
>
{showPassword ? (
<EyeOff className="h-5 w-5" />
) : (
<Eye className="h-5 w-5" />
)}
</button>
}
/>
{/* Password Strength Indicator */}
{password.length > 0 && (
<div className="absolute right-12 top-1/2 -translate-y-1/2">
<div className="flex items-center gap-1">
<div className="flex space-x-1">
{[1, 2, 3, 4].map((level) => (
<div
key={level}
className={cn(
"w-1 h-1 rounded-full",
level <= passwordStrength.strength ? "bg-success" : "bg-border"
)}
/>
))}
</div>
<span className={cn("text-xs", passwordStrength.color)}>
{passwordStrength.text}
</span>
</div>
</div>
)}
</div>
</div>
{/* Confirm Password Field */}
<div className="space-y-3">
<Label htmlFor="confirmPassword" className="text-text-secondary font-medium">
Confirm Password
</Label>
<div className="relative">
<Input
id="confirmPassword"
type={showConfirmPassword ? "text" : "password"}
placeholder="•••••••••••"
value={confirmPassword}
onChange={handleConfirmPasswordChange}
onBlur={handleConfirmPasswordBlur}
onFocus={handleConfirmPasswordFocus}
required
className={cn(
"pl-12 pr-12 h-12 text-lg",
getConfirmPasswordValidationState() === "valid" && "border-success focus:border-success",
getConfirmPasswordValidationState() === "invalid" && "border-destructive focus:border-destructive",
isFocused.confirmPassword && "ring-2 ring-primary/20"
)}
leftIcon={<Lock className="h-5 w-5" />}
rightIcon={
<button
type="button"
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
className="text-muted-foreground hover:text-foreground transition-colors duration-200"
>
{showConfirmPassword ? (
<EyeOff className="h-5 w-5" />
) : (
<Eye className="h-5 w-5" />
)}
</button>
}
/>
{/* Validation Indicator */}
{isValidating.confirmPassword && (
<div className="absolute right-3 top-1/2 -translate-y-1/2">
{getConfirmPasswordValidationState() === "valid" && (
<CheckCircle className="h-5 w-5 text-success animate-fade-in" />
)}
{getConfirmPasswordValidationState() === "invalid" && (
<AlertTriangle className="h-5 w-5 text-destructive animate-fade-in" />
)}
</div>
)}
</div>
</div>
{/* Submit Button */}
<Button
type="submit"
disabled={loading || !name || !email || !password || !confirmPassword}
variant="premium"
size="lg"
className="w-full h-12 text-lg group"
>
{loading ? (
<>
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
Creating Account...
</>
) : (
<>
<UserPlus className="mr-2 h-5 w-5 transition-transform duration-200 group-hover:scale-110" />
Create Account
<ArrowRight className="ml-2 h-5 w-5 transition-transform duration-200 group-hover:translate-x-1" />
</>
)}
</Button>
</form>
{/* Sign In Link */}
<div className="text-center">
<p className="text-sm text-text-tertiary mb-4">
Already have an account?{" "}
<Link
href={`/auth/login${redirect !== "/" ? `?redirect=${redirect}` : ""}`}
className="text-primary hover:text-primary/80 font-medium transition-colors duration-200"
>
Sign in
</Link>
</p>
</div>
{/* Terms and Privacy */}
<div className="text-center text-xs text-text-tertiary space-y-2">
<p>
By creating an account, you agree to our{" "}
<Link href="/terms" className="text-primary hover:text-primary/80 transition-colors duration-200">
Terms of Service
</Link>
{" "} and{" "}
<Link href="/privacy" className="text-primary hover:text-primary/80 transition-colors duration-200">
Privacy Policy
</Link>
</p>
</div>
</CardContent>
</Card>
</>
);
}
import { Suspense } from 'react';
import { Languages, Loader2 } from 'lucide-react';
import { RegisterForm } from './RegisterForm';
function LoadingFallback() {
return (
<Card variant="elevated" className="w-full max-w-md mx-auto">
<CardContent className="flex items-center justify-center py-16">
<div className="text-center space-y-4">
<Loader2 className="h-12 w-12 animate-spin text-primary mx-auto" />
<p className="text-lg font-medium text-foreground">Creating your account...</p>
<div className="w-16 h-1 bg-border-subtle rounded-full overflow-hidden">
<div className="h-full bg-primary animate-loading-shimmer" />
<div className="w-full max-w-md mx-auto">
<div className="rounded-xl bg-card border border-border shadow-lg p-8">
<div className="flex flex-col items-center justify-center py-8 space-y-4">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-primary text-primary-foreground">
<Languages className="h-6 w-6" />
</div>
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<p className="text-muted-foreground">Chargement...</p>
</div>
</CardContent>
</Card>
</div>
</div>
);
}
export default function RegisterPage() {
return (
<div className="min-h-screen bg-gradient-to-br from-surface via-surface-elevated to-background flex items-center justify-center p-4 relative overflow-hidden">
{/* Background Effects */}
{/* Fond dégradé */}
<div className="absolute inset-0">
<div className="absolute inset-0 bg-gradient-to-br from-primary/5 via-transparent to-accent/5" />
<div className="absolute inset-0 bg-[url('/grid.svg')] opacity-5" />
<div className="absolute inset-0 bg-gradient-to-t from-background via-background/90 to-background/50" />
</div>
{/* Floating Elements */}
{/* Éléments flottants décoratifs */}
<div className="absolute inset-0 overflow-hidden pointer-events-none">
<div className="absolute top-20 left-10 w-20 h-20 bg-primary/10 rounded-full blur-xl animate-pulse" />
<div className="absolute top-40 right-20 w-32 h-32 bg-accent/10 rounded-full blur-2xl animate-pulse animation-delay-2000" />
<div className="absolute bottom-20 left-1/4 w-16 h-16 bg-success/10 rounded-full blur-lg animate-pulse animation-delay-4000" />
<div className="absolute top-40 right-20 w-32 h-32 bg-accent/10 rounded-full blur-2xl animate-pulse" />
<div className="absolute bottom-20 left-1/4 w-16 h-16 bg-success/10 rounded-full blur-lg animate-pulse" />
</div>
<div className="w-full max-w-md">
{/* Formulaire — Suspense requis par useSearchParams() dans useRegister */}
<div className="relative z-10 w-full max-w-md">
<Suspense fallback={<LoadingFallback />}>
<RegisterForm />
</Suspense>

View File

@@ -0,0 +1,11 @@
export interface RegisterRequest {
email: string;
password: string;
name?: string;
}
export interface RegisterResponse {
id: string;
email: string;
tier: 'free' | 'pro';
}

View File

@@ -0,0 +1,38 @@
'use client';
import { useMutation } from '@tanstack/react-query';
import { useRouter, useSearchParams } from 'next/navigation';
import { apiClient } from '@/lib/apiClient';
import type { RegisterRequest, RegisterResponse } from './types';
import type { LoginResponse } from '../login/types';
interface ApiError {
message: string;
error?: string;
}
export function useRegister() {
const router = useRouter();
const searchParams = useSearchParams();
const redirect = searchParams.get('redirect') || '/dashboard';
return useMutation({
mutationFn: async (data: RegisterRequest) => {
await apiClient.post<RegisterResponse>('/api/v1/auth/register', data);
const loginResponse = await apiClient.post<LoginResponse>(
'/api/v1/auth/login',
{ email: data.email, password: data.password }
);
return loginResponse.data;
},
onSuccess: (data) => {
localStorage.setItem('token', data.access_token);
localStorage.setItem('refresh_token', data.refresh_token);
router.push(redirect);
},
onError: (error: ApiError) => {
console.error('[useRegister] Registration failed:', error.message);
},
});
}

View File

@@ -0,0 +1,145 @@
'use client';
import { useState } from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import {
Languages,
Menu,
X,
ChevronLeft,
LogOut
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
import { useUser } from './useUser';
import { useLogout } from './useLogout';
import { getNavItems } from './constants';
import { getInitials } from './utils';
export function DashboardHeader() {
const [mobileOpen, setMobileOpen] = useState(false);
const pathname = usePathname();
const { data: user, isLoading } = useUser();
const { logout } = useLogout();
const navItems = getNavItems(user?.tier === 'pro');
return (
<>
<header className="flex h-14 shrink-0 items-center justify-between border-b border-border bg-card px-4 lg:px-6">
{/* Mobile menu button */}
<Button
variant="ghost"
size="icon"
className="lg:hidden"
onClick={() => setMobileOpen(!mobileOpen)}
aria-label="Toggle menu"
>
{mobileOpen ? <X className="size-4" /> : <Menu className="size-4" />}
</Button>
{/* Mobile brand */}
<div className="flex items-center gap-2 lg:hidden">
<div className="flex size-6 items-center justify-center rounded-md bg-foreground">
<Languages className="size-3 text-background" />
</div>
<span className="text-sm font-semibold text-foreground">Office Translator</span>
</div>
{/* Page title - desktop */}
<div className="hidden items-center gap-3 lg:flex">
<h1 className="text-sm font-semibold text-foreground">Dashboard</h1>
<Separator orientation="vertical" className="h-4" />
<span className="text-sm text-muted-foreground">Manage your API and translation settings</span>
</div>
{/* Right side */}
{!isLoading && user && (
<div className="flex items-center gap-3">
<Badge
variant="secondary"
className={cn(
'border border-accent/20',
user.tier === 'pro' ? 'bg-accent/10 text-accent' : 'bg-muted text-muted-foreground'
)}
>
{user.tier === 'pro' ? 'Pro Plan' : 'Free Plan'}
</Badge>
<Avatar className="size-8">
<AvatarFallback className="bg-accent text-accent-foreground text-xs font-semibold">
{getInitials(user.name)}
</AvatarFallback>
</Avatar>
</div>
)}
</header>
{/* Mobile navigation drawer */}
{mobileOpen && (
<div className="border-b border-border bg-card px-4 py-3 lg:hidden">
<nav className="flex flex-col gap-1">
{navItems.map((item) => {
const isActive = pathname === item.href;
return (
<Link
key={item.href}
href={item.href}
onClick={() => setMobileOpen(false)}
className={cn(
'flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors',
isActive
? 'bg-secondary text-foreground'
: 'text-muted-foreground hover:bg-secondary/60 hover:text-foreground'
)}
>
<item.icon className="size-4 shrink-0" />
{item.label}
</Link>
);
})}
<Separator className="my-2" />
{!isLoading && user && (
<div className="flex items-center gap-3 px-3 py-2">
<Avatar className="size-8">
<AvatarFallback className="bg-accent text-accent-foreground text-xs font-semibold">
{getInitials(user.name)}
</AvatarFallback>
</Avatar>
<div className="flex flex-col gap-0.5">
<span className="text-sm font-medium text-foreground">{user.name}</span>
<Badge
variant="secondary"
className={cn(
'text-xs w-fit',
user.tier === 'pro' && 'border border-accent/20 bg-accent/10 text-accent'
)}
>
{user.tier === 'pro' ? 'Pro' : 'Free'}
</Badge>
</div>
</div>
)}
<button
onClick={logout}
className="flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-secondary/60 hover:text-foreground"
>
<LogOut className="size-4 shrink-0" />
Sign out
</button>
<Link
href="/"
className="flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-secondary/60 hover:text-foreground"
>
<ChevronLeft className="size-4 shrink-0" />
Back to home
</Link>
</nav>
</div>
)}
</>
);
}

View File

@@ -0,0 +1,43 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { DashboardSidebar } from './DashboardSidebar';
import { DashboardHeader } from './DashboardHeader';
export function DashboardLayoutClient({ children }: { children: React.ReactNode }) {
const router = useRouter();
const [mounted, setMounted] = useState(false);
const [isAuthenticated, setIsAuthenticated] = useState(false);
useEffect(() => {
setMounted(true);
const token = localStorage.getItem('token');
if (!token) {
router.push('/auth/login?redirect=/dashboard');
} else {
setIsAuthenticated(true);
}
}, [router]);
if (!mounted || !isAuthenticated) {
return (
<div className="flex h-screen items-center justify-center bg-background">
<div className="text-center space-y-4">
<div className="animate-spin rounded-full h-8 w-8 border-4 border-muted border-t-foreground mx-auto"></div>
<p className="text-sm text-muted-foreground">Loading...</p>
</div>
</div>
);
}
return (
<div className="flex h-screen bg-background">
<DashboardSidebar />
<div className="flex flex-1 flex-col overflow-hidden">
<DashboardHeader />
<main className="flex-1 overflow-y-auto">{children}</main>
</div>
</div>
);
}

View File

@@ -0,0 +1,111 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { Languages, ChevronLeft, LogOut } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator';
import { Button } from '@/components/ui/button';
import { useUser } from './useUser';
import { useLogout } from './useLogout';
import { getNavItems } from './constants';
import { getInitials } from './utils';
export function DashboardSidebar() {
const pathname = usePathname();
const { data: user, isLoading } = useUser();
const { logout } = useLogout();
const navItems = getNavItems(user?.tier === 'pro');
return (
<aside className="hidden w-64 shrink-0 border-r border-border bg-card lg:flex lg:flex-col">
{/* Brand */}
<div className="flex h-14 items-center gap-2.5 px-5">
<div className="flex size-7 items-center justify-center rounded-md bg-foreground">
<Languages className="size-3.5 text-background" />
</div>
<span className="text-sm font-semibold tracking-tight text-foreground">
Office Translator
</span>
</div>
<Separator />
{/* Navigation */}
<nav className="flex flex-1 flex-col gap-1 px-3 py-4">
{navItems.map((item) => {
const isActive = pathname === item.href;
return (
<Link
key={item.href}
href={item.href}
className={cn(
'flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors',
isActive
? 'bg-secondary text-foreground'
: 'text-muted-foreground hover:bg-secondary/60 hover:text-foreground'
)}
>
<item.icon className="size-4 shrink-0" />
{item.label}
</Link>
);
})}
</nav>
<Separator />
{/* User section */}
{!isLoading && user && (
<div className="flex items-center gap-3 px-5 py-4">
<Avatar className="size-8">
<AvatarFallback className="bg-accent text-accent-foreground text-xs font-semibold">
{getInitials(user.name)}
</AvatarFallback>
</Avatar>
<div className="flex flex-col gap-0.5">
<span className="text-sm font-medium leading-none text-foreground">{user.name}</span>
<span className="text-xs leading-none text-muted-foreground">{user.email}</span>
</div>
<Badge
variant="secondary"
className={cn(
'ml-auto text-xs',
user.tier === 'pro' && 'border border-accent/20 bg-accent/10 text-accent'
)}
>
{user.tier === 'pro' ? 'Pro' : 'Free'}
</Badge>
</div>
)}
<Separator />
{/* Logout */}
<div className="px-3 py-3">
<Button
variant="ghost"
size="sm"
className="w-full justify-start gap-2 text-muted-foreground hover:text-foreground"
onClick={logout}
>
<LogOut className="size-3.5" />
Sign out
</Button>
</div>
{/* Back to homepage */}
<div className="px-3 py-3">
<Button variant="ghost" size="sm" className="w-full justify-start gap-2 text-muted-foreground" asChild>
<Link href="/">
<ChevronLeft className="size-3.5" />
Back to home
</Link>
</Button>
</div>
</aside>
);
}

View File

@@ -0,0 +1,152 @@
'use client';
import { useState } from 'react';
import { Copy, Check, Trash2 } from 'lucide-react';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import type { ApiKey } from './types';
interface ApiKeyTableProps {
keys: ApiKey[];
onRevoke: (key: ApiKey) => void;
isRevoking: boolean;
}
function formatDate(dateString: string | null): string {
if (!dateString) return 'Never';
const date = new Date(dateString);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return 'Just now';
if (diffMins < 60) return `${diffMins} min ago`;
if (diffHours < 24) return `${diffHours} hour${diffHours > 1 ? 's' : ''} ago`;
if (diffDays < 7) return `${diffDays} day${diffDays > 1 ? 's' : ''} ago`;
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
}
export function ApiKeyTable({ keys, onRevoke, isRevoking }: ApiKeyTableProps) {
const [copiedId, setCopiedId] = useState<string | null>(null);
const copyPrefix = (keyId: string, prefix: string) => {
navigator.clipboard.writeText(prefix);
setCopiedId(keyId);
setTimeout(() => setCopiedId(null), 2000);
};
if (keys.length === 0) {
return (
<div className="rounded-lg border border-border p-8 text-center">
<p className="text-muted-foreground">No API keys yet. Generate your first key to get started.</p>
</div>
);
}
return (
<div className="rounded-lg border border-border">
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
Name
</TableHead>
<TableHead className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
Key
</TableHead>
<TableHead className="hidden text-xs font-medium uppercase tracking-wider text-muted-foreground md:table-cell">
Created
</TableHead>
<TableHead className="hidden text-xs font-medium uppercase tracking-wider text-muted-foreground lg:table-cell">
Last Used
</TableHead>
<TableHead className="hidden text-xs font-medium uppercase tracking-wider text-muted-foreground lg:table-cell">
Uses
</TableHead>
<TableHead className="text-right text-xs font-medium uppercase tracking-wider text-muted-foreground">
Actions
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{keys.map((key) => (
<TableRow key={key.id}>
<TableCell className="font-medium">
{key.name}
</TableCell>
<TableCell>
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs">
{key.key_prefix}...
</code>
</TableCell>
<TableCell className="hidden text-muted-foreground md:table-cell">
{formatDate(key.created_at)}
</TableCell>
<TableCell className="hidden text-muted-foreground lg:table-cell">
{formatDate(key.last_used_at)}
</TableCell>
<TableCell className="hidden lg:table-cell">
<Badge variant="secondary" className="text-xs">
{key.usage_count}
</Badge>
</TableCell>
<TableCell>
<div className="flex items-center justify-end gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
onClick={() => copyPrefix(key.id, key.key_prefix)}
aria-label="Copy key prefix"
>
{copiedId === key.id ? (
<Check className="size-3.5 text-accent" />
) : (
<Copy className="size-3.5 text-muted-foreground" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>
{copiedId === key.id ? 'Copied!' : 'Copy prefix'}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
onClick={() => onRevoke(key)}
disabled={isRevoking}
aria-label="Revoke key"
>
<Trash2 className="size-3.5 text-muted-foreground hover:text-destructive" />
</Button>
</TooltipTrigger>
<TooltipContent>Revoke</TooltipContent>
</Tooltip>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
}

View File

@@ -0,0 +1,224 @@
'use client';
import { useState, useMemo } from 'react';
import { AlertTriangle, Copy, Check, CheckCircle2 } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import type { ApiKeyCreateResponse } from './types';
const MAX_KEY_NAME_LENGTH = 100;
const VALID_KEY_NAME_REGEX = /^[a-zA-Z0-9\s\-_]+$/;
interface GenerateKeyDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onGenerate: (name?: string) => Promise<ApiKeyCreateResponse>;
isGenerating: boolean;
maxKeysReached: boolean;
}
interface ValidationResult {
isValid: boolean;
error: string | null;
}
export function GenerateKeyDialog({
open,
onOpenChange,
onGenerate,
isGenerating,
maxKeysReached,
}: GenerateKeyDialogProps) {
const [step, setStep] = useState<'name' | 'result'>('name');
const [keyName, setKeyName] = useState('');
const [generatedKey, setGeneratedKey] = useState<ApiKeyCreateResponse | null>(null);
const [copied, setCopied] = useState(false);
const [touched, setTouched] = useState(false);
const validation = useMemo<ValidationResult>(() => {
const trimmedName = keyName.trim();
if (trimmedName.length > MAX_KEY_NAME_LENGTH) {
return { isValid: false, error: `Name must be ${MAX_KEY_NAME_LENGTH} characters or less` };
}
if (trimmedName && !VALID_KEY_NAME_REGEX.test(trimmedName)) {
return {
isValid: false,
error: 'Name can only contain letters, numbers, spaces, hyphens, and underscores'
};
}
return { isValid: true, error: null };
}, [keyName]);
const handleGenerate = async () => {
if (!validation.isValid) return;
try {
const result = await onGenerate(keyName.trim() || undefined);
setGeneratedKey(result);
setStep('result');
setKeyName('');
setTouched(false);
} catch {
onOpenChange(false);
}
};
const copyKey = () => {
if (generatedKey?.key) {
navigator.clipboard.writeText(generatedKey.key);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
};
const handleClose = () => {
setStep('name');
setGeneratedKey(null);
setCopied(false);
setKeyName('');
onOpenChange(false);
};
if (maxKeysReached) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Maximum Keys Reached</DialogTitle>
<DialogDescription>
You have reached the maximum of 10 API keys. Please revoke an existing key before generating a new one.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button onClick={() => onOpenChange(false)}>Close</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
if (step === 'result' && generatedKey) {
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<div className="flex items-center gap-3 mb-2">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-accent/10">
<CheckCircle2 className="h-5 w-5 text-accent" />
</div>
<DialogTitle>API Key Generated!</DialogTitle>
</div>
<DialogDescription>
Your new API key has been created. Copy it now - it won't be shown again.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="rounded-lg border border-amber-200 bg-amber-50 p-3 dark:border-amber-900/50 dark:bg-amber-950/20">
<div className="flex items-start gap-2">
<AlertTriangle className="h-4 w-4 text-amber-600 dark:text-amber-500 mt-0.5 shrink-0" />
<p className="text-sm text-amber-800 dark:text-amber-200">
<strong>Important:</strong> This is the only time you'll see this key. Store it securely.
</p>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="apiKey">API Key</Label>
<div className="flex gap-2">
<Input
id="apiKey"
readOnly
value={generatedKey.key}
className="font-mono text-xs"
/>
<Button onClick={copyKey} variant="outline" size="icon">
{copied ? (
<Check className="h-4 w-4 text-accent" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
</div>
</div>
<div className="text-sm text-muted-foreground">
<span className="font-medium">Name:</span> {generatedKey.name}
</div>
</div>
<DialogFooter>
<Button onClick={handleClose}>
{copied ? 'Done' : 'I\'ve copied the key'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Generate New API Key</DialogTitle>
<DialogDescription>
Create a new API key for programmatic access to the translation API.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="keyName">Key Name (optional)</Label>
<Input
id="keyName"
placeholder="e.g., Production, Staging"
value={keyName}
onChange={(e) => {
setKeyName(e.target.value);
setTouched(true);
}}
onBlur={() => setTouched(true)}
maxLength={MAX_KEY_NAME_LENGTH + 10}
className={touched && validation.error ? 'border-destructive' : ''}
aria-invalid={touched && validation.error ? 'true' : 'false'}
aria-describedby={touched && validation.error ? 'keyName-error' : undefined}
/>
<p className="text-xs text-muted-foreground">
A descriptive name to help you identify this key later.
{keyName.length > 0 && (
<span className="ml-2">({keyName.length}/{MAX_KEY_NAME_LENGTH})</span>
)}
</p>
{touched && validation.error && (
<p id="keyName-error" className="text-xs text-destructive">
{validation.error}
</p>
)}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={handleGenerate} disabled={isGenerating || !validation.isValid}>
{isGenerating ? 'Generating...' : 'Generate Key'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,56 @@
'use client';
import { Key, Sparkles } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import Link from 'next/link';
export function ProUpgradePrompt() {
return (
<div className="flex items-center justify-center min-h-[60vh] p-6">
<Card className="max-w-md w-full border-border/50 bg-gradient-to-br from-card via-card to-accent/5">
<CardHeader className="text-center pb-4">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-br from-accent/20 to-accent/5">
<Key className="h-8 w-8 text-accent" />
</div>
<CardTitle className="text-2xl font-semibold">API Keys</CardTitle>
<CardDescription className="text-base">
Automate your translations with API access
</CardDescription>
</CardHeader>
<CardContent className="text-center space-y-6">
<div className="space-y-3">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Sparkles className="h-4 w-4 text-accent shrink-0" />
<span>Generate unlimited API keys</span>
</div>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Sparkles className="h-4 w-4 text-accent shrink-0" />
<span>Automate document translation</span>
</div>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Sparkles className="h-4 w-4 text-accent shrink-0" />
<span>Webhook notifications</span>
</div>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Sparkles className="h-4 w-4 text-accent shrink-0" />
<span>LLM translation modes</span>
</div>
</div>
<div className="pt-2">
<p className="text-sm text-muted-foreground mb-4">
API Keys are a <span className="text-accent font-medium">Pro</span> feature.
Upgrade to unlock API automation.
</p>
<Button asChild className="w-full bg-accent hover:bg-accent/90">
<Link href="/pricing">
Upgrade to Pro
</Link>
</Button>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,75 @@
'use client';
import { AlertTriangle } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
interface RevokeKeyDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onConfirm: () => void;
isRevoking: boolean;
keyName?: string;
}
export function RevokeKeyDialog({
open,
onOpenChange,
onConfirm,
isRevoking,
keyName,
}: RevokeKeyDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Revoke API Key</DialogTitle>
<DialogDescription>
Are you sure you want to revoke this API key?
{keyName && (
<span className="block mt-1 font-medium text-foreground">
"{keyName}"
</span>
)}
</DialogDescription>
</DialogHeader>
<div className="rounded-lg border border-destructive/50 bg-destructive/10 p-4">
<div className="flex items-start gap-3">
<AlertTriangle className="h-5 w-5 text-destructive shrink-0 mt-0.5" />
<div className="space-y-1">
<p className="text-sm font-medium text-destructive">This action cannot be undone</p>
<p className="text-sm text-muted-foreground">
Any applications using this key will lose access to the API immediately.
</p>
</div>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isRevoking}
>
Cancel
</Button>
<Button
variant="destructive"
onClick={onConfirm}
disabled={isRevoking}
>
{isRevoking ? 'Revoking...' : 'Revoke Key'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,64 @@
'use client';
import { useState, useMemo } from 'react';
import { Webhook, Copy, Check } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { API_BASE_URL } from '@/lib/apiClient';
function getWebhookSnippet(): string {
const baseUrl = API_BASE_URL.replace(/\/$/, '');
return `curl -X POST ${baseUrl}/api/v1/translate \\
-H "Authorization: Bearer sk_live_YOUR_API_KEY" \\
-H "Content-Type: multipart/form-data" \\
-F "file=@document.xlsx" \\
-F "source_lang=en" \\
-F "target_lang=fr" \\
-F "webhook_url=https://your-app.com/webhook/complete"`;
}
export function WebhookSnippet() {
const [copied, setCopied] = useState(false);
const webhookSnippet = useMemo(() => getWebhookSnippet(), []);
const copySnippet = () => {
navigator.clipboard.writeText(webhookSnippet);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<Webhook className="h-5 w-5 text-accent" />
<CardTitle className="text-base">Webhook Integration</CardTitle>
</div>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-sm text-muted-foreground">
Pass a <code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs">webhook_url</code> parameter
to receive a POST request when your translation is complete.
</p>
<div className="relative">
<Button
variant="ghost"
size="icon-sm"
className="absolute right-2 top-2 z-10"
onClick={copySnippet}
>
{copied ? (
<Check className="h-3.5 w-3.5 text-accent" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
</Button>
<pre className="overflow-x-auto rounded-lg border border-border bg-foreground p-4 text-xs leading-relaxed text-background/90">
<code>{webhookSnippet}</code>
</pre>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,217 @@
'use client';
import { useState, useEffect } from 'react';
import { Zap, Plus, AlertCircle } from 'lucide-react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { useUser } from '@/app/dashboard/useUser';
import { useApiKeys } from './useApiKeys';
import { MAX_API_KEYS, type ApiKey } from './types';
import { ProUpgradePrompt } from './ProUpgradePrompt';
import { ApiKeyTable } from './ApiKeyTable';
import { GenerateKeyDialog } from './GenerateKeyDialog';
import { RevokeKeyDialog } from './RevokeKeyDialog';
import { WebhookSnippet } from './WebhookSnippet';
import { useToast } from '@/components/ui/toast';
export default function ApiKeysPage() {
const { data: user, isLoading: isLoadingUser } = useUser();
const {
keys,
total,
isLoading: isLoadingKeys,
isGenerating,
isRevoking,
generateKey,
revokeKey,
errorDetails,
parseGenerateError,
parseRevokeError,
} = useApiKeys();
const { toast } = useToast();
const [generateDialogOpen, setGenerateDialogOpen] = useState(false);
const [revokeDialogOpen, setRevokeDialogOpen] = useState(false);
const [keyToRevoke, setKeyToRevoke] = useState<{ id: string; name: string } | null>(null);
const [apiError, setApiError] = useState<string | null>(null);
const isPro = user?.tier === 'pro';
const maxKeysReached = total >= MAX_API_KEYS;
const isLoading = isLoadingUser || isLoadingKeys;
// Handle API errors with specific error codes
useEffect(() => {
if (errorDetails?.code === 'PRO_FEATURE_REQUIRED') {
// Redirect to upgrade prompt will happen via isPro check
setApiError(null);
} else if (errorDetails?.code === 'API_KEY_LIMIT_REACHED') {
setApiError('You have reached the maximum of 10 API keys. Revoke an existing key to generate a new one.');
} else if (errorDetails) {
setApiError(errorDetails.message);
} else {
setApiError(null);
}
}, [errorDetails]);
const handleRevokeClick = (key: ApiKey) => {
setKeyToRevoke({ id: key.id, name: key.name });
setRevokeDialogOpen(true);
};
const handleRevokeConfirm = async () => {
if (!keyToRevoke) return;
try {
await revokeKey(keyToRevoke.id);
setRevokeDialogOpen(false);
setKeyToRevoke(null);
toast({
title: 'Key revoked',
description: 'The API key has been revoked successfully.',
});
} catch (error) {
const revokeError = parseRevokeError();
if (revokeError?.code === 'API_KEY_NOT_FOUND') {
toast({
variant: 'destructive',
title: 'Key Not Found',
description: 'The API key no longer exists. It may have already been revoked.',
});
} else {
toast({
variant: 'destructive',
title: 'Error',
description: revokeError?.message || 'Failed to revoke the API key. Please try again.',
});
}
}
};
const handleGenerateKey = async (name?: string) => {
try {
const result = await generateKey(name);
return result;
} catch (error) {
const genError = parseGenerateError();
if (genError?.code === 'API_KEY_LIMIT_REACHED') {
toast({
variant: 'destructive',
title: 'Limit Reached',
description: 'You have reached the maximum of 10 API keys. Revoke an existing key to generate a new one.',
});
} else if (genError?.code === 'PRO_FEATURE_REQUIRED') {
toast({
variant: 'destructive',
title: 'Pro Feature Required',
description: 'API keys are a Pro feature. Please upgrade your account.',
});
} else {
toast({
variant: 'destructive',
title: 'Error',
description: genError?.message || 'Failed to generate API key. Please try again.',
});
}
throw error;
}
};
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<div className="text-center space-y-4">
<div className="animate-spin rounded-full h-8 w-8 border-4 border-muted border-t-foreground mx-auto"></div>
<p className="text-sm text-muted-foreground">Loading...</p>
</div>
</div>
);
}
if (!isPro) {
return <ProUpgradePrompt />;
}
return (
<div className="space-y-6 p-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">API Keys</h1>
<p className="text-muted-foreground">
Manage your API keys for programmatic access to the translation API.
</p>
</div>
{apiError && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{apiError}</AlertDescription>
</Alert>
)}
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<div className="flex size-8 items-center justify-center rounded-lg bg-accent/10">
<Zap className="size-4 text-accent" />
</div>
<div>
<CardTitle className="text-base">API & Automation</CardTitle>
<CardDescription>Generate and manage your API keys for automation workflows</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium">
{total} of {MAX_API_KEYS} keys used
</p>
<p className="text-xs text-muted-foreground">
{maxKeysReached ? (
<span className="text-amber-600">Maximum keys reached. Revoke a key to generate a new one.</span>
) : (
`You can generate ${MAX_API_KEYS - total} more key${MAX_API_KEYS - total !== 1 ? 's' : ''}.`
)}
</p>
</div>
<Button
onClick={() => setGenerateDialogOpen(true)}
disabled={maxKeysReached || isGenerating}
className="gap-1.5"
>
<Plus className="size-3.5" />
Generate New Key
</Button>
</div>
<ApiKeyTable
keys={keys}
onRevoke={handleRevokeClick}
isRevoking={isRevoking}
/>
</CardContent>
</Card>
<Separator />
<WebhookSnippet />
<GenerateKeyDialog
open={generateDialogOpen}
onOpenChange={setGenerateDialogOpen}
onGenerate={handleGenerateKey}
isGenerating={isGenerating}
maxKeysReached={maxKeysReached}
/>
<RevokeKeyDialog
open={revokeDialogOpen}
onOpenChange={setRevokeDialogOpen}
onConfirm={handleRevokeConfirm}
isRevoking={isRevoking}
keyName={keyToRevoke?.name}
/>
</div>
);
}

View File

@@ -0,0 +1,40 @@
export interface ApiKey {
id: string;
name: string;
key_prefix: string;
is_active: boolean;
last_used_at: string | null;
usage_count: number;
created_at: string;
}
export interface ApiKeyCreateResponse {
id: string;
key: string;
name: string;
key_prefix: string;
created_at: string;
}
export interface ApiKeysListResponse {
data: ApiKey[];
meta: {
total: number;
};
}
export interface ApiKeyCreateApiResponse {
data: ApiKeyCreateResponse;
meta: Record<string, unknown>;
}
export interface ApiKeyRevokeResponse {
data: {
id: string;
revoked: boolean;
revoked_at: string;
};
meta: Record<string, unknown>;
}
export const MAX_API_KEYS = 10;

View File

@@ -0,0 +1,116 @@
'use client';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient, ApiClientError } from '@/lib/apiClient';
import type {
ApiKey,
ApiKeyCreateResponse,
ApiKeyRevokeResponse,
} from './types';
const API_KEYS_QUERY_KEY = ['api-keys'];
interface ApiKeysListApiResponse {
data: ApiKey[];
meta: {
total: number;
};
}
export type ApiKeyErrorCode = 'PRO_FEATURE_REQUIRED' | 'API_KEY_LIMIT_REACHED' | 'API_KEY_NOT_FOUND';
export interface ApiKeyError {
status: number;
code: ApiKeyErrorCode;
message: string;
}
export function useApiKeys() {
const queryClient = useQueryClient();
const {
data: keysData,
isLoading,
error,
} = useQuery<ApiKeysListApiResponse, ApiClientError>({
queryKey: API_KEYS_QUERY_KEY,
queryFn: async () => {
const response = await apiClient.get<ApiKeysListApiResponse>('/api/v1/api-keys');
return response.data;
},
retry: (failureCount, err) => {
if (err.status === 403 || err.status === 429) return false;
return failureCount < 2;
},
});
const keys = keysData?.data ?? [];
const total = keysData?.meta?.total ?? 0;
const generateKeyMutation = useMutation<ApiKeyCreateResponse, ApiClientError, string | undefined>({
mutationFn: async (name?: string): Promise<ApiKeyCreateResponse> => {
const response = await apiClient.post<ApiKeyCreateResponse>('/api/v1/api-keys', {
name: name || 'API Key',
});
return response.data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: API_KEYS_QUERY_KEY });
},
});
const revokeKeyMutation = useMutation<ApiKeyRevokeResponse, ApiClientError, string>({
mutationFn: async (keyId: string): Promise<ApiKeyRevokeResponse> => {
const response = await apiClient.delete<ApiKeyRevokeResponse>(`/api/v1/api-keys/${keyId}`);
return response.data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: API_KEYS_QUERY_KEY });
},
});
const generateKey = async (name?: string) => {
return generateKeyMutation.mutateAsync(name);
};
const revokeKey = async (keyId: string) => {
return revokeKeyMutation.mutateAsync(keyId);
};
const parseError = (error: ApiClientError | null): ApiKeyError | null => {
if (!error) return null;
const status = error.status || 500;
const code = error.code as ApiKeyErrorCode | string;
const message = error.message;
if (status === 403 && code === 'PRO_FEATURE_REQUIRED') {
return { status: 403, code: 'PRO_FEATURE_REQUIRED', message: message || 'Pro feature required' };
}
if (status === 429 && code === 'API_KEY_LIMIT_REACHED') {
return { status: 429, code: 'API_KEY_LIMIT_REACHED', message: message || 'Maximum API keys reached' };
}
if (status === 404 && code === 'API_KEY_NOT_FOUND') {
return { status: 404, code: 'API_KEY_NOT_FOUND', message: message || 'API key not found' };
}
// For non-matching errors, return with the actual code but cast appropriately for type safety
return { status, code: code as ApiKeyErrorCode, message };
};
return {
keys,
total,
isLoading,
error,
errorDetails: parseError(error),
isGenerating: generateKeyMutation.isPending,
isRevoking: revokeKeyMutation.isPending,
generateKey,
revokeKey,
generateError: generateKeyMutation.error,
revokeError: revokeKeyMutation.error,
parseGenerateError: () => parseError(generateKeyMutation.error),
parseRevokeError: () => parseError(revokeKeyMutation.error),
};
}

View File

@@ -0,0 +1,25 @@
import { LayoutDashboard, FileText, Key, BookText, type LucideIcon } from 'lucide-react';
export interface NavItem {
label: string;
href: string;
icon: LucideIcon;
proOnly?: boolean;
}
export const baseNavItems: NavItem[] = [
{ label: 'Overview', href: '/dashboard', icon: LayoutDashboard },
{ label: 'Translate', href: '/dashboard/translate', icon: FileText },
{ label: 'API Keys', href: '/dashboard/api-keys', icon: Key },
];
export const proNavItem: NavItem = {
label: 'Glossaries',
href: '/dashboard/glossaries',
icon: BookText,
proOnly: true
};
export function getNavItems(isPro: boolean): NavItem[] {
return isPro ? [...baseNavItems, proNavItem] : baseNavItems;
}

View File

@@ -0,0 +1,108 @@
'use client';
import { useState, useCallback } from 'react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { TermEditor } from './TermEditor';
import type { GlossaryTermInput } from './types';
interface CreateGlossaryDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onCreate: (data: { name: string; terms: GlossaryTermInput[] }) => Promise<void>;
isCreating: boolean;
}
export function CreateGlossaryDialog({
open,
onOpenChange,
onCreate,
isCreating,
}: CreateGlossaryDialogProps) {
const [name, setName] = useState('');
const [terms, setTerms] = useState<GlossaryTermInput[]>([{ source: '', target: '' }]);
const handleCreate = useCallback(async () => {
if (!name.trim()) return;
const validTerms = terms.filter(t => t.source.trim() && t.target.trim());
await onCreate({
name: name.trim(),
terms: validTerms,
});
setName('');
setTerms([{ source: '', target: '' }]);
}, [name, terms, onCreate]);
const handleOpenChange = useCallback((newOpen: boolean) => {
if (!newOpen) {
setName('');
setTerms([{ source: '', target: '' }]);
}
onOpenChange(newOpen);
}, [onOpenChange]);
const validTermsCount = terms.filter(t => t.source.trim() && t.target.trim()).length;
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Create New Glossary</DialogTitle>
<DialogDescription>
Create a glossary with custom terminology for your translations.
</DialogDescription>
</DialogHeader>
<div className="space-y-6 py-4">
<div className="space-y-2">
<Label htmlFor="glossary-name">Glossary Name</Label>
<Input
id="glossary-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g., Technical Terms FR-EN"
disabled={isCreating}
/>
</div>
<div className="space-y-2">
<Label>Terms ({validTermsCount} valid)</Label>
<TermEditor
terms={terms}
onChange={setTerms}
disabled={isCreating}
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => handleOpenChange(false)}
disabled={isCreating}
>
Cancel
</Button>
<Button
onClick={handleCreate}
disabled={isCreating || !name.trim()}
>
{isCreating ? 'Creating...' : 'Create Glossary'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,75 @@
'use client';
import { AlertTriangle } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
interface DeleteGlossaryDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onConfirm: () => void;
isDeleting: boolean;
glossaryName?: string;
}
export function DeleteGlossaryDialog({
open,
onOpenChange,
onConfirm,
isDeleting,
glossaryName,
}: DeleteGlossaryDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Delete Glossary</DialogTitle>
<DialogDescription>
Are you sure you want to delete this glossary?
{glossaryName && (
<span className="block mt-1 font-medium text-foreground">
"{glossaryName}"
</span>
)}
</DialogDescription>
</DialogHeader>
<div className="rounded-lg border border-destructive/50 bg-destructive/10 p-4">
<div className="flex items-start gap-3">
<AlertTriangle className="h-5 w-5 text-destructive shrink-0 mt-0.5" />
<div className="space-y-1">
<p className="text-sm font-medium text-destructive">This action cannot be undone</p>
<p className="text-sm text-muted-foreground">
All term pairs will be permanently removed.
</p>
</div>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isDeleting}
>
Cancel
</Button>
<Button
variant="destructive"
onClick={onConfirm}
disabled={isDeleting}
>
{isDeleting ? 'Deleting...' : 'Delete'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,216 @@
'use client';
import { useState, useCallback, useRef } from 'react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Download, Upload } from 'lucide-react';
import { TermEditor } from './TermEditor';
import { exportGlossaryToCsv, parseCsvToTerms } from './csvUtils';
import { useToast } from '@/components/ui/toast';
import type { Glossary, GlossaryTermInput } from './types';
import { MAX_TERMS_PER_GLOSSARY } from './types';
interface EditGlossaryDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
glossary: Glossary | null;
onSave: (id: string, data: { name: string; terms: GlossaryTermInput[] }) => Promise<void>;
isSaving: boolean;
}
export function EditGlossaryDialog({
open,
onOpenChange,
glossary,
onSave,
isSaving,
}: EditGlossaryDialogProps) {
const [name, setName] = useState('');
const [terms, setTerms] = useState<GlossaryTermInput[]>([]);
const fileInputRef = useRef<HTMLInputElement>(null);
const isInitialized = useRef(false);
if (glossary && !isInitialized.current) {
setName(glossary.name);
setTerms(glossary.terms.map(t => ({ source: t.source, target: t.target })));
isInitialized.current = true;
}
if (!open && isInitialized.current) {
isInitialized.current = false;
}
const handleSave = useCallback(async () => {
if (!glossary || !name.trim()) return;
const validTerms = terms.filter(t => t.source.trim() && t.target.trim());
await onSave(glossary.id, {
name: name.trim(),
terms: validTerms,
});
}, [glossary, name, terms, onSave]);
const handleExport = useCallback(() => {
if (!glossary) return;
const glossaryWithCurrentTerms: Glossary = {
...glossary,
name,
terms: terms.map((t, i) => ({
id: `temp-${i}`,
source: t.source,
target: t.target,
created_at: null,
})),
};
exportGlossaryToCsv(glossaryWithCurrentTerms);
}, [glossary, name, terms]);
const handleImportClick = useCallback(() => {
fileInputRef.current?.click();
}, []);
const { toast } = useToast();
const handleFileChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (event) => {
const text = event.target?.result;
if (typeof text === 'string') {
const importedTerms = parseCsvToTerms(text);
if (importedTerms.length > 0) {
if (importedTerms.length > MAX_TERMS_PER_GLOSSARY) {
toast({
variant: 'destructive',
title: 'Import failed',
description: `CSV contains ${importedTerms.length} terms, but maximum is ${MAX_TERMS_PER_GLOSSARY}. Please reduce the number of terms.`,
});
e.target.value = '';
return;
}
setTerms(importedTerms);
toast({
title: 'Import successful',
description: `${importedTerms.length} terms imported successfully.`,
});
} else {
toast({
variant: 'destructive',
title: 'Import failed',
description: 'No valid terms found in CSV file.',
});
}
}
};
reader.onerror = () => {
toast({
variant: 'destructive',
title: 'Import failed',
description: 'Failed to read CSV file.',
});
};
reader.readAsText(file);
e.target.value = '';
}, [toast]);
const validTermsCount = terms.filter(t => t.source.trim() && t.target.trim()).length;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Edit Glossary</DialogTitle>
<DialogDescription>
Update the glossary name and term pairs.
</DialogDescription>
</DialogHeader>
<div className="space-y-6 py-4">
<div className="space-y-2">
<Label htmlFor="glossary-name">Glossary Name</Label>
<Input
id="glossary-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Enter glossary name..."
disabled={isSaving}
/>
</div>
<div className="space-y-2">
<Label>Terms ({validTermsCount} valid)</Label>
<TermEditor
terms={terms}
onChange={setTerms}
disabled={isSaving}
/>
</div>
<div className="flex items-center gap-2 pt-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={handleExport}
disabled={isSaving || validTermsCount === 0}
className="gap-1.5"
>
<Download className="size-3.5" />
Export CSV
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleImportClick}
disabled={isSaving}
className="gap-1.5"
>
<Upload className="size-3.5" />
Import CSV
</Button>
<input
ref={fileInputRef}
type="file"
accept=".csv"
onChange={handleFileChange}
className="hidden"
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isSaving}
>
Cancel
</Button>
<Button
onClick={handleSave}
disabled={isSaving || !name.trim()}
>
{isSaving ? 'Saving...' : 'Save Changes'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,83 @@
'use client';
import { memo, useCallback } from 'react';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { BookText, Pencil, Trash2 } from 'lucide-react';
import type { GlossaryListItem } from './types';
interface GlossaryCardProps {
glossary: GlossaryListItem;
onEdit: (id: string) => void;
onDelete: (id: string, name: string) => void;
isDeleting?: boolean;
}
export const GlossaryCard = memo(function GlossaryCard({
glossary,
onEdit,
onDelete,
isDeleting = false,
}: GlossaryCardProps) {
const handleEdit = useCallback(() => {
onEdit(glossary.id);
}, [glossary.id, onEdit]);
const handleDelete = useCallback(() => {
onDelete(glossary.id, glossary.name);
}, [glossary.id, glossary.name, onDelete]);
const formattedDate = new Date(glossary.created_at).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
});
return (
<Card className="group hover:border-border/80 transition-colors">
<CardContent className="p-4">
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-3 min-w-0 flex-1">
<div className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-accent/10">
<BookText className="size-5 text-accent" />
</div>
<div className="min-w-0 flex-1">
<h3 className="font-medium text-foreground truncate">{glossary.name}</h3>
<div className="flex items-center gap-2 mt-1">
<Badge variant="secondary" className="text-xs">
{glossary.terms_count} {glossary.terms_count === 1 ? 'term' : 'terms'}
</Badge>
<span className="text-xs text-muted-foreground">
Created {formattedDate}
</span>
</div>
</div>
</div>
<div className="flex items-center gap-1 shrink-0">
<Button
variant="ghost"
size="icon-sm"
onClick={handleEdit}
className="opacity-0 group-hover:opacity-100 transition-opacity"
aria-label={`Edit ${glossary.name}`}
>
<Pencil className="size-3.5 text-muted-foreground hover:text-foreground" />
</Button>
<Button
variant="ghost"
size="icon-sm"
onClick={handleDelete}
disabled={isDeleting}
className="opacity-0 group-hover:opacity-100 transition-opacity"
aria-label={`Delete ${glossary.name}`}
>
<Trash2 className="size-3.5 text-muted-foreground hover:text-destructive" />
</Button>
</div>
</div>
</CardContent>
</Card>
);
});

View File

@@ -0,0 +1,56 @@
'use client';
import { BookText, Sparkles } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import Link from 'next/link';
export function ProUpgradePrompt() {
return (
<div className="flex items-center justify-center min-h-[60vh] p-6">
<Card className="max-w-md w-full border-border/50 bg-gradient-to-br from-card via-card to-accent/5">
<CardHeader className="text-center pb-4">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-br from-accent/20 to-accent/5">
<BookText className="h-8 w-8 text-accent" />
</div>
<CardTitle className="text-2xl font-semibold">Glossaries</CardTitle>
<CardDescription className="text-base">
Customize your translations with custom terminology
</CardDescription>
</CardHeader>
<CardContent className="text-center space-y-6">
<div className="space-y-3">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Sparkles className="h-4 w-4 text-accent shrink-0" />
<span>Create multiple glossaries</span>
</div>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Sparkles className="h-4 w-4 text-accent shrink-0" />
<span>Define sourcetarget term pairs</span>
</div>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Sparkles className="h-4 w-4 text-accent shrink-0" />
<span>Import/export via CSV</span>
</div>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Sparkles className="h-4 w-4 text-accent shrink-0" />
<span>Apply to LLM translations</span>
</div>
</div>
<div className="pt-2">
<p className="text-sm text-muted-foreground mb-4">
Glossaries are a <span className="text-accent font-medium">Pro</span> feature.
Upgrade to unlock custom terminology.
</p>
<Button asChild className="w-full bg-accent hover:bg-accent/90">
<Link href="/pricing">
Upgrade to Pro
</Link>
</Button>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,120 @@
'use client';
import { memo, useCallback, useMemo } from 'react';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { ArrowRight, Plus, Trash2 } from 'lucide-react';
import type { GlossaryTermInput, GlossaryTermInputWithId } from './types';
import { MAX_TERMS_PER_GLOSSARY, generateTermId } from './types';
interface TermEditorProps {
terms: GlossaryTermInput[];
onChange: (terms: GlossaryTermInput[]) => void;
disabled?: boolean;
}
// Generate stable IDs for terms based on index and content hash
function getTermKey(term: GlossaryTermInput, index: number): string {
// Create a stable key from content to help React reconciliation
const contentHash = `${term.source}-${term.target}`.slice(0, 50);
return `term-${index}-${contentHash}`;
}
export const TermEditor = memo(function TermEditor({
terms,
onChange,
disabled = false,
}: TermEditorProps) {
// Generate stable keys for current terms
const termKeys = useMemo(() => {
return terms.map((term, index) => getTermKey(term, index));
}, [terms]);
const addTerm = useCallback(() => {
if (terms.length >= MAX_TERMS_PER_GLOSSARY) return;
onChange([...terms, { source: '', target: '' }]);
}, [terms, onChange]);
const removeTerm = useCallback((index: number) => {
onChange(terms.filter((_, i) => i !== index));
}, [terms, onChange]);
const updateTerm = useCallback((index: number, field: 'source' | 'target', value: string) => {
const newTerms = [...terms];
newTerms[index] = { ...newTerms[index], [field]: value };
onChange(newTerms);
}, [terms, onChange]);
const maxTermsReached = terms.length >= MAX_TERMS_PER_GLOSSARY;
return (
<div className="space-y-3">
<div className="mb-2 grid grid-cols-[1fr_32px_1fr_36px] items-center gap-2 px-1">
<span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
Source Term
</span>
<span />
<span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
Target Translation
</span>
<span />
</div>
<div className="flex flex-col gap-2">
{terms.map((term, index) => (
<div
key={termKeys[index]}
className="group grid grid-cols-[1fr_32px_1fr_36px] items-center gap-2"
>
<Input
value={term.source}
onChange={(e) => updateTerm(index, 'source', e.target.value)}
placeholder="Source term..."
className="font-mono text-xs"
aria-label={`Source term ${index + 1}`}
disabled={disabled}
/>
<div className="flex items-center justify-center">
<ArrowRight className="size-3.5 text-muted-foreground" />
</div>
<Input
value={term.target}
onChange={(e) => updateTerm(index, 'target', e.target.value)}
placeholder="Translation..."
className="font-mono text-xs"
aria-label={`Target translation ${index + 1}`}
disabled={disabled}
/>
<Button
variant="ghost"
size="icon-sm"
onClick={() => removeTerm(index)}
disabled={disabled}
className="opacity-0 group-hover:opacity-100 transition-opacity"
aria-label={`Remove term ${index + 1}`}
>
<Trash2 className="size-3.5 text-muted-foreground hover:text-destructive" />
</Button>
</div>
))}
</div>
<Button
variant="outline"
size="sm"
onClick={addTerm}
disabled={disabled || maxTermsReached}
className="mt-3 gap-1.5 border-dashed"
>
<Plus className="size-3.5" />
Add Term
</Button>
{maxTermsReached && (
<p className="text-xs text-amber-600">
Maximum {MAX_TERMS_PER_GLOSSARY} terms per glossary reached.
</p>
)}
</div>
);
});

View File

@@ -0,0 +1,85 @@
import type { Glossary, GlossaryTermInput } from './types';
export function exportGlossaryToCsv(glossary: Glossary): void {
const csvContent = generateCsvContent(glossary.terms.map(t => ({ source: t.source, target: t.target })));
downloadCsv(csvContent, `${glossary.name.replace(/[^a-z0-9]/gi, '_')}.csv`);
}
export function generateCsvContent(terms: GlossaryTermInput[]): string {
const header = 'source,target';
const rows = terms
.filter(t => t.source.trim() && t.target.trim())
.map(t => `${escapeCsvField(t.source)},${escapeCsvField(t.target)}`);
return [header, ...rows].join('\n');
}
export function downloadCsv(content: string, filename: string): void {
const blob = new Blob([content], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
export function parseCsvToTerms(csvText: string): GlossaryTermInput[] {
const lines = csvText.split(/\r?\n/).filter(line => line.trim());
if (lines.length === 0) return [];
const firstLine = lines[0].toLowerCase();
const hasHeader = firstLine.includes('source') && firstLine.includes('target');
const dataLines = hasHeader ? lines.slice(1) : lines;
const terms: GlossaryTermInput[] = [];
for (const line of dataLines) {
const parsed = parseCsvLine(line);
if (parsed.length >= 2) {
const source = parsed[0].trim();
const target = parsed[1].trim();
if (source && target) {
terms.push({ source, target });
}
}
}
return terms;
}
function parseCsvLine(line: string): string[] {
const result: string[] = [];
let current = '';
let inQuotes = false;
for (let i = 0; i < line.length; i++) {
const char = line[i];
if (char === '"') {
if (inQuotes && line[i + 1] === '"') {
current += '"';
i++;
} else {
inQuotes = !inQuotes;
}
} else if (char === ',' && !inQuotes) {
result.push(current);
current = '';
} else {
current += char;
}
}
result.push(current);
return result;
}
function escapeCsvField(field: string): string {
if (field.includes(',') || field.includes('"') || field.includes('\n')) {
return `"${field.replace(/"/g, '""')}"`;
}
return field;
}

View File

@@ -0,0 +1,242 @@
'use client';
import { useState } from 'react';
import { BookText, Plus } from 'lucide-react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
import { useUser } from '@/app/dashboard/useUser';
import { useGlossaries, useGlossary } from './useGlossaries';
import type { Glossary, GlossaryTermInput, GlossaryListItem } from './types';
import { ProUpgradePrompt } from './ProUpgradePrompt';
import { GlossaryCard } from './GlossaryCard';
import { CreateGlossaryDialog } from './CreateGlossaryDialog';
import { EditGlossaryDialog } from './EditGlossaryDialog';
import { DeleteGlossaryDialog } from './DeleteGlossaryDialog';
import { useToast } from '@/components/ui/toast';
export default function GlossariesPage() {
const { data: user, isLoading: isLoadingUser } = useUser();
const {
glossaries,
total,
isLoading: isLoadingGlossaries,
isCreating,
isUpdating,
isDeleting,
createGlossary,
updateGlossary,
deleteGlossary,
} = useGlossaries();
const { toast } = useToast();
const [createDialogOpen, setCreateDialogOpen] = useState(false);
const [editDialogOpen, setEditDialogOpen] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [selectedGlossary, setSelectedGlossary] = useState<GlossaryListItem | null>(null);
const [glossaryToEdit, setGlossaryToEdit] = useState<Glossary | null>(null);
const [glossaryToDelete, setGlossaryToDelete] = useState<{ id: string; name: string } | null>(null);
const { glossary: fullGlossary, isLoading: isLoadingGlossaryDetail } = useGlossary(
selectedGlossary?.id || null
);
const isPro = user?.tier === 'pro';
const isLoading = isLoadingUser || isLoadingGlossaries;
const handleEditClick = (id: string) => {
const glossary = glossaries.find((g: GlossaryListItem) => g.id === id);
if (glossary) {
setSelectedGlossary(glossary);
setEditDialogOpen(true);
}
};
const handleDeleteClick = (id: string, name: string) => {
setGlossaryToDelete({ id, name });
setDeleteDialogOpen(true);
};
const handleCreateGlossary = async (data: { name: string; terms: GlossaryTermInput[] }) => {
try {
await createGlossary(data);
setCreateDialogOpen(false);
toast({
title: 'Glossary created',
description: `"${data.name}" has been created successfully.`,
});
} catch (error) {
toast({
variant: 'destructive',
title: 'Error',
description: 'Failed to create glossary. Please try again.',
});
throw error;
}
};
const handleSaveGlossary = async (id: string, data: { name: string; terms: GlossaryTermInput[] }) => {
try {
await updateGlossary(id, data);
setEditDialogOpen(false);
setSelectedGlossary(null);
toast({
title: 'Glossary updated',
description: `"${data.name}" has been updated successfully.`,
});
} catch (error) {
toast({
variant: 'destructive',
title: 'Error',
description: 'Failed to update glossary. Please try again.',
});
throw error;
}
};
const handleDeleteConfirm = async () => {
if (!glossaryToDelete) return;
try {
await deleteGlossary(glossaryToDelete.id);
setDeleteDialogOpen(false);
setGlossaryToDelete(null);
toast({
title: 'Glossary deleted',
description: 'The glossary has been deleted successfully.',
});
} catch (error) {
toast({
variant: 'destructive',
title: 'Error',
description: 'Failed to delete glossary. Please try again.',
});
}
};
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<div className="text-center space-y-4">
<div className="animate-spin rounded-full h-8 w-8 border-4 border-muted border-t-foreground mx-auto"></div>
<p className="text-sm text-muted-foreground">Loading...</p>
</div>
</div>
);
}
if (!isPro) {
return <ProUpgradePrompt />;
}
return (
<div className="space-y-6 p-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Glossaries</h1>
<p className="text-muted-foreground">
Manage custom terminology for your LLM translations.
</p>
</div>
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<div className="flex size-8 items-center justify-center rounded-lg bg-accent/10">
<BookText className="size-4 text-accent" />
</div>
<div>
<CardTitle className="text-base">Your Glossaries</CardTitle>
<CardDescription>Create and manage glossaries for consistent translations</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium">
{total} glossarie{total !== 1 ? 's' : ''}
</p>
<p className="text-xs text-muted-foreground">
Define term pairs to customize your LLM translations
</p>
</div>
<Button
onClick={() => setCreateDialogOpen(true)}
disabled={isCreating}
className="gap-1.5"
>
<Plus className="size-3.5" />
Create New Glossary
</Button>
</div>
{glossaries.length === 0 ? (
<div className="text-center py-8">
<BookText className="size-12 mx-auto text-muted-foreground/50 mb-4" />
<p className="text-muted-foreground">No glossaries yet</p>
<p className="text-sm text-muted-foreground/80">
Create your first glossary to customize translations
</p>
</div>
) : (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{glossaries.map((glossary: GlossaryListItem) => (
<GlossaryCard
key={glossary.id}
glossary={glossary}
onEdit={handleEditClick}
onDelete={handleDeleteClick}
isDeleting={isDeleting && glossaryToDelete?.id === glossary.id}
/>
))}
</div>
)}
</CardContent>
</Card>
<Separator />
<Card className="border-border/50">
<CardHeader>
<CardTitle className="text-sm">About Glossaries</CardTitle>
</CardHeader>
<CardContent className="text-sm text-muted-foreground space-y-2">
<p>
Glossaries let you define custom terminology for your translations. When using LLM translation modes, your terms will be applied to ensure consistent translations.
</p>
<p>
<strong>Format:</strong> Each term has a source (original) and target (translation) pair.
</p>
</CardContent>
</Card>
<CreateGlossaryDialog
open={createDialogOpen}
onOpenChange={setCreateDialogOpen}
onCreate={handleCreateGlossary}
isCreating={isCreating}
/>
{editDialogOpen && (fullGlossary || !isLoadingGlossaryDetail) && (
<EditGlossaryDialog
open={editDialogOpen}
onOpenChange={(open) => {
setEditDialogOpen(open);
if (!open) setSelectedGlossary(null);
}}
glossary={fullGlossary}
onSave={handleSaveGlossary}
isSaving={isUpdating}
/>
)}
<DeleteGlossaryDialog
open={deleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
onConfirm={handleDeleteConfirm}
isDeleting={isDeleting}
glossaryName={glossaryToDelete?.name}
/>
</div>
);
}

View File

@@ -0,0 +1,73 @@
export interface GlossaryTerm {
id: string;
source: string;
target: string;
created_at: string | null;
}
export interface Glossary {
id: string;
name: string;
terms: GlossaryTerm[];
created_at: string;
updated_at: string;
}
export interface GlossaryListItem {
id: string;
name: string;
terms_count: number;
created_at: string;
}
export interface GlossaryListResponse {
data: GlossaryListItem[];
meta: {
total: number;
page: number;
per_page: number;
total_pages: number;
};
}
export interface GlossaryDetailResponse {
data: Glossary;
meta: Record<string, unknown>;
}
export interface GlossaryCreateResponse {
data: Glossary;
meta: Record<string, unknown>;
}
export interface GlossaryUpdateResponse {
data: Glossary;
meta: Record<string, unknown>;
}
export interface GlossaryTermInput {
source: string;
target: string;
}
export interface GlossaryTermInputWithId extends GlossaryTermInput {
id: string;
}
export interface GlossaryCreateInput {
name: string;
terms?: GlossaryTermInput[];
}
export interface GlossaryUpdateInput {
name?: string;
terms?: GlossaryTermInput[];
}
export const MAX_TERMS_PER_GLOSSARY = 500;
// Generate unique IDs for React keys
let idCounter = 0;
export function generateTermId(): string {
return `term-${Date.now()}-${++idCounter}`;
}

View File

@@ -0,0 +1,180 @@
'use client';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useRouter } from 'next/navigation';
import { apiClient, ApiClientError } from '@/lib/apiClient';
import type { ApiResponse } from '@/lib/types';
import type {
GlossaryListItem,
Glossary,
GlossaryListResponse,
GlossaryDetailResponse,
GlossaryCreateInput,
GlossaryUpdateInput,
} from './types';
const GLOSSARIES_QUERY_KEY = ['glossaries'];
export type GlossaryErrorCode =
| 'PRO_FEATURE_REQUIRED'
| 'TERMS_LIMIT_EXCEEDED'
| 'GLOSSARY_NOT_FOUND'
| 'INVALID_GLOSSARY_ID'
| 'UNAUTHORIZED';
export interface GlossaryError {
status: number;
code: GlossaryErrorCode;
message: string;
}
interface UseGlossariesOptions {
page?: number;
perPage?: number;
}
export function useGlossaries(options: UseGlossariesOptions = {}) {
const { page = 1, perPage = 50 } = options;
const queryClient = useQueryClient();
const router = useRouter();
const {
data: glossariesData,
isLoading,
error,
} = useQuery<GlossaryListResponse, ApiClientError>({
queryKey: [...GLOSSARIES_QUERY_KEY, page, perPage],
queryFn: async () => {
const response = await apiClient.get<GlossaryListResponse>(`/api/v1/glossaries?page=${page}&per_page=${perPage}`);
return response.data;
},
retry: (failureCount, err) => {
if (err.status === 403 || err.status === 401) return false;
return failureCount < 2;
},
});
// Handle 401 redirect
if (error?.status === 401) {
router.push('/auth/login');
}
const glossaries = glossariesData?.data ?? [];
const total = glossariesData?.meta?.total ?? 0;
const createMutation = useMutation({
mutationFn: async (input: GlossaryCreateInput): Promise<Glossary> => {
const response = await apiClient.post<GlossaryDetailResponse>('/api/v1/glossaries', input);
return response.data.data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: GLOSSARIES_QUERY_KEY });
},
});
const updateMutation = useMutation({
mutationFn: async ({ id, data }: { id: string; data: GlossaryUpdateInput }): Promise<Glossary> => {
const response = await apiClient.patch<GlossaryDetailResponse>(`/api/v1/glossaries/${id}`, data);
return response.data.data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: GLOSSARIES_QUERY_KEY });
},
});
const deleteMutation = useMutation({
mutationFn: async (id: string): Promise<void> => {
await apiClient.delete(`/api/v1/glossaries/${id}`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: GLOSSARIES_QUERY_KEY });
},
});
const createGlossary = async (input: GlossaryCreateInput) => {
return createMutation.mutateAsync(input);
};
const updateGlossary = async (id: string, data: GlossaryUpdateInput) => {
return updateMutation.mutateAsync({ id, data });
};
const deleteGlossary = async (id: string) => {
return deleteMutation.mutateAsync(id);
};
const parseError = (error: Error | null): GlossaryError | null => {
if (!error) return null;
const apiError = error as ApiClientError;
const status = apiError.status || 500;
const code = apiError.code as GlossaryErrorCode | string;
const message = apiError.message;
if (status === 401) {
return { status: 401, code: 'UNAUTHORIZED', message: message || 'Session expired' };
}
if (status === 403 && code === 'PRO_FEATURE_REQUIRED') {
return { status: 403, code: 'PRO_FEATURE_REQUIRED', message: message || 'Pro feature required' };
}
if (status === 400 && code === 'TERMS_LIMIT_EXCEEDED') {
return { status: 400, code: 'TERMS_LIMIT_EXCEEDED', message: message || 'Maximum 500 terms per glossary' };
}
if (status === 404 && code === 'GLOSSARY_NOT_FOUND') {
return { status: 404, code: 'GLOSSARY_NOT_FOUND', message: message || 'Glossary not found' };
}
if (status === 400 && code === 'INVALID_GLOSSARY_ID') {
return { status: 400, code: 'INVALID_GLOSSARY_ID', message: message || 'Invalid glossary ID' };
}
return { status, code: code as GlossaryErrorCode, message };
};
return {
glossaries,
total,
isLoading,
error,
errorDetails: parseError(error),
isCreating: createMutation.isPending,
isUpdating: updateMutation.isPending,
isDeleting: deleteMutation.isPending,
createGlossary,
updateGlossary,
deleteGlossary,
createError: createMutation.error,
updateError: updateMutation.error,
deleteError: deleteMutation.error,
parseCreateError: () => parseError(createMutation.error),
parseUpdateError: () => parseError(updateMutation.error),
parseDeleteError: () => parseError(deleteMutation.error),
};
}
export function useGlossary(id: string | null) {
const {
data,
isLoading,
error,
} = useQuery<GlossaryDetailResponse, ApiClientError>({
queryKey: [...GLOSSARIES_QUERY_KEY, id],
queryFn: async () => {
if (!id) throw new Error('Glossary ID is required');
const response = await apiClient.get<GlossaryDetailResponse>(`/api/v1/glossaries/${id}`);
return response.data;
},
enabled: !!id,
retry: (failureCount, err) => {
if (err.status === 403 || err.status === 404) return false;
return failureCount < 2;
},
});
const glossary = data?.data ?? null;
return {
glossary,
isLoading,
error,
};
}

View File

@@ -0,0 +1,10 @@
import { DashboardLayoutClient } from './DashboardLayoutClient';
export default function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
// Auth check is done client-side in DashboardLayoutClient
return <DashboardLayoutClient>{children}</DashboardLayoutClient>;
}

View File

@@ -1,615 +1,102 @@
"use client";
'use client';
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import {
FileText,
CreditCard,
Settings,
LogOut,
ChevronRight,
Zap,
TrendingUp,
Clock,
Check,
ExternalLink,
Crown,
Users,
BarChart3,
Shield,
Globe2,
FileSpreadsheet,
Presentation,
AlertTriangle,
Download,
Eye,
RefreshCw,
Calendar,
Activity,
Target,
Award,
ArrowUpRight,
ArrowDownRight,
Upload,
LogIn,
UserPlus
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Progress } from "@/components/ui/progress";
import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardStats, CardFeature } from "@/components/ui/card";
import { cn } from "@/lib/utils";
interface User {
id: string;
email: string;
name: string;
plan: string;
subscription_status: string;
docs_translated_this_month: number;
pages_translated_this_month: number;
extra_credits: number;
plan_limits: {
docs_per_month: number;
max_pages_per_doc: number;
features: string[];
providers: string[];
};
}
interface UsageStats {
docs_used: number;
docs_limit: number;
docs_remaining: number;
pages_used: number;
extra_credits: number;
max_pages_per_doc: number;
allowed_providers: string[];
}
interface ActivityItem {
id: string;
type: "translation" | "upload" | "download" | "login" | "signup";
title: string;
description: string;
timestamp: string;
status: "success" | "pending" | "error";
amount?: number;
}
import Link from 'next/link';
import { FileText, Key, BookText, ChevronRight } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { useUser } from './useUser';
export default function DashboardPage() {
const router = useRouter();
const [user, setUser] = useState<User | null>(null);
const [usage, setUsage] = useState<UsageStats | null>(null);
const [loading, setLoading] = useState(true);
const [recentActivity, setRecentActivity] = useState<ActivityItem[]>([]);
const [timeRange, setTimeRange] = useState<"7d" | "30d" | "24h">("30d");
const [selectedMetric, setSelectedMetric] = useState<"documents" | "pages" | "users" | "revenue">("documents");
const { data: user, isLoading } = useUser();
useEffect(() => {
const token = localStorage.getItem("token");
if (!token) {
router.push("/auth/login?redirect=/dashboard");
return;
}
const fetchData = async () => {
try {
const [userRes, usageRes] = await Promise.all([
fetch("http://localhost:8000/api/auth/me", {
headers: { Authorization: `Bearer ${token}` },
}),
fetch("http://localhost:8000/api/auth/usage", {
headers: { Authorization: `Bearer ${token}` },
}),
]);
if (!userRes.ok) {
throw new Error("Session expired");
}
const userData = await userRes.json();
const usageData = await usageRes.json();
setUser(userData);
setUsage(usageData);
// Mock recent activity
setRecentActivity([
{
id: "1",
type: "translation",
title: "Document translated",
description: "Q4 Financial Report.xlsx",
timestamp: new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(),
status: "success",
amount: 15
},
{
id: "2",
type: "upload",
title: "Document uploaded",
description: "Marketing_Presentation.pptx",
timestamp: new Date(Date.now() - 4 * 60 * 60 * 1000).toISOString(),
status: "success"
},
{
id: "3",
type: "download",
title: "Document downloaded",
description: "Translated_Q4_Report.xlsx",
timestamp: new Date(Date.now() - 6 * 60 * 60 * 1000).toISOString(),
status: "success"
},
{
id: "4",
type: "login",
title: "User login",
description: "Login from new device",
timestamp: new Date(Date.now() - 8 * 60 * 60 * 1000).toISOString(),
status: "success"
}
]);
} catch (error) {
console.error("Dashboard data fetch error:", error);
localStorage.removeItem("token");
localStorage.removeItem("user");
router.push("/auth/login?redirect=/dashboard");
} finally {
setLoading(false);
}
};
fetchData();
}, [router]);
const handleLogout = () => {
localStorage.removeItem("token");
localStorage.removeItem("refresh_token");
localStorage.removeItem("user");
router.push("/");
};
const handleUpgrade = () => {
router.push("/pricing");
};
const handleManageBilling = async () => {
const token = localStorage.getItem("token");
try {
const res = await fetch("http://localhost:8000/api/auth/billing-portal", {
headers: { Authorization: `Bearer ${token}` },
});
const data = await res.json();
if (data.url) {
window.open(data.url, "_blank");
}
} catch (error) {
console.error("Failed to open billing portal:", error);
}
};
if (loading) {
if (isLoading) {
return (
<div className="min-h-screen bg-background flex items-center justify-center">
<div className="text-center space-y-4">
<div className="animate-spin rounded-full h-12 w-12 border-4 border-border-subtle border-t-primary"></div>
<p className="text-lg font-medium text-foreground">Loading your dashboard...</p>
</div>
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-4 border-muted border-t-foreground"></div>
</div>
);
}
if (!user || !usage) {
return null;
}
const docsPercentage = usage.docs_limit > 0
? Math.min(100, (usage.docs_used / usage.docs_limit) * 100)
: 0;
const planColors: Record<string, string> = {
free: "bg-zinc-600",
starter: "bg-blue-500",
pro: "bg-teal-500",
business: "bg-purple-500",
enterprise: "bg-amber-500",
};
const getActivityIcon = (type: ActivityItem["type"]) => {
switch (type) {
case "translation": return <FileText className="h-4 w-4" />;
case "upload": return <Upload className="h-4 w-4" />;
case "download": return <Download className="h-4 w-4" />;
case "login": return <LogIn className="h-4 w-4" />;
case "signup": return <UserPlus className="h-4 w-4" />;
default: return <Activity className="h-4 w-4" />;
}
};
const getStatusColor = (status: ActivityItem["status"]) => {
switch (status) {
case "success": return "text-success";
case "pending": return "text-warning";
case "error": return "text-destructive";
default: return "text-text-tertiary";
}
};
const formatTimeAgo = (timestamp: string) => {
const now = new Date();
const past = new Date(timestamp);
const diffInSeconds = Math.floor((now.getTime() - past.getTime()) / 1000);
if (diffInSeconds < 60) return `${diffInSeconds}s ago`;
if (diffInSeconds < 3600) return `${Math.floor(diffInSeconds / 60)}m ago`;
if (diffInSeconds < 86400) return `${Math.floor(diffInSeconds / 3600)}h ago`;
return `${Math.floor(diffInSeconds / 86400)}d ago`;
};
const firstName = user?.name?.split(' ')[0] || 'User';
return (
<div className="min-h-screen bg-gradient-to-b from-surface via-surface-elevated to-background">
{/* Header */}
<header className="sticky top-0 z-50 glass border-b border-border/20">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between h-16">
<Link href="/" className="flex items-center gap-3 group">
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-gradient-to-br from-primary to-accent text-white font-bold shadow-lg group-hover:shadow-xl group-hover:shadow-primary/25 transition-all duration-300 group-hover:-translate-y-0.5">
A
</div>
<span className="text-lg font-semibold text-white group-hover:text-primary transition-colors duration-300">
Translate Co.
</span>
</Link>
<div className="mx-auto max-w-5xl px-4 py-6 lg:px-8 lg:py-8">
{/* Page heading */}
<div className="mb-6">
<h2 className="text-xl font-semibold tracking-tight text-foreground">
Welcome back, {firstName}!
</h2>
<p className="mt-1 text-sm text-muted-foreground">
Monitor your usage, manage API keys, and configure translation preferences.
</p>
</div>
<div className="flex items-center gap-4">
<Link href="/">
<Button variant="glass" size="sm" className="group">
<FileText className="h-4 w-4 mr-2 transition-transform duration-200 group-hover:scale-110" />
Translate
<ChevronRight className="h-4 w-4 ml-1 transition-transform duration-200 group-hover:translate-x-1" />
</Button>
</Link>
<div className="flex items-center gap-2">
<div className="h-8 w-8 rounded-full bg-gradient-to-br from-primary to-accent text-white text-sm font-bold flex items-center justify-center">
{user.name.charAt(0).toUpperCase()}
</div>
<div className="text-right">
<p className="text-sm font-medium text-foreground">{user.name}</p>
<Badge
variant="outline"
className={cn("ml-2", planColors[user.plan])}
>
{user.plan.charAt(0).toUpperCase() + user.plan.slice(1)}
</Badge>
</div>
</div>
<Button
variant="ghost"
size="icon"
onClick={handleLogout}
className="text-text-tertiary hover:text-destructive transition-colors duration-200"
>
<LogOut className="h-4 w-4" />
</Button>
</div>
</div>
</div>
</header>
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Welcome Section */}
<div className="mb-8">
<h1 className="text-4xl font-bold text-white mb-2">
Welcome back, <span className="text-primary">{user.name.split(" ")[0]}</span>!
</h1>
<p className="text-lg text-text-secondary">
Here's an overview of your translation usage
</p>
</div>
{/* Stats Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
{/* Current Plan */}
<CardStats
title="Current Plan"
value={user.plan.charAt(0).toUpperCase() + user.plan.slice(1)}
change={undefined}
icon={<Crown className="h-5 w-5 text-amber-400" />}
/>
{/* Documents Used */}
<CardStats
title="Documents This Month"
value={`${usage.docs_used} / ${usage.docs_limit === -1 ? "∞" : usage.docs_limit}`}
change={{
value: 15,
type: "increase",
period: "this month"
}}
icon={<FileText className="h-5 w-5 text-primary" />}
/>
{/* Pages Translated */}
<CardStats
title="Pages Translated"
value={usage.pages_used}
icon={<TrendingUp className="h-5 w-5 text-teal-400" />}
/>
{/* Extra Credits */}
<CardStats
title="Extra Credits"
value={usage.extra_credits}
icon={<Zap className="h-5 w-5 text-amber-400" />}
/>
</div>
{/* Quick Actions & Recent Activity */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
{/* Available Features */}
<Card variant="elevated" className="animate-fade-in-up animation-delay-200">
<CardHeader>
<CardTitle className="flex items-center gap-3">
<Shield className="h-5 w-5 text-primary" />
Your Plan Features
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<ul className="space-y-3">
{user.plan_limits.features.map((feature, idx) => (
<li key={idx} className="flex items-start gap-3">
<Check className="h-5 w-5 text-success flex-shrink-0 mt-0.5" />
<span className="text-sm text-text-secondary">{feature}</span>
</li>
))}
</ul>
</CardContent>
</Card>
{/* Quick Actions */}
<Card variant="elevated" className="animate-fade-in-up animation-delay-400">
<CardHeader>
<CardTitle className="flex items-center gap-3">
<Settings className="h-5 w-5 text-primary" />
Quick Actions
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<Link href="/">
<button className="w-full flex items-center justify-between p-4 rounded-lg bg-surface hover:bg-surface-hover transition-colors group">
<div className="flex items-center gap-3">
<FileText className="h-5 w-5 text-teal-400" />
<span className="text-white">Translate a Document</span>
</div>
<ChevronRight className="h-4 w-4 text-text-tertiary group-hover:text-primary transition-colors duration-200" />
</button>
</Link>
<Link href="/settings/services">
<button className="w-full flex items-center justify-between p-4 rounded-lg bg-surface hover:bg-surface-hover transition-colors group">
<div className="flex items-center gap-3">
<Settings className="h-5 w-5 text-blue-400" />
<span className="text-white">Configure Providers</span>
</div>
<ChevronRight className="h-4 w-4 text-text-tertiary group-hover:text-primary transition-colors duration-200" />
</button>
</Link>
{user.plan !== "free" && (
<button
onClick={handleUpgrade}
className="w-full flex items-center justify-between p-4 rounded-lg bg-gradient-to-r from-amber-500 to-orange-600 text-white hover:from-amber-600 hover:to-orange-700 transition-all duration-300 group"
>
<div className="flex items-center gap-3">
<Crown className="h-5 w-5" />
<span>Upgrade Plan</span>
</div>
<ArrowUpRight className="h-4 w-4 transition-transform duration-200 group-hover:translate-x-1" />
</button>
)}
{user.plan !== "free" && (
<button
onClick={handleManageBilling}
className="w-full flex items-center justify-between p-4 rounded-lg bg-surface hover:bg-surface-hover transition-colors group"
>
<div className="flex items-center gap-3">
<CreditCard className="h-5 w-5 text-purple-400" />
<span>Manage Billing</span>
</div>
<ExternalLink className="h-4 w-4 text-text-tertiary group-hover:text-primary transition-colors duration-200" />
</button>
)}
<Link href="/pricing">
<button className="w-full flex items-center justify-between p-4 rounded-lg bg-surface hover:bg-surface-hover transition-colors group">
<div className="flex items-center gap-3">
<Crown className="h-5 w-5 text-amber-400" />
<span>View Plans & Pricing</span>
</div>
<ChevronRight className="h-4 w-4 text-text-tertiary group-hover:text-primary transition-colors duration-200" />
</button>
</Link>
</CardContent>
</Card>
</div>
{/* Charts Section */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
{/* Usage Chart */}
<Card variant="elevated" className="animate-fade-in-up animation-delay-600">
<CardHeader>
<CardTitle className="flex items-center gap-3">
<BarChart3 className="h-5 w-5 text-primary" />
Usage Overview
</CardTitle>
<div className="flex items-center gap-2 ml-auto">
<Button
variant="ghost"
size="sm"
onClick={() => setTimeRange(timeRange === "7d" ? "30d" : "7d")}
className={cn("text-xs", timeRange === "7d" && "text-primary")}
>
7D
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setTimeRange(timeRange === "30d" ? "24h" : "30d")}
className={cn("text-xs", timeRange === "30d" && "text-primary")}
>
30D
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setTimeRange("24h")}
className={cn("text-xs", timeRange === "24h" && "text-primary")}
>
24H
</Button>
</div>
{/* Quick actions */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<Link href="/dashboard/translate">
<Card className="cursor-pointer transition-colors hover:bg-secondary/50">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Translate Document</CardTitle>
<FileText className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="h-64 flex items-center justify-center">
{/* Mock Chart */}
<div className="relative w-full h-full flex items-center justify-center">
<svg className="w-full h-full" viewBox="0 0 100 100">
<defs>
<linearGradient id="gradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stopColor="#3b82f6" />
<stop offset="100%" stopColor="#8b5cf6" />
</linearGradient>
</defs>
<circle
cx="50"
cy="50"
r="40"
fill="none"
stroke="currentColor"
strokeWidth="2"
className="text-border"
/>
<circle
cx="50"
cy="50"
r="40"
fill="none"
stroke="url(#gradient)"
strokeWidth="2"
className="opacity-80"
style={{
strokeDasharray: `${2 * Math.PI * 40}`,
strokeDashoffset: `${2 * Math.PI * 40 * 0.25}`,
animation: "progress 2s ease-in-out infinite"
}}
/>
</svg>
<div className="absolute inset-0 flex flex-col items-center justify-center">
<div className="text-6xl font-bold text-text-tertiary">85%</div>
<div className="text-sm text-text-tertiary">Usage</div>
</div>
</div>
</div>
<CardDescription>
Upload and translate Excel, Word, or PowerPoint files
</CardDescription>
</CardContent>
</Card>
</Link>
{/* Recent Activity */}
<Card variant="elevated" className="animate-fade-in-up animation-delay-800">
<CardHeader>
<CardTitle className="flex items-center gap-3">
<Activity className="h-5 w-5 text-primary" />
Recent Activity
<Button
variant="ghost"
size="icon"
onClick={() => setRecentActivity([])}
className="ml-auto text-text-tertiary hover:text-primary transition-colors duration-200"
>
<RefreshCw className="h-4 w-4" />
</Button>
</CardTitle>
<Link href="/dashboard/api-keys">
<Card className="cursor-pointer transition-colors hover:bg-secondary/50">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">API Keys</CardTitle>
<Key className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="space-y-4">
{recentActivity.slice(0, 5).map((activity) => (
<div key={activity.id} className="flex items-start gap-4 p-3 rounded-lg bg-surface/50 hover:bg-surface transition-colors">
<div className="flex-shrink-0 mt-1">
<div className={cn(
"w-10 h-10 rounded-lg flex items-center justify-center",
activity.status === "success" && "bg-success/20 text-success",
activity.status === "pending" && "bg-warning/20 text-warning",
activity.status === "error" && "bg-destructive/20 text-destructive"
)}>
{getActivityIcon(activity.type)}
</div>
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-foreground mb-1">{activity.title}</p>
<p className="text-xs text-text-tertiary">{activity.description}</p>
<div className="flex items-center gap-2 mt-2">
<span className="text-xs text-text-tertiary">{formatTimeAgo(activity.timestamp)}</span>
{activity.amount && (
<Badge variant="outline" size="sm">
{activity.amount}
</Badge>
)}
</div>
</div>
</div>
))}
</div>
<CardDescription>
Manage your API keys for automation
</CardDescription>
</CardContent>
</Card>
</div>
</Link>
{/* Available Providers */}
<Card variant="elevated" className="animate-fade-in-up animation-delay-1000">
<CardHeader>
<CardTitle className="flex items-center gap-3">
<Globe2 className="h-5 w-5 text-primary" />
Available Translation Providers
</CardTitle>
</CardHeader>
<CardContent>
{usage && (
<div className="flex flex-wrap gap-3">
{["ollama", "google", "deepl", "openai", "libre", "azure"].map((provider) => {
const isAvailable = usage.allowed_providers.includes(provider);
return (
<Badge
key={provider}
variant="outline"
className={cn(
"capitalize",
isAvailable
? "border-success/50 text-success bg-success/10"
: "border-border text-text-tertiary"
)}
>
{isAvailable && <Check className="h-3 w-3 mr-1" />}
{provider}
</Badge>
);
})}
</div>
)}
{user && user.plan === "free" && (
<p className="text-sm text-text-tertiary mt-4">
<Link href="/pricing" className="text-primary hover:text-primary/80">
Upgrade your plan
</Link>
{" "}
to access more translation providers including Google, DeepL, and OpenAI.
{user?.tier === 'pro' && (
<Link href="/dashboard/glossaries">
<Card className="cursor-pointer transition-colors hover:bg-secondary/50">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Glossaries</CardTitle>
<BookText className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<CardDescription>
Create custom terminology for translations
</CardDescription>
</CardContent>
</Card>
</Link>
)}
</div>
{/* Plan info */}
{user?.tier === 'free' && (
<Card className="mt-6 border-primary/50 bg-primary/5">
<CardContent className="flex items-center justify-between pt-6">
<div>
<p className="text-sm font-medium text-foreground">Upgrade to Pro</p>
<p className="text-xs text-muted-foreground">
Get unlimited translations, API access, and custom glossaries
</p>
)}
</div>
<Link href="/pricing">
<Button variant="premium" size="sm" className="gap-1">
View Plans
<ChevronRight className="h-4 w-4" />
</Button>
</Link>
</CardContent>
</Card>
</main>
)}
</div>
);
}

View File

@@ -0,0 +1,51 @@
'use client';
import { useRef } from 'react';
import { Upload } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { UseFileUploadReturn } from './types';
interface FileDropZoneProps {
upload: UseFileUploadReturn;
}
export function FileDropZone({ upload }: FileDropZoneProps) {
const inputRef = useRef<HTMLInputElement>(null);
const handleClick = () => {
inputRef.current?.click();
};
return (
<div
className={cn(
'relative flex flex-col items-center justify-center gap-3 rounded-lg border-2 border-dashed px-6 py-10 transition-colors cursor-pointer',
upload.isDragOver
? 'border-primary bg-primary/5'
: 'border-border bg-muted/30 hover:border-muted-foreground/30'
)}
onDragOver={upload.handleDragOver}
onDragLeave={upload.handleDragLeave}
onDrop={upload.handleDrop}
onClick={handleClick}
>
<div className="flex size-12 items-center justify-center rounded-xl bg-secondary">
<Upload className="size-5 text-muted-foreground" />
</div>
<div className="flex flex-col items-center gap-1">
<p className="text-sm font-medium text-foreground">
Drag & drop your .xlsx, .docx, or .pptx file here
</p>
<p className="text-xs text-muted-foreground">or click to browse</p>
</div>
<input
ref={inputRef}
type="file"
accept=".xlsx,.docx,.pptx"
className="hidden"
onChange={upload.handleFileSelect}
aria-label="Upload file"
/>
</div>
);
}

View File

@@ -0,0 +1,53 @@
'use client';
import { FileSpreadsheet, FileText, Presentation, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
const FILE_ICONS: Record<string, React.ElementType> = {
xlsx: FileSpreadsheet,
docx: FileText,
pptx: Presentation,
};
interface FilePreviewProps {
file: File;
onRemove: () => void;
}
function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
export function FilePreview({ file, onRemove }: FilePreviewProps) {
const ext = file.name.split('.').pop()?.toLowerCase() || '';
const FileIcon = FILE_ICONS[ext] || FileText;
return (
<div className="flex items-center gap-3">
<div className="flex size-10 items-center justify-center rounded-lg bg-secondary">
<FileIcon className="size-5 text-foreground" />
</div>
<div className="flex flex-col min-w-0 flex-1">
<span className="text-sm font-medium text-foreground truncate">
{file.name}
</span>
<span className="text-xs text-muted-foreground">
{formatFileSize(file.size)} · .{ext}
</span>
</div>
<Button
variant="ghost"
size="icon-sm"
className="ml-2 text-muted-foreground hover:text-foreground shrink-0"
onClick={(e) => {
e.stopPropagation();
onRemove();
}}
>
<X className="size-4" />
</Button>
</div>
);
}

View File

@@ -0,0 +1,104 @@
'use client';
import { ArrowRight, Loader2, AlertCircle } from 'lucide-react';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import type { Language } from './types';
interface LanguageSelectorProps {
sourceLang: string;
targetLang: string;
languages: Language[];
isLoading?: boolean;
error?: string | null;
onSourceChange: (value: string) => void;
onTargetChange: (value: string) => void;
}
export function LanguageSelector({
sourceLang,
targetLang,
languages,
isLoading,
error,
onSourceChange,
onTargetChange,
}: LanguageSelectorProps) {
return (
<div className="flex flex-col gap-2">
{error && (
<div className="flex items-center gap-2 rounded-md bg-destructive/10 px-3 py-2 text-xs text-destructive">
<AlertCircle className="size-3.5" />
<span>Failed to load languages: {error}</span>
</div>
)}
<div className="flex items-center gap-3">
<div className="flex flex-1 flex-col gap-1.5">
<label className="text-xs font-medium text-muted-foreground">
Source Language
</label>
<Select
value={sourceLang}
onValueChange={onSourceChange}
disabled={isLoading}
>
<SelectTrigger className="w-full">
{isLoading ? (
<div className="flex items-center gap-2">
<Loader2 className="size-3.5 animate-spin" />
<span className="text-muted-foreground">Loading...</span>
</div>
) : (
<SelectValue placeholder="Auto-detect" />
)}
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">Auto-detect</SelectItem>
{languages.map((lang) => (
<SelectItem key={lang.code} value={lang.code}>
{lang.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<ArrowRight className="mt-5 size-4 shrink-0 text-muted-foreground" />
<div className="flex flex-1 flex-col gap-1.5">
<label className="text-xs font-medium text-muted-foreground">
Target Language
</label>
<Select
value={targetLang}
onValueChange={onTargetChange}
disabled={isLoading}
>
<SelectTrigger className="w-full">
{isLoading ? (
<div className="flex items-center gap-2">
<Loader2 className="size-3.5 animate-spin" />
<span className="text-muted-foreground">Loading...</span>
</div>
) : (
<SelectValue placeholder="Select language" />
)}
</SelectTrigger>
<SelectContent>
{languages.map((lang) => (
<SelectItem key={lang.code} value={lang.code}>
{lang.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,112 @@
'use client';
import { Loader2, CheckCircle2, Lock } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { Provider, AvailableProvider } from './types';
interface ProviderSelectorProps {
provider: Provider | null;
onProviderChange: (provider: Provider) => void;
availableProviders: AvailableProvider[];
isLoadingProviders: boolean;
isPro: boolean;
}
export function ProviderSelector({
provider,
onProviderChange,
availableProviders,
isLoadingProviders,
isPro,
}: ProviderSelectorProps) {
if (isLoadingProviders) {
return (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 className="size-3.5 animate-spin" />
<span>Loading providers</span>
</div>
);
}
if (availableProviders.length === 0) {
return (
<p className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-700">
No providers are configured. Ask your administrator to enable at least one in the
admin settings.
</p>
);
}
const classicProviders = availableProviders.filter((p) => p.mode === 'classic');
const llmProviders = availableProviders.filter((p) => p.mode === 'llm');
const renderCard = (p: AvailableProvider, locked: boolean) => {
const isSelected = provider === p.id;
return (
<button
key={p.id}
type="button"
disabled={locked}
onClick={() => !locked && onProviderChange(p.id)}
className={cn(
'flex w-full items-center justify-between rounded-lg border px-3 py-2.5 text-left text-sm transition-colors',
isSelected
? 'border-primary bg-primary/5 text-primary'
: locked
? 'cursor-not-allowed border-border/40 bg-muted/30 text-muted-foreground'
: 'border-border/60 bg-background hover:border-primary/40 hover:bg-muted/40'
)}
>
<div className="flex flex-col">
<span className="font-medium leading-tight">{p.label}</span>
<span className="text-xs text-muted-foreground">{p.description}</span>
{p.mode === 'llm' && p.model && (
<span className="mt-0.5 text-[10px] font-mono text-muted-foreground/80" title="Modèle configuré par l'admin">
Modèle : {p.model}
</span>
)}
</div>
{locked ? (
<Lock className="size-3.5 shrink-0 text-muted-foreground/60" />
) : isSelected ? (
<CheckCircle2 className="size-4 shrink-0 text-primary" />
) : null}
</button>
);
};
return (
<div className="space-y-3">
<p className="text-xs font-medium text-muted-foreground">Translation Provider</p>
{/* Classic providers — available to everyone */}
{classicProviders.length > 0 && (
<div className="space-y-1.5">
{classicProviders.map((p) => renderCard(p, false))}
</div>
)}
{/* LLM providers — Pro only */}
{llmProviders.length > 0 && (
<div className="space-y-1.5">
<div className="flex items-center gap-2">
<div className="h-px flex-1 bg-border/50" />
<span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
LLM · Context-Aware {!isPro && '· Pro'}
</span>
<div className="h-px flex-1 bg-border/50" />
</div>
{llmProviders.map((p) => renderCard(p, !isPro))}
{!isPro && (
<p className="text-xs text-muted-foreground">
<a href="/pricing" className="text-primary hover:underline">
Upgrade to Pro
</a>{' '}
to use LLM-powered translation.
</p>
)}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,153 @@
'use client';
import { useState, useEffect, useRef } from 'react';
import { CheckCircle, Download, Plus, Loader2 } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { useNotification } from '@/components/ui/notification';
interface TranslationCompleteProps {
jobId: string;
fileName: string | null;
onNewTranslation: () => void;
}
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
export function TranslationComplete({
jobId,
fileName,
onNewTranslation,
}: TranslationCompleteProps) {
const [isDownloading, setIsDownloading] = useState(false);
const { success, error } = useNotification();
const blobUrlRef = useRef<string | null>(null);
const handleDownload = async () => {
setIsDownloading(true);
try {
const token = localStorage.getItem('token');
const headers: Record<string, string> = {};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const response = await fetch(`${API_BASE}/api/v1/download/${jobId}`, { headers });
if (!response.ok) {
let errorMessage = 'Download failed';
try {
const errorData = await response.json();
errorMessage = errorData.message || errorData.error || errorMessage;
} catch {
// Response not JSON
}
throw new Error(errorMessage);
}
const contentDisposition = response.headers.get('Content-Disposition');
let downloadFilename = 'translated_document';
if (contentDisposition) {
const filenameMatch = contentDisposition.match(/filename\*?=['"]?(?:UTF-\d['"]*)?([^;\r\n"']+)/i);
if (filenameMatch && filenameMatch[1]) {
downloadFilename = filenameMatch[1];
}
} else if (fileName) {
const ext = fileName.split('.').pop() || '';
const baseName = fileName.replace(/\.[^.]+$/, '');
downloadFilename = `${baseName}_translated.${ext}`;
}
const blob = await response.blob();
const url = URL.createObjectURL(blob);
blobUrlRef.current = url;
const a = document.createElement('a');
a.href = url;
a.download = downloadFilename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => {
if (blobUrlRef.current) {
URL.revokeObjectURL(blobUrlRef.current);
blobUrlRef.current = null;
}
}, 1000);
success({
title: 'Download Complete',
description: `${downloadFilename} has been downloaded successfully.`,
});
} catch (err) {
error({
title: 'Download Failed',
description: err instanceof Error ? err.message : 'Failed to download the translated file.',
});
} finally {
setIsDownloading(false);
setTimeout(() => {
if (blobUrlRef.current) {
URL.revokeObjectURL(blobUrlRef.current);
blobUrlRef.current = null;
}
}, 5000);
}
};
useEffect(() => {
return () => {
if (blobUrlRef.current) {
URL.revokeObjectURL(blobUrlRef.current);
blobUrlRef.current = null;
}
};
}, []);
return (
<Card className="border-success/40 bg-gradient-to-br from-success/10 to-success/5 overflow-hidden">
<CardContent className="p-6 text-center">
<div className="w-14 h-14 mx-auto mb-4 rounded-full bg-success/20 flex items-center justify-center">
<CheckCircle className="w-8 h-8 text-success" />
</div>
<h3 className="text-lg font-semibold mb-2">Translation Complete!</h3>
<p className="text-sm text-muted-foreground mb-5">
{fileName ? `"${fileName}" has been translated successfully.` : 'Your document has been translated successfully.'}
</p>
<div className="flex flex-col sm:flex-row gap-3 justify-center">
<Button
onClick={handleDownload}
disabled={isDownloading}
className="gap-2"
>
{isDownloading ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Downloading...
</>
) : (
<>
<Download className="w-4 h-4" />
Download Translated File
</>
)}
</Button>
<Button
variant="outline"
onClick={onNewTranslation}
className="gap-2"
>
<Plus className="w-4 h-4" />
New Translation
</Button>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,87 @@
'use client';
import { Lock } from 'lucide-react';
import { cn } from '@/lib/utils';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import type { TranslationMode } from './types';
interface TranslationModeToggleProps {
mode: TranslationMode;
onModeChange: (mode: TranslationMode) => void;
isPro: boolean;
}
export function TranslationModeToggle({
mode,
onModeChange,
isPro,
}: TranslationModeToggleProps) {
return (
<TooltipProvider>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium text-muted-foreground">
Translation Mode
</label>
<div className="flex rounded-lg border border-border bg-muted p-1">
<button
type="button"
className={cn(
'flex-1 rounded-md px-4 py-2 text-sm font-medium transition-all',
mode === 'classic'
? 'bg-card text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'
)}
onClick={() => onModeChange('classic')}
>
Classic
<span className="ml-1.5 text-xs text-muted-foreground">
Fast
</span>
</button>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className={cn(
'flex-1 rounded-md px-4 py-2 text-sm font-medium transition-all relative',
mode === 'llm'
? 'bg-card text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground',
!isPro && 'cursor-not-allowed opacity-60'
)}
onClick={() => isPro && onModeChange('llm')}
disabled={!isPro}
>
Pro LLM
<span className="ml-1.5 text-xs text-muted-foreground">
Context-Aware
</span>
{!isPro && (
<Lock className="absolute right-2 top-1/2 -translate-y-1/2 size-3 text-muted-foreground" />
)}
</button>
</TooltipTrigger>
{!isPro && (
<TooltipContent side="top">
<p>Upgrade to Pro for LLM translation</p>
</TooltipContent>
)}
</Tooltip>
</div>
{!isPro && (
<p className="text-xs text-muted-foreground">
<a href="/pricing" className="text-primary hover:underline">
Upgrade to Pro
</a>{' '}
for LLM-powered translations
</p>
)}
</div>
</TooltipProvider>
);
}

View File

@@ -0,0 +1,109 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { AlertTriangle, Loader2, Clock, WifiOff } from 'lucide-react';
import { Progress } from '@/components/ui/progress';
interface TranslationProgressProps {
progress: number;
currentStep: string;
estimatedRemaining: number | null;
error: string | null;
isPolling?: boolean;
isUploading?: boolean;
isCompleted?: boolean;
}
function formatTimeRemaining(seconds: number | null): string {
if (seconds === null || seconds <= 0) return '';
if (seconds < 60) return `${seconds}s remaining`;
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
if (remainingSeconds === 0) return `${minutes} min remaining`;
return `${minutes}m ${remainingSeconds}s remaining`;
}
export function TranslationProgress({
progress,
currentStep,
estimatedRemaining,
error,
isPolling = true,
isUploading = false,
isCompleted = false,
}: TranslationProgressProps) {
// Disable CSS transition on the very first render so that when progress
// resets from a previous job's 100% → 0%, there is no visible backward sweep.
const [animate, setAnimate] = useState(false);
const prevProgressRef = useRef(progress);
useEffect(() => {
if (progress > 0) {
setAnimate(true);
} else if (progress === 0) {
// Momentarily cut the transition to snap to 0, then re-enable.
setAnimate(false);
const t = setTimeout(() => setAnimate(true), 50);
return () => clearTimeout(t);
}
prevProgressRef.current = progress;
}, [progress]);
if (error) {
return (
<div
className="rounded-lg bg-destructive/10 border border-destructive/30 p-4"
role="alert"
aria-live="assertive"
>
<div className="flex items-start gap-3">
<AlertTriangle className="h-5 w-5 text-destructive flex-shrink-0 mt-0.5" aria-hidden="true" />
<div>
<p className="text-sm font-medium text-destructive mb-1">Translation Failed</p>
<p className="text-sm text-destructive/80">{error}</p>
</div>
</div>
</div>
);
}
const timeRemaining = formatTimeRemaining(estimatedRemaining);
// Only show "Connection lost" when polling was active and then stopped —
// never during the initial upload phase.
const showConnectionLost = !isPolling && !isCompleted && !isUploading;
return (
<div className="space-y-3">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
{currentStep || 'Processing...'}
</span>
<span className="text-primary font-medium tabular-nums" aria-live="polite">
{Math.round(progress)}%
</span>
</div>
<Progress
value={progress}
animate={animate}
className="h-2"
aria-label="Translation progress"
aria-valuenow={Math.round(progress)}
aria-valuemin={0}
aria-valuemax={100}
/>
{showConnectionLost && (
<div className="flex items-center gap-2 text-xs text-amber-600">
<WifiOff className="h-3 w-3" aria-hidden="true" />
<span>Connection lost. Retrying...</span>
</div>
)}
{timeRemaining && (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Clock className="h-3 w-3" aria-hidden="true" />
<span>{timeRemaining}</span>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,200 @@
'use client';
import { useEffect, useRef } from 'react';
import { Languages, ShieldCheck, Clock, ArrowRight, RotateCcw, Loader2 } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { FileDropZone } from './FileDropZone';
import { FilePreview } from './FilePreview';
import { useFileUpload } from './useFileUpload';
import { useTranslationConfig } from './useTranslationConfig';
import { useTranslationSubmit } from './useTranslationSubmit';
import { LanguageSelector } from './LanguageSelector';
import { ProviderSelector } from './ProviderSelector';
import { TranslationProgress } from './TranslationProgress';
import { TranslationComplete } from './TranslationComplete';
import { useNotification } from '@/components/ui/notification';
export default function TranslatePage() {
const upload = useFileUpload();
const config = useTranslationConfig(!!upload.file);
const submit = useTranslationSubmit();
const { error: showError } = useNotification();
const lastErrorRef = useRef<string | null>(null);
const handleTranslate = async () => {
if (!upload.file || !config.isConfigValid) return;
await submit.submitTranslation(upload.file, config.getConfig());
};
useEffect(() => {
if (submit.error && submit.error !== lastErrorRef.current) {
lastErrorRef.current = submit.error;
showError({
title: 'Translation Error',
description: submit.error,
});
}
}, [submit.error, showError]);
const handleNewTranslation = () => {
submit.reset();
upload.removeFile();
};
const isConfiguring = upload.file && submit.status === 'idle' && !submit.isSubmitting;
const isProcessing = (submit.status === 'processing' || submit.isSubmitting) && submit.status !== 'completed';
const isCompleted = submit.status === 'completed';
const isFailed = submit.status === 'failed';
return (
<div className="mx-auto max-w-xl px-4 py-6 lg:px-8">
<Card className="border-border/70 shadow-lg">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Languages className="size-5" />
Office Translator
</CardTitle>
<CardDescription>
Upload an Excel, Word, or PowerPoint file to translate
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
{upload.file && !isProcessing && !isCompleted && (
<div className="flex flex-col items-center justify-center gap-3 rounded-lg border-2 border-success/40 bg-success/5 px-6 py-4">
<FilePreview file={upload.file} onRemove={upload.removeFile} />
</div>
)}
{!upload.file && !isProcessing && !isCompleted && (
<FileDropZone upload={upload} />
)}
{upload.error && !isProcessing && !isCompleted && (
<p className="text-sm text-destructive">{upload.error}</p>
)}
{isConfiguring && (
<>
<div className="my-2 flex items-center gap-2">
<div className="h-px flex-1 bg-border" />
<span className="text-xs text-muted-foreground">Configuration</span>
<div className="h-px flex-1 bg-border" />
</div>
<LanguageSelector
sourceLang={config.sourceLang}
targetLang={config.targetLang}
languages={config.languages}
isLoading={config.isLoadingLanguages}
error={config.languagesError}
onSourceChange={config.setSourceLang}
onTargetChange={config.setTargetLang}
/>
<ProviderSelector
provider={config.provider}
onProviderChange={config.setProvider}
availableProviders={config.availableProviders}
isLoadingProviders={config.isLoadingProviders}
isPro={config.isPro}
/>
<Button
size="lg"
className="w-full text-sm font-semibold"
disabled={!config.isConfigValid || submit.isSubmitting}
onClick={handleTranslate}
>
{submit.isSubmitting ? (
<>
<Loader2 className="mr-2 size-4 animate-spin" />
Uploading...
</>
) : (
<>
Translate Document
<ArrowRight className="ml-2 size-4" />
</>
)}
</Button>
</>
)}
{isProcessing && !isCompleted && (
<>
<div className="flex items-center justify-between text-sm text-muted-foreground mb-2">
<span>File: {submit.fileName || upload.file?.name}</span>
</div>
<TranslationProgress
progress={submit.progress}
currentStep={submit.currentStep || (submit.isSubmitting ? 'Uploading file...' : 'Starting translation...')}
estimatedRemaining={submit.estimatedRemaining}
error={null}
isPolling={submit.isPolling}
isUploading={submit.isSubmitting}
isCompleted={false}
/>
<Button
variant="outline"
size="sm"
onClick={handleNewTranslation}
className="w-full mt-2"
>
<RotateCcw className="mr-2 size-4" />
Cancel
</Button>
</>
)}
{isCompleted && submit.jobId && (
<TranslationComplete
jobId={submit.jobId}
fileName={submit.fileName}
onNewTranslation={handleNewTranslation}
/>
)}
{isFailed && (
<>
<TranslationProgress
progress={submit.progress}
currentStep={submit.currentStep}
estimatedRemaining={submit.estimatedRemaining}
error={submit.error}
isPolling={false}
isCompleted={false}
/>
<Button
variant="outline"
onClick={handleNewTranslation}
className="w-full mt-2"
>
<RotateCcw className="mr-2 size-4" />
Try Again
</Button>
</>
)}
{!upload.file && !isProcessing && !isCompleted && !isFailed && (
<p className="text-xs text-muted-foreground">
Supported formats: Excel (.xlsx), Word (.docx), PowerPoint (.pptx)
</p>
)}
</CardContent>
</Card>
<div className="flex items-center justify-center gap-4 mt-4 text-xs text-muted-foreground">
<div className="flex items-center gap-1.5">
<ShieldCheck className="size-3.5" />
<span>Zero Data Retention</span>
</div>
<div className="h-3 w-px bg-border" />
<div className="flex items-center gap-1.5">
<Clock className="size-3.5" />
<span>Files deleted after 60 min</span>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,107 @@
export type SupportedFormat = 'xlsx' | 'docx' | 'pptx';
export interface FileUploadState {
file: File | null;
error: string | null;
isDragOver: boolean;
}
export interface FileUploadActions {
handleDrop: (e: React.DragEvent) => void;
handleDragOver: (e: React.DragEvent) => void;
handleDragLeave: (e: React.DragEvent) => void;
handleFileSelect: (e: React.ChangeEvent<HTMLInputElement>) => void;
removeFile: () => void;
}
export interface UseFileUploadReturn extends FileUploadState, FileUploadActions {}
export type TranslationMode = 'classic' | 'llm';
/** Provider identifier — always matches the admin-side key. */
export type Provider = string;
export interface Language {
code: string;
name: string;
}
/** A provider returned by GET /api/v1/providers/available */
export interface AvailableProvider {
id: Provider;
label: string;
description: string;
mode: 'classic' | 'llm';
/** LLM model used (e.g. deepseek/deepseek-v3.2) — same as admin config */
model?: string;
}
export interface TranslationConfig {
sourceLang: string;
targetLang: string;
mode: TranslationMode;
provider?: Provider;
}
export interface UseTranslationConfigReturn {
sourceLang: string;
targetLang: string;
/** Derived from selected provider — read-only. */
mode: TranslationMode;
provider: Provider | null;
availableProviders: AvailableProvider[];
isLoadingProviders: boolean;
languages: Language[];
isPro: boolean;
isConfigValid: boolean;
isLoadingLanguages: boolean;
languagesError: string | null;
setSourceLang: (lang: string) => void;
setTargetLang: (lang: string) => void;
setProvider: (provider: Provider | null) => void;
getConfig: () => TranslationConfig;
}
export type TranslationStatus = 'idle' | 'processing' | 'completed' | 'failed';
export interface TranslationJob {
id: string;
status: TranslationStatus;
progress_percent: number;
current_step: string;
file_name?: string;
source_lang?: string;
target_lang?: string;
created_at?: string;
completed_at?: string;
error_message?: string;
}
export interface TranslationSubmitResponse {
data: TranslationJob;
meta: {
rate_limit_remaining?: number;
};
}
export interface TranslationStatusResponse {
data: TranslationJob;
meta: {
estimated_remaining_seconds?: number | null;
};
}
export interface UseTranslationSubmitReturn {
submitTranslation: (file: File, config: TranslationConfig) => Promise<void>;
jobId: string | null;
status: TranslationStatus;
progress: number;
currentStep: string;
error: string | null;
estimatedRemaining: number | null;
fileName: string | null;
reset: () => void;
isSubmitting: boolean;
isPolling: boolean;
pollingFailures: number;
}

View File

@@ -0,0 +1,88 @@
import { useState, useCallback } from 'react';
import type { UseFileUploadReturn } from './types';
const ACCEPTED_EXTENSIONS = ['xlsx', 'docx', 'pptx'];
const MAX_FILE_SIZE = 50 * 1024 * 1024;
export const ERROR_MESSAGES = {
INVALID_FORMAT: 'Format non supporté. Formats acceptés : .xlsx, .docx, .pptx',
FILE_TOO_LARGE: 'Fichier trop volumineux (max 50 MB)',
} as const;
export function useFileUpload(): UseFileUploadReturn {
const [file, setFile] = useState<File | null>(null);
const [error, setError] = useState<string | null>(null);
const [isDragOver, setIsDragOver] = useState(false);
const validateFile = useCallback((file: File): string | null => {
const ext = file.name.split('.').pop()?.toLowerCase();
if (!ext || !ACCEPTED_EXTENSIONS.includes(ext)) {
return ERROR_MESSAGES.INVALID_FORMAT;
}
if (file.size > MAX_FILE_SIZE) {
return ERROR_MESSAGES.FILE_TOO_LARGE;
}
return null;
}, []);
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragOver(false);
const droppedFile = e.dataTransfer.files[0];
if (droppedFile) {
const validationError = validateFile(droppedFile);
if (validationError) {
setError(validationError);
setFile(null);
} else {
setFile(droppedFile);
setError(null);
}
}
}, [validateFile]);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragOver(true);
}, []);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragOver(false);
}, []);
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const selected = e.target.files?.[0];
if (selected) {
const validationError = validateFile(selected);
if (validationError) {
setError(validationError);
setFile(null);
} else {
setFile(selected);
setError(null);
}
}
}, [validateFile]);
const removeFile = useCallback(() => {
setFile(null);
setError(null);
setIsDragOver(false);
}, []);
return {
file,
error,
isDragOver,
handleDrop,
handleDragOver,
handleDragLeave,
handleFileSelect,
removeFile,
};
}

View File

@@ -0,0 +1,216 @@
'use client';
import { useState, useEffect, useCallback, useMemo } from 'react';
import type {
UseTranslationConfigReturn,
Language,
TranslationMode,
Provider,
TranslationConfig,
AvailableProvider,
} from './types';
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
/** Fallback when API fails — Google is always available server-side */
const FALLBACK_PROVIDERS: AvailableProvider[] = [
{ id: 'google', label: 'Google Traduction', description: 'Traduction rapide, 130+ langues', mode: 'classic' },
];
const FALLBACK_LANGUAGES: Language[] = [
// Top 5 — dominant on the internet
{ code: 'en', name: 'English' },
{ code: 'es', name: 'Spanish' },
{ code: 'de', name: 'German' },
{ code: 'fr', name: 'French' },
{ code: 'ja', name: 'Japanese' },
// Top 6-15
{ code: 'pt', name: 'Portuguese' },
{ code: 'ru', name: 'Russian' },
{ code: 'it', name: 'Italian' },
{ code: 'zh-CN', name: 'Chinese (Simplified)' },
{ code: 'zh-TW', name: 'Chinese (Traditional)' },
{ code: 'pl', name: 'Polish' },
{ code: 'nl', name: 'Dutch' },
{ code: 'tr', name: 'Turkish' },
{ code: 'ko', name: 'Korean' },
{ code: 'ar', name: 'Arabic' },
// Top 16-25
{ code: 'fa', name: 'Persian (Farsi)' },
{ code: 'vi', name: 'Vietnamese' },
{ code: 'id', name: 'Indonesian' },
{ code: 'uk', name: 'Ukrainian' },
{ code: 'sv', name: 'Swedish' },
{ code: 'cs', name: 'Czech' },
{ code: 'el', name: 'Greek' },
{ code: 'he', name: 'Hebrew' },
{ code: 'hi', name: 'Hindi' },
{ code: 'ro', name: 'Romanian' },
// Others
{ code: 'da', name: 'Danish' },
{ code: 'fi', name: 'Finnish' },
{ code: 'no', name: 'Norwegian' },
{ code: 'hu', name: 'Hungarian' },
{ code: 'th', name: 'Thai' },
{ code: 'sk', name: 'Slovak' },
{ code: 'bg', name: 'Bulgarian' },
{ code: 'hr', name: 'Croatian' },
{ code: 'ca', name: 'Catalan' },
{ code: 'ms', name: 'Malay' },
];
export function useTranslationConfig(hasFile: boolean): UseTranslationConfigReturn {
const [sourceLang, setSourceLang] = useState('auto');
const [targetLang, setTargetLang] = useState('');
const [provider, setProvider] = useState<Provider | null>(null);
const [availableProviders, setAvailableProviders] = useState<AvailableProvider[]>([]);
const [isLoadingProviders, setIsLoadingProviders] = useState(false);
const [languages, setLanguages] = useState<Language[]>([]);
const [isPro, setIsPro] = useState(false);
const [isLoadingLanguages, setIsLoadingLanguages] = useState(false);
const [languagesError, setLanguagesError] = useState<string | null>(null);
// Fetch available (admin-configured) providers
useEffect(() => {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 8000);
const fetchProviders = async () => {
setIsLoadingProviders(true);
try {
const token = localStorage.getItem('token');
const headers: Record<string, string> = {};
if (token) headers['Authorization'] = `Bearer ${token}`;
const response = await fetch(`${API_BASE}/api/v1/providers/available`, {
headers,
signal: controller.signal,
});
if (response.ok) {
const data = await response.json();
const list = data.providers || [];
setAvailableProviders(list.length > 0 ? list : FALLBACK_PROVIDERS);
} else {
setAvailableProviders(FALLBACK_PROVIDERS);
}
} catch {
// Backend down or timeout — use fallback so user can still try
setAvailableProviders(FALLBACK_PROVIDERS);
} finally {
clearTimeout(timeoutId);
setIsLoadingProviders(false);
}
};
fetchProviders();
return () => { controller.abort(); clearTimeout(timeoutId); };
}, []);
// Fetch supported languages
useEffect(() => {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 8000);
const fetchLanguages = async () => {
setIsLoadingLanguages(true);
setLanguagesError(null);
try {
const token = localStorage.getItem('token');
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (token) headers['Authorization'] = `Bearer ${token}`;
const response = await fetch(`${API_BASE}/api/v1/languages`, {
headers,
signal: controller.signal,
});
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
const data = await response.json();
const langList: Language[] = Object.entries(data.supported_languages || {}).map(
([code, name]) => ({ code, name: name as string })
);
setLanguages(langList.length > 0 ? langList : FALLBACK_LANGUAGES);
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') {
console.warn('Language fetch timed out, using fallback list');
} else {
setLanguagesError(error instanceof Error ? error.message : 'Failed to load languages');
}
setLanguages(FALLBACK_LANGUAGES);
} finally {
clearTimeout(timeoutId);
setIsLoadingLanguages(false);
}
};
fetchLanguages();
return () => { controller.abort(); clearTimeout(timeoutId); };
}, []);
// Check user tier
useEffect(() => {
const checkTier = async () => {
const userStr = localStorage.getItem('user');
if (userStr) {
try {
const user = JSON.parse(userStr);
if (user.tier) { setIsPro(user.tier === 'pro'); return; }
} catch { /* continue */ }
}
try {
const token = localStorage.getItem('token');
if (!token) { setIsPro(false); return; }
const response = await fetch(`${API_BASE}/api/v1/auth/me`, {
headers: { 'Authorization': `Bearer ${token}` },
});
if (response.ok) {
const result = await response.json();
const user = result.data;
setIsPro(user.tier === 'pro');
localStorage.setItem('user', JSON.stringify(user));
} else {
setIsPro(false);
}
} catch { setIsPro(false); }
};
checkTier();
}, []);
// Mode is derived from the selected provider, never set manually.
const mode = useMemo<TranslationMode>(() => {
if (!provider) return 'classic';
const p = availableProviders.find((ap) => ap.id === provider);
return p?.mode === 'llm' ? 'llm' : 'classic';
}, [provider, availableProviders]);
const isConfigValid = useMemo(() => {
if (!hasFile || !targetLang) return false;
if (!provider) return false;
return true;
}, [hasFile, targetLang, provider]);
const getConfig = useCallback((): TranslationConfig => ({
sourceLang,
targetLang,
mode,
provider: provider ?? undefined,
}), [sourceLang, targetLang, mode, provider]);
return {
sourceLang,
targetLang,
mode,
provider,
availableProviders,
isLoadingProviders,
languages,
isPro,
isConfigValid,
isLoadingLanguages,
languagesError,
setSourceLang,
setTargetLang,
setProvider,
getConfig,
};
}

View File

@@ -0,0 +1,209 @@
'use client';
import { useState, useEffect, useCallback, useRef } from 'react';
import type {
UseTranslationSubmitReturn,
TranslationConfig,
TranslationStatus,
TranslationSubmitResponse,
TranslationStatusResponse
} from './types';
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
const POLLING_INTERVAL_MS = 2000;
const MAX_POLLING_FAILURES = 3;
export function useTranslationSubmit(): UseTranslationSubmitReturn {
const [jobId, setJobId] = useState<string | null>(null);
const [status, setStatus] = useState<TranslationStatus>('idle');
const [progress, setProgress] = useState(0);
const [currentStep, setCurrentStep] = useState('');
const [error, setError] = useState<string | null>(null);
const [estimatedRemaining, setEstimatedRemaining] = useState<number | null>(null);
const [fileName, setFileName] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [pollingFailures, setPollingFailures] = useState(0);
const [isPolling, setIsPolling] = useState(false);
const pollingIntervalRef = useRef<NodeJS.Timeout | null>(null);
const isPollingRef = useRef(false);
// Use a ref for failure count to avoid stale closure in the interval callback.
// If we relied on state, the setInterval callback would always read the initial
// value of pollingFailures (0) and never reach MAX_POLLING_FAILURES.
const pollingFailuresRef = useRef(0);
const stopPolling = useCallback(() => {
if (pollingIntervalRef.current) {
clearInterval(pollingIntervalRef.current);
pollingIntervalRef.current = null;
}
isPollingRef.current = false;
setIsPolling(false);
}, []);
const pollProgress = useCallback(async (id: string) => {
if (isPollingRef.current) return;
isPollingRef.current = true;
try {
const token = localStorage.getItem('token');
const headers: Record<string, string> = {};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const response = await fetch(`${API_BASE}/api/v1/translations/${id}`, { headers });
if (!response.ok) {
if (response.status === 404) {
stopPolling();
setStatus('failed');
setError('Translation job not found');
return;
}
throw new Error(`HTTP error! status: ${response.status}`);
}
const data: TranslationStatusResponse = await response.json();
const job = data.data;
setStatus(job.status as TranslationStatus);
setProgress(job.progress_percent || 0);
setCurrentStep(job.current_step || '');
setEstimatedRemaining(data.meta.estimated_remaining_seconds ?? null);
pollingFailuresRef.current = 0;
setPollingFailures(0);
if (job.file_name) {
setFileName(job.file_name);
}
if (job.status === 'completed' || job.status === 'failed') {
stopPolling();
if (job.status === 'failed') {
setError(job.error_message || 'Translation failed');
}
}
} catch (err) {
console.error('Polling error:', err);
pollingFailuresRef.current += 1;
setPollingFailures(pollingFailuresRef.current);
if (pollingFailuresRef.current >= MAX_POLLING_FAILURES) {
stopPolling();
setStatus('failed');
setError('Lost connection to translation service. Please check your internet connection and try again.');
}
} finally {
isPollingRef.current = false;
}
}, [stopPolling]);
const startPolling = useCallback((id: string) => {
stopPolling();
pollingFailuresRef.current = 0;
setIsPolling(true);
setPollingFailures(0);
pollProgress(id);
pollingIntervalRef.current = setInterval(() => {
pollProgress(id);
}, POLLING_INTERVAL_MS);
}, [pollProgress, stopPolling]);
const submitTranslation = useCallback(async (file: File, config: TranslationConfig) => {
setIsSubmitting(true);
setError(null);
setProgress(0);
setCurrentStep('Uploading file...');
setEstimatedRemaining(null);
setStatus('processing'); // IMPORTANT: Set to 'processing' IMMEDIATELY so progress bar shows
setFileName(file.name);
setJobId(null);
try {
const formData = new FormData();
formData.append('file', file);
formData.append('source_lang', config.sourceLang);
formData.append('target_lang', config.targetLang);
formData.append('mode', config.mode);
// Provider is configured server-side by admin — only send the provider name.
if (config.mode === 'llm' && config.provider) {
formData.append('provider', config.provider);
}
const token = localStorage.getItem('token');
const headers: Record<string, string> = {};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const response = await fetch(`${API_BASE}/api/v1/translate`, {
method: 'POST',
headers,
body: formData,
});
if (!response.ok) {
let errorMessage = `Translation failed: ${response.status}`;
try {
const errorData = await response.json();
errorMessage = errorData.message || errorData.error || errorMessage;
} catch {
// Response not JSON, use default message
}
throw new Error(errorMessage);
}
const data: TranslationSubmitResponse = await response.json();
setJobId(data.data.id);
setFileName(data.data.file_name || file.name);
setProgress(data.data.progress_percent || 5); // Start with at least 5%
setCurrentStep(data.data.current_step || 'Translating...');
startPolling(data.data.id);
} catch (err) {
setStatus('failed');
setError(err instanceof Error ? err.message : 'Translation failed');
setIsSubmitting(false);
}
// NOTE: Don't set isSubmitting(false) here - let polling handle the transition
}, [startPolling]);
const reset = useCallback(() => {
stopPolling();
setJobId(null);
setStatus('idle');
setProgress(0);
setCurrentStep('');
setError(null);
setEstimatedRemaining(null);
setFileName(null);
setIsSubmitting(false);
setPollingFailures(0);
}, [stopPolling]);
useEffect(() => {
return () => {
stopPolling();
};
}, [stopPolling]);
return {
submitTranslation,
jobId,
status,
progress,
currentStep,
error,
estimatedRemaining,
fileName,
reset,
isSubmitting,
isPolling,
pollingFailures,
};
}

View File

@@ -0,0 +1,7 @@
export interface User {
id: string;
email: string;
name: string;
tier: 'free' | 'pro';
created_at: string;
}

View File

@@ -0,0 +1,16 @@
'use client';
import { useRouter } from 'next/navigation';
export function useLogout() {
const router = useRouter();
const logout = () => {
localStorage.removeItem('token');
localStorage.removeItem('refresh_token');
localStorage.removeItem('user');
router.push('/');
};
return { logout };
}

View File

@@ -0,0 +1,29 @@
'use client';
import { useQuery, UseQueryResult } from '@tanstack/react-query';
import { useRouter } from 'next/navigation';
import { apiClient, ApiClientError } from '@/lib/apiClient';
import type { User } from './types';
export function useUser(): UseQueryResult<User, ApiClientError> {
const router = useRouter();
return useQuery({
queryKey: ['user', 'me'],
queryFn: async (): Promise<User> => {
const response = await apiClient.get<User>('/api/v1/auth/me');
return response.data;
},
retry: (failureCount, error) => {
if (error.status === 401) {
localStorage.removeItem('token');
localStorage.removeItem('refresh_token');
localStorage.removeItem('user');
router.push('/auth/login?redirect=/dashboard');
return false;
}
return failureCount < 2;
},
staleTime: 5 * 60 * 1000,
});
}

View File

@@ -0,0 +1,19 @@
/**
* Génère les initiales d'un nom (max 2 caractères)
* @param name - Le nom complet
* @returns Les initiales en majuscules
* @example getInitials("John Doe") // "JD"
* @example getInitials("Jane") // "J"
*/
export function getInitials(name: string): string {
if (!name || typeof name !== 'string') {
return '?';
}
return name
.split(' ')
.map(n => n[0])
.join('')
.toUpperCase()
.slice(0, 2);
}

View File

@@ -1,15 +1,19 @@
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
import { Sidebar } from "@/components/sidebar";
import { QueryProvider } from "@/providers/QueryProvider";
import { NotificationProvider } from "@/components/ui/notification";
import { I18nProvider } from "@/lib/i18n";
export const dynamic = 'force-dynamic';
const inter = Inter({
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Translate Co. - Document Translation",
description: "Translate Excel, Word, and PowerPoint documents while preserving formatting",
title: "Office Translator - Translate Documents, Keep the Format",
description: "Translate Excel, Word, and PowerPoint documents with zero formatting loss. Fast, private, and accurate document translation.",
};
export default function RootLayout({
@@ -19,13 +23,14 @@ export default function RootLayout({
}>) {
return (
<html lang="en" className="dark">
<body className={`${inter.className} bg-[#262626] text-zinc-100 antialiased`}>
<Sidebar />
<main className="ml-64 min-h-screen p-8">
<div className="max-w-6xl mx-auto">
{children}
</div>
</main>
<body className={`${inter.className} bg-background text-foreground antialiased`}>
<I18nProvider>
<QueryProvider>
<NotificationProvider>
{children}
</NotificationProvider>
</QueryProvider>
</I18nProvider>
</body>
</html>
);

Some files were not shown because too many files have changed in this diff Show More