feat: revue de code, doc CODE_REVIEW, forfaits 2026, traduction LLM, providers avec modèle
Made-with: Cursor
This commit is contained in:
62
config.py
62
config.py
@@ -2,36 +2,38 @@
|
|||||||
Configuration module for the Document Translation API
|
Configuration module for the Document Translation API
|
||||||
SaaS-ready with comprehensive settings for production deployment
|
SaaS-ready with comprehensive settings for production deployment
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
# ============== Translation Service ==============
|
# ============== Translation Service ==============
|
||||||
TRANSLATION_SERVICE = os.getenv("TRANSLATION_SERVICE", "google")
|
TRANSLATION_SERVICE = os.getenv("TRANSLATION_SERVICE", "google")
|
||||||
DEEPL_API_KEY = os.getenv("DEEPL_API_KEY", "")
|
DEEPL_API_KEY = os.getenv("DEEPL_API_KEY", "")
|
||||||
|
|
||||||
# Ollama Configuration
|
# Ollama Configuration
|
||||||
OLLAMA_BASE_URL = os.getenv("OLLAMA_BASE_URL", "http://localhost:11434")
|
OLLAMA_BASE_URL = os.getenv("OLLAMA_BASE_URL", "http://localhost:11434")
|
||||||
OLLAMA_MODEL = os.getenv("OLLAMA_MODEL", "llama3")
|
OLLAMA_MODEL = os.getenv("OLLAMA_MODEL", "llama3")
|
||||||
OLLAMA_VISION_MODEL = os.getenv("OLLAMA_VISION_MODEL", "llava")
|
OLLAMA_VISION_MODEL = os.getenv("OLLAMA_VISION_MODEL", "llava")
|
||||||
|
|
||||||
# ============== File Upload Configuration ==============
|
# ============== File Upload Configuration ==============
|
||||||
MAX_FILE_SIZE_MB = int(os.getenv("MAX_FILE_SIZE_MB", "50"))
|
MAX_FILE_SIZE_MB = int(os.getenv("MAX_FILE_SIZE_MB", "50"))
|
||||||
MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024
|
MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024
|
||||||
|
|
||||||
# Directories
|
# Directories
|
||||||
BASE_DIR = Path(__file__).parent
|
BASE_DIR = Path(__file__).parent
|
||||||
UPLOAD_DIR = BASE_DIR / "uploads"
|
UPLOAD_DIR = BASE_DIR / "uploads"
|
||||||
OUTPUT_DIR = BASE_DIR / "outputs"
|
OUTPUT_DIR = BASE_DIR / "outputs"
|
||||||
TEMP_DIR = BASE_DIR / "temp"
|
TEMP_DIR = BASE_DIR / "temp"
|
||||||
LOGS_DIR = BASE_DIR / "logs"
|
LOGS_DIR = BASE_DIR / "logs"
|
||||||
|
|
||||||
# Supported file types
|
# Supported file types
|
||||||
SUPPORTED_EXTENSIONS = {".xlsx", ".docx", ".pptx"}
|
SUPPORTED_EXTENSIONS = {".xlsx", ".docx", ".pptx"}
|
||||||
|
|
||||||
# ============== Rate Limiting (SaaS) ==============
|
# ============== Rate Limiting (SaaS) ==============
|
||||||
RATE_LIMIT_ENABLED = os.getenv("RATE_LIMIT_ENABLED", "true").lower() == "true"
|
RATE_LIMIT_ENABLED = os.getenv("RATE_LIMIT_ENABLED", "true").lower() == "true"
|
||||||
RATE_LIMIT_PER_MINUTE = int(os.getenv("RATE_LIMIT_PER_MINUTE", "30"))
|
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_MINUTE = int(os.getenv("TRANSLATIONS_PER_MINUTE", "10"))
|
||||||
TRANSLATIONS_PER_HOUR = int(os.getenv("TRANSLATIONS_PER_HOUR", "50"))
|
TRANSLATIONS_PER_HOUR = int(os.getenv("TRANSLATIONS_PER_HOUR", "50"))
|
||||||
MAX_CONCURRENT_TRANSLATIONS = int(os.getenv("MAX_CONCURRENT_TRANSLATIONS", "5"))
|
MAX_CONCURRENT_TRANSLATIONS = int(os.getenv("MAX_CONCURRENT_TRANSLATIONS", "5"))
|
||||||
|
|
||||||
# ============== Cleanup Service ==============
|
# ============== Cleanup Service ==============
|
||||||
CLEANUP_ENABLED = os.getenv("CLEANUP_ENABLED", "true").lower() == "true"
|
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"))
|
FILE_TTL_MINUTES = int(os.getenv("FILE_TTL_MINUTES", "60"))
|
||||||
INPUT_FILE_TTL_MINUTES = int(os.getenv("INPUT_FILE_TTL_MINUTES", "30"))
|
INPUT_FILE_TTL_MINUTES = int(os.getenv("INPUT_FILE_TTL_MINUTES", "30"))
|
||||||
OUTPUT_FILE_TTL_MINUTES = int(os.getenv("OUTPUT_FILE_TTL_MINUTES", "120"))
|
OUTPUT_FILE_TTL_MINUTES = int(os.getenv("OUTPUT_FILE_TTL_MINUTES", "120"))
|
||||||
|
|
||||||
# Disk space thresholds
|
# Disk space thresholds
|
||||||
DISK_WARNING_THRESHOLD_GB = float(os.getenv("DISK_WARNING_THRESHOLD_GB", "5.0"))
|
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"))
|
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 ==============
|
# ============== Security ==============
|
||||||
ENABLE_HSTS = os.getenv("ENABLE_HSTS", "false").lower() == "true"
|
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"))
|
MAX_REQUEST_SIZE_MB = int(os.getenv("MAX_REQUEST_SIZE_MB", "100"))
|
||||||
REQUEST_TIMEOUT_SECONDS = int(os.getenv("REQUEST_TIMEOUT_SECONDS", "300"))
|
REQUEST_TIMEOUT_SECONDS = int(os.getenv("REQUEST_TIMEOUT_SECONDS", "300"))
|
||||||
|
|
||||||
# ============== Monitoring ==============
|
# ============== Monitoring ==============
|
||||||
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO")
|
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"))
|
MAX_MEMORY_PERCENT = float(os.getenv("MAX_MEMORY_PERCENT", "80"))
|
||||||
|
|
||||||
# ============== API Configuration ==============
|
# ============== API Configuration ==============
|
||||||
API_TITLE = "Document Translation API"
|
API_TITLE = "Document Translation API"
|
||||||
API_VERSION = "1.0.0"
|
API_VERSION = "1.0.0"
|
||||||
API_DESCRIPTION = """
|
API_DESCRIPTION = """
|
||||||
Advanced Document Translation API with strict formatting preservation.
|
Advanced Document Translation API with strict formatting preservation.
|
||||||
|
|
||||||
Supports:
|
## Supported Formats
|
||||||
- Excel (.xlsx) - Preserves cell formatting, formulas, merged cells, images
|
- Excel (.xlsx) - Preserves cell formatting, formulas, merged cells, images
|
||||||
- Word (.docx) - Preserves styles, tables, images, headers/footers
|
- Word (.docx) - Preserves styles, tables, images, headers/footers
|
||||||
- PowerPoint (.pptx) - Preserves layouts, animations, embedded media
|
- PowerPoint (.pptx) - Preserves layouts, animations, embedded media
|
||||||
|
|
||||||
SaaS Features:
|
## SaaS Features
|
||||||
- Rate limiting per client IP
|
- Rate limiting per client IP
|
||||||
- Automatic file cleanup
|
- Automatic file cleanup
|
||||||
- Health monitoring
|
- Health monitoring
|
||||||
- Request logging
|
- Request logging
|
||||||
"""
|
|
||||||
|
## API Versioning
|
||||||
|
All API endpoints are versioned under /api/v1/ prefix for backward compatibility.
|
||||||
|
"""
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def ensure_directories(cls):
|
def ensure_directories(cls):
|
||||||
"""Create necessary directories if they don't exist"""
|
"""Create necessary directories if they don't exist"""
|
||||||
|
|||||||
@@ -1,17 +1,46 @@
|
|||||||
"""
|
"""
|
||||||
Database module for the Document Translation API
|
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__ = [
|
__all__ = [
|
||||||
"get_db",
|
"get_db",
|
||||||
"engine",
|
"get_db_session",
|
||||||
"SessionLocal",
|
"get_async_session",
|
||||||
|
"engine",
|
||||||
|
"AsyncSessionLocal",
|
||||||
"init_db",
|
"init_db",
|
||||||
|
"get_engine",
|
||||||
|
"Base",
|
||||||
"User",
|
"User",
|
||||||
"Subscription",
|
|
||||||
"Translation",
|
"Translation",
|
||||||
"ApiKey"
|
"ApiKey",
|
||||||
|
"UsageLog",
|
||||||
|
"PaymentHistory",
|
||||||
|
"PlanType",
|
||||||
|
"SubscriptionStatus",
|
||||||
|
"Glossary",
|
||||||
|
"GlossaryTerm",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,135 +1,174 @@
|
|||||||
"""
|
"""
|
||||||
Database connection and session management
|
Database connection and session management
|
||||||
Supports both PostgreSQL (production) and SQLite (development/testing)
|
Supports both PostgreSQL (production) and SQLite (development/testing)
|
||||||
|
Async SQLAlchemy 2.0 implementation
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import logging
|
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 contextlib import contextmanager
|
||||||
|
|
||||||
from sqlalchemy import create_engine, event
|
from database.utils import convert_to_async_url
|
||||||
from sqlalchemy.orm import sessionmaker, Session
|
|
||||||
from sqlalchemy.pool import QueuePool, StaticPool
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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", "")
|
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_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:
|
if DATABASE_URL and _is_postgres:
|
||||||
# PostgreSQL configuration
|
async_database_url = convert_to_async_url(DATABASE_URL)
|
||||||
engine = create_engine(
|
engine: AsyncEngine = create_async_engine(
|
||||||
DATABASE_URL,
|
async_database_url,
|
||||||
poolclass=QueuePool,
|
poolclass=QueuePool,
|
||||||
pool_size=5,
|
pool_size=5,
|
||||||
max_overflow=10,
|
max_overflow=10,
|
||||||
pool_timeout=30,
|
pool_timeout=30,
|
||||||
pool_recycle=1800, # Recycle connections after 30 minutes
|
pool_recycle=1800,
|
||||||
pool_pre_ping=True, # Check connection health before use
|
pool_pre_ping=True,
|
||||||
echo=os.getenv("DATABASE_ECHO", "false").lower() == "true",
|
echo=os.getenv("DATABASE_ECHO", "false").lower() == "true",
|
||||||
)
|
)
|
||||||
logger.info("✅ Database configured with PostgreSQL")
|
logger.info("✅ Database configured with PostgreSQL (async)")
|
||||||
else:
|
else:
|
||||||
# SQLite configuration (for development/testing or when no DATABASE_URL)
|
|
||||||
sqlite_path = os.getenv("SQLITE_PATH", "data/translate.db")
|
sqlite_path = os.getenv("SQLITE_PATH", "data/translate.db")
|
||||||
os.makedirs(os.path.dirname(sqlite_path), exist_ok=True)
|
os.makedirs(
|
||||||
|
os.path.dirname(sqlite_path) if os.path.dirname(sqlite_path) else ".",
|
||||||
sqlite_url = f"sqlite:///./{sqlite_path}"
|
exist_ok=True,
|
||||||
engine = create_engine(
|
)
|
||||||
sqlite_url,
|
|
||||||
|
async_database_url = f"sqlite+aiosqlite:///./{sqlite_path}"
|
||||||
|
engine: AsyncEngine = create_async_engine(
|
||||||
|
async_database_url,
|
||||||
connect_args={"check_same_thread": False},
|
connect_args={"check_same_thread": False},
|
||||||
poolclass=StaticPool,
|
poolclass=StaticPool,
|
||||||
echo=os.getenv("DATABASE_ECHO", "false").lower() == "true",
|
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
|
if not DATABASE_URL:
|
||||||
SessionLocal = sessionmaker(
|
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,
|
autocommit=False,
|
||||||
autoflush=False,
|
autoflush=False,
|
||||||
bind=engine,
|
|
||||||
expire_on_commit=False,
|
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.
|
Async dependency for FastAPI to get database session.
|
||||||
Usage: db: Session = Depends(get_db)
|
Usage: db: AsyncSession = Depends(get_db)
|
||||||
"""
|
"""
|
||||||
db = SessionLocal()
|
async with AsyncSessionLocal() as session:
|
||||||
try:
|
try:
|
||||||
yield db
|
yield session
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
await session.close()
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
@asynccontextmanager
|
||||||
def get_db_session() -> Generator[Session, None, None]:
|
async def get_db_session() -> AsyncGenerator[AsyncSession, None]:
|
||||||
"""
|
"""
|
||||||
Context manager for database session.
|
Async context manager for database session.
|
||||||
Usage: with get_db_session() as db: ...
|
Usage: async with get_db_session() as db: ...
|
||||||
"""
|
"""
|
||||||
db = SessionLocal()
|
async with AsyncSessionLocal() as session:
|
||||||
try:
|
try:
|
||||||
yield db
|
yield session
|
||||||
db.commit()
|
await session.commit()
|
||||||
except Exception:
|
except Exception:
|
||||||
db.rollback()
|
await session.rollback()
|
||||||
raise
|
raise
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
|
|
||||||
# Alias for backward compatibility
|
get_async_session = get_db_session
|
||||||
get_sync_session = get_db_session
|
|
||||||
|
|
||||||
|
|
||||||
def init_db():
|
async def init_db():
|
||||||
"""
|
"""
|
||||||
Initialize database tables.
|
Initialize database tables asynchronously.
|
||||||
Call this on application startup.
|
Call this on application startup.
|
||||||
"""
|
"""
|
||||||
from database.models import Base
|
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.
|
Check if database connection is healthy.
|
||||||
Returns True if connection works, False otherwise.
|
Returns True if connection works, False otherwise.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
with engine.connect() as conn:
|
async with engine.connect() as conn:
|
||||||
conn.execute("SELECT 1")
|
await conn.execute(text("SELECT 1"))
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Database connection check failed: {e}")
|
logger.error(f"Database connection check failed: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
# Connection pool stats (for monitoring)
|
|
||||||
def get_pool_stats() -> dict:
|
def get_pool_stats() -> dict:
|
||||||
"""Get database connection pool statistics"""
|
"""Get database connection pool statistics"""
|
||||||
if hasattr(engine.pool, 'status'):
|
if hasattr(engine.pool, "status"):
|
||||||
return {
|
return {
|
||||||
"pool_size": engine.pool.size(),
|
"pool_size": engine.pool.size(),
|
||||||
"checked_in": engine.pool.checkedin(),
|
"checked_in": engine.pool.checkedin(),
|
||||||
@@ -137,3 +176,8 @@ def get_pool_stats() -> dict:
|
|||||||
"overflow": engine.pool.overflow(),
|
"overflow": engine.pool.overflow(),
|
||||||
}
|
}
|
||||||
return {"status": "pool stats not available"}
|
return {"status": "pool stats not available"}
|
||||||
|
|
||||||
|
|
||||||
|
def get_engine() -> AsyncEngine:
|
||||||
|
"""Get the async engine instance"""
|
||||||
|
return engine
|
||||||
|
|||||||
@@ -1,19 +1,34 @@
|
|||||||
"""
|
"""
|
||||||
SQLAlchemy models for the Document Translation API
|
SQLAlchemy models for the Document Translation API
|
||||||
"""
|
"""
|
||||||
import os
|
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime
|
from datetime import datetime, timezone
|
||||||
from typing import Optional, List
|
import warnings
|
||||||
|
|
||||||
from sqlalchemy import (
|
from sqlalchemy import (
|
||||||
Column, String, Integer, Float, Boolean, DateTime, Text,
|
Column,
|
||||||
ForeignKey, Enum, Index, JSON, BigInteger
|
String,
|
||||||
|
Integer,
|
||||||
|
Float,
|
||||||
|
Boolean,
|
||||||
|
DateTime,
|
||||||
|
Text,
|
||||||
|
ForeignKey,
|
||||||
|
Enum,
|
||||||
|
Index,
|
||||||
|
JSON,
|
||||||
|
BigInteger,
|
||||||
|
CheckConstraint,
|
||||||
)
|
)
|
||||||
from sqlalchemy.orm import relationship, declarative_base
|
from sqlalchemy.orm import relationship, declarative_base
|
||||||
from sqlalchemy.dialects.postgresql import UUID as PG_UUID
|
|
||||||
import enum
|
import enum
|
||||||
|
|
||||||
|
|
||||||
|
def _utcnow() -> datetime:
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
Base = declarative_base()
|
Base = declarative_base()
|
||||||
|
|
||||||
|
|
||||||
@@ -22,6 +37,11 @@ def generate_uuid():
|
|||||||
return str(uuid.uuid4())
|
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):
|
class PlanType(str, enum.Enum):
|
||||||
FREE = "free"
|
FREE = "free"
|
||||||
STARTER = "starter"
|
STARTER = "starter"
|
||||||
@@ -40,57 +60,78 @@ class SubscriptionStatus(str, enum.Enum):
|
|||||||
|
|
||||||
class User(Base):
|
class User(Base):
|
||||||
"""User model for authentication and billing"""
|
"""User model for authentication and billing"""
|
||||||
|
|
||||||
__tablename__ = "users"
|
__tablename__ = "users"
|
||||||
|
|
||||||
id = Column(String(36), primary_key=True, default=generate_uuid)
|
id = Column(String(36), primary_key=True, default=generate_uuid)
|
||||||
email = Column(String(255), unique=True, nullable=False, index=True)
|
email = Column(String(255), unique=True, nullable=False, index=True)
|
||||||
name = Column(String(255), nullable=False)
|
name = Column(String(255), nullable=False)
|
||||||
password_hash = Column(String(255), nullable=False)
|
hashed_password = Column(String(255), nullable=False)
|
||||||
|
|
||||||
# Account status
|
tier = Column(String(10), default="free", nullable=False)
|
||||||
|
daily_translation_count = Column(Integer, default=0, nullable=False)
|
||||||
|
|
||||||
email_verified = Column(Boolean, default=False)
|
email_verified = Column(Boolean, default=False)
|
||||||
is_active = Column(Boolean, default=True)
|
is_active = Column(Boolean, default=True)
|
||||||
avatar_url = Column(String(500), nullable=True)
|
avatar_url = Column(String(500), nullable=True)
|
||||||
|
|
||||||
# Subscription info
|
|
||||||
plan = Column(Enum(PlanType), default=PlanType.FREE)
|
plan = Column(Enum(PlanType), default=PlanType.FREE)
|
||||||
subscription_status = Column(Enum(SubscriptionStatus), default=SubscriptionStatus.ACTIVE)
|
subscription_status = Column(
|
||||||
|
Enum(SubscriptionStatus), default=SubscriptionStatus.ACTIVE
|
||||||
# Stripe integration
|
)
|
||||||
|
|
||||||
stripe_customer_id = Column(String(255), nullable=True, index=True)
|
stripe_customer_id = Column(String(255), nullable=True, index=True)
|
||||||
stripe_subscription_id = Column(String(255), nullable=True)
|
stripe_subscription_id = Column(String(255), nullable=True)
|
||||||
|
|
||||||
# Usage tracking (reset monthly)
|
|
||||||
docs_translated_this_month = Column(Integer, default=0)
|
docs_translated_this_month = Column(Integer, default=0)
|
||||||
pages_translated_this_month = Column(Integer, default=0)
|
pages_translated_this_month = Column(Integer, default=0)
|
||||||
api_calls_this_month = Column(Integer, default=0)
|
api_calls_this_month = Column(Integer, default=0)
|
||||||
extra_credits = Column(Integer, default=0) # Purchased credits
|
extra_credits = Column(Integer, default=0)
|
||||||
usage_reset_date = Column(DateTime, default=datetime.utcnow)
|
usage_reset_date = Column(DateTime, default=_utcnow)
|
||||||
|
|
||||||
# Timestamps
|
created_at = Column(DateTime, default=_utcnow)
|
||||||
created_at = Column(DateTime, default=datetime.utcnow)
|
updated_at = Column(DateTime, default=_utcnow, onupdate=_utcnow)
|
||||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
|
||||||
last_login_at = Column(DateTime, nullable=True)
|
last_login_at = Column(DateTime, nullable=True)
|
||||||
|
|
||||||
# Relationships
|
translations = relationship("Translation", back_populates="user", lazy="select")
|
||||||
translations = relationship("Translation", back_populates="user", lazy="dynamic")
|
api_keys = relationship("ApiKey", back_populates="user", lazy="select")
|
||||||
api_keys = relationship("ApiKey", back_populates="user", lazy="dynamic")
|
|
||||||
|
|
||||||
# Indexes
|
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
Index('ix_users_email_active', 'email', 'is_active'),
|
CheckConstraint("tier IN ('free', 'pro')", name="ck_users_tier"),
|
||||||
Index('ix_users_stripe_customer', 'stripe_customer_id'),
|
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:
|
def to_dict(self) -> dict:
|
||||||
"""Convert user to dictionary for API response"""
|
|
||||||
return {
|
return {
|
||||||
"id": self.id,
|
"id": self.id,
|
||||||
"email": self.email,
|
"email": self.email,
|
||||||
"name": self.name,
|
"name": self.name,
|
||||||
"avatar_url": self.avatar_url,
|
"avatar_url": self.avatar_url,
|
||||||
|
"tier": self.tier,
|
||||||
"plan": self.plan.value if self.plan else "free",
|
"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,
|
"docs_translated_this_month": self.docs_translated_this_month,
|
||||||
"pages_translated_this_month": self.pages_translated_this_month,
|
"pages_translated_this_month": self.pages_translated_this_month,
|
||||||
"api_calls_this_month": self.api_calls_this_month,
|
"api_calls_this_month": self.api_calls_this_month,
|
||||||
@@ -102,44 +143,49 @@ class User(Base):
|
|||||||
|
|
||||||
class Translation(Base):
|
class Translation(Base):
|
||||||
"""Translation history for analytics and billing"""
|
"""Translation history for analytics and billing"""
|
||||||
|
|
||||||
__tablename__ = "translations"
|
__tablename__ = "translations"
|
||||||
|
|
||||||
id = Column(String(36), primary_key=True, default=generate_uuid)
|
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
|
# File info
|
||||||
original_filename = Column(String(255), nullable=False)
|
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)
|
file_size_bytes = Column(BigInteger, default=0)
|
||||||
page_count = Column(Integer, default=0)
|
page_count = Column(Integer, default=0)
|
||||||
|
|
||||||
# Translation details
|
# Translation details
|
||||||
source_language = Column(String(10), default="auto")
|
source_language = Column(String(10), default="auto")
|
||||||
target_language = Column(String(10), nullable=False)
|
target_language = Column(String(10), nullable=False)
|
||||||
provider = Column(String(50), nullable=False) # google, deepl, ollama, etc.
|
provider = Column(String(50), nullable=False) # google, deepl, ollama, etc.
|
||||||
|
|
||||||
# Processing info
|
# 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)
|
error_message = Column(Text, nullable=True)
|
||||||
processing_time_ms = Column(Integer, nullable=True)
|
processing_time_ms = Column(Integer, nullable=True)
|
||||||
|
|
||||||
# Cost tracking (for paid providers)
|
# Cost tracking (for paid providers)
|
||||||
characters_translated = Column(Integer, default=0)
|
characters_translated = Column(Integer, default=0)
|
||||||
estimated_cost_usd = Column(Float, default=0.0)
|
estimated_cost_usd = Column(Float, default=0.0)
|
||||||
|
|
||||||
# Timestamps
|
# Timestamps
|
||||||
created_at = Column(DateTime, default=datetime.utcnow)
|
created_at = Column(DateTime, default=_utcnow)
|
||||||
completed_at = Column(DateTime, nullable=True)
|
completed_at = Column(DateTime, nullable=True)
|
||||||
|
|
||||||
# Relationship
|
# Relationship
|
||||||
user = relationship("User", back_populates="translations")
|
user = relationship("User", back_populates="translations")
|
||||||
|
|
||||||
# Indexes
|
# Indexes
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
Index('ix_translations_user_date', 'user_id', 'created_at'),
|
Index("ix_translations_user_date", "user_id", "created_at"),
|
||||||
Index('ix_translations_status', 'status'),
|
Index("ix_translations_status", "status"),
|
||||||
)
|
)
|
||||||
|
|
||||||
def to_dict(self) -> dict:
|
def to_dict(self) -> dict:
|
||||||
return {
|
return {
|
||||||
"id": self.id,
|
"id": self.id,
|
||||||
@@ -154,43 +200,49 @@ class Translation(Base):
|
|||||||
"processing_time_ms": self.processing_time_ms,
|
"processing_time_ms": self.processing_time_ms,
|
||||||
"characters_translated": self.characters_translated,
|
"characters_translated": self.characters_translated,
|
||||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
"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):
|
class ApiKey(Base):
|
||||||
"""API keys for programmatic access"""
|
"""API keys for programmatic access"""
|
||||||
|
|
||||||
__tablename__ = "api_keys"
|
__tablename__ = "api_keys"
|
||||||
|
|
||||||
id = Column(String(36), primary_key=True, default=generate_uuid)
|
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
|
# Key info
|
||||||
name = Column(String(100), nullable=False) # User-friendly name
|
name = Column(String(100), nullable=False) # User-friendly name
|
||||||
key_hash = Column(String(255), nullable=False) # SHA256 of the key
|
key_hash = Column(String(255), nullable=False) # SHA256 of the key
|
||||||
key_prefix = Column(String(10), nullable=False) # First 8 chars for identification
|
key_prefix = Column(String(10), nullable=False) # First 8 chars for identification
|
||||||
|
|
||||||
# Permissions
|
# Permissions
|
||||||
is_active = Column(Boolean, default=True)
|
is_active = Column(Boolean, default=True)
|
||||||
scopes = Column(JSON, default=list) # ["translate", "read", "write"]
|
scopes = Column(JSON, default=list) # ["translate", "read", "write"]
|
||||||
|
|
||||||
# Usage tracking
|
# Usage tracking
|
||||||
last_used_at = Column(DateTime, nullable=True)
|
last_used_at = Column(DateTime, nullable=True)
|
||||||
usage_count = Column(Integer, default=0)
|
usage_count = Column(Integer, default=0)
|
||||||
|
|
||||||
# Timestamps
|
# Timestamps
|
||||||
created_at = Column(DateTime, default=datetime.utcnow)
|
created_at = Column(DateTime, default=_utcnow)
|
||||||
expires_at = Column(DateTime, nullable=True)
|
expires_at = Column(DateTime, nullable=True)
|
||||||
|
revoked_at = Column(DateTime, nullable=True) # Set when is_active=False
|
||||||
|
|
||||||
# Relationship
|
# Relationship
|
||||||
user = relationship("User", back_populates="api_keys")
|
user = relationship("User", back_populates="api_keys")
|
||||||
|
|
||||||
# Indexes
|
# Indexes
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
Index('ix_api_keys_prefix', 'key_prefix'),
|
Index("ix_api_keys_prefix", "key_prefix"),
|
||||||
Index('ix_api_keys_hash', 'key_hash'),
|
Index("ix_api_keys_hash", "key_hash"),
|
||||||
)
|
)
|
||||||
|
|
||||||
def to_dict(self) -> dict:
|
def to_dict(self) -> dict:
|
||||||
return {
|
return {
|
||||||
"id": self.id,
|
"id": self.id,
|
||||||
@@ -198,7 +250,9 @@ class ApiKey(Base):
|
|||||||
"key_prefix": self.key_prefix,
|
"key_prefix": self.key_prefix,
|
||||||
"is_active": self.is_active,
|
"is_active": self.is_active,
|
||||||
"scopes": self.scopes,
|
"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,
|
"usage_count": self.usage_count,
|
||||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||||
"expires_at": self.expires_at.isoformat() if self.expires_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):
|
class UsageLog(Base):
|
||||||
"""Daily usage aggregation for billing and analytics"""
|
"""Daily usage aggregation for billing and analytics"""
|
||||||
|
|
||||||
__tablename__ = "usage_logs"
|
__tablename__ = "usage_logs"
|
||||||
|
|
||||||
id = Column(String(36), primary_key=True, default=generate_uuid)
|
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 (for daily aggregation)
|
||||||
date = Column(DateTime, nullable=False, index=True)
|
date = Column(DateTime, nullable=False, index=True)
|
||||||
|
|
||||||
# Aggregated counts
|
# Aggregated counts
|
||||||
documents_count = Column(Integer, default=0)
|
documents_count = Column(Integer, default=0)
|
||||||
pages_count = Column(Integer, default=0)
|
pages_count = Column(Integer, default=0)
|
||||||
characters_count = Column(BigInteger, default=0)
|
characters_count = Column(BigInteger, default=0)
|
||||||
api_calls_count = Column(Integer, default=0)
|
api_calls_count = Column(Integer, default=0)
|
||||||
|
|
||||||
# By provider breakdown (JSON)
|
# By provider breakdown (JSON)
|
||||||
provider_breakdown = Column(JSON, default=dict)
|
provider_breakdown = Column(JSON, default=dict)
|
||||||
|
|
||||||
# Indexes
|
# Indexes
|
||||||
__table_args__ = (
|
__table_args__ = (Index("ix_usage_logs_user_date", "user_id", "date", unique=True),)
|
||||||
Index('ix_usage_logs_user_date', 'user_id', 'date', unique=True),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class PaymentHistory(Base):
|
class PaymentHistory(Base):
|
||||||
"""Payment and invoice history"""
|
"""Payment and invoice history"""
|
||||||
|
|
||||||
__tablename__ = "payment_history"
|
__tablename__ = "payment_history"
|
||||||
|
|
||||||
id = Column(String(36), primary_key=True, default=generate_uuid)
|
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 info
|
||||||
stripe_payment_intent_id = Column(String(255), nullable=True)
|
stripe_payment_intent_id = Column(String(255), nullable=True)
|
||||||
stripe_invoice_id = Column(String(255), nullable=True)
|
stripe_invoice_id = Column(String(255), nullable=True)
|
||||||
|
|
||||||
# Payment details
|
# Payment details
|
||||||
amount_cents = Column(Integer, nullable=False)
|
amount_cents = Column(Integer, nullable=False)
|
||||||
currency = Column(String(3), default="usd")
|
currency = Column(String(3), default="usd")
|
||||||
payment_type = Column(String(50), nullable=False) # subscription, credits, one_time
|
payment_type = Column(String(50), nullable=False) # subscription, credits, one_time
|
||||||
status = Column(String(20), nullable=False) # succeeded, failed, pending, refunded
|
status = Column(String(20), nullable=False) # succeeded, failed, pending, refunded
|
||||||
|
|
||||||
# Description
|
# Description
|
||||||
description = Column(String(255), nullable=True)
|
description = Column(String(255), nullable=True)
|
||||||
|
|
||||||
# Timestamps
|
# Timestamps
|
||||||
created_at = Column(DateTime, default=datetime.utcnow)
|
created_at = Column(DateTime, default=_utcnow)
|
||||||
|
|
||||||
# Indexes
|
# Indexes
|
||||||
__table_args__ = (
|
__table_args__ = (Index("ix_payment_history_user", "user_id", "created_at"),)
|
||||||
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,
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,54 +2,64 @@
|
|||||||
Repository layer for database operations
|
Repository layer for database operations
|
||||||
Provides clean interface for CRUD operations
|
Provides clean interface for CRUD operations
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import secrets
|
import secrets
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import Optional, List, Dict, Any
|
from typing import Optional, List, Dict, Any
|
||||||
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from sqlalchemy import and_, func, or_
|
from sqlalchemy import and_, func, or_
|
||||||
|
|
||||||
from database.models import (
|
from database.models import (
|
||||||
User, Translation, ApiKey, UsageLog, PaymentHistory,
|
User,
|
||||||
PlanType, SubscriptionStatus
|
Translation,
|
||||||
|
ApiKey,
|
||||||
|
UsageLog,
|
||||||
|
PaymentHistory,
|
||||||
|
PlanType,
|
||||||
|
SubscriptionStatus,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class UserRepository:
|
class UserRepository:
|
||||||
"""Repository for User database operations"""
|
"""Repository for User database operations"""
|
||||||
|
|
||||||
def __init__(self, db: Session):
|
def __init__(self, db: Session):
|
||||||
self.db = db
|
self.db = db
|
||||||
|
|
||||||
def get_by_id(self, user_id: str) -> Optional[User]:
|
def get_by_id(self, user_id: str) -> Optional[User]:
|
||||||
"""Get user by ID"""
|
"""Get user by ID"""
|
||||||
return self.db.query(User).filter(User.id == user_id).first()
|
return self.db.query(User).filter(User.id == user_id).first()
|
||||||
|
|
||||||
def get_by_email(self, email: str) -> Optional[User]:
|
def get_by_email(self, email: str) -> Optional[User]:
|
||||||
"""Get user by email (case-insensitive)"""
|
"""Get user by email (case-insensitive)"""
|
||||||
return self.db.query(User).filter(
|
return (
|
||||||
func.lower(User.email) == email.lower()
|
self.db.query(User).filter(func.lower(User.email) == email.lower()).first()
|
||||||
).first()
|
)
|
||||||
|
|
||||||
def get_by_stripe_customer(self, stripe_customer_id: str) -> Optional[User]:
|
def get_by_stripe_customer(self, stripe_customer_id: str) -> Optional[User]:
|
||||||
"""Get user by Stripe customer ID"""
|
"""Get user by Stripe customer ID"""
|
||||||
return self.db.query(User).filter(
|
return (
|
||||||
User.stripe_customer_id == stripe_customer_id
|
self.db.query(User)
|
||||||
).first()
|
.filter(User.stripe_customer_id == stripe_customer_id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
def create(
|
def create(
|
||||||
self,
|
self,
|
||||||
email: str,
|
email: str,
|
||||||
name: str,
|
name: str,
|
||||||
password_hash: str,
|
hashed_password: str,
|
||||||
plan: PlanType = PlanType.FREE
|
tier: str = "free",
|
||||||
) -> User:
|
) -> 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(
|
user = User(
|
||||||
email=email.lower(),
|
email=email.lower(),
|
||||||
name=name,
|
name=name,
|
||||||
password_hash=password_hash,
|
hashed_password=hashed_password,
|
||||||
|
tier=tier,
|
||||||
plan=plan,
|
plan=plan,
|
||||||
subscription_status=SubscriptionStatus.ACTIVE,
|
subscription_status=SubscriptionStatus.ACTIVE,
|
||||||
)
|
)
|
||||||
@@ -57,94 +67,90 @@ class UserRepository:
|
|||||||
self.db.commit()
|
self.db.commit()
|
||||||
self.db.refresh(user)
|
self.db.refresh(user)
|
||||||
return user
|
return user
|
||||||
|
|
||||||
def update(self, user_id: str, **kwargs) -> Optional[User]:
|
def update(self, user_id: str, **kwargs) -> Optional[User]:
|
||||||
"""Update user fields"""
|
"""Update user fields"""
|
||||||
user = self.get_by_id(user_id)
|
user = self.get_by_id(user_id)
|
||||||
if not user:
|
if not user:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
for key, value in kwargs.items():
|
for key, value in kwargs.items():
|
||||||
if hasattr(user, key):
|
if hasattr(user, key):
|
||||||
setattr(user, key, value)
|
setattr(user, key, value)
|
||||||
|
|
||||||
user.updated_at = datetime.utcnow()
|
user.updated_at = datetime.now(timezone.utc)
|
||||||
self.db.commit()
|
self.db.commit()
|
||||||
self.db.refresh(user)
|
self.db.refresh(user)
|
||||||
return user
|
return user
|
||||||
|
|
||||||
def delete(self, user_id: str) -> bool:
|
def delete(self, user_id: str) -> bool:
|
||||||
"""Delete a user"""
|
"""Delete a user"""
|
||||||
user = self.get_by_id(user_id)
|
user = self.get_by_id(user_id)
|
||||||
if not user:
|
if not user:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
self.db.delete(user)
|
self.db.delete(user)
|
||||||
self.db.commit()
|
self.db.commit()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def increment_usage(
|
def increment_usage(
|
||||||
self,
|
self, user_id: str, docs: int = 0, pages: int = 0, api_calls: int = 0
|
||||||
user_id: str,
|
|
||||||
docs: int = 0,
|
|
||||||
pages: int = 0,
|
|
||||||
api_calls: int = 0
|
|
||||||
) -> Optional[User]:
|
) -> Optional[User]:
|
||||||
"""Increment usage counters"""
|
"""Increment usage counters"""
|
||||||
user = self.get_by_id(user_id)
|
user = self.get_by_id(user_id)
|
||||||
if not user:
|
if not user:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Check if usage needs to be reset (monthly)
|
# Check if usage needs to be reset (monthly)
|
||||||
if user.usage_reset_date:
|
if user.usage_reset_date:
|
||||||
now = datetime.utcnow()
|
now = datetime.now(timezone.utc)
|
||||||
if now.month != user.usage_reset_date.month or now.year != user.usage_reset_date.year:
|
if (
|
||||||
|
now.month != user.usage_reset_date.month
|
||||||
|
or now.year != user.usage_reset_date.year
|
||||||
|
):
|
||||||
user.docs_translated_this_month = 0
|
user.docs_translated_this_month = 0
|
||||||
user.pages_translated_this_month = 0
|
user.pages_translated_this_month = 0
|
||||||
user.api_calls_this_month = 0
|
user.api_calls_this_month = 0
|
||||||
user.usage_reset_date = now
|
user.usage_reset_date = now
|
||||||
|
|
||||||
user.docs_translated_this_month += docs
|
user.docs_translated_this_month += docs
|
||||||
user.pages_translated_this_month += pages
|
user.pages_translated_this_month += pages
|
||||||
user.api_calls_this_month += api_calls
|
user.api_calls_this_month += api_calls
|
||||||
|
|
||||||
self.db.commit()
|
self.db.commit()
|
||||||
self.db.refresh(user)
|
self.db.refresh(user)
|
||||||
return user
|
return user
|
||||||
|
|
||||||
def add_credits(self, user_id: str, credits: int) -> Optional[User]:
|
def add_credits(self, user_id: str, credits: int) -> Optional[User]:
|
||||||
"""Add extra credits to user"""
|
"""Add extra credits to user"""
|
||||||
user = self.get_by_id(user_id)
|
user = self.get_by_id(user_id)
|
||||||
if not user:
|
if not user:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
user.extra_credits += credits
|
user.extra_credits += credits
|
||||||
self.db.commit()
|
self.db.commit()
|
||||||
self.db.refresh(user)
|
self.db.refresh(user)
|
||||||
return user
|
return user
|
||||||
|
|
||||||
def use_credits(self, user_id: str, credits: int) -> bool:
|
def use_credits(self, user_id: str, credits: int) -> bool:
|
||||||
"""Use credits from user balance"""
|
"""Use credits from user balance"""
|
||||||
user = self.get_by_id(user_id)
|
user = self.get_by_id(user_id)
|
||||||
if not user or user.extra_credits < credits:
|
if not user or user.extra_credits < credits:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
user.extra_credits -= credits
|
user.extra_credits -= credits
|
||||||
self.db.commit()
|
self.db.commit()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def get_all_users(
|
def get_all_users(
|
||||||
self,
|
self, skip: int = 0, limit: int = 100, plan: Optional[PlanType] = None
|
||||||
skip: int = 0,
|
|
||||||
limit: int = 100,
|
|
||||||
plan: Optional[PlanType] = None
|
|
||||||
) -> List[User]:
|
) -> List[User]:
|
||||||
"""Get all users with pagination"""
|
"""Get all users with pagination"""
|
||||||
query = self.db.query(User)
|
query = self.db.query(User)
|
||||||
if plan:
|
if plan:
|
||||||
query = query.filter(User.plan == plan)
|
query = query.filter(User.plan == plan)
|
||||||
return query.offset(skip).limit(limit).all()
|
return query.offset(skip).limit(limit).all()
|
||||||
|
|
||||||
def count_users(self, plan: Optional[PlanType] = None) -> int:
|
def count_users(self, plan: Optional[PlanType] = None) -> int:
|
||||||
"""Count total users"""
|
"""Count total users"""
|
||||||
query = self.db.query(func.count(User.id))
|
query = self.db.query(func.count(User.id))
|
||||||
@@ -155,10 +161,10 @@ class UserRepository:
|
|||||||
|
|
||||||
class TranslationRepository:
|
class TranslationRepository:
|
||||||
"""Repository for Translation database operations"""
|
"""Repository for Translation database operations"""
|
||||||
|
|
||||||
def __init__(self, db: Session):
|
def __init__(self, db: Session):
|
||||||
self.db = db
|
self.db = db
|
||||||
|
|
||||||
def create(
|
def create(
|
||||||
self,
|
self,
|
||||||
user_id: str,
|
user_id: str,
|
||||||
@@ -186,7 +192,36 @@ class TranslationRepository:
|
|||||||
self.db.commit()
|
self.db.commit()
|
||||||
self.db.refresh(translation)
|
self.db.refresh(translation)
|
||||||
return 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(
|
def update_status(
|
||||||
self,
|
self,
|
||||||
translation_id: str,
|
translation_id: str,
|
||||||
@@ -196,13 +231,13 @@ class TranslationRepository:
|
|||||||
characters_translated: Optional[int] = None,
|
characters_translated: Optional[int] = None,
|
||||||
) -> Optional[Translation]:
|
) -> Optional[Translation]:
|
||||||
"""Update translation status"""
|
"""Update translation status"""
|
||||||
translation = self.db.query(Translation).filter(
|
translation = (
|
||||||
Translation.id == translation_id
|
self.db.query(Translation).filter(Translation.id == translation_id).first()
|
||||||
).first()
|
)
|
||||||
|
|
||||||
if not translation:
|
if not translation:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
translation.status = status
|
translation.status = status
|
||||||
if error_message:
|
if error_message:
|
||||||
translation.error_message = error_message
|
translation.error_message = error_message
|
||||||
@@ -211,12 +246,12 @@ class TranslationRepository:
|
|||||||
if characters_translated:
|
if characters_translated:
|
||||||
translation.characters_translated = characters_translated
|
translation.characters_translated = characters_translated
|
||||||
if status == "completed":
|
if status == "completed":
|
||||||
translation.completed_at = datetime.utcnow()
|
translation.completed_at = datetime.now(timezone.utc)
|
||||||
|
|
||||||
self.db.commit()
|
self.db.commit()
|
||||||
self.db.refresh(translation)
|
self.db.refresh(translation)
|
||||||
return translation
|
return translation
|
||||||
|
|
||||||
def get_user_translations(
|
def get_user_translations(
|
||||||
self,
|
self,
|
||||||
user_id: str,
|
user_id: str,
|
||||||
@@ -228,24 +263,33 @@ class TranslationRepository:
|
|||||||
query = self.db.query(Translation).filter(Translation.user_id == user_id)
|
query = self.db.query(Translation).filter(Translation.user_id == user_id)
|
||||||
if status:
|
if status:
|
||||||
query = query.filter(Translation.status == 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]:
|
def get_user_stats(self, user_id: str, days: int = 30) -> Dict[str, Any]:
|
||||||
"""Get user's translation statistics"""
|
"""Get user's translation statistics"""
|
||||||
since = datetime.utcnow() - timedelta(days=days)
|
since = datetime.now(timezone.utc) - timedelta(days=days)
|
||||||
|
|
||||||
result = self.db.query(
|
result = (
|
||||||
func.count(Translation.id).label("total_translations"),
|
self.db.query(
|
||||||
func.sum(Translation.page_count).label("total_pages"),
|
func.count(Translation.id).label("total_translations"),
|
||||||
func.sum(Translation.characters_translated).label("total_characters"),
|
func.sum(Translation.page_count).label("total_pages"),
|
||||||
).filter(
|
func.sum(Translation.characters_translated).label("total_characters"),
|
||||||
and_(
|
|
||||||
Translation.user_id == user_id,
|
|
||||||
Translation.created_at >= since,
|
|
||||||
Translation.status == "completed",
|
|
||||||
)
|
)
|
||||||
).first()
|
.filter(
|
||||||
|
and_(
|
||||||
|
Translation.user_id == user_id,
|
||||||
|
Translation.created_at >= since,
|
||||||
|
Translation.status == "completed",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"total_translations": result.total_translations or 0,
|
"total_translations": result.total_translations or 0,
|
||||||
"total_pages": result.total_pages or 0,
|
"total_pages": result.total_pages or 0,
|
||||||
@@ -256,15 +300,15 @@ class TranslationRepository:
|
|||||||
|
|
||||||
class ApiKeyRepository:
|
class ApiKeyRepository:
|
||||||
"""Repository for API Key database operations"""
|
"""Repository for API Key database operations"""
|
||||||
|
|
||||||
def __init__(self, db: Session):
|
def __init__(self, db: Session):
|
||||||
self.db = db
|
self.db = db
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def hash_key(key: str) -> str:
|
def hash_key(key: str) -> str:
|
||||||
"""Hash an API key"""
|
"""Hash an API key"""
|
||||||
return hashlib.sha256(key.encode()).hexdigest()
|
return hashlib.sha256(key.encode()).hexdigest()
|
||||||
|
|
||||||
def create(
|
def create(
|
||||||
self,
|
self,
|
||||||
user_id: str,
|
user_id: str,
|
||||||
@@ -277,11 +321,11 @@ class ApiKeyRepository:
|
|||||||
raw_key = f"tr_{secrets.token_urlsafe(32)}"
|
raw_key = f"tr_{secrets.token_urlsafe(32)}"
|
||||||
key_hash = self.hash_key(raw_key)
|
key_hash = self.hash_key(raw_key)
|
||||||
key_prefix = raw_key[:10]
|
key_prefix = raw_key[:10]
|
||||||
|
|
||||||
expires_at = None
|
expires_at = None
|
||||||
if expires_in_days:
|
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(
|
api_key = ApiKey(
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
name=name,
|
name=name,
|
||||||
@@ -293,49 +337,60 @@ class ApiKeyRepository:
|
|||||||
self.db.add(api_key)
|
self.db.add(api_key)
|
||||||
self.db.commit()
|
self.db.commit()
|
||||||
self.db.refresh(api_key)
|
self.db.refresh(api_key)
|
||||||
|
|
||||||
return api_key, raw_key
|
return api_key, raw_key
|
||||||
|
|
||||||
def get_by_key(self, raw_key: str) -> Optional[ApiKey]:
|
def get_by_key(self, raw_key: str) -> Optional[ApiKey]:
|
||||||
"""Get API key by raw key value"""
|
"""Get API key by raw key value"""
|
||||||
key_hash = self.hash_key(raw_key)
|
key_hash = self.hash_key(raw_key)
|
||||||
api_key = self.db.query(ApiKey).filter(
|
api_key = (
|
||||||
and_(
|
self.db.query(ApiKey)
|
||||||
ApiKey.key_hash == key_hash,
|
.filter(
|
||||||
ApiKey.is_active == True,
|
and_(
|
||||||
|
ApiKey.key_hash == key_hash,
|
||||||
|
ApiKey.is_active == True,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
).first()
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
if api_key:
|
if api_key:
|
||||||
# Check expiration
|
# 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
|
return None
|
||||||
|
|
||||||
# Update last used
|
# Update last used
|
||||||
api_key.last_used_at = datetime.utcnow()
|
api_key.last_used_at = datetime.now(timezone.utc)
|
||||||
api_key.usage_count += 1
|
api_key.usage_count += 1
|
||||||
self.db.commit()
|
self.db.commit()
|
||||||
|
|
||||||
return api_key
|
return api_key
|
||||||
|
|
||||||
def get_user_keys(self, user_id: str) -> List[ApiKey]:
|
def get_user_keys(self, user_id: str) -> List[ApiKey]:
|
||||||
"""Get all API keys for a user"""
|
"""Get all API keys for a user"""
|
||||||
return self.db.query(ApiKey).filter(
|
return (
|
||||||
ApiKey.user_id == user_id
|
self.db.query(ApiKey)
|
||||||
).order_by(ApiKey.created_at.desc()).all()
|
.filter(ApiKey.user_id == user_id)
|
||||||
|
.order_by(ApiKey.created_at.desc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
def revoke(self, key_id: str, user_id: str) -> bool:
|
def revoke(self, key_id: str, user_id: str) -> bool:
|
||||||
"""Revoke an API key"""
|
"""Revoke an API key"""
|
||||||
api_key = self.db.query(ApiKey).filter(
|
api_key = (
|
||||||
and_(
|
self.db.query(ApiKey)
|
||||||
ApiKey.id == key_id,
|
.filter(
|
||||||
ApiKey.user_id == user_id,
|
and_(
|
||||||
|
ApiKey.id == key_id,
|
||||||
|
ApiKey.user_id == user_id,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
).first()
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
if not api_key:
|
if not api_key:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
api_key.is_active = False
|
api_key.is_active = False
|
||||||
self.db.commit()
|
self.db.commit()
|
||||||
return True
|
return True
|
||||||
|
|||||||
14
database/utils.py
Normal file
14
database/utils.py
Normal 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
187
docs/CODE_REVIEW.md
Normal 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 d’accè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.299–311) : `file_path = config.OUTPUT_DIR / filename`. Si `filename = "../../../etc/passwd"`, le chemin peut sortir de `OUTPUT_DIR`.
|
||||||
|
- **`DELETE /api/v1/cleanup/{filename}`** (L.314–325) : 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"]`** : s’assurer 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 d’erreurs
|
||||||
|
|
||||||
|
- Middleware global + handlers dédiés (TranslationError, ValidationError, etc.) → correct.
|
||||||
|
- OpenRouter : retry 429 + levée d’exception 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 d’auth, exposition de chemins/fichiers.
|
||||||
|
- **Recommandation** : renforcer l’auth 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 l’appel API.
|
||||||
|
- **Fetch** : plusieurs appels sans timeout (pricing, subscription, admin verify, settings, translate, polling, download). Ajouter `AbortController` + timeout (ex. 8–30 s selon l’endpoint).
|
||||||
|
|
||||||
|
### 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 l’access token expire, l’utilisateur est déconnecté. Implémenter un refresh automatique (intercepteur ou hook) pour améliorer l’UX.
|
||||||
|
|
||||||
|
### 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 l’API).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Plan d’action 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 s’assurer que tout chemin dérivé reste sous `UPLOAD_DIR`.
|
||||||
|
4. **CORS** : en prod, définir `CORS_ORIGINS` explicitement (pas `*`).
|
||||||
|
5. **Admin** : en prod, n’utiliser 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 d’une revue de code complète. Aucune modification n’a été appliquée automatiquement ; les changements sont à planifier et à tester.*
|
||||||
25
frontend/messages/en.json
Normal file
25
frontend/messages/en.json
Normal 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
25
frontend/messages/fr.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
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;
|
export default nextConfig;
|
||||||
|
|||||||
3451
frontend/package-lock.json
generated
3451
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -3,13 +3,18 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev --webpack",
|
||||||
"build": "next build",
|
"dev:turbo": "next dev",
|
||||||
|
"build": "next build --webpack",
|
||||||
|
"build:turbo": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint"
|
"lint": "eslint",
|
||||||
|
"test": "vitest",
|
||||||
|
"test:run": "vitest run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mlc-ai/web-llm": "^0.2.80",
|
"@mlc-ai/web-llm": "^0.2.80",
|
||||||
|
"@radix-ui/react-avatar": "^1.1.11",
|
||||||
"@radix-ui/react-checkbox": "^1.3.3",
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
@@ -21,14 +26,15 @@
|
|||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
"@radix-ui/react-switch": "^1.2.6",
|
"@radix-ui/react-switch": "^1.2.6",
|
||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
|
"@radix-ui/react-toast": "^1.2.15",
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"axios": "^1.13.2",
|
"@tanstack/react-query": "^5.90.21",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"framer-motion": "^12.23.24",
|
"framer-motion": "^12.23.24",
|
||||||
"lightningcss-win32-x64-msvc": "^1.30.2",
|
|
||||||
"lucide-react": "^0.555.0",
|
"lucide-react": "^0.555.0",
|
||||||
"next": "16.0.6",
|
"next": "16.0.6",
|
||||||
|
"next-intl": "^4.8.3",
|
||||||
"react": "19.2.0",
|
"react": "19.2.0",
|
||||||
"react-dom": "19.2.0",
|
"react-dom": "19.2.0",
|
||||||
"react-dropzone": "^14.3.8",
|
"react-dropzone": "^14.3.8",
|
||||||
@@ -37,14 +43,19 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
|
"@testing-library/react": "^16.3.2",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
|
"@vitejs/plugin-react": "^5.1.4",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.0.6",
|
"eslint-config-next": "16.0.6",
|
||||||
|
"jsdom": "^28.1.0",
|
||||||
"lightningcss": "^1.30.2",
|
"lightningcss": "^1.30.2",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"typescript": "^5"
|
"typescript": "^5",
|
||||||
|
"vitest": "^4.0.18"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
5
frontend/public/grid.svg
Normal file
5
frontend/public/grid.svg
Normal 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 |
18
frontend/src/app/(app)/layout.tsx
Normal file
18
frontend/src/app/(app)/layout.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
155
frontend/src/app/(app)/settings/services/page.tsx
Normal file
155
frontend/src/app/(app)/settings/services/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
630
frontend/src/app/(app)/settings/subscription/page.tsx
Normal file
630
frontend/src/app/(app)/settings/subscription/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
110
frontend/src/app/admin/AdminHeader.tsx
Normal file
110
frontend/src/app/admin/AdminHeader.tsx
Normal 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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
87
frontend/src/app/admin/AdminSidebar.tsx
Normal file
87
frontend/src/app/admin/AdminSidebar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
42
frontend/src/app/admin/DateRangeFilter.tsx
Normal file
42
frontend/src/app/admin/DateRangeFilter.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
119
frontend/src/app/admin/FormatBreakdownChart.tsx
Normal file
119
frontend/src/app/admin/FormatBreakdownChart.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
115
frontend/src/app/admin/ProviderBreakdownChart.tsx
Normal file
115
frontend/src/app/admin/ProviderBreakdownChart.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
141
frontend/src/app/admin/ProviderStatus.tsx
Normal file
141
frontend/src/app/admin/ProviderStatus.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
116
frontend/src/app/admin/StatsOverview.tsx
Normal file
116
frontend/src/app/admin/StatsOverview.tsx
Normal 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'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
163
frontend/src/app/admin/SystemHealthCards.tsx
Normal file
163
frontend/src/app/admin/SystemHealthCards.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
133
frontend/src/app/admin/TopUsersTable.tsx
Normal file
133
frontend/src/app/admin/TopUsersTable.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
frontend/src/app/admin/constants.ts
Normal file
15
frontend/src/app/admin/constants.ts
Normal 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 },
|
||||||
|
];
|
||||||
86
frontend/src/app/admin/layout.tsx
Normal file
86
frontend/src/app/admin/layout.tsx
Normal 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'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,55 +2,25 @@
|
|||||||
|
|
||||||
import { useState, Suspense } from "react";
|
import { useState, Suspense } from "react";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import { useTranslationStore } from "@/lib/store";
|
import { useAdminLogin } from "./useAdminLogin";
|
||||||
import { Shield, Lock, Eye, EyeOff, AlertCircle } from "lucide-react";
|
import { Shield, Lock, Eye, EyeOff, AlertCircle } from "lucide-react";
|
||||||
|
|
||||||
function AdminLoginContent() {
|
function AdminLoginContent() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const { setAdminToken } = useTranslationStore();
|
const { login, isLoading, error } = useAdminLogin();
|
||||||
|
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
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) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setLoading(true);
|
await login(password);
|
||||||
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);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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="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">
|
<div className="w-full max-w-md">
|
||||||
{/* Logo */}
|
|
||||||
<div className="text-center mb-8">
|
<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">
|
<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" />
|
<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>
|
<p className="text-gray-400 mt-2">Connexion requise</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Form */}
|
|
||||||
<form onSubmit={handleSubmit} className="bg-black/30 backdrop-blur-xl rounded-2xl border border-white/10 p-8">
|
<form onSubmit={handleSubmit} className="bg-black/30 backdrop-blur-xl rounded-2xl border border-white/10 p-8">
|
||||||
{error && (
|
{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">
|
<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="••••••••"
|
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"
|
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
|
required
|
||||||
|
disabled={isLoading}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -93,10 +63,10 @@ function AdminLoginContent() {
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
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"
|
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" />
|
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||||
Connexion...
|
Connexion...
|
||||||
|
|||||||
18
frontend/src/app/admin/login/types.ts
Normal file
18
frontend/src/app/admin/login/types.ts
Normal 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;
|
||||||
|
}
|
||||||
89
frontend/src/app/admin/login/useAdminLogin.ts
Normal file
89
frontend/src/app/admin/login/useAdminLogin.ts
Normal 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 };
|
||||||
|
}
|
||||||
@@ -1,614 +1,113 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState, Suspense } from "react";
|
import { Shield, RefreshCw, Loader2, AlertCircle } from "lucide-react";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { Button } from "@/components/ui/button";
|
||||||
import { useTranslationStore } from "@/lib/store";
|
import { SystemHealthCards } from "./SystemHealthCards";
|
||||||
import { motion } from "framer-motion";
|
import { ProviderStatus } from "./ProviderStatus";
|
||||||
import { Users, Activity, Settings, FileText, TrendingUp, Server, Key, LogOut, RefreshCw, Search, ChevronRight, Shield, Zap, Globe, DollarSign } from "lucide-react";
|
import { useAdminDashboard } from "./useAdminDashboard";
|
||||||
|
import { useCleanup } from "./useCleanup";
|
||||||
interface DashboardData {
|
import {
|
||||||
translations_today: number;
|
TooltipProvider,
|
||||||
translations_total: number;
|
Tooltip,
|
||||||
active_users: number;
|
TooltipContent,
|
||||||
popular_languages: { [key: string]: number };
|
TooltipTrigger,
|
||||||
average_processing_time: number;
|
} from "@/components/ui/tooltip";
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AdminPage() {
|
export default function AdminPage() {
|
||||||
|
const { data, isLoading, error, refetch } = useAdminDashboard();
|
||||||
|
const { isPurging, purgeResult, triggerCleanup } = useCleanup();
|
||||||
|
|
||||||
|
const handlePurge = async () => {
|
||||||
|
await triggerCleanup();
|
||||||
|
refetch();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={
|
<TooltipProvider>
|
||||||
<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="space-y-6">
|
||||||
<div className="text-white text-xl">Chargement...</div>
|
<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>
|
</div>
|
||||||
}>
|
</TooltipProvider>
|
||||||
<AdminContent />
|
|
||||||
</Suspense>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
574
frontend/src/app/admin/settings/page.tsx
Normal file
574
frontend/src/app/admin/settings/page.tsx
Normal 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 "Récupérer les modèles" 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
126
frontend/src/app/admin/stats/page.tsx
Normal file
126
frontend/src/app/admin/stats/page.tsx
Normal 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'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'erreur élevé détecté ({data.error_rate.toFixed(1)}%)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TooltipProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
61
frontend/src/app/admin/system/CleanupSection.tsx
Normal file
61
frontend/src/app/admin/system/CleanupSection.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
51
frontend/src/app/admin/system/DiskSpaceCard.tsx
Normal file
51
frontend/src/app/admin/system/DiskSpaceCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
61
frontend/src/app/admin/system/page.tsx
Normal file
61
frontend/src/app/admin/system/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
frontend/src/app/admin/system/useSystemPage.ts
Normal file
20
frontend/src/app/admin/system/useSystemPage.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
76
frontend/src/app/admin/types.ts
Normal file
76
frontend/src/app/admin/types.ts
Normal 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;
|
||||||
|
};
|
||||||
|
}
|
||||||
97
frontend/src/app/admin/useAdminDashboard.ts
Normal file
97
frontend/src/app/admin/useAdminDashboard.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
87
frontend/src/app/admin/useCleanup.ts
Normal file
87
frontend/src/app/admin/useCleanup.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
159
frontend/src/app/admin/useTranslationStats.ts
Normal file
159
frontend/src/app/admin/useTranslationStats.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
116
frontend/src/app/admin/users/UserStats.tsx
Normal file
116
frontend/src/app/admin/users/UserStats.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
383
frontend/src/app/admin/users/UserTable.tsx
Normal file
383
frontend/src/app/admin/users/UserTable.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
114
frontend/src/app/admin/users/page.tsx
Normal file
114
frontend/src/app/admin/users/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
68
frontend/src/app/admin/users/types.ts
Normal file
68
frontend/src/app/admin/users/types.ts
Normal 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",
|
||||||
|
};
|
||||||
92
frontend/src/app/admin/users/useAdminUsers.ts
Normal file
92
frontend/src/app/admin/users/useAdminUsers.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
100
frontend/src/app/admin/users/useRevokeApiKey.ts
Normal file
100
frontend/src/app/admin/users/useRevokeApiKey.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
96
frontend/src/app/admin/users/useUpdateUserTier.ts
Normal file
96
frontend/src/app/admin/users/useUpdateUserTier.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
126
frontend/src/app/auth/login/LoginForm.tsx
Normal file
126
frontend/src/app/auth/login/LoginForm.tsx
Normal 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't have an account?{' '}
|
||||||
|
<Link href="/auth/register" className="text-primary hover:underline">
|
||||||
|
Sign up for free
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,381 +1,36 @@
|
|||||||
"use client";
|
import { Suspense } from 'react';
|
||||||
|
import { LoginForm } from './LoginForm';
|
||||||
import { useState, Suspense } from "react";
|
import { Loader2, Languages } from 'lucide-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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function LoadingFallback() {
|
function LoadingFallback() {
|
||||||
return (
|
return (
|
||||||
<Card variant="elevated" className="w-full max-w-md mx-auto">
|
<div className="w-full max-w-md mx-auto">
|
||||||
<CardContent className="flex items-center justify-center py-16">
|
<div className="rounded-xl bg-card border border-border shadow-lg p-8">
|
||||||
<div className="text-center space-y-4">
|
<div className="flex flex-col items-center justify-center py-8 space-y-4">
|
||||||
<Loader2 className="h-12 w-12 animate-spin text-primary mx-auto" />
|
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-primary text-primary-foreground">
|
||||||
<p className="text-lg font-medium text-foreground">Loading...</p>
|
<Languages className="h-6 w-6" />
|
||||||
<div className="w-16 h-1 bg-border-subtle rounded-full overflow-hidden">
|
|
||||||
<div className="h-full bg-primary animate-loading-shimmer" />
|
|
||||||
</div>
|
</div>
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||||
|
<p className="text-muted-foreground">Loading...</p>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
return (
|
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">
|
<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">
|
||||||
<div className="absolute inset-0 bg-gradient-to-br from-primary/5 via-transparent to-accent/5" />
|
<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-[url('/grid.svg')] opacity-5" />
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-background via-background/90 to-background/50" />
|
<div className="absolute inset-0 bg-gradient-to-t from-background via-background/90 to-background/50" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Animated Background Elements */}
|
|
||||||
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
<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 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-float-delayed" />
|
<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-float-slow" />
|
<div className="absolute top-1/2 right-1/4 w-16 h-16 bg-success/5 rounded-full blur-xl animate-pulse" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Suspense fallback={<LoadingFallback />}>
|
<Suspense fallback={<LoadingFallback />}>
|
||||||
|
|||||||
16
frontend/src/app/auth/login/types.ts
Normal file
16
frontend/src/app/auth/login/types.ts
Normal 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;
|
||||||
|
}
|
||||||
27
frontend/src/app/auth/login/useLogin.ts
Normal file
27
frontend/src/app/auth/login/useLogin.ts
Normal 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);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
293
frontend/src/app/auth/register/RegisterForm.tsx
Normal file
293
frontend/src/app/auth/register/RegisterForm.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,601 +1,42 @@
|
|||||||
"use client";
|
import { Suspense } from 'react';
|
||||||
|
import { Languages, Loader2 } from 'lucide-react';
|
||||||
import { useState, Suspense } from "react";
|
import { RegisterForm } from './RegisterForm';
|
||||||
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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function LoadingFallback() {
|
function LoadingFallback() {
|
||||||
return (
|
return (
|
||||||
<Card variant="elevated" className="w-full max-w-md mx-auto">
|
<div className="w-full max-w-md mx-auto">
|
||||||
<CardContent className="flex items-center justify-center py-16">
|
<div className="rounded-xl bg-card border border-border shadow-lg p-8">
|
||||||
<div className="text-center space-y-4">
|
<div className="flex flex-col items-center justify-center py-8 space-y-4">
|
||||||
<Loader2 className="h-12 w-12 animate-spin text-primary mx-auto" />
|
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-primary text-primary-foreground">
|
||||||
<p className="text-lg font-medium text-foreground">Creating your account...</p>
|
<Languages className="h-6 w-6" />
|
||||||
<div className="w-16 h-1 bg-border-subtle rounded-full overflow-hidden">
|
|
||||||
<div className="h-full bg-primary animate-loading-shimmer" />
|
|
||||||
</div>
|
</div>
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||||
|
<p className="text-muted-foreground">Chargement...</p>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function RegisterPage() {
|
export default function RegisterPage() {
|
||||||
return (
|
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">
|
<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">
|
||||||
<div className="absolute inset-0 bg-gradient-to-br from-primary/5 via-transparent to-accent/5" />
|
<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-[url('/grid.svg')] opacity-5" />
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-background via-background/90 to-background/50" />
|
<div className="absolute inset-0 bg-gradient-to-t from-background via-background/90 to-background/50" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Floating Elements */}
|
{/* Éléments flottants décoratifs */}
|
||||||
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
<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-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 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 animation-delay-4000" />
|
<div className="absolute bottom-20 left-1/4 w-16 h-16 bg-success/10 rounded-full blur-lg animate-pulse" />
|
||||||
</div>
|
</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 />}>
|
<Suspense fallback={<LoadingFallback />}>
|
||||||
<RegisterForm />
|
<RegisterForm />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|||||||
11
frontend/src/app/auth/register/types.ts
Normal file
11
frontend/src/app/auth/register/types.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
export interface RegisterRequest {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RegisterResponse {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
tier: 'free' | 'pro';
|
||||||
|
}
|
||||||
38
frontend/src/app/auth/register/useRegister.ts
Normal file
38
frontend/src/app/auth/register/useRegister.ts
Normal 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);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
145
frontend/src/app/dashboard/DashboardHeader.tsx
Normal file
145
frontend/src/app/dashboard/DashboardHeader.tsx
Normal 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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
43
frontend/src/app/dashboard/DashboardLayoutClient.tsx
Normal file
43
frontend/src/app/dashboard/DashboardLayoutClient.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
111
frontend/src/app/dashboard/DashboardSidebar.tsx
Normal file
111
frontend/src/app/dashboard/DashboardSidebar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
152
frontend/src/app/dashboard/api-keys/ApiKeyTable.tsx
Normal file
152
frontend/src/app/dashboard/api-keys/ApiKeyTable.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
224
frontend/src/app/dashboard/api-keys/GenerateKeyDialog.tsx
Normal file
224
frontend/src/app/dashboard/api-keys/GenerateKeyDialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
56
frontend/src/app/dashboard/api-keys/ProUpgradePrompt.tsx
Normal file
56
frontend/src/app/dashboard/api-keys/ProUpgradePrompt.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
75
frontend/src/app/dashboard/api-keys/RevokeKeyDialog.tsx
Normal file
75
frontend/src/app/dashboard/api-keys/RevokeKeyDialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
64
frontend/src/app/dashboard/api-keys/WebhookSnippet.tsx
Normal file
64
frontend/src/app/dashboard/api-keys/WebhookSnippet.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
217
frontend/src/app/dashboard/api-keys/page.tsx
Normal file
217
frontend/src/app/dashboard/api-keys/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
40
frontend/src/app/dashboard/api-keys/types.ts
Normal file
40
frontend/src/app/dashboard/api-keys/types.ts
Normal 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;
|
||||||
116
frontend/src/app/dashboard/api-keys/useApiKeys.ts
Normal file
116
frontend/src/app/dashboard/api-keys/useApiKeys.ts
Normal 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),
|
||||||
|
};
|
||||||
|
}
|
||||||
25
frontend/src/app/dashboard/constants.ts
Normal file
25
frontend/src/app/dashboard/constants.ts
Normal 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;
|
||||||
|
}
|
||||||
108
frontend/src/app/dashboard/glossaries/CreateGlossaryDialog.tsx
Normal file
108
frontend/src/app/dashboard/glossaries/CreateGlossaryDialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
216
frontend/src/app/dashboard/glossaries/EditGlossaryDialog.tsx
Normal file
216
frontend/src/app/dashboard/glossaries/EditGlossaryDialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
83
frontend/src/app/dashboard/glossaries/GlossaryCard.tsx
Normal file
83
frontend/src/app/dashboard/glossaries/GlossaryCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
});
|
||||||
56
frontend/src/app/dashboard/glossaries/ProUpgradePrompt.tsx
Normal file
56
frontend/src/app/dashboard/glossaries/ProUpgradePrompt.tsx
Normal 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 source→target 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
120
frontend/src/app/dashboard/glossaries/TermEditor.tsx
Normal file
120
frontend/src/app/dashboard/glossaries/TermEditor.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
});
|
||||||
85
frontend/src/app/dashboard/glossaries/csvUtils.ts
Normal file
85
frontend/src/app/dashboard/glossaries/csvUtils.ts
Normal 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;
|
||||||
|
}
|
||||||
242
frontend/src/app/dashboard/glossaries/page.tsx
Normal file
242
frontend/src/app/dashboard/glossaries/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
73
frontend/src/app/dashboard/glossaries/types.ts
Normal file
73
frontend/src/app/dashboard/glossaries/types.ts
Normal 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}`;
|
||||||
|
}
|
||||||
180
frontend/src/app/dashboard/glossaries/useGlossaries.ts
Normal file
180
frontend/src/app/dashboard/glossaries/useGlossaries.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
10
frontend/src/app/dashboard/layout.tsx
Normal file
10
frontend/src/app/dashboard/layout.tsx
Normal 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>;
|
||||||
|
}
|
||||||
@@ -1,615 +1,102 @@
|
|||||||
"use client";
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import Link from 'next/link';
|
||||||
import { useRouter } from "next/navigation";
|
import { FileText, Key, BookText, ChevronRight } from 'lucide-react';
|
||||||
import Link from "next/link";
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
FileText,
|
import { useUser } from './useUser';
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
const router = useRouter();
|
const { data: user, isLoading } = useUser();
|
||||||
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");
|
|
||||||
|
|
||||||
useEffect(() => {
|
if (isLoading) {
|
||||||
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) {
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background flex items-center justify-center">
|
<div className="flex items-center justify-center py-12">
|
||||||
<div className="text-center space-y-4">
|
<div className="animate-spin rounded-full h-8 w-8 border-4 border-muted border-t-foreground"></div>
|
||||||
<div className="animate-spin rounded-full h-12 w-12 border-4 border-border-subtle border-t-primary"></div>
|
|
||||||
<p className="text-lg font-medium text-foreground">Loading your dashboard...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!user || !usage) {
|
const firstName = user?.name?.split(' ')[0] || 'User';
|
||||||
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`;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gradient-to-b from-surface via-surface-elevated to-background">
|
<div className="mx-auto max-w-5xl px-4 py-6 lg:px-8 lg:py-8">
|
||||||
{/* Header */}
|
{/* Page heading */}
|
||||||
<header className="sticky top-0 z-50 glass border-b border-border/20">
|
<div className="mb-6">
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
<h2 className="text-xl font-semibold tracking-tight text-foreground">
|
||||||
<div className="flex items-center justify-between h-16">
|
Welcome back, {firstName}!
|
||||||
<Link href="/" className="flex items-center gap-3 group">
|
</h2>
|
||||||
<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">
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
文A
|
Monitor your usage, manage API keys, and configure translation preferences.
|
||||||
</div>
|
</p>
|
||||||
<span className="text-lg font-semibold text-white group-hover:text-primary transition-colors duration-300">
|
</div>
|
||||||
Translate Co.
|
|
||||||
</span>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-4">
|
{/* Quick actions */}
|
||||||
<Link href="/">
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
<Button variant="glass" size="sm" className="group">
|
<Link href="/dashboard/translate">
|
||||||
<FileText className="h-4 w-4 mr-2 transition-transform duration-200 group-hover:scale-110" />
|
<Card className="cursor-pointer transition-colors hover:bg-secondary/50">
|
||||||
Translate
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<ChevronRight className="h-4 w-4 ml-1 transition-transform duration-200 group-hover:translate-x-1" />
|
<CardTitle className="text-sm font-medium">Translate Document</CardTitle>
|
||||||
</Button>
|
<FileText className="h-4 w-4 text-muted-foreground" />
|
||||||
</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>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="h-64 flex items-center justify-center">
|
<CardDescription>
|
||||||
{/* Mock Chart */}
|
Upload and translate Excel, Word, or PowerPoint files
|
||||||
<div className="relative w-full h-full flex items-center justify-center">
|
</CardDescription>
|
||||||
<svg className="w-full h-full" viewBox="0 0 100 100">
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="gradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
|
||||||
<stop offset="0%" stopColor="#3b82f6" />
|
|
||||||
<stop offset="100%" stopColor="#8b5cf6" />
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
<circle
|
|
||||||
cx="50"
|
|
||||||
cy="50"
|
|
||||||
r="40"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
className="text-border"
|
|
||||||
/>
|
|
||||||
<circle
|
|
||||||
cx="50"
|
|
||||||
cy="50"
|
|
||||||
r="40"
|
|
||||||
fill="none"
|
|
||||||
stroke="url(#gradient)"
|
|
||||||
strokeWidth="2"
|
|
||||||
className="opacity-80"
|
|
||||||
style={{
|
|
||||||
strokeDasharray: `${2 * Math.PI * 40}`,
|
|
||||||
strokeDashoffset: `${2 * Math.PI * 40 * 0.25}`,
|
|
||||||
animation: "progress 2s ease-in-out infinite"
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
|
||||||
<div className="text-6xl font-bold text-text-tertiary">85%</div>
|
|
||||||
<div className="text-sm text-text-tertiary">Usage</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
</Link>
|
||||||
|
|
||||||
{/* Recent Activity */}
|
<Link href="/dashboard/api-keys">
|
||||||
<Card variant="elevated" className="animate-fade-in-up animation-delay-800">
|
<Card className="cursor-pointer transition-colors hover:bg-secondary/50">
|
||||||
<CardHeader>
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle className="flex items-center gap-3">
|
<CardTitle className="text-sm font-medium">API Keys</CardTitle>
|
||||||
<Activity className="h-5 w-5 text-primary" />
|
<Key className="h-4 w-4 text-muted-foreground" />
|
||||||
Recent Activity
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => setRecentActivity([])}
|
|
||||||
className="ml-auto text-text-tertiary hover:text-primary transition-colors duration-200"
|
|
||||||
>
|
|
||||||
<RefreshCw className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="space-y-4">
|
<CardDescription>
|
||||||
{recentActivity.slice(0, 5).map((activity) => (
|
Manage your API keys for automation
|
||||||
<div key={activity.id} className="flex items-start gap-4 p-3 rounded-lg bg-surface/50 hover:bg-surface transition-colors">
|
</CardDescription>
|
||||||
<div className="flex-shrink-0 mt-1">
|
|
||||||
<div className={cn(
|
|
||||||
"w-10 h-10 rounded-lg flex items-center justify-center",
|
|
||||||
activity.status === "success" && "bg-success/20 text-success",
|
|
||||||
activity.status === "pending" && "bg-warning/20 text-warning",
|
|
||||||
activity.status === "error" && "bg-destructive/20 text-destructive"
|
|
||||||
)}>
|
|
||||||
{getActivityIcon(activity.type)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<p className="text-sm font-medium text-foreground mb-1">{activity.title}</p>
|
|
||||||
<p className="text-xs text-text-tertiary">{activity.description}</p>
|
|
||||||
<div className="flex items-center gap-2 mt-2">
|
|
||||||
<span className="text-xs text-text-tertiary">{formatTimeAgo(activity.timestamp)}</span>
|
|
||||||
{activity.amount && (
|
|
||||||
<Badge variant="outline" size="sm">
|
|
||||||
{activity.amount}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</Link>
|
||||||
|
|
||||||
{/* Available Providers */}
|
{user?.tier === 'pro' && (
|
||||||
<Card variant="elevated" className="animate-fade-in-up animation-delay-1000">
|
<Link href="/dashboard/glossaries">
|
||||||
<CardHeader>
|
<Card className="cursor-pointer transition-colors hover:bg-secondary/50">
|
||||||
<CardTitle className="flex items-center gap-3">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<Globe2 className="h-5 w-5 text-primary" />
|
<CardTitle className="text-sm font-medium">Glossaries</CardTitle>
|
||||||
Available Translation Providers
|
<BookText className="h-4 w-4 text-muted-foreground" />
|
||||||
</CardTitle>
|
</CardHeader>
|
||||||
</CardHeader>
|
<CardContent>
|
||||||
<CardContent>
|
<CardDescription>
|
||||||
{usage && (
|
Create custom terminology for translations
|
||||||
<div className="flex flex-wrap gap-3">
|
</CardDescription>
|
||||||
{["ollama", "google", "deepl", "openai", "libre", "azure"].map((provider) => {
|
</CardContent>
|
||||||
const isAvailable = usage.allowed_providers.includes(provider);
|
</Card>
|
||||||
return (
|
</Link>
|
||||||
<Badge
|
)}
|
||||||
key={provider}
|
</div>
|
||||||
variant="outline"
|
|
||||||
className={cn(
|
{/* Plan info */}
|
||||||
"capitalize",
|
{user?.tier === 'free' && (
|
||||||
isAvailable
|
<Card className="mt-6 border-primary/50 bg-primary/5">
|
||||||
? "border-success/50 text-success bg-success/10"
|
<CardContent className="flex items-center justify-between pt-6">
|
||||||
: "border-border text-text-tertiary"
|
<div>
|
||||||
)}
|
<p className="text-sm font-medium text-foreground">Upgrade to Pro</p>
|
||||||
>
|
<p className="text-xs text-muted-foreground">
|
||||||
{isAvailable && <Check className="h-3 w-3 mr-1" />}
|
Get unlimited translations, API access, and custom glossaries
|
||||||
{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.
|
|
||||||
</p>
|
</p>
|
||||||
)}
|
</div>
|
||||||
|
<Link href="/pricing">
|
||||||
|
<Button variant="premium" size="sm" className="gap-1">
|
||||||
|
View Plans
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</main>
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
51
frontend/src/app/dashboard/translate/FileDropZone.tsx
Normal file
51
frontend/src/app/dashboard/translate/FileDropZone.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
53
frontend/src/app/dashboard/translate/FilePreview.tsx
Normal file
53
frontend/src/app/dashboard/translate/FilePreview.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
104
frontend/src/app/dashboard/translate/LanguageSelector.tsx
Normal file
104
frontend/src/app/dashboard/translate/LanguageSelector.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
112
frontend/src/app/dashboard/translate/ProviderSelector.tsx
Normal file
112
frontend/src/app/dashboard/translate/ProviderSelector.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
153
frontend/src/app/dashboard/translate/TranslationComplete.tsx
Normal file
153
frontend/src/app/dashboard/translate/TranslationComplete.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
109
frontend/src/app/dashboard/translate/TranslationProgress.tsx
Normal file
109
frontend/src/app/dashboard/translate/TranslationProgress.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
200
frontend/src/app/dashboard/translate/page.tsx
Normal file
200
frontend/src/app/dashboard/translate/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
107
frontend/src/app/dashboard/translate/types.ts
Normal file
107
frontend/src/app/dashboard/translate/types.ts
Normal 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;
|
||||||
|
}
|
||||||
88
frontend/src/app/dashboard/translate/useFileUpload.ts
Normal file
88
frontend/src/app/dashboard/translate/useFileUpload.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
216
frontend/src/app/dashboard/translate/useTranslationConfig.ts
Normal file
216
frontend/src/app/dashboard/translate/useTranslationConfig.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
209
frontend/src/app/dashboard/translate/useTranslationSubmit.ts
Normal file
209
frontend/src/app/dashboard/translate/useTranslationSubmit.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
7
frontend/src/app/dashboard/types.ts
Normal file
7
frontend/src/app/dashboard/types.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export interface User {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
tier: 'free' | 'pro';
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
16
frontend/src/app/dashboard/useLogout.ts
Normal file
16
frontend/src/app/dashboard/useLogout.ts
Normal 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 };
|
||||||
|
}
|
||||||
29
frontend/src/app/dashboard/useUser.ts
Normal file
29
frontend/src/app/dashboard/useUser.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
19
frontend/src/app/dashboard/utils.ts
Normal file
19
frontend/src/app/dashboard/utils.ts
Normal 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);
|
||||||
|
}
|
||||||
@@ -1,15 +1,19 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Inter } from "next/font/google";
|
import { Inter } from "next/font/google";
|
||||||
import "./globals.css";
|
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({
|
const inter = Inter({
|
||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Translate Co. - Document Translation",
|
title: "Office Translator - Translate Documents, Keep the Format",
|
||||||
description: "Translate Excel, Word, and PowerPoint documents while preserving formatting",
|
description: "Translate Excel, Word, and PowerPoint documents with zero formatting loss. Fast, private, and accurate document translation.",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
@@ -19,13 +23,14 @@ export default function RootLayout({
|
|||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html lang="en" className="dark">
|
<html lang="en" className="dark">
|
||||||
<body className={`${inter.className} bg-[#262626] text-zinc-100 antialiased`}>
|
<body className={`${inter.className} bg-background text-foreground antialiased`}>
|
||||||
<Sidebar />
|
<I18nProvider>
|
||||||
<main className="ml-64 min-h-screen p-8">
|
<QueryProvider>
|
||||||
<div className="max-w-6xl mx-auto">
|
<NotificationProvider>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</NotificationProvider>
|
||||||
</main>
|
</QueryProvider>
|
||||||
|
</I18nProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user