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

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

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

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

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

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

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

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

After

Width:  |  Height:  |  Size: 408 B

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,5 @@
import { redirect } from 'next/navigation';
export default function LoginPage() {
redirect('/auth/login');
}

View File

@@ -1,59 +1,5 @@
"use client";
import { FileUploader } from "@/components/file-uploader";
import {
LandingHero,
FeaturesSection,
PricingPreview,
SelfHostCTA
} from "@/components/landing-sections";
import Link from "next/link";
import { LandingPage } from "@/components/landing/landing-page"
export default function Home() {
return (
<div className="space-y-0 -m-8">
{/* Hero Section */}
<LandingHero />
{/* Upload Section */}
<div id="upload" className="px-8 py-12 bg-zinc-900/30">
<div className="max-w-6xl mx-auto">
<div className="mb-6">
<h2 className="text-2xl font-bold text-white">Translate Your Document</h2>
<p className="text-zinc-400 mt-1">
Upload and translate Excel, Word, and PowerPoint files while preserving all formatting.
</p>
</div>
<FileUploader />
</div>
</div>
{/* Features Section */}
<FeaturesSection />
{/* Pricing Preview */}
<PricingPreview />
{/* Self-Host CTA */}
<SelfHostCTA />
{/* Footer */}
<footer className="border-t border-zinc-800 py-8 px-8">
<div className="max-w-6xl mx-auto flex flex-col md:flex-row items-center justify-between gap-4">
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-teal-500 text-white font-bold text-sm">
A
</div>
<span className="text-sm text-zinc-400">© 2024 Translate Co. All rights reserved.</span>
</div>
<div className="flex items-center gap-6 text-sm text-zinc-500">
<Link href="/pricing" className="hover:text-zinc-300">Pricing</Link>
<Link href="/terms" className="hover:text-zinc-300">Terms</Link>
<Link href="/privacy" className="hover:text-zinc-300">Privacy</Link>
</div>
</div>
</footer>
</div>
);
return <LandingPage />
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,848 +0,0 @@
"use client";
import { useState, useEffect } from "react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { Switch } from "@/components/ui/switch";
import { useTranslationStore, webllmModels, openaiModels, openrouterModels } from "@/lib/store";
import { providers, testOpenAIConnection, testOllamaConnection, getOllamaModels, type OllamaModel } from "@/lib/api";
import { useWebLLM } from "@/lib/webllm";
import { Save, Loader2, Cloud, Check, ExternalLink, Wifi, CheckCircle, XCircle, Download, Trash2, Cpu, Server, RefreshCw, Zap, Shield, ArrowRight, AlertCircle } from "lucide-react";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Progress } from "@/components/ui/progress";
import { cn } from "@/lib/utils";
export default function TranslationServicesPage() {
const { settings, updateSettings } = useTranslationStore();
const [isSaving, setIsSaving] = useState(false);
const [selectedProvider, setSelectedProvider] = useState(settings.defaultProvider);
const [translateImages, setTranslateImages] = useState(settings.translateImages);
// Provider-specific states
const [deeplApiKey, setDeeplApiKey] = useState(settings.deeplApiKey);
const [openaiApiKey, setOpenaiApiKey] = useState(settings.openaiApiKey);
const [openaiModel, setOpenaiModel] = useState(settings.openaiModel);
const [openrouterApiKey, setOpenrouterApiKey] = useState(settings.openrouterApiKey);
const [openrouterModel, setOpenrouterModel] = useState(settings.openrouterModel);
const [libreUrl, setLibreUrl] = useState(settings.libreTranslateUrl);
const [webllmModel, setWebllmModel] = useState(settings.webllmModel);
// Ollama states
const [ollamaUrl, setOllamaUrl] = useState(settings.ollamaUrl);
const [ollamaModel, setOllamaModel] = useState(settings.ollamaModel);
const [ollamaModels, setOllamaModels] = useState<OllamaModel[]>([]);
const [loadingOllamaModels, setLoadingOllamaModels] = useState(false);
const [ollamaTestStatus, setOllamaTestStatus] = useState<"idle" | "testing" | "success" | "error">("idle");
const [ollamaTestMessage, setOllamaTestMessage] = useState("");
// OpenAI connection test state
const [openaiTestStatus, setOpenaiTestStatus] = useState<"idle" | "testing" | "success" | "error">("idle");
const [openaiTestMessage, setOpenaiTestMessage] = useState("");
// OpenRouter connection test state
const [openrouterTestStatus, setOpenrouterTestStatus] = useState<"idle" | "testing" | "success" | "error">("idle");
const [openrouterTestMessage, setOpenrouterTestMessage] = useState("");
// WebLLM hook
const webllm = useWebLLM();
useEffect(() => {
setSelectedProvider(settings.defaultProvider);
setTranslateImages(settings.translateImages);
setDeeplApiKey(settings.deeplApiKey);
setOpenaiApiKey(settings.openaiApiKey);
setOpenaiModel(settings.openaiModel);
setOpenrouterApiKey(settings.openrouterApiKey);
setOpenrouterModel(settings.openrouterModel);
setLibreUrl(settings.libreTranslateUrl);
setWebllmModel(settings.webllmModel);
setOllamaUrl(settings.ollamaUrl);
setOllamaModel(settings.ollamaModel);
}, [settings]);
// Load Ollama models when provider is selected
const loadOllamaModels = async () => {
setLoadingOllamaModels(true);
try {
const models = await getOllamaModels(ollamaUrl);
setOllamaModels(models);
} catch (error) {
console.error("Failed to load Ollama models:", error);
} finally {
setLoadingOllamaModels(false);
}
};
useEffect(() => {
if (selectedProvider === "ollama") {
loadOllamaModels();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedProvider]);
const handleTestOllama = async () => {
setOllamaTestStatus("testing");
setOllamaTestMessage("");
try {
const result = await testOllamaConnection(ollamaUrl);
setOllamaTestStatus(result.success ? "success" : "error");
setOllamaTestMessage(result.message);
if (result.success) {
await loadOllamaModels();
updateSettings({ ollamaUrl, ollamaModel });
setOllamaTestMessage(result.message + " - Settings saved!");
}
} catch {
setOllamaTestStatus("error");
setOllamaTestMessage("Connection test failed");
}
};
const handleTestOpenAI = async () => {
if (!openaiApiKey.trim()) {
setOpenaiTestStatus("error");
setOpenaiTestMessage("Please enter an API key first");
return;
}
setOpenaiTestStatus("testing");
setOpenaiTestMessage("");
try {
const result = await testOpenAIConnection(openaiApiKey);
setOpenaiTestStatus(result.success ? "success" : "error");
setOpenaiTestMessage(result.message);
if (result.success) {
updateSettings({ openaiApiKey, openaiModel });
setOpenaiTestMessage(result.message + " - Settings saved!");
}
} catch {
setOpenaiTestStatus("error");
setOpenaiTestMessage("Connection test failed");
}
};
// Test OpenRouter connection
const testOpenRouterConnection = async () => {
if (!openrouterApiKey) {
setOpenrouterTestStatus("error");
setOpenrouterTestMessage("API key required");
return;
}
setOpenrouterTestStatus("testing");
try {
const response = await fetch("https://openrouter.ai/api/v1/models", {
headers: { Authorization: `Bearer ${openrouterApiKey}` }
});
if (response.ok) {
setOpenrouterTestStatus("success");
setOpenrouterTestMessage("Connected successfully!");
} else {
setOpenrouterTestStatus("error");
setOpenrouterTestMessage("Invalid API key");
}
} catch {
setOpenrouterTestStatus("error");
setOpenrouterTestMessage("Connection test failed");
}
};
const handleSave = async () => {
setIsSaving(true);
try {
updateSettings({
defaultProvider: selectedProvider,
translateImages,
deeplApiKey,
openaiApiKey,
openaiModel,
openrouterApiKey,
openrouterModel,
libreTranslateUrl: libreUrl,
webllmModel,
ollamaUrl,
ollamaModel,
});
await new Promise((resolve) => setTimeout(resolve, 500));
} finally {
setIsSaving(false);
}
};
return (
<div className="min-h-screen bg-gradient-to-b from-surface via-surface-elevated to-background">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Header */}
<div className="mb-8">
<Badge variant="outline" className="mb-4 border-primary/30 text-primary bg-primary/10">
<Cloud className="h-3 w-3 mr-1" />
Translation Services
</Badge>
<h1 className="text-4xl font-bold text-white mb-2">
Translation Providers
</h1>
<p className="text-lg text-text-secondary">
Select and configure your preferred translation service
</p>
</div>
{/* Provider Selection */}
<Card variant="elevated" className="mb-8 animate-fade-in-up">
<CardHeader>
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-primary/20">
<Cloud className="h-5 w-5 text-primary" />
</div>
<div>
<CardTitle className="text-white">Choose Provider</CardTitle>
<CardDescription>
Select your default translation service
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{providers.map((provider) => (
<Card
key={provider.id}
variant={selectedProvider === provider.id ? "gradient" : "glass"}
className={cn(
"cursor-pointer transition-all duration-300 hover:scale-105 group",
selectedProvider === provider.id && "ring-2 ring-primary/50"
)}
onClick={() => setSelectedProvider(provider.id as typeof selectedProvider)}
>
<CardContent className="p-6 text-center">
<div className="text-4xl mb-3 group-hover:scale-110 transition-transform duration-300">
{provider.icon}
</div>
<h3 className="font-semibold text-white mb-2">{provider.name}</h3>
<p className="text-sm text-text-tertiary mb-4">{provider.description}</p>
{selectedProvider === provider.id && (
<Badge className="bg-primary text-white border-0">
<Check className="h-3 w-3 mr-1" />
Selected
</Badge>
)}
</CardContent>
</Card>
))}
</div>
</CardContent>
</Card>
{/* Google - No config needed */}
{selectedProvider === "google" && (
<Card variant="gradient" className="mb-8 animate-fade-in-up border-l-4 border-l-success">
<CardContent className="p-8">
<div className="flex items-center gap-4">
<div className="p-3 rounded-full bg-success/20">
<CheckCircle className="h-8 w-8 text-success" />
</div>
<div>
<h3 className="text-xl font-semibold text-white mb-2">Ready to Use!</h3>
<p className="text-text-secondary">
Google Translate works out of the box. No configuration needed.
</p>
</div>
</div>
</CardContent>
</Card>
)}
{/* Ollama Settings */}
{selectedProvider === "ollama" && (
<Card variant="elevated" className="mb-8 animate-fade-in-up">
<CardHeader>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-orange-500/20">
<Server className="h-5 w-5 text-orange-400" />
</div>
<div>
<CardTitle className="text-white">Ollama Configuration</CardTitle>
<CardDescription>
Connect to your local Ollama server
</CardDescription>
</div>
</div>
{ollamaTestStatus !== "idle" && ollamaTestStatus !== "testing" && (
<Badge
variant="outline"
className={
ollamaTestStatus === "success"
? "border-success/50 text-success bg-success/10"
: "border-destructive/50 text-destructive bg-destructive/10"
}
>
{ollamaTestStatus === "success" && <CheckCircle className="h-3 w-3 mr-1" />}
{ollamaTestStatus === "error" && <XCircle className="h-3 w-3 mr-1" />}
{ollamaTestStatus === "success" ? "Connected" : "Error"}
</Badge>
)}
</div>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-3">
<Label htmlFor="ollama-url" className="text-text-secondary font-medium">
Server URL
</Label>
<div className="flex gap-3">
<Input
id="ollama-url"
value={ollamaUrl}
onChange={(e) => setOllamaUrl(e.target.value)}
placeholder="http://localhost:11434"
className="bg-surface border-border-subtle text-white placeholder:text-text-tertiary focus:border-primary focus:ring-primary/20"
/>
<Button
variant="outline"
onClick={handleTestOllama}
disabled={ollamaTestStatus === "testing"}
className="border-border-subtle text-text-secondary hover:bg-surface-hover hover:text-primary group"
>
{ollamaTestStatus === "testing" ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Wifi className="h-4 w-4 transition-transform duration-200 group-hover:scale-110" />
)}
</Button>
</div>
{ollamaTestMessage && (
<div className={cn(
"flex items-center gap-2 p-3 rounded-lg",
ollamaTestStatus === "success"
? "bg-success/10 text-success border border-success/30"
: "bg-destructive/10 text-destructive border border-destructive/30"
)}>
{ollamaTestStatus === "success" ? (
<CheckCircle className="h-4 w-4" />
) : (
<XCircle className="h-4 w-4" />
)}
<span className="text-sm">{ollamaTestMessage}</span>
</div>
)}
</div>
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label htmlFor="ollama-model" className="text-text-secondary font-medium">
Model
</Label>
<Button
variant="ghost"
size="sm"
onClick={loadOllamaModels}
disabled={loadingOllamaModels}
className="text-text-tertiary hover:text-primary h-8 px-3 group"
>
<RefreshCw className={cn(
"h-4 w-4 mr-2 transition-transform duration-200 group-hover:rotate-180",
loadingOllamaModels && "animate-spin"
)} />
Refresh Models
</Button>
</div>
<Select
value={ollamaModel}
onValueChange={setOllamaModel}
>
<SelectTrigger className="bg-surface border-border-subtle text-white focus:border-primary focus:ring-primary/20">
<SelectValue placeholder="Select a model" />
</SelectTrigger>
<SelectContent className="bg-surface-elevated border-border-subtle">
{ollamaModels.length > 0 ? (
ollamaModels.map((model) => (
<SelectItem
key={model.name}
value={model.name}
className="text-white hover:bg-surface-hover focus:bg-primary/20 focus:text-primary"
>
{model.name}
</SelectItem>
))
) : (
<SelectItem value={ollamaModel} className="text-white">
{ollamaModel || "No models found"}
</SelectItem>
)}
</SelectContent>
</Select>
<div className="p-4 rounded-lg bg-primary/10 border border-primary/30">
<p className="text-sm text-primary">
<strong>💡 Tip:</strong> Don't have Ollama? Install it from{" "}
<a
href="https://ollama.ai"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:text-primary/80 underline"
>
ollama.ai
</a>
{" "}then run: <code className="bg-surface px-2 py-1 rounded text-primary">ollama pull llama3.2</code>
</p>
</div>
</div>
</CardContent>
</Card>
)}
{/* OpenAI Settings */}
{selectedProvider === "openai" && (
<Card variant="elevated" className="mb-8 animate-fade-in-up">
<CardHeader>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-primary/20">
<Zap className="h-5 w-5 text-primary" />
</div>
<div>
<CardTitle className="text-white">OpenAI Settings</CardTitle>
<CardDescription>
Configure your OpenAI API for GPT-4 Vision translations
</CardDescription>
</div>
</div>
{openaiTestStatus !== "idle" && openaiTestStatus !== "testing" && (
<Badge
variant="outline"
className={
openaiTestStatus === "success"
? "border-success/50 text-success bg-success/10"
: "border-destructive/50 text-destructive bg-destructive/10"
}
>
{openaiTestStatus === "success" && <CheckCircle className="h-3 w-3 mr-1" />}
{openaiTestStatus === "error" && <XCircle className="h-3 w-3 mr-1" />}
{openaiTestStatus === "success" ? "Connected" : "Error"}
</Badge>
)}
</div>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-3">
<Label htmlFor="openai-key" className="text-text-secondary font-medium">
API Key
</Label>
<div className="flex gap-3">
<Input
id="openai-key"
type="password"
value={openaiApiKey}
onChange={(e) => setOpenaiApiKey(e.target.value)}
onKeyDown={(e) => e.stopPropagation()}
placeholder="sk-..."
className="bg-surface border-border-subtle text-white placeholder:text-text-tertiary focus:border-primary focus:ring-primary/20"
/>
<Button
variant="outline"
onClick={handleTestOpenAI}
disabled={openaiTestStatus === "testing"}
className="border-border-subtle text-text-secondary hover:bg-surface-hover hover:text-primary group"
>
{openaiTestStatus === "testing" ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Wifi className="h-4 w-4 transition-transform duration-200 group-hover:scale-110" />
)}
</Button>
</div>
{openaiTestMessage && (
<div className={cn(
"flex items-center gap-2 p-3 rounded-lg",
openaiTestStatus === "success"
? "bg-success/10 text-success border border-success/30"
: "bg-destructive/10 text-destructive border border-destructive/30"
)}>
{openaiTestStatus === "success" ? (
<CheckCircle className="h-4 w-4" />
) : (
<XCircle className="h-4 w-4" />
)}
<span className="text-sm">{openaiTestMessage}</span>
</div>
)}
<p className="text-sm text-text-tertiary">
Get your API key from{" "}
<a
href="https://platform.openai.com/api-keys"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:text-primary/80 underline"
>
platform.openai.com
</a>
</p>
</div>
<div className="space-y-3">
<Label htmlFor="openai-model" className="text-text-secondary font-medium">
Model
</Label>
<Select
value={openaiModel}
onValueChange={setOpenaiModel}
>
<SelectTrigger className="bg-surface border-border-subtle text-white focus:border-primary focus:ring-primary/20">
<SelectValue placeholder="Select a model" />
</SelectTrigger>
<SelectContent className="bg-surface-elevated border-border-subtle">
{openaiModels.map((model) => (
<SelectItem
key={model.id}
value={model.id}
className="text-white hover:bg-surface-hover focus:bg-primary/20 focus:text-primary"
>
<span className="flex items-center justify-between gap-4">
<span>{model.name}</span>
{model.vision && (
<Badge variant="outline" className="border-primary/50 text-primary bg-primary/10 text-xs">
Vision
</Badge>
)}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
<div className="p-4 rounded-lg bg-accent/10 border border-accent/30">
<p className="text-sm text-accent">
<strong>💡 Vision Models:</strong> Models with Vision can translate text in images
</p>
</div>
</div>
</CardContent>
</Card>
)}
{/* OpenRouter Settings */}
{selectedProvider === "openrouter" && (
<Card variant="gradient" className="mb-8 animate-fade-in-up">
<CardHeader>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-accent/20">
<Zap className="h-5 w-5 text-accent" />
</div>
<div>
<CardTitle className="text-white">OpenRouter Settings</CardTitle>
<CardDescription>
Access DeepSeek, Mistral, Llama & more - Best value for translation
</CardDescription>
</div>
</div>
{openrouterTestStatus !== "idle" && openrouterTestStatus !== "testing" && (
<Badge
variant="outline"
className={
openrouterTestStatus === "success"
? "border-success/50 text-success bg-success/10"
: "border-destructive/50 text-destructive bg-destructive/10"
}
>
{openrouterTestStatus === "success" && <CheckCircle className="h-3 w-3 mr-1" />}
{openrouterTestStatus === "error" && <XCircle className="h-3 w-3 mr-1" />}
{openrouterTestStatus === "success" ? "Connected" : "Error"}
</Badge>
)}
</div>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-3">
<Label htmlFor="openrouter-key" className="text-text-secondary font-medium">
API Key
</Label>
<div className="flex gap-3">
<Input
id="openrouter-key"
type="password"
value={openrouterApiKey}
onChange={(e) => setOpenrouterApiKey(e.target.value)}
onKeyDown={(e) => e.stopPropagation()}
placeholder="sk-or-..."
className="bg-surface border-border-subtle text-white placeholder:text-text-tertiary focus:border-primary focus:ring-primary/20"
/>
<Button
variant="outline"
onClick={testOpenRouterConnection}
disabled={openrouterTestStatus === "testing"}
className="border-border-subtle text-text-secondary hover:bg-surface-hover hover:text-accent group"
>
{openrouterTestStatus === "testing" ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Wifi className="h-4 w-4 transition-transform duration-200 group-hover:scale-110" />
)}
</Button>
</div>
{openrouterTestMessage && (
<div className={cn(
"flex items-center gap-2 p-3 rounded-lg",
openrouterTestStatus === "success"
? "bg-success/10 text-success border border-success/30"
: "bg-destructive/10 text-destructive border border-destructive/30"
)}>
{openrouterTestStatus === "success" ? (
<CheckCircle className="h-4 w-4" />
) : (
<XCircle className="h-4 w-4" />
)}
<span className="text-sm">{openrouterTestMessage}</span>
</div>
)}
<p className="text-sm text-text-tertiary">
Get your free API key from{" "}
<a
href="https://openrouter.ai/keys"
target="_blank"
rel="noopener noreferrer"
className="text-accent hover:text-accent/80 underline"
>
openrouter.ai/keys
</a>
</p>
</div>
<div className="space-y-3">
<Label htmlFor="openrouter-model" className="text-text-secondary font-medium">
Model
</Label>
<Select
value={openrouterModel}
onValueChange={setOpenrouterModel}
>
<SelectTrigger className="bg-surface border-border-subtle text-white focus:border-accent focus:ring-accent/20">
<SelectValue placeholder="Select a model" />
</SelectTrigger>
<SelectContent className="bg-surface-elevated border-border-subtle">
{openrouterModels.map((model) => (
<SelectItem
key={model.id}
value={model.id}
className="text-white hover:bg-surface-hover focus:bg-accent/20 focus:text-accent"
>
<span className="flex items-center justify-between gap-4">
<span>{model.name}</span>
<Badge variant="outline" className="border-accent/50 text-accent bg-accent/10 text-xs">
{model.description.split(' - ')[1]}
</Badge>
</span>
</SelectItem>
))}
</SelectContent>
</Select>
<div className="p-4 rounded-lg bg-gradient-to-r from-accent/20 to-primary/20 border border-accent/30">
<p className="text-sm text-white">
<strong>💡 Recommended:</strong> DeepSeek Chat at $0.14/M tokens translates 200 pages for ~$0.50
</p>
</div>
</div>
</CardContent>
</Card>
)}
{/* WebLLM Settings */}
{selectedProvider === "webllm" && (
<Card variant="elevated" className="mb-8 animate-fade-in-up">
<CardHeader>
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-success/20">
<Cpu className="h-5 w-5 text-success" />
</div>
<div>
<CardTitle className="text-white">WebLLM Settings</CardTitle>
<CardDescription>
Run AI models directly in your browser using WebGPU - no server required!
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-6">
{/* WebGPU Support Check */}
{!webllm.isWebGPUSupported() && (
<div className="p-4 rounded-lg bg-destructive/10 border border-destructive/30">
<div className="flex items-center gap-3">
<AlertCircle className="h-5 w-5 text-destructive" />
<p className="text-destructive text-sm">
WebGPU is not supported in this browser. Please use Chrome 113+, Edge 113+, or another WebGPU-compatible browser.
</p>
</div>
</div>
)}
<div className="space-y-3">
<Label htmlFor="webllm-model" className="text-text-secondary font-medium">
Model
</Label>
<Select value={webllmModel} onValueChange={setWebllmModel}>
<SelectTrigger className="bg-surface border-border-subtle text-white focus:border-success focus:ring-success/20">
<SelectValue placeholder="Select a model" />
</SelectTrigger>
<SelectContent className="bg-surface-elevated border-border-subtle">
{webllmModels.map((model) => (
<SelectItem
key={model.id}
value={model.id}
className="text-white hover:bg-surface-hover focus:bg-success/20 focus:text-success"
>
<span className="flex items-center justify-between gap-4">
<span>{model.name}</span>
<Badge variant="outline" className="border-success/50 text-success bg-success/10 text-xs">
{model.size}
</Badge>
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Model Loading Status */}
{webllm.isLoading && (
<div className="space-y-3">
<div className="flex items-center justify-between text-sm">
<span className="text-text-secondary">{webllm.loadStatus}</span>
<span className="text-success">{webllm.loadProgress}%</span>
</div>
<Progress value={webllm.loadProgress} className="h-2" />
</div>
)}
{webllm.isLoaded && (
<div className="p-4 rounded-lg bg-success/10 border border-success/30">
<div className="flex items-center gap-3">
<CheckCircle className="h-5 w-5 text-success" />
<p className="text-success text-sm">
Model loaded: {webllm.currentModel}
</p>
</div>
</div>
)}
{webllm.error && (
<div className="p-4 rounded-lg bg-destructive/10 border border-destructive/30">
<div className="flex items-center gap-3">
<XCircle className="h-5 w-5 text-destructive" />
<p className="text-destructive text-sm">{webllm.error}</p>
</div>
</div>
)}
{/* Action Buttons */}
<div className="flex gap-3">
<Button
onClick={() => webllm.loadModel(webllmModel)}
disabled={webllm.isLoading || !webllm.isWebGPUSupported()}
className="bg-success hover:bg-success/90 text-white flex-1 group"
>
{webllm.isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Loading...
</>
) : webllm.isLoaded && webllm.currentModel === webllmModel ? (
<>
<CheckCircle className="mr-2 h-4 w-4" />
Loaded
</>
) : (
<>
<Download className="mr-2 h-4 w-4 transition-transform duration-200 group-hover:scale-110" />
Load Model
</>
)}
</Button>
<Button
onClick={() => webllm.clearCache()}
variant="outline"
className="border-destructive/50 text-destructive hover:bg-destructive/10 group"
>
<Trash2 className="mr-2 h-4 w-4 transition-transform duration-200 group-hover:scale-110" />
Clear Cache
</Button>
</div>
<div className="p-4 rounded-lg bg-success/10 border border-success/30">
<p className="text-sm text-success">
<strong>💡 Tip:</strong> Models are downloaded once and cached in your browser (~1-5GB depending on model). Loading may take a minute on first use.
</p>
</div>
</CardContent>
</Card>
)}
{/* Image Translation - Only for Ollama and OpenAI */}
{(selectedProvider === "ollama" || selectedProvider === "openai") && (
<Card variant="elevated" className="mb-8 animate-fade-in-up">
<CardHeader>
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-accent/20">
<Shield className="h-5 w-5 text-accent" />
</div>
<div>
<CardTitle className="text-white">Advanced Options</CardTitle>
<CardDescription>
Additional translation features
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between rounded-lg border border-border-subtle p-6 bg-surface/50">
<div className="space-y-1">
<div className="flex items-center gap-3">
<Label className="text-text-secondary font-medium">Translate Images by Default</Label>
<Badge variant="outline" className="border-primary/50 text-primary bg-primary/10 text-xs">
Vision Models
</Badge>
</div>
<p className="text-sm text-text-tertiary">
Extract and translate text from embedded images using vision models
</p>
</div>
<Switch
checked={translateImages}
onCheckedChange={setTranslateImages}
/>
</div>
</CardContent>
</Card>
)}
{/* Save Button */}
<div className="flex justify-end animate-fade-in-up">
<Button
onClick={handleSave}
disabled={isSaving}
size="lg"
className="bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90 text-white group"
>
{isSaving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Saving...
</>
) : (
<>
<Save className="mr-2 h-4 w-4 transition-transform duration-200 group-hover:scale-110" />
Save Settings
</>
)}
</Button>
</div>
</div>
</div>
);
}

View File

@@ -20,7 +20,8 @@ import {
Eye,
Trash2,
Copy,
ExternalLink
ExternalLink,
ChevronRight
} from "lucide-react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
@@ -183,23 +184,25 @@ const FilePreview = ({ file, onRemove }: FilePreviewProps) => {
};
export function FileUploader() {
const { settings, isTranslating, progress, setTranslating, setProgress } = useTranslationStore();
const { settings } = useTranslationStore();
const webllm = useWebLLM();
const [file, setFile] = useState<File | null>(null);
const [targetLanguage, setTargetLanguage] = useState(settings.defaultTargetLanguage);
const [provider, setProvider] = useState<ProviderType>(settings.defaultProvider);
const [provider, setProvider] = useState<ProviderType>(settings.defaultProvider as ProviderType);
const [translateImages, setTranslateImages] = useState(settings.translateImages);
const [downloadUrl, setDownloadUrl] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [translationStatus, setTranslationStatus] = useState<string>("");
const [showAdvanced, setShowAdvanced] = useState(false);
const [isTranslating, setTranslating] = useState(false);
const [progress, setProgress] = useState(0);
const fileInputRef = useRef<HTMLInputElement>(null);
// Sync with store settings when they change
useEffect(() => {
setTargetLanguage(settings.defaultTargetLanguage);
setProvider(settings.defaultProvider);
setProvider(settings.defaultProvider as ProviderType);
setTranslateImages(settings.translateImages);
}, [settings.defaultTargetLanguage, settings.defaultProvider, settings.translateImages]);
@@ -227,17 +230,6 @@ export function FileUploader() {
const handleTranslate = async () => {
if (!file) return;
// Validate provider-specific requirements
if (provider === "openai" && !settings.openaiApiKey) {
setError("OpenAI API key not configured. Go to Settings > Translation Services to add your API key.");
return;
}
if (provider === "deepl" && !settings.deeplApiKey) {
setError("DeepL API key not configured. Go to Settings > Translation Services to add your API key.");
return;
}
// WebLLM specific validation
if (provider === "webllm") {
if (!webllm.isWebGPUSupported()) {

View File

@@ -1,594 +0,0 @@
"use client";
import { useState, useEffect } from "react";
import Link from "next/link";
import {
ArrowRight,
Check,
FileText,
Globe2,
Zap,
Shield,
Server,
Sparkles,
FileSpreadsheet,
Presentation,
Star,
TrendingUp,
Users,
Clock,
Award,
ChevronRight,
Play,
BarChart3,
Brain,
Lock,
Zap as ZapIcon
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardFeature } from "@/components/ui/card";
import { cn } from "@/lib/utils";
interface User {
name: string;
plan: string;
}
export function LandingHero() {
const [user, setUser] = useState<User | null>(null);
const [isLoaded, setIsLoaded] = useState(false);
useEffect(() => {
const storedUser = localStorage.getItem("user");
if (storedUser) {
try {
setUser(JSON.parse(storedUser));
} catch {
setUser(null);
}
}
// Trigger animation after mount
setTimeout(() => setIsLoaded(true), 100);
}, []);
return (
<div className="relative overflow-hidden">
{/* Enhanced Background with animated gradient */}
<div className="absolute inset-0">
<div className="absolute inset-0 bg-gradient-to-br from-primary/5 via-transparent to-accent/5" />
<div className="absolute inset-0 bg-[url('/grid.svg')] opacity-5" />
<div className="absolute inset-0 bg-gradient-to-t from-background via-background/90 to-background/50" />
</div>
{/* Animated floating elements */}
<div className="absolute inset-0 overflow-hidden pointer-events-none">
<div className={cn(
"absolute top-20 left-10 w-20 h-20 bg-primary/10 rounded-full blur-xl animate-pulse",
isLoaded && "animate-float"
)} />
<div className={cn(
"absolute top-40 right-20 w-32 h-32 bg-accent/10 rounded-full blur-2xl animate-pulse animation-delay-2000",
isLoaded && "animate-float-delayed"
)} />
<div className={cn(
"absolute bottom-20 left-1/4 w-16 h-16 bg-success/10 rounded-full blur-lg animate-pulse animation-delay-4000",
isLoaded && "animate-float-slow"
)} />
</div>
{/* Hero content */}
<div className={cn(
"relative px-4 py-24 sm:py-32 transition-all duration-1000 ease-out",
isLoaded ? "opacity-100 translate-y-0" : "opacity-0 translate-y-4"
)}>
<div className="text-center max-w-5xl mx-auto">
{/* Premium Badge */}
<Badge
variant="premium"
size="lg"
className="mb-8 animate-slide-up animation-delay-200"
>
<Sparkles className="w-4 h-4 mr-2" />
AI-Powered Document Translation
</Badge>
{/* Enhanced Headline */}
<h1 className="text-display text-4xl sm:text-6xl lg:text-7xl font-bold text-white mb-6 leading-tight">
<span className={cn(
"block bg-gradient-to-r from-primary via-accent to-primary bg-clip-text text-transparent bg-size-200 animate-gradient",
isLoaded && "animate-gradient-shift"
)}>
Translate Documents
</span>
<span className="block text-3xl sm:text-4xl lg:text-5xl mt-2">
<span className="relative">
Instantly
<span className="absolute -bottom-2 left-0 right-0 h-1 bg-gradient-to-r from-primary to-accent rounded-full animate-underline-expand" />
</span>
</span>
</h1>
{/* Enhanced Description */}
<p className={cn(
"text-xl text-text-secondary mb-12 max-w-3xl mx-auto leading-relaxed",
isLoaded && "animate-slide-up animation-delay-400"
)}>
Upload Word, Excel, and PowerPoint files. Get perfect translations while preserving
all formatting, styles, and layouts. Powered by advanced AI technology.
</p>
{/* Enhanced CTA Buttons */}
<div className={cn(
"flex flex-col sm:flex-row gap-6 justify-center mb-16",
isLoaded && "animate-slide-up animation-delay-600"
)}>
{user ? (
<Link href="#upload">
<Button size="lg" variant="premium" className="group px-8 py-4 text-lg">
Start Translating
<ArrowRight className="ml-2 h-5 w-5 transition-transform group-hover:translate-x-1" />
</Button>
</Link>
) : (
<>
<Link href="/auth/register">
<Button size="lg" variant="premium" className="group px-8 py-4 text-lg">
Get Started Free
<ArrowRight className="ml-2 h-5 w-5 transition-transform group-hover:translate-x-1" />
</Button>
</Link>
<Link href="/pricing">
<Button
size="lg"
variant="glass"
className="px-8 py-4 text-lg border-2 hover:border-primary/50"
>
View Pricing
</Button>
</Link>
</>
)}
</div>
{/* Enhanced Supported formats */}
<div className={cn(
"flex flex-wrap justify-center gap-4 mb-16",
isLoaded && "animate-slide-up animation-delay-800"
)}>
{[
{ icon: FileText, name: "Word", ext: ".docx", color: "text-blue-400" },
{ icon: FileSpreadsheet, name: "Excel", ext: ".xlsx", color: "text-green-400" },
{ icon: Presentation, name: "PowerPoint", ext: ".pptx", color: "text-orange-400" },
].map((format, idx) => (
<Card
key={format.name}
variant="glass"
className="group px-6 py-4 hover:scale-105 transition-all duration-300"
>
<div className="flex items-center gap-3">
<div className={cn(
"w-12 h-12 rounded-xl flex items-center justify-center transition-all duration-300",
format.color
)}>
<format.icon className="w-6 h-6" />
</div>
<div className="text-left">
<div className="font-semibold text-white">{format.name}</div>
<div className="text-sm text-text-tertiary">{format.ext}</div>
</div>
</div>
</Card>
))}
</div>
{/* Trust Indicators */}
<div className={cn(
"flex flex-wrap justify-center gap-8 text-sm text-text-tertiary",
isLoaded && "animate-slide-up animation-delay-1000"
)}>
{[
{ icon: Users, text: "10,000+ Users" },
{ icon: Star, text: "4.9/5 Rating" },
{ icon: Shield, text: "Bank-level Security" },
{ icon: ZapIcon, text: "Lightning Fast" },
].map((indicator, idx) => (
<div key={indicator.text} className="flex items-center gap-2">
<indicator.icon className="w-4 h-4 text-primary" />
<span>{indicator.text}</span>
</div>
))}
</div>
</div>
</div>
</div>
);
}
export function FeaturesSection() {
const [ref, setRef] = useState(false);
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setRef(true);
}
},
{ threshold: 0.1 }
);
const element = document.getElementById('features-section');
if (element) {
observer.observe(element);
}
return () => observer.disconnect();
}, []);
const features = [
{
icon: Globe2,
title: "100+ Languages",
description: "Translate between any language pair with high accuracy using advanced AI models",
color: "text-blue-400",
stats: "100+",
},
{
icon: FileText,
title: "Preserve Formatting",
description: "All styles, fonts, colors, tables, and charts remain intact",
color: "text-green-400",
stats: "100%",
},
{
icon: Zap,
title: "Lightning Fast",
description: "Batch processing translates entire documents in seconds",
color: "text-amber-400",
stats: "2s",
},
{
icon: Shield,
title: "Secure & Private",
description: "Your documents are encrypted and never stored permanently",
color: "text-purple-400",
stats: "AES-256",
},
{
icon: Brain,
title: "AI-Powered",
description: "Advanced neural translation for natural, context-aware results",
color: "text-teal-400",
stats: "GPT-4",
},
{
icon: Server,
title: "Enterprise Ready",
description: "API access, team management, and dedicated support for businesses",
color: "text-orange-400",
stats: "99.9%",
},
];
return (
<div id="features-section" className="py-24 px-4 relative">
{/* Background decoration */}
<div className="absolute inset-0 bg-gradient-to-b from-transparent to-surface/50 pointer-events-none" />
<div className="max-w-7xl mx-auto">
<div className="text-center mb-16">
<Badge variant="glass" className="mb-4">
Features
</Badge>
<h2 className="text-3xl font-bold text-white mb-4">
Everything You Need for Document Translation
</h2>
<p className="text-xl text-text-secondary max-w-3xl mx-auto">
Professional-grade translation with enterprise features, available to everyone.
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{features.map((feature, idx) => {
const Icon = feature.icon;
return (
<CardFeature
key={feature.title}
icon={<Icon className="w-6 h-6" />}
title={feature.title}
description={feature.description}
color="primary"
className={cn(
"group",
ref && "animate-fade-in-up",
`animation-delay-${idx * 100}`
)}
/>
);
})}
</div>
{/* Enhanced Stats Row */}
<div className="mt-20 grid grid-cols-2 md:grid-cols-4 gap-8">
{[
{ value: "10M+", label: "Documents Translated", icon: FileText },
{ value: "150+", label: "Countries", icon: Globe2 },
{ value: "99.9%", label: "Uptime", icon: Shield },
{ value: "24/7", label: "Support", icon: Clock },
].map((stat, idx) => (
<div
key={stat.label}
className={cn(
"text-center p-6 rounded-xl surface-elevated border border-border-subtle",
ref && "animate-fade-in-up",
`animation-delay-${idx * 100 + 600}`
)}
>
<stat.icon className="w-8 h-8 mx-auto mb-3 text-primary" />
<div className="text-2xl font-bold text-white mb-1">{stat.value}</div>
<div className="text-sm text-text-secondary">{stat.label}</div>
</div>
))}
</div>
</div>
</div>
);
}
export function PricingPreview() {
const [ref, setRef] = useState(false);
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setRef(true);
}
},
{ threshold: 0.1 }
);
const element = document.getElementById('pricing-preview');
if (element) {
observer.observe(element);
}
return () => observer.disconnect();
}, []);
const plans = [
{
name: "Free",
price: "$0",
description: "Perfect for trying out",
features: ["5 documents/day", "10 pages/doc", "Basic support"],
cta: "Get Started",
href: "/auth/register",
popular: false,
},
{
name: "Pro",
price: "$29",
period: "/month",
description: "For professionals",
features: ["200 documents/month", "Unlimited pages", "Priority support", "API access"],
cta: "Start Free Trial",
href: "/pricing",
popular: true,
},
{
name: "Business",
price: "$79",
period: "/month",
description: "For teams",
features: ["1000 documents/month", "Team management", "Dedicated support", "SLA"],
cta: "Contact Sales",
href: "/pricing",
popular: false,
},
];
return (
<div id="pricing-preview" className="py-24 px-4 relative">
{/* Background decoration */}
<div className="absolute inset-0 bg-gradient-to-b from-surface/50 to-transparent pointer-events-none" />
<div className="max-w-5xl mx-auto">
<div className="text-center mb-16">
<Badge variant="glass" className="mb-4">
Pricing
</Badge>
<h2 className="text-3xl font-bold text-white mb-4">
Simple, Transparent Pricing
</h2>
<p className="text-xl text-text-secondary max-w-3xl mx-auto">
Start free, upgrade when you need more.
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
{plans.map((plan, idx) => (
<Card
key={plan.name}
variant={plan.popular ? "gradient" : "elevated"}
className={cn(
"relative overflow-hidden group",
ref && "animate-fade-in-up",
`animation-delay-${idx * 100}`
)}
>
{plan.popular && (
<div className="absolute -top-4 left-1/2 transform -translate-x-1/2">
<Badge variant="premium" className="animate-pulse">
Most Popular
</Badge>
</div>
)}
<CardHeader className="text-center pb-4">
<CardTitle className="text-xl mb-2">{plan.name}</CardTitle>
<CardDescription>{plan.description}</CardDescription>
<div className="my-6">
<span className="text-4xl font-bold text-white">
{plan.price}
</span>
{plan.period && (
<span className="text-lg text-text-secondary ml-1">
{plan.period}
</span>
)}
</div>
</CardHeader>
<CardContent className="pt-0">
<ul className="space-y-3 mb-6">
{plan.features.map((feature) => (
<li key={feature} className="flex items-center gap-2 text-sm text-text-secondary">
<Check className="h-4 w-4 text-success flex-shrink-0" />
<span>{feature}</span>
</li>
))}
</ul>
<Link href={plan.href}>
<Button
variant={plan.popular ? "default" : "outline"}
className="w-full group"
size="lg"
>
{plan.cta}
<ChevronRight className="ml-2 h-4 w-4 transition-transform group-hover:translate-x-1" />
</Button>
</Link>
</CardContent>
{/* Hover effect for popular plan */}
{plan.popular && (
<div className="absolute inset-0 bg-gradient-to-r from-primary/20 to-accent/20 opacity-0 group-hover:opacity-100 transition-opacity duration-300 pointer-events-none" />
)}
</Card>
))}
</div>
<div className="text-center mt-12">
<Link href="/pricing" className="group">
<Button variant="ghost" className="text-primary hover:text-primary/80">
View all plans and features
<ArrowRight className="ml-2 h-4 w-4 transition-transform group-hover:translate-x-1" />
</Button>
</Link>
</div>
</div>
</div>
);
}
export function SelfHostCTA() {
return null; // Removed for commercial version
}
// Custom animations
const style = document.createElement('style');
style.textContent = `
@keyframes float {
0%, 100% { transform: translateY(0px) rotate(0deg); }
33% { transform: translateY(-20px) rotate(120deg); }
66% { transform: translateY(-10px) rotate(240deg); }
}
@keyframes float-delayed {
0%, 100% { transform: translateY(0px) rotate(0deg); }
33% { transform: translateY(-30px) rotate(90deg); }
66% { transform: translateY(-15px) rotate(180deg); }
}
@keyframes float-slow {
0%, 100% { transform: translateY(0px) translateX(0px); }
25% { transform: translateY(-15px) translateX(10px); }
50% { transform: translateY(-25px) translateX(-10px); }
75% { transform: translateY(-10px) translateX(5px); }
}
@keyframes gradient-shift {
0%, 100% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
}
@keyframes underline-expand {
0% { width: 0%; left: 50%; }
100% { width: 100%; left: 0%; }
}
@keyframes fade-in-up {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slide-up {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-float {
animation: float 6s ease-in-out infinite;
}
.animate-float-delayed {
animation: float-delayed 8s ease-in-out infinite;
}
.animate-float-slow {
animation: float-slow 10s ease-in-out infinite;
}
.animate-gradient {
background-size: 200% 200%;
animation: gradient-shift 3s ease-in-out infinite;
}
.animate-gradient-shift {
animation: gradient-shift 4s ease-in-out infinite;
}
.animate-underline-expand {
animation: underline-expand 1s ease-out forwards;
}
.animate-fade-in-up {
animation: fade-in-up 0.6s ease-out forwards;
}
.animate-slide-up {
animation: slide-up 0.6s ease-out forwards;
}
.animation-delay-200 { animation-delay: 200ms; }
.animation-delay-400 { animation-delay: 400ms; }
.animation-delay-600 { animation-delay: 600ms; }
.animation-delay-800 { animation-delay: 800ms; }
.animation-delay-1000 { animation-delay: 1000ms; }
.animation-delay-2000 { animation-delay: 2000ms; }
.animation-delay-4000 { animation-delay: 4000ms; }
.bg-size-200 {
background-size: 200% 200%;
}
`;
if (typeof document !== 'undefined') {
document.head.appendChild(style);
}

View File

@@ -0,0 +1,86 @@
import {
Globe2,
FileText,
Zap,
Shield,
Brain,
Server
} from "lucide-react"
const features = [
{
icon: Globe2,
title: "100+ Languages",
description: "Translate between any language pair with high accuracy",
color: "text-blue-400",
},
{
icon: FileText,
title: "Preserve Formatting",
description: "All styles, fonts, colors, tables, and charts remain intact",
color: "text-green-400",
},
{
icon: Zap,
title: "Lightning Fast",
description: "Batch processing translates entire documents in seconds",
color: "text-amber-400",
},
{
icon: Shield,
title: "Secure & Private",
description: "Your documents are encrypted and never stored permanently",
color: "text-purple-400",
},
{
icon: Brain,
title: "AI-Powered",
description: "Advanced neural translation for natural, context-aware results",
color: "text-teal-400",
},
{
icon: Server,
title: "Enterprise Ready",
description: "API access, team management, and dedicated support",
color: "text-orange-400",
},
]
export function FeaturesSection() {
return (
<section className="py-16 px-6">
<div className="max-w-5xl mx-auto">
<div className="text-center mb-12">
<h2 className="text-2xl font-bold text-foreground mb-3">
Everything You Need for Document Translation
</h2>
<p className="text-muted-foreground max-w-2xl mx-auto">
Professional-grade translation with enterprise features, available to everyone.
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{features.map((feature) => {
const Icon = feature.icon
return (
<div
key={feature.title}
className="flex flex-col items-center text-center p-6 rounded-xl border border-border bg-card/50 hover:bg-card transition-colors"
>
<div className={`mb-4 ${feature.color}`}>
<Icon className="size-8" />
</div>
<h3 className="text-base font-semibold text-foreground mb-2">
{feature.title}
</h3>
<p className="text-sm text-muted-foreground">
{feature.description}
</p>
</div>
)
})}
</div>
</div>
</section>
)
}

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