feat: revue de code, doc CODE_REVIEW, forfaits 2026, traduction LLM, providers avec modèle
Made-with: Cursor
This commit is contained in:
145
frontend/src/app/dashboard/DashboardHeader.tsx
Normal file
145
frontend/src/app/dashboard/DashboardHeader.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import {
|
||||
Languages,
|
||||
Menu,
|
||||
X,
|
||||
ChevronLeft,
|
||||
LogOut
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { useUser } from './useUser';
|
||||
import { useLogout } from './useLogout';
|
||||
import { getNavItems } from './constants';
|
||||
import { getInitials } from './utils';
|
||||
|
||||
export function DashboardHeader() {
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const pathname = usePathname();
|
||||
const { data: user, isLoading } = useUser();
|
||||
const { logout } = useLogout();
|
||||
|
||||
const navItems = getNavItems(user?.tier === 'pro');
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="flex h-14 shrink-0 items-center justify-between border-b border-border bg-card px-4 lg:px-6">
|
||||
{/* Mobile menu button */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="lg:hidden"
|
||||
onClick={() => setMobileOpen(!mobileOpen)}
|
||||
aria-label="Toggle menu"
|
||||
>
|
||||
{mobileOpen ? <X className="size-4" /> : <Menu className="size-4" />}
|
||||
</Button>
|
||||
|
||||
{/* Mobile brand */}
|
||||
<div className="flex items-center gap-2 lg:hidden">
|
||||
<div className="flex size-6 items-center justify-center rounded-md bg-foreground">
|
||||
<Languages className="size-3 text-background" />
|
||||
</div>
|
||||
<span className="text-sm font-semibold text-foreground">Office Translator</span>
|
||||
</div>
|
||||
|
||||
{/* Page title - desktop */}
|
||||
<div className="hidden items-center gap-3 lg:flex">
|
||||
<h1 className="text-sm font-semibold text-foreground">Dashboard</h1>
|
||||
<Separator orientation="vertical" className="h-4" />
|
||||
<span className="text-sm text-muted-foreground">Manage your API and translation settings</span>
|
||||
</div>
|
||||
|
||||
{/* Right side */}
|
||||
{!isLoading && user && (
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={cn(
|
||||
'border border-accent/20',
|
||||
user.tier === 'pro' ? 'bg-accent/10 text-accent' : 'bg-muted text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{user.tier === 'pro' ? 'Pro Plan' : 'Free Plan'}
|
||||
</Badge>
|
||||
<Avatar className="size-8">
|
||||
<AvatarFallback className="bg-accent text-accent-foreground text-xs font-semibold">
|
||||
{getInitials(user.name)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{/* Mobile navigation drawer */}
|
||||
{mobileOpen && (
|
||||
<div className="border-b border-border bg-card px-4 py-3 lg:hidden">
|
||||
<nav className="flex flex-col gap-1">
|
||||
{navItems.map((item) => {
|
||||
const isActive = pathname === item.href;
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className={cn(
|
||||
'flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors',
|
||||
isActive
|
||||
? 'bg-secondary text-foreground'
|
||||
: 'text-muted-foreground hover:bg-secondary/60 hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
<item.icon className="size-4 shrink-0" />
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
<Separator className="my-2" />
|
||||
{!isLoading && user && (
|
||||
<div className="flex items-center gap-3 px-3 py-2">
|
||||
<Avatar className="size-8">
|
||||
<AvatarFallback className="bg-accent text-accent-foreground text-xs font-semibold">
|
||||
{getInitials(user.name)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-sm font-medium text-foreground">{user.name}</span>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={cn(
|
||||
'text-xs w-fit',
|
||||
user.tier === 'pro' && 'border border-accent/20 bg-accent/10 text-accent'
|
||||
)}
|
||||
>
|
||||
{user.tier === 'pro' ? 'Pro' : 'Free'}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={logout}
|
||||
className="flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-secondary/60 hover:text-foreground"
|
||||
>
|
||||
<LogOut className="size-4 shrink-0" />
|
||||
Sign out
|
||||
</button>
|
||||
<Link
|
||||
href="/"
|
||||
className="flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-secondary/60 hover:text-foreground"
|
||||
>
|
||||
<ChevronLeft className="size-4 shrink-0" />
|
||||
Back to home
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
43
frontend/src/app/dashboard/DashboardLayoutClient.tsx
Normal file
43
frontend/src/app/dashboard/DashboardLayoutClient.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { DashboardSidebar } from './DashboardSidebar';
|
||||
import { DashboardHeader } from './DashboardHeader';
|
||||
|
||||
export function DashboardLayoutClient({ children }: { children: React.ReactNode }) {
|
||||
const router = useRouter();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) {
|
||||
router.push('/auth/login?redirect=/dashboard');
|
||||
} else {
|
||||
setIsAuthenticated(true);
|
||||
}
|
||||
}, [router]);
|
||||
|
||||
if (!mounted || !isAuthenticated) {
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center bg-background">
|
||||
<div className="text-center space-y-4">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-4 border-muted border-t-foreground mx-auto"></div>
|
||||
<p className="text-sm text-muted-foreground">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-background">
|
||||
<DashboardSidebar />
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
<DashboardHeader />
|
||||
<main className="flex-1 overflow-y-auto">{children}</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
111
frontend/src/app/dashboard/DashboardSidebar.tsx
Normal file
111
frontend/src/app/dashboard/DashboardSidebar.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { Languages, ChevronLeft, LogOut } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useUser } from './useUser';
|
||||
import { useLogout } from './useLogout';
|
||||
import { getNavItems } from './constants';
|
||||
import { getInitials } from './utils';
|
||||
|
||||
export function DashboardSidebar() {
|
||||
const pathname = usePathname();
|
||||
const { data: user, isLoading } = useUser();
|
||||
const { logout } = useLogout();
|
||||
|
||||
const navItems = getNavItems(user?.tier === 'pro');
|
||||
|
||||
return (
|
||||
<aside className="hidden w-64 shrink-0 border-r border-border bg-card lg:flex lg:flex-col">
|
||||
{/* Brand */}
|
||||
<div className="flex h-14 items-center gap-2.5 px-5">
|
||||
<div className="flex size-7 items-center justify-center rounded-md bg-foreground">
|
||||
<Languages className="size-3.5 text-background" />
|
||||
</div>
|
||||
<span className="text-sm font-semibold tracking-tight text-foreground">
|
||||
Office Translator
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex flex-1 flex-col gap-1 px-3 py-4">
|
||||
{navItems.map((item) => {
|
||||
const isActive = pathname === item.href;
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
'flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors',
|
||||
isActive
|
||||
? 'bg-secondary text-foreground'
|
||||
: 'text-muted-foreground hover:bg-secondary/60 hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
<item.icon className="size-4 shrink-0" />
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* User section */}
|
||||
{!isLoading && user && (
|
||||
<div className="flex items-center gap-3 px-5 py-4">
|
||||
<Avatar className="size-8">
|
||||
<AvatarFallback className="bg-accent text-accent-foreground text-xs font-semibold">
|
||||
{getInitials(user.name)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-sm font-medium leading-none text-foreground">{user.name}</span>
|
||||
<span className="text-xs leading-none text-muted-foreground">{user.email}</span>
|
||||
</div>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={cn(
|
||||
'ml-auto text-xs',
|
||||
user.tier === 'pro' && 'border border-accent/20 bg-accent/10 text-accent'
|
||||
)}
|
||||
>
|
||||
{user.tier === 'pro' ? 'Pro' : 'Free'}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Logout */}
|
||||
<div className="px-3 py-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-start gap-2 text-muted-foreground hover:text-foreground"
|
||||
onClick={logout}
|
||||
>
|
||||
<LogOut className="size-3.5" />
|
||||
Sign out
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Back to homepage */}
|
||||
<div className="px-3 py-3">
|
||||
<Button variant="ghost" size="sm" className="w-full justify-start gap-2 text-muted-foreground" asChild>
|
||||
<Link href="/">
|
||||
<ChevronLeft className="size-3.5" />
|
||||
Back to home
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
152
frontend/src/app/dashboard/api-keys/ApiKeyTable.tsx
Normal file
152
frontend/src/app/dashboard/api-keys/ApiKeyTable.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Copy, Check, Trash2 } from 'lucide-react';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import type { ApiKey } from './types';
|
||||
|
||||
interface ApiKeyTableProps {
|
||||
keys: ApiKey[];
|
||||
onRevoke: (key: ApiKey) => void;
|
||||
isRevoking: boolean;
|
||||
}
|
||||
|
||||
function formatDate(dateString: string | null): string {
|
||||
if (!dateString) return 'Never';
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMs / 3600000);
|
||||
const diffDays = Math.floor(diffMs / 86400000);
|
||||
|
||||
if (diffMins < 1) return 'Just now';
|
||||
if (diffMins < 60) return `${diffMins} min ago`;
|
||||
if (diffHours < 24) return `${diffHours} hour${diffHours > 1 ? 's' : ''} ago`;
|
||||
if (diffDays < 7) return `${diffDays} day${diffDays > 1 ? 's' : ''} ago`;
|
||||
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
}
|
||||
|
||||
export function ApiKeyTable({ keys, onRevoke, isRevoking }: ApiKeyTableProps) {
|
||||
const [copiedId, setCopiedId] = useState<string | null>(null);
|
||||
|
||||
const copyPrefix = (keyId: string, prefix: string) => {
|
||||
navigator.clipboard.writeText(prefix);
|
||||
setCopiedId(keyId);
|
||||
setTimeout(() => setCopiedId(null), 2000);
|
||||
};
|
||||
|
||||
if (keys.length === 0) {
|
||||
return (
|
||||
<div className="rounded-lg border border-border p-8 text-center">
|
||||
<p className="text-muted-foreground">No API keys yet. Generate your first key to get started.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Name
|
||||
</TableHead>
|
||||
<TableHead className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Key
|
||||
</TableHead>
|
||||
<TableHead className="hidden text-xs font-medium uppercase tracking-wider text-muted-foreground md:table-cell">
|
||||
Created
|
||||
</TableHead>
|
||||
<TableHead className="hidden text-xs font-medium uppercase tracking-wider text-muted-foreground lg:table-cell">
|
||||
Last Used
|
||||
</TableHead>
|
||||
<TableHead className="hidden text-xs font-medium uppercase tracking-wider text-muted-foreground lg:table-cell">
|
||||
Uses
|
||||
</TableHead>
|
||||
<TableHead className="text-right text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Actions
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{keys.map((key) => (
|
||||
<TableRow key={key.id}>
|
||||
<TableCell className="font-medium">
|
||||
{key.name}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs">
|
||||
{key.key_prefix}...
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell className="hidden text-muted-foreground md:table-cell">
|
||||
{formatDate(key.created_at)}
|
||||
</TableCell>
|
||||
<TableCell className="hidden text-muted-foreground lg:table-cell">
|
||||
{formatDate(key.last_used_at)}
|
||||
</TableCell>
|
||||
<TableCell className="hidden lg:table-cell">
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{key.usage_count}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => copyPrefix(key.id, key.key_prefix)}
|
||||
aria-label="Copy key prefix"
|
||||
>
|
||||
{copiedId === key.id ? (
|
||||
<Check className="size-3.5 text-accent" />
|
||||
) : (
|
||||
<Copy className="size-3.5 text-muted-foreground" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{copiedId === key.id ? 'Copied!' : 'Copy prefix'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => onRevoke(key)}
|
||||
disabled={isRevoking}
|
||||
aria-label="Revoke key"
|
||||
>
|
||||
<Trash2 className="size-3.5 text-muted-foreground hover:text-destructive" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Revoke</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
224
frontend/src/app/dashboard/api-keys/GenerateKeyDialog.tsx
Normal file
224
frontend/src/app/dashboard/api-keys/GenerateKeyDialog.tsx
Normal file
@@ -0,0 +1,224 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo } from 'react';
|
||||
import { AlertTriangle, Copy, Check, CheckCircle2 } from 'lucide-react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import type { ApiKeyCreateResponse } from './types';
|
||||
|
||||
const MAX_KEY_NAME_LENGTH = 100;
|
||||
const VALID_KEY_NAME_REGEX = /^[a-zA-Z0-9\s\-_]+$/;
|
||||
|
||||
interface GenerateKeyDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onGenerate: (name?: string) => Promise<ApiKeyCreateResponse>;
|
||||
isGenerating: boolean;
|
||||
maxKeysReached: boolean;
|
||||
}
|
||||
|
||||
interface ValidationResult {
|
||||
isValid: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export function GenerateKeyDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onGenerate,
|
||||
isGenerating,
|
||||
maxKeysReached,
|
||||
}: GenerateKeyDialogProps) {
|
||||
const [step, setStep] = useState<'name' | 'result'>('name');
|
||||
const [keyName, setKeyName] = useState('');
|
||||
const [generatedKey, setGeneratedKey] = useState<ApiKeyCreateResponse | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [touched, setTouched] = useState(false);
|
||||
|
||||
const validation = useMemo<ValidationResult>(() => {
|
||||
const trimmedName = keyName.trim();
|
||||
|
||||
if (trimmedName.length > MAX_KEY_NAME_LENGTH) {
|
||||
return { isValid: false, error: `Name must be ${MAX_KEY_NAME_LENGTH} characters or less` };
|
||||
}
|
||||
|
||||
if (trimmedName && !VALID_KEY_NAME_REGEX.test(trimmedName)) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: 'Name can only contain letters, numbers, spaces, hyphens, and underscores'
|
||||
};
|
||||
}
|
||||
|
||||
return { isValid: true, error: null };
|
||||
}, [keyName]);
|
||||
|
||||
const handleGenerate = async () => {
|
||||
if (!validation.isValid) return;
|
||||
|
||||
try {
|
||||
const result = await onGenerate(keyName.trim() || undefined);
|
||||
setGeneratedKey(result);
|
||||
setStep('result');
|
||||
setKeyName('');
|
||||
setTouched(false);
|
||||
} catch {
|
||||
onOpenChange(false);
|
||||
}
|
||||
};
|
||||
|
||||
const copyKey = () => {
|
||||
if (generatedKey?.key) {
|
||||
navigator.clipboard.writeText(generatedKey.key);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setStep('name');
|
||||
setGeneratedKey(null);
|
||||
setCopied(false);
|
||||
setKeyName('');
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
if (maxKeysReached) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Maximum Keys Reached</DialogTitle>
|
||||
<DialogDescription>
|
||||
You have reached the maximum of 10 API keys. Please revoke an existing key before generating a new one.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button onClick={() => onOpenChange(false)}>Close</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
if (step === 'result' && generatedKey) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-accent/10">
|
||||
<CheckCircle2 className="h-5 w-5 text-accent" />
|
||||
</div>
|
||||
<DialogTitle>API Key Generated!</DialogTitle>
|
||||
</div>
|
||||
<DialogDescription>
|
||||
Your new API key has been created. Copy it now - it won't be shown again.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg border border-amber-200 bg-amber-50 p-3 dark:border-amber-900/50 dark:bg-amber-950/20">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertTriangle className="h-4 w-4 text-amber-600 dark:text-amber-500 mt-0.5 shrink-0" />
|
||||
<p className="text-sm text-amber-800 dark:text-amber-200">
|
||||
<strong>Important:</strong> This is the only time you'll see this key. Store it securely.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="apiKey">API Key</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="apiKey"
|
||||
readOnly
|
||||
value={generatedKey.key}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
<Button onClick={copyKey} variant="outline" size="icon">
|
||||
{copied ? (
|
||||
<Check className="h-4 w-4 text-accent" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<span className="font-medium">Name:</span> {generatedKey.name}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button onClick={handleClose}>
|
||||
{copied ? 'Done' : 'I\'ve copied the key'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Generate New API Key</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a new API key for programmatic access to the translation API.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="keyName">Key Name (optional)</Label>
|
||||
<Input
|
||||
id="keyName"
|
||||
placeholder="e.g., Production, Staging"
|
||||
value={keyName}
|
||||
onChange={(e) => {
|
||||
setKeyName(e.target.value);
|
||||
setTouched(true);
|
||||
}}
|
||||
onBlur={() => setTouched(true)}
|
||||
maxLength={MAX_KEY_NAME_LENGTH + 10}
|
||||
className={touched && validation.error ? 'border-destructive' : ''}
|
||||
aria-invalid={touched && validation.error ? 'true' : 'false'}
|
||||
aria-describedby={touched && validation.error ? 'keyName-error' : undefined}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
A descriptive name to help you identify this key later.
|
||||
{keyName.length > 0 && (
|
||||
<span className="ml-2">({keyName.length}/{MAX_KEY_NAME_LENGTH})</span>
|
||||
)}
|
||||
</p>
|
||||
{touched && validation.error && (
|
||||
<p id="keyName-error" className="text-xs text-destructive">
|
||||
{validation.error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleGenerate} disabled={isGenerating || !validation.isValid}>
|
||||
{isGenerating ? 'Generating...' : 'Generate Key'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
56
frontend/src/app/dashboard/api-keys/ProUpgradePrompt.tsx
Normal file
56
frontend/src/app/dashboard/api-keys/ProUpgradePrompt.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
'use client';
|
||||
|
||||
import { Key, Sparkles } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import Link from 'next/link';
|
||||
|
||||
export function ProUpgradePrompt() {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[60vh] p-6">
|
||||
<Card className="max-w-md w-full border-border/50 bg-gradient-to-br from-card via-card to-accent/5">
|
||||
<CardHeader className="text-center pb-4">
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-br from-accent/20 to-accent/5">
|
||||
<Key className="h-8 w-8 text-accent" />
|
||||
</div>
|
||||
<CardTitle className="text-2xl font-semibold">API Keys</CardTitle>
|
||||
<CardDescription className="text-base">
|
||||
Automate your translations with API access
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="text-center space-y-6">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Sparkles className="h-4 w-4 text-accent shrink-0" />
|
||||
<span>Generate unlimited API keys</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Sparkles className="h-4 w-4 text-accent shrink-0" />
|
||||
<span>Automate document translation</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Sparkles className="h-4 w-4 text-accent shrink-0" />
|
||||
<span>Webhook notifications</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Sparkles className="h-4 w-4 text-accent shrink-0" />
|
||||
<span>LLM translation modes</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-2">
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
API Keys are a <span className="text-accent font-medium">Pro</span> feature.
|
||||
Upgrade to unlock API automation.
|
||||
</p>
|
||||
<Button asChild className="w-full bg-accent hover:bg-accent/90">
|
||||
<Link href="/pricing">
|
||||
Upgrade to Pro
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
75
frontend/src/app/dashboard/api-keys/RevokeKeyDialog.tsx
Normal file
75
frontend/src/app/dashboard/api-keys/RevokeKeyDialog.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
'use client';
|
||||
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
interface RevokeKeyDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onConfirm: () => void;
|
||||
isRevoking: boolean;
|
||||
keyName?: string;
|
||||
}
|
||||
|
||||
export function RevokeKeyDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onConfirm,
|
||||
isRevoking,
|
||||
keyName,
|
||||
}: RevokeKeyDialogProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Revoke API Key</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to revoke this API key?
|
||||
{keyName && (
|
||||
<span className="block mt-1 font-medium text-foreground">
|
||||
"{keyName}"
|
||||
</span>
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="rounded-lg border border-destructive/50 bg-destructive/10 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="h-5 w-5 text-destructive shrink-0 mt-0.5" />
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-destructive">This action cannot be undone</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Any applications using this key will lose access to the API immediately.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isRevoking}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={onConfirm}
|
||||
disabled={isRevoking}
|
||||
>
|
||||
{isRevoking ? 'Revoking...' : 'Revoke Key'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
64
frontend/src/app/dashboard/api-keys/WebhookSnippet.tsx
Normal file
64
frontend/src/app/dashboard/api-keys/WebhookSnippet.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo } from 'react';
|
||||
import { Webhook, Copy, Check } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { API_BASE_URL } from '@/lib/apiClient';
|
||||
|
||||
function getWebhookSnippet(): string {
|
||||
const baseUrl = API_BASE_URL.replace(/\/$/, '');
|
||||
return `curl -X POST ${baseUrl}/api/v1/translate \\
|
||||
-H "Authorization: Bearer sk_live_YOUR_API_KEY" \\
|
||||
-H "Content-Type: multipart/form-data" \\
|
||||
-F "file=@document.xlsx" \\
|
||||
-F "source_lang=en" \\
|
||||
-F "target_lang=fr" \\
|
||||
-F "webhook_url=https://your-app.com/webhook/complete"`;
|
||||
}
|
||||
|
||||
export function WebhookSnippet() {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const webhookSnippet = useMemo(() => getWebhookSnippet(), []);
|
||||
|
||||
const copySnippet = () => {
|
||||
navigator.clipboard.writeText(webhookSnippet);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Webhook className="h-5 w-5 text-accent" />
|
||||
<CardTitle className="text-base">Webhook Integration</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Pass a <code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs">webhook_url</code> parameter
|
||||
to receive a POST request when your translation is complete.
|
||||
</p>
|
||||
<div className="relative">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="absolute right-2 top-2 z-10"
|
||||
onClick={copySnippet}
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-3.5 w-3.5 text-accent" />
|
||||
) : (
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
<pre className="overflow-x-auto rounded-lg border border-border bg-foreground p-4 text-xs leading-relaxed text-background/90">
|
||||
<code>{webhookSnippet}</code>
|
||||
</pre>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
217
frontend/src/app/dashboard/api-keys/page.tsx
Normal file
217
frontend/src/app/dashboard/api-keys/page.tsx
Normal file
@@ -0,0 +1,217 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Zap, Plus, AlertCircle } from 'lucide-react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { useUser } from '@/app/dashboard/useUser';
|
||||
import { useApiKeys } from './useApiKeys';
|
||||
import { MAX_API_KEYS, type ApiKey } from './types';
|
||||
import { ProUpgradePrompt } from './ProUpgradePrompt';
|
||||
import { ApiKeyTable } from './ApiKeyTable';
|
||||
import { GenerateKeyDialog } from './GenerateKeyDialog';
|
||||
import { RevokeKeyDialog } from './RevokeKeyDialog';
|
||||
import { WebhookSnippet } from './WebhookSnippet';
|
||||
import { useToast } from '@/components/ui/toast';
|
||||
|
||||
export default function ApiKeysPage() {
|
||||
const { data: user, isLoading: isLoadingUser } = useUser();
|
||||
const {
|
||||
keys,
|
||||
total,
|
||||
isLoading: isLoadingKeys,
|
||||
isGenerating,
|
||||
isRevoking,
|
||||
generateKey,
|
||||
revokeKey,
|
||||
errorDetails,
|
||||
parseGenerateError,
|
||||
parseRevokeError,
|
||||
} = useApiKeys();
|
||||
const { toast } = useToast();
|
||||
|
||||
const [generateDialogOpen, setGenerateDialogOpen] = useState(false);
|
||||
const [revokeDialogOpen, setRevokeDialogOpen] = useState(false);
|
||||
const [keyToRevoke, setKeyToRevoke] = useState<{ id: string; name: string } | null>(null);
|
||||
const [apiError, setApiError] = useState<string | null>(null);
|
||||
|
||||
const isPro = user?.tier === 'pro';
|
||||
const maxKeysReached = total >= MAX_API_KEYS;
|
||||
const isLoading = isLoadingUser || isLoadingKeys;
|
||||
|
||||
// Handle API errors with specific error codes
|
||||
useEffect(() => {
|
||||
if (errorDetails?.code === 'PRO_FEATURE_REQUIRED') {
|
||||
// Redirect to upgrade prompt will happen via isPro check
|
||||
setApiError(null);
|
||||
} else if (errorDetails?.code === 'API_KEY_LIMIT_REACHED') {
|
||||
setApiError('You have reached the maximum of 10 API keys. Revoke an existing key to generate a new one.');
|
||||
} else if (errorDetails) {
|
||||
setApiError(errorDetails.message);
|
||||
} else {
|
||||
setApiError(null);
|
||||
}
|
||||
}, [errorDetails]);
|
||||
|
||||
const handleRevokeClick = (key: ApiKey) => {
|
||||
setKeyToRevoke({ id: key.id, name: key.name });
|
||||
setRevokeDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleRevokeConfirm = async () => {
|
||||
if (!keyToRevoke) return;
|
||||
try {
|
||||
await revokeKey(keyToRevoke.id);
|
||||
setRevokeDialogOpen(false);
|
||||
setKeyToRevoke(null);
|
||||
toast({
|
||||
title: 'Key revoked',
|
||||
description: 'The API key has been revoked successfully.',
|
||||
});
|
||||
} catch (error) {
|
||||
const revokeError = parseRevokeError();
|
||||
if (revokeError?.code === 'API_KEY_NOT_FOUND') {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Key Not Found',
|
||||
description: 'The API key no longer exists. It may have already been revoked.',
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Error',
|
||||
description: revokeError?.message || 'Failed to revoke the API key. Please try again.',
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleGenerateKey = async (name?: string) => {
|
||||
try {
|
||||
const result = await generateKey(name);
|
||||
return result;
|
||||
} catch (error) {
|
||||
const genError = parseGenerateError();
|
||||
if (genError?.code === 'API_KEY_LIMIT_REACHED') {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Limit Reached',
|
||||
description: 'You have reached the maximum of 10 API keys. Revoke an existing key to generate a new one.',
|
||||
});
|
||||
} else if (genError?.code === 'PRO_FEATURE_REQUIRED') {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Pro Feature Required',
|
||||
description: 'API keys are a Pro feature. Please upgrade your account.',
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Error',
|
||||
description: genError?.message || 'Failed to generate API key. Please try again.',
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[60vh]">
|
||||
<div className="text-center space-y-4">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-4 border-muted border-t-foreground mx-auto"></div>
|
||||
<p className="text-sm text-muted-foreground">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isPro) {
|
||||
return <ProUpgradePrompt />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">API Keys</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Manage your API keys for programmatic access to the translation API.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{apiError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>{apiError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex size-8 items-center justify-center rounded-lg bg-accent/10">
|
||||
<Zap className="size-4 text-accent" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-base">API & Automation</CardTitle>
|
||||
<CardDescription>Generate and manage your API keys for automation workflows</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium">
|
||||
{total} of {MAX_API_KEYS} keys used
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{maxKeysReached ? (
|
||||
<span className="text-amber-600">Maximum keys reached. Revoke a key to generate a new one.</span>
|
||||
) : (
|
||||
`You can generate ${MAX_API_KEYS - total} more key${MAX_API_KEYS - total !== 1 ? 's' : ''}.`
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => setGenerateDialogOpen(true)}
|
||||
disabled={maxKeysReached || isGenerating}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
Generate New Key
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ApiKeyTable
|
||||
keys={keys}
|
||||
onRevoke={handleRevokeClick}
|
||||
isRevoking={isRevoking}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Separator />
|
||||
|
||||
<WebhookSnippet />
|
||||
|
||||
<GenerateKeyDialog
|
||||
open={generateDialogOpen}
|
||||
onOpenChange={setGenerateDialogOpen}
|
||||
onGenerate={handleGenerateKey}
|
||||
isGenerating={isGenerating}
|
||||
maxKeysReached={maxKeysReached}
|
||||
/>
|
||||
|
||||
<RevokeKeyDialog
|
||||
open={revokeDialogOpen}
|
||||
onOpenChange={setRevokeDialogOpen}
|
||||
onConfirm={handleRevokeConfirm}
|
||||
isRevoking={isRevoking}
|
||||
keyName={keyToRevoke?.name}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
40
frontend/src/app/dashboard/api-keys/types.ts
Normal file
40
frontend/src/app/dashboard/api-keys/types.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
export interface ApiKey {
|
||||
id: string;
|
||||
name: string;
|
||||
key_prefix: string;
|
||||
is_active: boolean;
|
||||
last_used_at: string | null;
|
||||
usage_count: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface ApiKeyCreateResponse {
|
||||
id: string;
|
||||
key: string;
|
||||
name: string;
|
||||
key_prefix: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface ApiKeysListResponse {
|
||||
data: ApiKey[];
|
||||
meta: {
|
||||
total: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ApiKeyCreateApiResponse {
|
||||
data: ApiKeyCreateResponse;
|
||||
meta: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ApiKeyRevokeResponse {
|
||||
data: {
|
||||
id: string;
|
||||
revoked: boolean;
|
||||
revoked_at: string;
|
||||
};
|
||||
meta: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export const MAX_API_KEYS = 10;
|
||||
116
frontend/src/app/dashboard/api-keys/useApiKeys.ts
Normal file
116
frontend/src/app/dashboard/api-keys/useApiKeys.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
'use client';
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { apiClient, ApiClientError } from '@/lib/apiClient';
|
||||
import type {
|
||||
ApiKey,
|
||||
ApiKeyCreateResponse,
|
||||
ApiKeyRevokeResponse,
|
||||
} from './types';
|
||||
|
||||
const API_KEYS_QUERY_KEY = ['api-keys'];
|
||||
|
||||
interface ApiKeysListApiResponse {
|
||||
data: ApiKey[];
|
||||
meta: {
|
||||
total: number;
|
||||
};
|
||||
}
|
||||
|
||||
export type ApiKeyErrorCode = 'PRO_FEATURE_REQUIRED' | 'API_KEY_LIMIT_REACHED' | 'API_KEY_NOT_FOUND';
|
||||
|
||||
export interface ApiKeyError {
|
||||
status: number;
|
||||
code: ApiKeyErrorCode;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export function useApiKeys() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const {
|
||||
data: keysData,
|
||||
isLoading,
|
||||
error,
|
||||
} = useQuery<ApiKeysListApiResponse, ApiClientError>({
|
||||
queryKey: API_KEYS_QUERY_KEY,
|
||||
queryFn: async () => {
|
||||
const response = await apiClient.get<ApiKeysListApiResponse>('/api/v1/api-keys');
|
||||
return response.data;
|
||||
},
|
||||
retry: (failureCount, err) => {
|
||||
if (err.status === 403 || err.status === 429) return false;
|
||||
return failureCount < 2;
|
||||
},
|
||||
});
|
||||
|
||||
const keys = keysData?.data ?? [];
|
||||
const total = keysData?.meta?.total ?? 0;
|
||||
|
||||
const generateKeyMutation = useMutation<ApiKeyCreateResponse, ApiClientError, string | undefined>({
|
||||
mutationFn: async (name?: string): Promise<ApiKeyCreateResponse> => {
|
||||
const response = await apiClient.post<ApiKeyCreateResponse>('/api/v1/api-keys', {
|
||||
name: name || 'API Key',
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: API_KEYS_QUERY_KEY });
|
||||
},
|
||||
});
|
||||
|
||||
const revokeKeyMutation = useMutation<ApiKeyRevokeResponse, ApiClientError, string>({
|
||||
mutationFn: async (keyId: string): Promise<ApiKeyRevokeResponse> => {
|
||||
const response = await apiClient.delete<ApiKeyRevokeResponse>(`/api/v1/api-keys/${keyId}`);
|
||||
return response.data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: API_KEYS_QUERY_KEY });
|
||||
},
|
||||
});
|
||||
|
||||
const generateKey = async (name?: string) => {
|
||||
return generateKeyMutation.mutateAsync(name);
|
||||
};
|
||||
|
||||
const revokeKey = async (keyId: string) => {
|
||||
return revokeKeyMutation.mutateAsync(keyId);
|
||||
};
|
||||
|
||||
const parseError = (error: ApiClientError | null): ApiKeyError | null => {
|
||||
if (!error) return null;
|
||||
|
||||
const status = error.status || 500;
|
||||
const code = error.code as ApiKeyErrorCode | string;
|
||||
const message = error.message;
|
||||
|
||||
if (status === 403 && code === 'PRO_FEATURE_REQUIRED') {
|
||||
return { status: 403, code: 'PRO_FEATURE_REQUIRED', message: message || 'Pro feature required' };
|
||||
}
|
||||
if (status === 429 && code === 'API_KEY_LIMIT_REACHED') {
|
||||
return { status: 429, code: 'API_KEY_LIMIT_REACHED', message: message || 'Maximum API keys reached' };
|
||||
}
|
||||
if (status === 404 && code === 'API_KEY_NOT_FOUND') {
|
||||
return { status: 404, code: 'API_KEY_NOT_FOUND', message: message || 'API key not found' };
|
||||
}
|
||||
|
||||
// For non-matching errors, return with the actual code but cast appropriately for type safety
|
||||
return { status, code: code as ApiKeyErrorCode, message };
|
||||
};
|
||||
|
||||
return {
|
||||
keys,
|
||||
total,
|
||||
isLoading,
|
||||
error,
|
||||
errorDetails: parseError(error),
|
||||
isGenerating: generateKeyMutation.isPending,
|
||||
isRevoking: revokeKeyMutation.isPending,
|
||||
generateKey,
|
||||
revokeKey,
|
||||
generateError: generateKeyMutation.error,
|
||||
revokeError: revokeKeyMutation.error,
|
||||
parseGenerateError: () => parseError(generateKeyMutation.error),
|
||||
parseRevokeError: () => parseError(revokeKeyMutation.error),
|
||||
};
|
||||
}
|
||||
25
frontend/src/app/dashboard/constants.ts
Normal file
25
frontend/src/app/dashboard/constants.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { LayoutDashboard, FileText, Key, BookText, type LucideIcon } from 'lucide-react';
|
||||
|
||||
export interface NavItem {
|
||||
label: string;
|
||||
href: string;
|
||||
icon: LucideIcon;
|
||||
proOnly?: boolean;
|
||||
}
|
||||
|
||||
export const baseNavItems: NavItem[] = [
|
||||
{ label: 'Overview', href: '/dashboard', icon: LayoutDashboard },
|
||||
{ label: 'Translate', href: '/dashboard/translate', icon: FileText },
|
||||
{ label: 'API Keys', href: '/dashboard/api-keys', icon: Key },
|
||||
];
|
||||
|
||||
export const proNavItem: NavItem = {
|
||||
label: 'Glossaries',
|
||||
href: '/dashboard/glossaries',
|
||||
icon: BookText,
|
||||
proOnly: true
|
||||
};
|
||||
|
||||
export function getNavItems(isPro: boolean): NavItem[] {
|
||||
return isPro ? [...baseNavItems, proNavItem] : baseNavItems;
|
||||
}
|
||||
108
frontend/src/app/dashboard/glossaries/CreateGlossaryDialog.tsx
Normal file
108
frontend/src/app/dashboard/glossaries/CreateGlossaryDialog.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { TermEditor } from './TermEditor';
|
||||
import type { GlossaryTermInput } from './types';
|
||||
|
||||
interface CreateGlossaryDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onCreate: (data: { name: string; terms: GlossaryTermInput[] }) => Promise<void>;
|
||||
isCreating: boolean;
|
||||
}
|
||||
|
||||
export function CreateGlossaryDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onCreate,
|
||||
isCreating,
|
||||
}: CreateGlossaryDialogProps) {
|
||||
const [name, setName] = useState('');
|
||||
const [terms, setTerms] = useState<GlossaryTermInput[]>([{ source: '', target: '' }]);
|
||||
|
||||
const handleCreate = useCallback(async () => {
|
||||
if (!name.trim()) return;
|
||||
|
||||
const validTerms = terms.filter(t => t.source.trim() && t.target.trim());
|
||||
|
||||
await onCreate({
|
||||
name: name.trim(),
|
||||
terms: validTerms,
|
||||
});
|
||||
|
||||
setName('');
|
||||
setTerms([{ source: '', target: '' }]);
|
||||
}, [name, terms, onCreate]);
|
||||
|
||||
const handleOpenChange = useCallback((newOpen: boolean) => {
|
||||
if (!newOpen) {
|
||||
setName('');
|
||||
setTerms([{ source: '', target: '' }]);
|
||||
}
|
||||
onOpenChange(newOpen);
|
||||
}, [onOpenChange]);
|
||||
|
||||
const validTermsCount = terms.filter(t => t.source.trim() && t.target.trim()).length;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="sm:max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create New Glossary</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a glossary with custom terminology for your translations.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="glossary-name">Glossary Name</Label>
|
||||
<Input
|
||||
id="glossary-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g., Technical Terms FR-EN"
|
||||
disabled={isCreating}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Terms ({validTermsCount} valid)</Label>
|
||||
<TermEditor
|
||||
terms={terms}
|
||||
onChange={setTerms}
|
||||
disabled={isCreating}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleOpenChange(false)}
|
||||
disabled={isCreating}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCreate}
|
||||
disabled={isCreating || !name.trim()}
|
||||
>
|
||||
{isCreating ? 'Creating...' : 'Create Glossary'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
'use client';
|
||||
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
interface DeleteGlossaryDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onConfirm: () => void;
|
||||
isDeleting: boolean;
|
||||
glossaryName?: string;
|
||||
}
|
||||
|
||||
export function DeleteGlossaryDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onConfirm,
|
||||
isDeleting,
|
||||
glossaryName,
|
||||
}: DeleteGlossaryDialogProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Glossary</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete this glossary?
|
||||
{glossaryName && (
|
||||
<span className="block mt-1 font-medium text-foreground">
|
||||
"{glossaryName}"
|
||||
</span>
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="rounded-lg border border-destructive/50 bg-destructive/10 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="h-5 w-5 text-destructive shrink-0 mt-0.5" />
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-destructive">This action cannot be undone</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
All term pairs will be permanently removed.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={onConfirm}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{isDeleting ? 'Deleting...' : 'Delete'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
216
frontend/src/app/dashboard/glossaries/EditGlossaryDialog.tsx
Normal file
216
frontend/src/app/dashboard/glossaries/EditGlossaryDialog.tsx
Normal file
@@ -0,0 +1,216 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback, useRef } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Download, Upload } from 'lucide-react';
|
||||
import { TermEditor } from './TermEditor';
|
||||
import { exportGlossaryToCsv, parseCsvToTerms } from './csvUtils';
|
||||
import { useToast } from '@/components/ui/toast';
|
||||
import type { Glossary, GlossaryTermInput } from './types';
|
||||
import { MAX_TERMS_PER_GLOSSARY } from './types';
|
||||
|
||||
interface EditGlossaryDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
glossary: Glossary | null;
|
||||
onSave: (id: string, data: { name: string; terms: GlossaryTermInput[] }) => Promise<void>;
|
||||
isSaving: boolean;
|
||||
}
|
||||
|
||||
export function EditGlossaryDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
glossary,
|
||||
onSave,
|
||||
isSaving,
|
||||
}: EditGlossaryDialogProps) {
|
||||
const [name, setName] = useState('');
|
||||
const [terms, setTerms] = useState<GlossaryTermInput[]>([]);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const isInitialized = useRef(false);
|
||||
|
||||
if (glossary && !isInitialized.current) {
|
||||
setName(glossary.name);
|
||||
setTerms(glossary.terms.map(t => ({ source: t.source, target: t.target })));
|
||||
isInitialized.current = true;
|
||||
}
|
||||
|
||||
if (!open && isInitialized.current) {
|
||||
isInitialized.current = false;
|
||||
}
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!glossary || !name.trim()) return;
|
||||
|
||||
const validTerms = terms.filter(t => t.source.trim() && t.target.trim());
|
||||
|
||||
await onSave(glossary.id, {
|
||||
name: name.trim(),
|
||||
terms: validTerms,
|
||||
});
|
||||
}, [glossary, name, terms, onSave]);
|
||||
|
||||
const handleExport = useCallback(() => {
|
||||
if (!glossary) return;
|
||||
|
||||
const glossaryWithCurrentTerms: Glossary = {
|
||||
...glossary,
|
||||
name,
|
||||
terms: terms.map((t, i) => ({
|
||||
id: `temp-${i}`,
|
||||
source: t.source,
|
||||
target: t.target,
|
||||
created_at: null,
|
||||
})),
|
||||
};
|
||||
|
||||
exportGlossaryToCsv(glossaryWithCurrentTerms);
|
||||
}, [glossary, name, terms]);
|
||||
|
||||
const handleImportClick = useCallback(() => {
|
||||
fileInputRef.current?.click();
|
||||
}, []);
|
||||
|
||||
const { toast } = useToast();
|
||||
|
||||
const handleFileChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
const text = event.target?.result;
|
||||
if (typeof text === 'string') {
|
||||
const importedTerms = parseCsvToTerms(text);
|
||||
if (importedTerms.length > 0) {
|
||||
if (importedTerms.length > MAX_TERMS_PER_GLOSSARY) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Import failed',
|
||||
description: `CSV contains ${importedTerms.length} terms, but maximum is ${MAX_TERMS_PER_GLOSSARY}. Please reduce the number of terms.`,
|
||||
});
|
||||
e.target.value = '';
|
||||
return;
|
||||
}
|
||||
setTerms(importedTerms);
|
||||
toast({
|
||||
title: 'Import successful',
|
||||
description: `${importedTerms.length} terms imported successfully.`,
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Import failed',
|
||||
description: 'No valid terms found in CSV file.',
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
reader.onerror = () => {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Import failed',
|
||||
description: 'Failed to read CSV file.',
|
||||
});
|
||||
};
|
||||
reader.readAsText(file);
|
||||
|
||||
e.target.value = '';
|
||||
}, [toast]);
|
||||
|
||||
const validTermsCount = terms.filter(t => t.source.trim() && t.target.trim()).length;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Glossary</DialogTitle>
|
||||
<DialogDescription>
|
||||
Update the glossary name and term pairs.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="glossary-name">Glossary Name</Label>
|
||||
<Input
|
||||
id="glossary-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Enter glossary name..."
|
||||
disabled={isSaving}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Terms ({validTermsCount} valid)</Label>
|
||||
<TermEditor
|
||||
terms={terms}
|
||||
onChange={setTerms}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 pt-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleExport}
|
||||
disabled={isSaving || validTermsCount === 0}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<Download className="size-3.5" />
|
||||
Export CSV
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleImportClick}
|
||||
disabled={isSaving}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<Upload className="size-3.5" />
|
||||
Import CSV
|
||||
</Button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".csv"
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isSaving}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={isSaving || !name.trim()}
|
||||
>
|
||||
{isSaving ? 'Saving...' : 'Save Changes'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
83
frontend/src/app/dashboard/glossaries/GlossaryCard.tsx
Normal file
83
frontend/src/app/dashboard/glossaries/GlossaryCard.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
'use client';
|
||||
|
||||
import { memo, useCallback } from 'react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { BookText, Pencil, Trash2 } from 'lucide-react';
|
||||
import type { GlossaryListItem } from './types';
|
||||
|
||||
interface GlossaryCardProps {
|
||||
glossary: GlossaryListItem;
|
||||
onEdit: (id: string) => void;
|
||||
onDelete: (id: string, name: string) => void;
|
||||
isDeleting?: boolean;
|
||||
}
|
||||
|
||||
export const GlossaryCard = memo(function GlossaryCard({
|
||||
glossary,
|
||||
onEdit,
|
||||
onDelete,
|
||||
isDeleting = false,
|
||||
}: GlossaryCardProps) {
|
||||
const handleEdit = useCallback(() => {
|
||||
onEdit(glossary.id);
|
||||
}, [glossary.id, onEdit]);
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
onDelete(glossary.id, glossary.name);
|
||||
}, [glossary.id, glossary.name, onDelete]);
|
||||
|
||||
const formattedDate = new Date(glossary.created_at).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
|
||||
return (
|
||||
<Card className="group hover:border-border/80 transition-colors">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex items-start gap-3 min-w-0 flex-1">
|
||||
<div className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-accent/10">
|
||||
<BookText className="size-5 text-accent" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="font-medium text-foreground truncate">{glossary.name}</h3>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{glossary.terms_count} {glossary.terms_count === 1 ? 'term' : 'terms'}
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Created {formattedDate}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={handleEdit}
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
aria-label={`Edit ${glossary.name}`}
|
||||
>
|
||||
<Pencil className="size-3.5 text-muted-foreground hover:text-foreground" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={handleDelete}
|
||||
disabled={isDeleting}
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
aria-label={`Delete ${glossary.name}`}
|
||||
>
|
||||
<Trash2 className="size-3.5 text-muted-foreground hover:text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
});
|
||||
56
frontend/src/app/dashboard/glossaries/ProUpgradePrompt.tsx
Normal file
56
frontend/src/app/dashboard/glossaries/ProUpgradePrompt.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
'use client';
|
||||
|
||||
import { BookText, Sparkles } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import Link from 'next/link';
|
||||
|
||||
export function ProUpgradePrompt() {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[60vh] p-6">
|
||||
<Card className="max-w-md w-full border-border/50 bg-gradient-to-br from-card via-card to-accent/5">
|
||||
<CardHeader className="text-center pb-4">
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-br from-accent/20 to-accent/5">
|
||||
<BookText className="h-8 w-8 text-accent" />
|
||||
</div>
|
||||
<CardTitle className="text-2xl font-semibold">Glossaries</CardTitle>
|
||||
<CardDescription className="text-base">
|
||||
Customize your translations with custom terminology
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="text-center space-y-6">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Sparkles className="h-4 w-4 text-accent shrink-0" />
|
||||
<span>Create multiple glossaries</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Sparkles className="h-4 w-4 text-accent shrink-0" />
|
||||
<span>Define source→target term pairs</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Sparkles className="h-4 w-4 text-accent shrink-0" />
|
||||
<span>Import/export via CSV</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Sparkles className="h-4 w-4 text-accent shrink-0" />
|
||||
<span>Apply to LLM translations</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-2">
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Glossaries are a <span className="text-accent font-medium">Pro</span> feature.
|
||||
Upgrade to unlock custom terminology.
|
||||
</p>
|
||||
<Button asChild className="w-full bg-accent hover:bg-accent/90">
|
||||
<Link href="/pricing">
|
||||
Upgrade to Pro
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
120
frontend/src/app/dashboard/glossaries/TermEditor.tsx
Normal file
120
frontend/src/app/dashboard/glossaries/TermEditor.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
'use client';
|
||||
|
||||
import { memo, useCallback, useMemo } from 'react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ArrowRight, Plus, Trash2 } from 'lucide-react';
|
||||
import type { GlossaryTermInput, GlossaryTermInputWithId } from './types';
|
||||
import { MAX_TERMS_PER_GLOSSARY, generateTermId } from './types';
|
||||
|
||||
interface TermEditorProps {
|
||||
terms: GlossaryTermInput[];
|
||||
onChange: (terms: GlossaryTermInput[]) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
// Generate stable IDs for terms based on index and content hash
|
||||
function getTermKey(term: GlossaryTermInput, index: number): string {
|
||||
// Create a stable key from content to help React reconciliation
|
||||
const contentHash = `${term.source}-${term.target}`.slice(0, 50);
|
||||
return `term-${index}-${contentHash}`;
|
||||
}
|
||||
|
||||
export const TermEditor = memo(function TermEditor({
|
||||
terms,
|
||||
onChange,
|
||||
disabled = false,
|
||||
}: TermEditorProps) {
|
||||
// Generate stable keys for current terms
|
||||
const termKeys = useMemo(() => {
|
||||
return terms.map((term, index) => getTermKey(term, index));
|
||||
}, [terms]);
|
||||
|
||||
const addTerm = useCallback(() => {
|
||||
if (terms.length >= MAX_TERMS_PER_GLOSSARY) return;
|
||||
onChange([...terms, { source: '', target: '' }]);
|
||||
}, [terms, onChange]);
|
||||
|
||||
const removeTerm = useCallback((index: number) => {
|
||||
onChange(terms.filter((_, i) => i !== index));
|
||||
}, [terms, onChange]);
|
||||
|
||||
const updateTerm = useCallback((index: number, field: 'source' | 'target', value: string) => {
|
||||
const newTerms = [...terms];
|
||||
newTerms[index] = { ...newTerms[index], [field]: value };
|
||||
onChange(newTerms);
|
||||
}, [terms, onChange]);
|
||||
|
||||
const maxTermsReached = terms.length >= MAX_TERMS_PER_GLOSSARY;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="mb-2 grid grid-cols-[1fr_32px_1fr_36px] items-center gap-2 px-1">
|
||||
<span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Source Term
|
||||
</span>
|
||||
<span />
|
||||
<span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Target Translation
|
||||
</span>
|
||||
<span />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
{terms.map((term, index) => (
|
||||
<div
|
||||
key={termKeys[index]}
|
||||
className="group grid grid-cols-[1fr_32px_1fr_36px] items-center gap-2"
|
||||
>
|
||||
<Input
|
||||
value={term.source}
|
||||
onChange={(e) => updateTerm(index, 'source', e.target.value)}
|
||||
placeholder="Source term..."
|
||||
className="font-mono text-xs"
|
||||
aria-label={`Source term ${index + 1}`}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<div className="flex items-center justify-center">
|
||||
<ArrowRight className="size-3.5 text-muted-foreground" />
|
||||
</div>
|
||||
<Input
|
||||
value={term.target}
|
||||
onChange={(e) => updateTerm(index, 'target', e.target.value)}
|
||||
placeholder="Translation..."
|
||||
className="font-mono text-xs"
|
||||
aria-label={`Target translation ${index + 1}`}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => removeTerm(index)}
|
||||
disabled={disabled}
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
aria-label={`Remove term ${index + 1}`}
|
||||
>
|
||||
<Trash2 className="size-3.5 text-muted-foreground hover:text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={addTerm}
|
||||
disabled={disabled || maxTermsReached}
|
||||
className="mt-3 gap-1.5 border-dashed"
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
Add Term
|
||||
</Button>
|
||||
|
||||
{maxTermsReached && (
|
||||
<p className="text-xs text-amber-600">
|
||||
Maximum {MAX_TERMS_PER_GLOSSARY} terms per glossary reached.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
85
frontend/src/app/dashboard/glossaries/csvUtils.ts
Normal file
85
frontend/src/app/dashboard/glossaries/csvUtils.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import type { Glossary, GlossaryTermInput } from './types';
|
||||
|
||||
export function exportGlossaryToCsv(glossary: Glossary): void {
|
||||
const csvContent = generateCsvContent(glossary.terms.map(t => ({ source: t.source, target: t.target })));
|
||||
downloadCsv(csvContent, `${glossary.name.replace(/[^a-z0-9]/gi, '_')}.csv`);
|
||||
}
|
||||
|
||||
export function generateCsvContent(terms: GlossaryTermInput[]): string {
|
||||
const header = 'source,target';
|
||||
const rows = terms
|
||||
.filter(t => t.source.trim() && t.target.trim())
|
||||
.map(t => `${escapeCsvField(t.source)},${escapeCsvField(t.target)}`);
|
||||
return [header, ...rows].join('\n');
|
||||
}
|
||||
|
||||
export function downloadCsv(content: string, filename: string): void {
|
||||
const blob = new Blob([content], { type: 'text/csv;charset=utf-8;' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
export function parseCsvToTerms(csvText: string): GlossaryTermInput[] {
|
||||
const lines = csvText.split(/\r?\n/).filter(line => line.trim());
|
||||
if (lines.length === 0) return [];
|
||||
|
||||
const firstLine = lines[0].toLowerCase();
|
||||
const hasHeader = firstLine.includes('source') && firstLine.includes('target');
|
||||
|
||||
const dataLines = hasHeader ? lines.slice(1) : lines;
|
||||
|
||||
const terms: GlossaryTermInput[] = [];
|
||||
|
||||
for (const line of dataLines) {
|
||||
const parsed = parseCsvLine(line);
|
||||
if (parsed.length >= 2) {
|
||||
const source = parsed[0].trim();
|
||||
const target = parsed[1].trim();
|
||||
if (source && target) {
|
||||
terms.push({ source, target });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return terms;
|
||||
}
|
||||
|
||||
function parseCsvLine(line: string): string[] {
|
||||
const result: string[] = [];
|
||||
let current = '';
|
||||
let inQuotes = false;
|
||||
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const char = line[i];
|
||||
|
||||
if (char === '"') {
|
||||
if (inQuotes && line[i + 1] === '"') {
|
||||
current += '"';
|
||||
i++;
|
||||
} else {
|
||||
inQuotes = !inQuotes;
|
||||
}
|
||||
} else if (char === ',' && !inQuotes) {
|
||||
result.push(current);
|
||||
current = '';
|
||||
} else {
|
||||
current += char;
|
||||
}
|
||||
}
|
||||
|
||||
result.push(current);
|
||||
return result;
|
||||
}
|
||||
|
||||
function escapeCsvField(field: string): string {
|
||||
if (field.includes(',') || field.includes('"') || field.includes('\n')) {
|
||||
return `"${field.replace(/"/g, '""')}"`;
|
||||
}
|
||||
return field;
|
||||
}
|
||||
242
frontend/src/app/dashboard/glossaries/page.tsx
Normal file
242
frontend/src/app/dashboard/glossaries/page.tsx
Normal file
@@ -0,0 +1,242 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { BookText, Plus } from 'lucide-react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { useUser } from '@/app/dashboard/useUser';
|
||||
import { useGlossaries, useGlossary } from './useGlossaries';
|
||||
import type { Glossary, GlossaryTermInput, GlossaryListItem } from './types';
|
||||
import { ProUpgradePrompt } from './ProUpgradePrompt';
|
||||
import { GlossaryCard } from './GlossaryCard';
|
||||
import { CreateGlossaryDialog } from './CreateGlossaryDialog';
|
||||
import { EditGlossaryDialog } from './EditGlossaryDialog';
|
||||
import { DeleteGlossaryDialog } from './DeleteGlossaryDialog';
|
||||
import { useToast } from '@/components/ui/toast';
|
||||
|
||||
export default function GlossariesPage() {
|
||||
const { data: user, isLoading: isLoadingUser } = useUser();
|
||||
const {
|
||||
glossaries,
|
||||
total,
|
||||
isLoading: isLoadingGlossaries,
|
||||
isCreating,
|
||||
isUpdating,
|
||||
isDeleting,
|
||||
createGlossary,
|
||||
updateGlossary,
|
||||
deleteGlossary,
|
||||
} = useGlossaries();
|
||||
const { toast } = useToast();
|
||||
|
||||
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||||
const [editDialogOpen, setEditDialogOpen] = useState(false);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [selectedGlossary, setSelectedGlossary] = useState<GlossaryListItem | null>(null);
|
||||
const [glossaryToEdit, setGlossaryToEdit] = useState<Glossary | null>(null);
|
||||
const [glossaryToDelete, setGlossaryToDelete] = useState<{ id: string; name: string } | null>(null);
|
||||
|
||||
const { glossary: fullGlossary, isLoading: isLoadingGlossaryDetail } = useGlossary(
|
||||
selectedGlossary?.id || null
|
||||
);
|
||||
|
||||
const isPro = user?.tier === 'pro';
|
||||
const isLoading = isLoadingUser || isLoadingGlossaries;
|
||||
|
||||
const handleEditClick = (id: string) => {
|
||||
const glossary = glossaries.find((g: GlossaryListItem) => g.id === id);
|
||||
if (glossary) {
|
||||
setSelectedGlossary(glossary);
|
||||
setEditDialogOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteClick = (id: string, name: string) => {
|
||||
setGlossaryToDelete({ id, name });
|
||||
setDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleCreateGlossary = async (data: { name: string; terms: GlossaryTermInput[] }) => {
|
||||
try {
|
||||
await createGlossary(data);
|
||||
setCreateDialogOpen(false);
|
||||
toast({
|
||||
title: 'Glossary created',
|
||||
description: `"${data.name}" has been created successfully.`,
|
||||
});
|
||||
} catch (error) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Error',
|
||||
description: 'Failed to create glossary. Please try again.',
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveGlossary = async (id: string, data: { name: string; terms: GlossaryTermInput[] }) => {
|
||||
try {
|
||||
await updateGlossary(id, data);
|
||||
setEditDialogOpen(false);
|
||||
setSelectedGlossary(null);
|
||||
toast({
|
||||
title: 'Glossary updated',
|
||||
description: `"${data.name}" has been updated successfully.`,
|
||||
});
|
||||
} catch (error) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Error',
|
||||
description: 'Failed to update glossary. Please try again.',
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteConfirm = async () => {
|
||||
if (!glossaryToDelete) return;
|
||||
try {
|
||||
await deleteGlossary(glossaryToDelete.id);
|
||||
setDeleteDialogOpen(false);
|
||||
setGlossaryToDelete(null);
|
||||
toast({
|
||||
title: 'Glossary deleted',
|
||||
description: 'The glossary has been deleted successfully.',
|
||||
});
|
||||
} catch (error) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Error',
|
||||
description: 'Failed to delete glossary. Please try again.',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[60vh]">
|
||||
<div className="text-center space-y-4">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-4 border-muted border-t-foreground mx-auto"></div>
|
||||
<p className="text-sm text-muted-foreground">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isPro) {
|
||||
return <ProUpgradePrompt />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Glossaries</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Manage custom terminology for your LLM translations.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex size-8 items-center justify-center rounded-lg bg-accent/10">
|
||||
<BookText className="size-4 text-accent" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-base">Your Glossaries</CardTitle>
|
||||
<CardDescription>Create and manage glossaries for consistent translations</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium">
|
||||
{total} glossarie{total !== 1 ? 's' : ''}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Define term pairs to customize your LLM translations
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => setCreateDialogOpen(true)}
|
||||
disabled={isCreating}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
Create New Glossary
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{glossaries.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<BookText className="size-12 mx-auto text-muted-foreground/50 mb-4" />
|
||||
<p className="text-muted-foreground">No glossaries yet</p>
|
||||
<p className="text-sm text-muted-foreground/80">
|
||||
Create your first glossary to customize translations
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{glossaries.map((glossary: GlossaryListItem) => (
|
||||
<GlossaryCard
|
||||
key={glossary.id}
|
||||
glossary={glossary}
|
||||
onEdit={handleEditClick}
|
||||
onDelete={handleDeleteClick}
|
||||
isDeleting={isDeleting && glossaryToDelete?.id === glossary.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Separator />
|
||||
|
||||
<Card className="border-border/50">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm">About Glossaries</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-sm text-muted-foreground space-y-2">
|
||||
<p>
|
||||
Glossaries let you define custom terminology for your translations. When using LLM translation modes, your terms will be applied to ensure consistent translations.
|
||||
</p>
|
||||
<p>
|
||||
<strong>Format:</strong> Each term has a source (original) and target (translation) pair.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<CreateGlossaryDialog
|
||||
open={createDialogOpen}
|
||||
onOpenChange={setCreateDialogOpen}
|
||||
onCreate={handleCreateGlossary}
|
||||
isCreating={isCreating}
|
||||
/>
|
||||
|
||||
{editDialogOpen && (fullGlossary || !isLoadingGlossaryDetail) && (
|
||||
<EditGlossaryDialog
|
||||
open={editDialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
setEditDialogOpen(open);
|
||||
if (!open) setSelectedGlossary(null);
|
||||
}}
|
||||
glossary={fullGlossary}
|
||||
onSave={handleSaveGlossary}
|
||||
isSaving={isUpdating}
|
||||
/>
|
||||
)}
|
||||
|
||||
<DeleteGlossaryDialog
|
||||
open={deleteDialogOpen}
|
||||
onOpenChange={setDeleteDialogOpen}
|
||||
onConfirm={handleDeleteConfirm}
|
||||
isDeleting={isDeleting}
|
||||
glossaryName={glossaryToDelete?.name}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
73
frontend/src/app/dashboard/glossaries/types.ts
Normal file
73
frontend/src/app/dashboard/glossaries/types.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
export interface GlossaryTerm {
|
||||
id: string;
|
||||
source: string;
|
||||
target: string;
|
||||
created_at: string | null;
|
||||
}
|
||||
|
||||
export interface Glossary {
|
||||
id: string;
|
||||
name: string;
|
||||
terms: GlossaryTerm[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface GlossaryListItem {
|
||||
id: string;
|
||||
name: string;
|
||||
terms_count: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface GlossaryListResponse {
|
||||
data: GlossaryListItem[];
|
||||
meta: {
|
||||
total: number;
|
||||
page: number;
|
||||
per_page: number;
|
||||
total_pages: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface GlossaryDetailResponse {
|
||||
data: Glossary;
|
||||
meta: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface GlossaryCreateResponse {
|
||||
data: Glossary;
|
||||
meta: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface GlossaryUpdateResponse {
|
||||
data: Glossary;
|
||||
meta: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface GlossaryTermInput {
|
||||
source: string;
|
||||
target: string;
|
||||
}
|
||||
|
||||
export interface GlossaryTermInputWithId extends GlossaryTermInput {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface GlossaryCreateInput {
|
||||
name: string;
|
||||
terms?: GlossaryTermInput[];
|
||||
}
|
||||
|
||||
export interface GlossaryUpdateInput {
|
||||
name?: string;
|
||||
terms?: GlossaryTermInput[];
|
||||
}
|
||||
|
||||
export const MAX_TERMS_PER_GLOSSARY = 500;
|
||||
|
||||
// Generate unique IDs for React keys
|
||||
let idCounter = 0;
|
||||
export function generateTermId(): string {
|
||||
return `term-${Date.now()}-${++idCounter}`;
|
||||
}
|
||||
180
frontend/src/app/dashboard/glossaries/useGlossaries.ts
Normal file
180
frontend/src/app/dashboard/glossaries/useGlossaries.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
'use client';
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { apiClient, ApiClientError } from '@/lib/apiClient';
|
||||
import type { ApiResponse } from '@/lib/types';
|
||||
import type {
|
||||
GlossaryListItem,
|
||||
Glossary,
|
||||
GlossaryListResponse,
|
||||
GlossaryDetailResponse,
|
||||
GlossaryCreateInput,
|
||||
GlossaryUpdateInput,
|
||||
} from './types';
|
||||
|
||||
const GLOSSARIES_QUERY_KEY = ['glossaries'];
|
||||
|
||||
export type GlossaryErrorCode =
|
||||
| 'PRO_FEATURE_REQUIRED'
|
||||
| 'TERMS_LIMIT_EXCEEDED'
|
||||
| 'GLOSSARY_NOT_FOUND'
|
||||
| 'INVALID_GLOSSARY_ID'
|
||||
| 'UNAUTHORIZED';
|
||||
|
||||
export interface GlossaryError {
|
||||
status: number;
|
||||
code: GlossaryErrorCode;
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface UseGlossariesOptions {
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
}
|
||||
|
||||
export function useGlossaries(options: UseGlossariesOptions = {}) {
|
||||
const { page = 1, perPage = 50 } = options;
|
||||
const queryClient = useQueryClient();
|
||||
const router = useRouter();
|
||||
|
||||
const {
|
||||
data: glossariesData,
|
||||
isLoading,
|
||||
error,
|
||||
} = useQuery<GlossaryListResponse, ApiClientError>({
|
||||
queryKey: [...GLOSSARIES_QUERY_KEY, page, perPage],
|
||||
queryFn: async () => {
|
||||
const response = await apiClient.get<GlossaryListResponse>(`/api/v1/glossaries?page=${page}&per_page=${perPage}`);
|
||||
return response.data;
|
||||
},
|
||||
retry: (failureCount, err) => {
|
||||
if (err.status === 403 || err.status === 401) return false;
|
||||
return failureCount < 2;
|
||||
},
|
||||
});
|
||||
|
||||
// Handle 401 redirect
|
||||
if (error?.status === 401) {
|
||||
router.push('/auth/login');
|
||||
}
|
||||
|
||||
const glossaries = glossariesData?.data ?? [];
|
||||
const total = glossariesData?.meta?.total ?? 0;
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: async (input: GlossaryCreateInput): Promise<Glossary> => {
|
||||
const response = await apiClient.post<GlossaryDetailResponse>('/api/v1/glossaries', input);
|
||||
return response.data.data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: GLOSSARIES_QUERY_KEY });
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: async ({ id, data }: { id: string; data: GlossaryUpdateInput }): Promise<Glossary> => {
|
||||
const response = await apiClient.patch<GlossaryDetailResponse>(`/api/v1/glossaries/${id}`, data);
|
||||
return response.data.data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: GLOSSARIES_QUERY_KEY });
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: async (id: string): Promise<void> => {
|
||||
await apiClient.delete(`/api/v1/glossaries/${id}`);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: GLOSSARIES_QUERY_KEY });
|
||||
},
|
||||
});
|
||||
|
||||
const createGlossary = async (input: GlossaryCreateInput) => {
|
||||
return createMutation.mutateAsync(input);
|
||||
};
|
||||
|
||||
const updateGlossary = async (id: string, data: GlossaryUpdateInput) => {
|
||||
return updateMutation.mutateAsync({ id, data });
|
||||
};
|
||||
|
||||
const deleteGlossary = async (id: string) => {
|
||||
return deleteMutation.mutateAsync(id);
|
||||
};
|
||||
|
||||
const parseError = (error: Error | null): GlossaryError | null => {
|
||||
if (!error) return null;
|
||||
|
||||
const apiError = error as ApiClientError;
|
||||
const status = apiError.status || 500;
|
||||
const code = apiError.code as GlossaryErrorCode | string;
|
||||
const message = apiError.message;
|
||||
|
||||
if (status === 401) {
|
||||
return { status: 401, code: 'UNAUTHORIZED', message: message || 'Session expired' };
|
||||
}
|
||||
if (status === 403 && code === 'PRO_FEATURE_REQUIRED') {
|
||||
return { status: 403, code: 'PRO_FEATURE_REQUIRED', message: message || 'Pro feature required' };
|
||||
}
|
||||
if (status === 400 && code === 'TERMS_LIMIT_EXCEEDED') {
|
||||
return { status: 400, code: 'TERMS_LIMIT_EXCEEDED', message: message || 'Maximum 500 terms per glossary' };
|
||||
}
|
||||
if (status === 404 && code === 'GLOSSARY_NOT_FOUND') {
|
||||
return { status: 404, code: 'GLOSSARY_NOT_FOUND', message: message || 'Glossary not found' };
|
||||
}
|
||||
if (status === 400 && code === 'INVALID_GLOSSARY_ID') {
|
||||
return { status: 400, code: 'INVALID_GLOSSARY_ID', message: message || 'Invalid glossary ID' };
|
||||
}
|
||||
|
||||
return { status, code: code as GlossaryErrorCode, message };
|
||||
};
|
||||
|
||||
return {
|
||||
glossaries,
|
||||
total,
|
||||
isLoading,
|
||||
error,
|
||||
errorDetails: parseError(error),
|
||||
isCreating: createMutation.isPending,
|
||||
isUpdating: updateMutation.isPending,
|
||||
isDeleting: deleteMutation.isPending,
|
||||
createGlossary,
|
||||
updateGlossary,
|
||||
deleteGlossary,
|
||||
createError: createMutation.error,
|
||||
updateError: updateMutation.error,
|
||||
deleteError: deleteMutation.error,
|
||||
parseCreateError: () => parseError(createMutation.error),
|
||||
parseUpdateError: () => parseError(updateMutation.error),
|
||||
parseDeleteError: () => parseError(deleteMutation.error),
|
||||
};
|
||||
}
|
||||
|
||||
export function useGlossary(id: string | null) {
|
||||
const {
|
||||
data,
|
||||
isLoading,
|
||||
error,
|
||||
} = useQuery<GlossaryDetailResponse, ApiClientError>({
|
||||
queryKey: [...GLOSSARIES_QUERY_KEY, id],
|
||||
queryFn: async () => {
|
||||
if (!id) throw new Error('Glossary ID is required');
|
||||
const response = await apiClient.get<GlossaryDetailResponse>(`/api/v1/glossaries/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
enabled: !!id,
|
||||
retry: (failureCount, err) => {
|
||||
if (err.status === 403 || err.status === 404) return false;
|
||||
return failureCount < 2;
|
||||
},
|
||||
});
|
||||
|
||||
const glossary = data?.data ?? null;
|
||||
|
||||
return {
|
||||
glossary,
|
||||
isLoading,
|
||||
error,
|
||||
};
|
||||
}
|
||||
10
frontend/src/app/dashboard/layout.tsx
Normal file
10
frontend/src/app/dashboard/layout.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { DashboardLayoutClient } from './DashboardLayoutClient';
|
||||
|
||||
export default function DashboardLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
// Auth check is done client-side in DashboardLayoutClient
|
||||
return <DashboardLayoutClient>{children}</DashboardLayoutClient>;
|
||||
}
|
||||
@@ -1,615 +1,102 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { 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>
|
||||
);
|
||||
}
|
||||
|
||||
51
frontend/src/app/dashboard/translate/FileDropZone.tsx
Normal file
51
frontend/src/app/dashboard/translate/FileDropZone.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
'use client';
|
||||
|
||||
import { useRef } from 'react';
|
||||
import { Upload } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { UseFileUploadReturn } from './types';
|
||||
|
||||
interface FileDropZoneProps {
|
||||
upload: UseFileUploadReturn;
|
||||
}
|
||||
|
||||
export function FileDropZone({ upload }: FileDropZoneProps) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleClick = () => {
|
||||
inputRef.current?.click();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'relative flex flex-col items-center justify-center gap-3 rounded-lg border-2 border-dashed px-6 py-10 transition-colors cursor-pointer',
|
||||
upload.isDragOver
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-border bg-muted/30 hover:border-muted-foreground/30'
|
||||
)}
|
||||
onDragOver={upload.handleDragOver}
|
||||
onDragLeave={upload.handleDragLeave}
|
||||
onDrop={upload.handleDrop}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<div className="flex size-12 items-center justify-center rounded-xl bg-secondary">
|
||||
<Upload className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
Drag & drop your .xlsx, .docx, or .pptx file here
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">or click to browse</p>
|
||||
</div>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept=".xlsx,.docx,.pptx"
|
||||
className="hidden"
|
||||
onChange={upload.handleFileSelect}
|
||||
aria-label="Upload file"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
53
frontend/src/app/dashboard/translate/FilePreview.tsx
Normal file
53
frontend/src/app/dashboard/translate/FilePreview.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
'use client';
|
||||
|
||||
import { FileSpreadsheet, FileText, Presentation, X } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
const FILE_ICONS: Record<string, React.ElementType> = {
|
||||
xlsx: FileSpreadsheet,
|
||||
docx: FileText,
|
||||
pptx: Presentation,
|
||||
};
|
||||
|
||||
interface FilePreviewProps {
|
||||
file: File;
|
||||
onRemove: () => void;
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
export function FilePreview({ file, onRemove }: FilePreviewProps) {
|
||||
const ext = file.name.split('.').pop()?.toLowerCase() || '';
|
||||
const FileIcon = FILE_ICONS[ext] || FileText;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-10 items-center justify-center rounded-lg bg-secondary">
|
||||
<FileIcon className="size-5 text-foreground" />
|
||||
</div>
|
||||
<div className="flex flex-col min-w-0 flex-1">
|
||||
<span className="text-sm font-medium text-foreground truncate">
|
||||
{file.name}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatFileSize(file.size)} · .{ext}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="ml-2 text-muted-foreground hover:text-foreground shrink-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove();
|
||||
}}
|
||||
>
|
||||
<X className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
104
frontend/src/app/dashboard/translate/LanguageSelector.tsx
Normal file
104
frontend/src/app/dashboard/translate/LanguageSelector.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
'use client';
|
||||
|
||||
import { ArrowRight, Loader2, AlertCircle } from 'lucide-react';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import type { Language } from './types';
|
||||
|
||||
interface LanguageSelectorProps {
|
||||
sourceLang: string;
|
||||
targetLang: string;
|
||||
languages: Language[];
|
||||
isLoading?: boolean;
|
||||
error?: string | null;
|
||||
onSourceChange: (value: string) => void;
|
||||
onTargetChange: (value: string) => void;
|
||||
}
|
||||
|
||||
export function LanguageSelector({
|
||||
sourceLang,
|
||||
targetLang,
|
||||
languages,
|
||||
isLoading,
|
||||
error,
|
||||
onSourceChange,
|
||||
onTargetChange,
|
||||
}: LanguageSelectorProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 rounded-md bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
||||
<AlertCircle className="size-3.5" />
|
||||
<span>Failed to load languages: {error}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex flex-1 flex-col gap-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
Source Language
|
||||
</label>
|
||||
<Select
|
||||
value={sourceLang}
|
||||
onValueChange={onSourceChange}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
<span className="text-muted-foreground">Loading...</span>
|
||||
</div>
|
||||
) : (
|
||||
<SelectValue placeholder="Auto-detect" />
|
||||
)}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto">Auto-detect</SelectItem>
|
||||
{languages.map((lang) => (
|
||||
<SelectItem key={lang.code} value={lang.code}>
|
||||
{lang.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<ArrowRight className="mt-5 size-4 shrink-0 text-muted-foreground" />
|
||||
|
||||
<div className="flex flex-1 flex-col gap-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
Target Language
|
||||
</label>
|
||||
<Select
|
||||
value={targetLang}
|
||||
onValueChange={onTargetChange}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
<span className="text-muted-foreground">Loading...</span>
|
||||
</div>
|
||||
) : (
|
||||
<SelectValue placeholder="Select language" />
|
||||
)}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{languages.map((lang) => (
|
||||
<SelectItem key={lang.code} value={lang.code}>
|
||||
{lang.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
112
frontend/src/app/dashboard/translate/ProviderSelector.tsx
Normal file
112
frontend/src/app/dashboard/translate/ProviderSelector.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
'use client';
|
||||
|
||||
import { Loader2, CheckCircle2, Lock } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { Provider, AvailableProvider } from './types';
|
||||
|
||||
interface ProviderSelectorProps {
|
||||
provider: Provider | null;
|
||||
onProviderChange: (provider: Provider) => void;
|
||||
availableProviders: AvailableProvider[];
|
||||
isLoadingProviders: boolean;
|
||||
isPro: boolean;
|
||||
}
|
||||
|
||||
export function ProviderSelector({
|
||||
provider,
|
||||
onProviderChange,
|
||||
availableProviders,
|
||||
isLoadingProviders,
|
||||
isPro,
|
||||
}: ProviderSelectorProps) {
|
||||
if (isLoadingProviders) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
<span>Loading providers…</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (availableProviders.length === 0) {
|
||||
return (
|
||||
<p className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-700">
|
||||
No providers are configured. Ask your administrator to enable at least one in the
|
||||
admin settings.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
const classicProviders = availableProviders.filter((p) => p.mode === 'classic');
|
||||
const llmProviders = availableProviders.filter((p) => p.mode === 'llm');
|
||||
|
||||
const renderCard = (p: AvailableProvider, locked: boolean) => {
|
||||
const isSelected = provider === p.id;
|
||||
return (
|
||||
<button
|
||||
key={p.id}
|
||||
type="button"
|
||||
disabled={locked}
|
||||
onClick={() => !locked && onProviderChange(p.id)}
|
||||
className={cn(
|
||||
'flex w-full items-center justify-between rounded-lg border px-3 py-2.5 text-left text-sm transition-colors',
|
||||
isSelected
|
||||
? 'border-primary bg-primary/5 text-primary'
|
||||
: locked
|
||||
? 'cursor-not-allowed border-border/40 bg-muted/30 text-muted-foreground'
|
||||
: 'border-border/60 bg-background hover:border-primary/40 hover:bg-muted/40'
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium leading-tight">{p.label}</span>
|
||||
<span className="text-xs text-muted-foreground">{p.description}</span>
|
||||
{p.mode === 'llm' && p.model && (
|
||||
<span className="mt-0.5 text-[10px] font-mono text-muted-foreground/80" title="Modèle configuré par l'admin">
|
||||
Modèle : {p.model}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{locked ? (
|
||||
<Lock className="size-3.5 shrink-0 text-muted-foreground/60" />
|
||||
) : isSelected ? (
|
||||
<CheckCircle2 className="size-4 shrink-0 text-primary" />
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs font-medium text-muted-foreground">Translation Provider</p>
|
||||
|
||||
{/* Classic providers — available to everyone */}
|
||||
{classicProviders.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
{classicProviders.map((p) => renderCard(p, false))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* LLM providers — Pro only */}
|
||||
{llmProviders.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-px flex-1 bg-border/50" />
|
||||
<span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||
LLM · Context-Aware {!isPro && '· Pro'}
|
||||
</span>
|
||||
<div className="h-px flex-1 bg-border/50" />
|
||||
</div>
|
||||
{llmProviders.map((p) => renderCard(p, !isPro))}
|
||||
{!isPro && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<a href="/pricing" className="text-primary hover:underline">
|
||||
Upgrade to Pro
|
||||
</a>{' '}
|
||||
to use LLM-powered translation.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
153
frontend/src/app/dashboard/translate/TranslationComplete.tsx
Normal file
153
frontend/src/app/dashboard/translate/TranslationComplete.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { CheckCircle, Download, Plus, Loader2 } from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useNotification } from '@/components/ui/notification';
|
||||
|
||||
interface TranslationCompleteProps {
|
||||
jobId: string;
|
||||
fileName: string | null;
|
||||
onNewTranslation: () => void;
|
||||
}
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
|
||||
|
||||
export function TranslationComplete({
|
||||
jobId,
|
||||
fileName,
|
||||
onNewTranslation,
|
||||
}: TranslationCompleteProps) {
|
||||
const [isDownloading, setIsDownloading] = useState(false);
|
||||
const { success, error } = useNotification();
|
||||
const blobUrlRef = useRef<string | null>(null);
|
||||
|
||||
const handleDownload = async () => {
|
||||
setIsDownloading(true);
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const headers: Record<string, string> = {};
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE}/api/v1/download/${jobId}`, { headers });
|
||||
|
||||
if (!response.ok) {
|
||||
let errorMessage = 'Download failed';
|
||||
try {
|
||||
const errorData = await response.json();
|
||||
errorMessage = errorData.message || errorData.error || errorMessage;
|
||||
} catch {
|
||||
// Response not JSON
|
||||
}
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
const contentDisposition = response.headers.get('Content-Disposition');
|
||||
let downloadFilename = 'translated_document';
|
||||
|
||||
if (contentDisposition) {
|
||||
const filenameMatch = contentDisposition.match(/filename\*?=['"]?(?:UTF-\d['"]*)?([^;\r\n"']+)/i);
|
||||
if (filenameMatch && filenameMatch[1]) {
|
||||
downloadFilename = filenameMatch[1];
|
||||
}
|
||||
} else if (fileName) {
|
||||
const ext = fileName.split('.').pop() || '';
|
||||
const baseName = fileName.replace(/\.[^.]+$/, '');
|
||||
downloadFilename = `${baseName}_translated.${ext}`;
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
blobUrlRef.current = url;
|
||||
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = downloadFilename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
|
||||
setTimeout(() => {
|
||||
if (blobUrlRef.current) {
|
||||
URL.revokeObjectURL(blobUrlRef.current);
|
||||
blobUrlRef.current = null;
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
success({
|
||||
title: 'Download Complete',
|
||||
description: `${downloadFilename} has been downloaded successfully.`,
|
||||
});
|
||||
} catch (err) {
|
||||
error({
|
||||
title: 'Download Failed',
|
||||
description: err instanceof Error ? err.message : 'Failed to download the translated file.',
|
||||
});
|
||||
} finally {
|
||||
setIsDownloading(false);
|
||||
setTimeout(() => {
|
||||
if (blobUrlRef.current) {
|
||||
URL.revokeObjectURL(blobUrlRef.current);
|
||||
blobUrlRef.current = null;
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (blobUrlRef.current) {
|
||||
URL.revokeObjectURL(blobUrlRef.current);
|
||||
blobUrlRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Card className="border-success/40 bg-gradient-to-br from-success/10 to-success/5 overflow-hidden">
|
||||
<CardContent className="p-6 text-center">
|
||||
<div className="w-14 h-14 mx-auto mb-4 rounded-full bg-success/20 flex items-center justify-center">
|
||||
<CheckCircle className="w-8 h-8 text-success" />
|
||||
</div>
|
||||
|
||||
<h3 className="text-lg font-semibold mb-2">Translation Complete!</h3>
|
||||
<p className="text-sm text-muted-foreground mb-5">
|
||||
{fileName ? `"${fileName}" has been translated successfully.` : 'Your document has been translated successfully.'}
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-3 justify-center">
|
||||
<Button
|
||||
onClick={handleDownload}
|
||||
disabled={isDownloading}
|
||||
className="gap-2"
|
||||
>
|
||||
{isDownloading ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Downloading...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download className="w-4 h-4" />
|
||||
Download Translated File
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onNewTranslation}
|
||||
className="gap-2"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
New Translation
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
'use client';
|
||||
|
||||
import { Lock } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import type { TranslationMode } from './types';
|
||||
|
||||
interface TranslationModeToggleProps {
|
||||
mode: TranslationMode;
|
||||
onModeChange: (mode: TranslationMode) => void;
|
||||
isPro: boolean;
|
||||
}
|
||||
|
||||
export function TranslationModeToggle({
|
||||
mode,
|
||||
onModeChange,
|
||||
isPro,
|
||||
}: TranslationModeToggleProps) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
Translation Mode
|
||||
</label>
|
||||
<div className="flex rounded-lg border border-border bg-muted p-1">
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex-1 rounded-md px-4 py-2 text-sm font-medium transition-all',
|
||||
mode === 'classic'
|
||||
? 'bg-card text-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
onClick={() => onModeChange('classic')}
|
||||
>
|
||||
Classic
|
||||
<span className="ml-1.5 text-xs text-muted-foreground">
|
||||
Fast
|
||||
</span>
|
||||
</button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex-1 rounded-md px-4 py-2 text-sm font-medium transition-all relative',
|
||||
mode === 'llm'
|
||||
? 'bg-card text-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
!isPro && 'cursor-not-allowed opacity-60'
|
||||
)}
|
||||
onClick={() => isPro && onModeChange('llm')}
|
||||
disabled={!isPro}
|
||||
>
|
||||
Pro LLM
|
||||
<span className="ml-1.5 text-xs text-muted-foreground">
|
||||
Context-Aware
|
||||
</span>
|
||||
{!isPro && (
|
||||
<Lock className="absolute right-2 top-1/2 -translate-y-1/2 size-3 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
{!isPro && (
|
||||
<TooltipContent side="top">
|
||||
<p>Upgrade to Pro for LLM translation</p>
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
</div>
|
||||
{!isPro && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<a href="/pricing" className="text-primary hover:underline">
|
||||
Upgrade to Pro
|
||||
</a>{' '}
|
||||
for LLM-powered translations
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
109
frontend/src/app/dashboard/translate/TranslationProgress.tsx
Normal file
109
frontend/src/app/dashboard/translate/TranslationProgress.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { AlertTriangle, Loader2, Clock, WifiOff } from 'lucide-react';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
|
||||
interface TranslationProgressProps {
|
||||
progress: number;
|
||||
currentStep: string;
|
||||
estimatedRemaining: number | null;
|
||||
error: string | null;
|
||||
isPolling?: boolean;
|
||||
isUploading?: boolean;
|
||||
isCompleted?: boolean;
|
||||
}
|
||||
|
||||
function formatTimeRemaining(seconds: number | null): string {
|
||||
if (seconds === null || seconds <= 0) return '';
|
||||
if (seconds < 60) return `${seconds}s remaining`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = seconds % 60;
|
||||
if (remainingSeconds === 0) return `${minutes} min remaining`;
|
||||
return `${minutes}m ${remainingSeconds}s remaining`;
|
||||
}
|
||||
|
||||
export function TranslationProgress({
|
||||
progress,
|
||||
currentStep,
|
||||
estimatedRemaining,
|
||||
error,
|
||||
isPolling = true,
|
||||
isUploading = false,
|
||||
isCompleted = false,
|
||||
}: TranslationProgressProps) {
|
||||
// Disable CSS transition on the very first render so that when progress
|
||||
// resets from a previous job's 100% → 0%, there is no visible backward sweep.
|
||||
const [animate, setAnimate] = useState(false);
|
||||
const prevProgressRef = useRef(progress);
|
||||
|
||||
useEffect(() => {
|
||||
if (progress > 0) {
|
||||
setAnimate(true);
|
||||
} else if (progress === 0) {
|
||||
// Momentarily cut the transition to snap to 0, then re-enable.
|
||||
setAnimate(false);
|
||||
const t = setTimeout(() => setAnimate(true), 50);
|
||||
return () => clearTimeout(t);
|
||||
}
|
||||
prevProgressRef.current = progress;
|
||||
}, [progress]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div
|
||||
className="rounded-lg bg-destructive/10 border border-destructive/30 p-4"
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="h-5 w-5 text-destructive flex-shrink-0 mt-0.5" aria-hidden="true" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-destructive mb-1">Translation Failed</p>
|
||||
<p className="text-sm text-destructive/80">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const timeRemaining = formatTimeRemaining(estimatedRemaining);
|
||||
// Only show "Connection lost" when polling was active and then stopped —
|
||||
// never during the initial upload phase.
|
||||
const showConnectionLost = !isPolling && !isCompleted && !isUploading;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground flex items-center gap-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
|
||||
{currentStep || 'Processing...'}
|
||||
</span>
|
||||
<span className="text-primary font-medium tabular-nums" aria-live="polite">
|
||||
{Math.round(progress)}%
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={progress}
|
||||
animate={animate}
|
||||
className="h-2"
|
||||
aria-label="Translation progress"
|
||||
aria-valuenow={Math.round(progress)}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
/>
|
||||
{showConnectionLost && (
|
||||
<div className="flex items-center gap-2 text-xs text-amber-600">
|
||||
<WifiOff className="h-3 w-3" aria-hidden="true" />
|
||||
<span>Connection lost. Retrying...</span>
|
||||
</div>
|
||||
)}
|
||||
{timeRemaining && (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Clock className="h-3 w-3" aria-hidden="true" />
|
||||
<span>{timeRemaining}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
200
frontend/src/app/dashboard/translate/page.tsx
Normal file
200
frontend/src/app/dashboard/translate/page.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { Languages, ShieldCheck, Clock, ArrowRight, RotateCcw, Loader2 } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { FileDropZone } from './FileDropZone';
|
||||
import { FilePreview } from './FilePreview';
|
||||
import { useFileUpload } from './useFileUpload';
|
||||
import { useTranslationConfig } from './useTranslationConfig';
|
||||
import { useTranslationSubmit } from './useTranslationSubmit';
|
||||
import { LanguageSelector } from './LanguageSelector';
|
||||
import { ProviderSelector } from './ProviderSelector';
|
||||
import { TranslationProgress } from './TranslationProgress';
|
||||
import { TranslationComplete } from './TranslationComplete';
|
||||
import { useNotification } from '@/components/ui/notification';
|
||||
|
||||
export default function TranslatePage() {
|
||||
const upload = useFileUpload();
|
||||
const config = useTranslationConfig(!!upload.file);
|
||||
const submit = useTranslationSubmit();
|
||||
const { error: showError } = useNotification();
|
||||
const lastErrorRef = useRef<string | null>(null);
|
||||
|
||||
const handleTranslate = async () => {
|
||||
if (!upload.file || !config.isConfigValid) return;
|
||||
await submit.submitTranslation(upload.file, config.getConfig());
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (submit.error && submit.error !== lastErrorRef.current) {
|
||||
lastErrorRef.current = submit.error;
|
||||
showError({
|
||||
title: 'Translation Error',
|
||||
description: submit.error,
|
||||
});
|
||||
}
|
||||
}, [submit.error, showError]);
|
||||
|
||||
const handleNewTranslation = () => {
|
||||
submit.reset();
|
||||
upload.removeFile();
|
||||
};
|
||||
|
||||
const isConfiguring = upload.file && submit.status === 'idle' && !submit.isSubmitting;
|
||||
const isProcessing = (submit.status === 'processing' || submit.isSubmitting) && submit.status !== 'completed';
|
||||
const isCompleted = submit.status === 'completed';
|
||||
const isFailed = submit.status === 'failed';
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-xl px-4 py-6 lg:px-8">
|
||||
<Card className="border-border/70 shadow-lg">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Languages className="size-5" />
|
||||
Office Translator
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Upload an Excel, Word, or PowerPoint file to translate
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
{upload.file && !isProcessing && !isCompleted && (
|
||||
<div className="flex flex-col items-center justify-center gap-3 rounded-lg border-2 border-success/40 bg-success/5 px-6 py-4">
|
||||
<FilePreview file={upload.file} onRemove={upload.removeFile} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!upload.file && !isProcessing && !isCompleted && (
|
||||
<FileDropZone upload={upload} />
|
||||
)}
|
||||
|
||||
{upload.error && !isProcessing && !isCompleted && (
|
||||
<p className="text-sm text-destructive">{upload.error}</p>
|
||||
)}
|
||||
|
||||
{isConfiguring && (
|
||||
<>
|
||||
<div className="my-2 flex items-center gap-2">
|
||||
<div className="h-px flex-1 bg-border" />
|
||||
<span className="text-xs text-muted-foreground">Configuration</span>
|
||||
<div className="h-px flex-1 bg-border" />
|
||||
</div>
|
||||
|
||||
<LanguageSelector
|
||||
sourceLang={config.sourceLang}
|
||||
targetLang={config.targetLang}
|
||||
languages={config.languages}
|
||||
isLoading={config.isLoadingLanguages}
|
||||
error={config.languagesError}
|
||||
onSourceChange={config.setSourceLang}
|
||||
onTargetChange={config.setTargetLang}
|
||||
/>
|
||||
|
||||
<ProviderSelector
|
||||
provider={config.provider}
|
||||
onProviderChange={config.setProvider}
|
||||
availableProviders={config.availableProviders}
|
||||
isLoadingProviders={config.isLoadingProviders}
|
||||
isPro={config.isPro}
|
||||
/>
|
||||
|
||||
<Button
|
||||
size="lg"
|
||||
className="w-full text-sm font-semibold"
|
||||
disabled={!config.isConfigValid || submit.isSubmitting}
|
||||
onClick={handleTranslate}
|
||||
>
|
||||
{submit.isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||
Uploading...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Translate Document
|
||||
<ArrowRight className="ml-2 size-4" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isProcessing && !isCompleted && (
|
||||
<>
|
||||
<div className="flex items-center justify-between text-sm text-muted-foreground mb-2">
|
||||
<span>File: {submit.fileName || upload.file?.name}</span>
|
||||
</div>
|
||||
<TranslationProgress
|
||||
progress={submit.progress}
|
||||
currentStep={submit.currentStep || (submit.isSubmitting ? 'Uploading file...' : 'Starting translation...')}
|
||||
estimatedRemaining={submit.estimatedRemaining}
|
||||
error={null}
|
||||
isPolling={submit.isPolling}
|
||||
isUploading={submit.isSubmitting}
|
||||
isCompleted={false}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleNewTranslation}
|
||||
className="w-full mt-2"
|
||||
>
|
||||
<RotateCcw className="mr-2 size-4" />
|
||||
Cancel
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isCompleted && submit.jobId && (
|
||||
<TranslationComplete
|
||||
jobId={submit.jobId}
|
||||
fileName={submit.fileName}
|
||||
onNewTranslation={handleNewTranslation}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isFailed && (
|
||||
<>
|
||||
<TranslationProgress
|
||||
progress={submit.progress}
|
||||
currentStep={submit.currentStep}
|
||||
estimatedRemaining={submit.estimatedRemaining}
|
||||
error={submit.error}
|
||||
isPolling={false}
|
||||
isCompleted={false}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleNewTranslation}
|
||||
className="w-full mt-2"
|
||||
>
|
||||
<RotateCcw className="mr-2 size-4" />
|
||||
Try Again
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!upload.file && !isProcessing && !isCompleted && !isFailed && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Supported formats: Excel (.xlsx), Word (.docx), PowerPoint (.pptx)
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex items-center justify-center gap-4 mt-4 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<ShieldCheck className="size-3.5" />
|
||||
<span>Zero Data Retention</span>
|
||||
</div>
|
||||
<div className="h-3 w-px bg-border" />
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Clock className="size-3.5" />
|
||||
<span>Files deleted after 60 min</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
107
frontend/src/app/dashboard/translate/types.ts
Normal file
107
frontend/src/app/dashboard/translate/types.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
export type SupportedFormat = 'xlsx' | 'docx' | 'pptx';
|
||||
|
||||
export interface FileUploadState {
|
||||
file: File | null;
|
||||
error: string | null;
|
||||
isDragOver: boolean;
|
||||
}
|
||||
|
||||
export interface FileUploadActions {
|
||||
handleDrop: (e: React.DragEvent) => void;
|
||||
handleDragOver: (e: React.DragEvent) => void;
|
||||
handleDragLeave: (e: React.DragEvent) => void;
|
||||
handleFileSelect: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
removeFile: () => void;
|
||||
}
|
||||
|
||||
export interface UseFileUploadReturn extends FileUploadState, FileUploadActions {}
|
||||
|
||||
export type TranslationMode = 'classic' | 'llm';
|
||||
|
||||
/** Provider identifier — always matches the admin-side key. */
|
||||
export type Provider = string;
|
||||
|
||||
export interface Language {
|
||||
code: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/** A provider returned by GET /api/v1/providers/available */
|
||||
export interface AvailableProvider {
|
||||
id: Provider;
|
||||
label: string;
|
||||
description: string;
|
||||
mode: 'classic' | 'llm';
|
||||
/** LLM model used (e.g. deepseek/deepseek-v3.2) — same as admin config */
|
||||
model?: string;
|
||||
}
|
||||
|
||||
export interface TranslationConfig {
|
||||
sourceLang: string;
|
||||
targetLang: string;
|
||||
mode: TranslationMode;
|
||||
provider?: Provider;
|
||||
}
|
||||
|
||||
export interface UseTranslationConfigReturn {
|
||||
sourceLang: string;
|
||||
targetLang: string;
|
||||
/** Derived from selected provider — read-only. */
|
||||
mode: TranslationMode;
|
||||
provider: Provider | null;
|
||||
availableProviders: AvailableProvider[];
|
||||
isLoadingProviders: boolean;
|
||||
languages: Language[];
|
||||
isPro: boolean;
|
||||
isConfigValid: boolean;
|
||||
isLoadingLanguages: boolean;
|
||||
languagesError: string | null;
|
||||
setSourceLang: (lang: string) => void;
|
||||
setTargetLang: (lang: string) => void;
|
||||
setProvider: (provider: Provider | null) => void;
|
||||
getConfig: () => TranslationConfig;
|
||||
}
|
||||
|
||||
export type TranslationStatus = 'idle' | 'processing' | 'completed' | 'failed';
|
||||
|
||||
export interface TranslationJob {
|
||||
id: string;
|
||||
status: TranslationStatus;
|
||||
progress_percent: number;
|
||||
current_step: string;
|
||||
file_name?: string;
|
||||
source_lang?: string;
|
||||
target_lang?: string;
|
||||
created_at?: string;
|
||||
completed_at?: string;
|
||||
error_message?: string;
|
||||
}
|
||||
|
||||
export interface TranslationSubmitResponse {
|
||||
data: TranslationJob;
|
||||
meta: {
|
||||
rate_limit_remaining?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface TranslationStatusResponse {
|
||||
data: TranslationJob;
|
||||
meta: {
|
||||
estimated_remaining_seconds?: number | null;
|
||||
};
|
||||
}
|
||||
|
||||
export interface UseTranslationSubmitReturn {
|
||||
submitTranslation: (file: File, config: TranslationConfig) => Promise<void>;
|
||||
jobId: string | null;
|
||||
status: TranslationStatus;
|
||||
progress: number;
|
||||
currentStep: string;
|
||||
error: string | null;
|
||||
estimatedRemaining: number | null;
|
||||
fileName: string | null;
|
||||
reset: () => void;
|
||||
isSubmitting: boolean;
|
||||
isPolling: boolean;
|
||||
pollingFailures: number;
|
||||
}
|
||||
88
frontend/src/app/dashboard/translate/useFileUpload.ts
Normal file
88
frontend/src/app/dashboard/translate/useFileUpload.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import type { UseFileUploadReturn } from './types';
|
||||
|
||||
const ACCEPTED_EXTENSIONS = ['xlsx', 'docx', 'pptx'];
|
||||
const MAX_FILE_SIZE = 50 * 1024 * 1024;
|
||||
|
||||
export const ERROR_MESSAGES = {
|
||||
INVALID_FORMAT: 'Format non supporté. Formats acceptés : .xlsx, .docx, .pptx',
|
||||
FILE_TOO_LARGE: 'Fichier trop volumineux (max 50 MB)',
|
||||
} as const;
|
||||
|
||||
export function useFileUpload(): UseFileUploadReturn {
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
|
||||
const validateFile = useCallback((file: File): string | null => {
|
||||
const ext = file.name.split('.').pop()?.toLowerCase();
|
||||
|
||||
if (!ext || !ACCEPTED_EXTENSIONS.includes(ext)) {
|
||||
return ERROR_MESSAGES.INVALID_FORMAT;
|
||||
}
|
||||
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
return ERROR_MESSAGES.FILE_TOO_LARGE;
|
||||
}
|
||||
|
||||
return null;
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragOver(false);
|
||||
|
||||
const droppedFile = e.dataTransfer.files[0];
|
||||
if (droppedFile) {
|
||||
const validationError = validateFile(droppedFile);
|
||||
if (validationError) {
|
||||
setError(validationError);
|
||||
setFile(null);
|
||||
} else {
|
||||
setFile(droppedFile);
|
||||
setError(null);
|
||||
}
|
||||
}
|
||||
}, [validateFile]);
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragOver(true);
|
||||
}, []);
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragOver(false);
|
||||
}, []);
|
||||
|
||||
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const selected = e.target.files?.[0];
|
||||
if (selected) {
|
||||
const validationError = validateFile(selected);
|
||||
if (validationError) {
|
||||
setError(validationError);
|
||||
setFile(null);
|
||||
} else {
|
||||
setFile(selected);
|
||||
setError(null);
|
||||
}
|
||||
}
|
||||
}, [validateFile]);
|
||||
|
||||
const removeFile = useCallback(() => {
|
||||
setFile(null);
|
||||
setError(null);
|
||||
setIsDragOver(false);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
file,
|
||||
error,
|
||||
isDragOver,
|
||||
handleDrop,
|
||||
handleDragOver,
|
||||
handleDragLeave,
|
||||
handleFileSelect,
|
||||
removeFile,
|
||||
};
|
||||
}
|
||||
216
frontend/src/app/dashboard/translate/useTranslationConfig.ts
Normal file
216
frontend/src/app/dashboard/translate/useTranslationConfig.ts
Normal file
@@ -0,0 +1,216 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import type {
|
||||
UseTranslationConfigReturn,
|
||||
Language,
|
||||
TranslationMode,
|
||||
Provider,
|
||||
TranslationConfig,
|
||||
AvailableProvider,
|
||||
} from './types';
|
||||
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
|
||||
|
||||
/** Fallback when API fails — Google is always available server-side */
|
||||
const FALLBACK_PROVIDERS: AvailableProvider[] = [
|
||||
{ id: 'google', label: 'Google Traduction', description: 'Traduction rapide, 130+ langues', mode: 'classic' },
|
||||
];
|
||||
|
||||
const FALLBACK_LANGUAGES: Language[] = [
|
||||
// Top 5 — dominant on the internet
|
||||
{ code: 'en', name: 'English' },
|
||||
{ code: 'es', name: 'Spanish' },
|
||||
{ code: 'de', name: 'German' },
|
||||
{ code: 'fr', name: 'French' },
|
||||
{ code: 'ja', name: 'Japanese' },
|
||||
// Top 6-15
|
||||
{ code: 'pt', name: 'Portuguese' },
|
||||
{ code: 'ru', name: 'Russian' },
|
||||
{ code: 'it', name: 'Italian' },
|
||||
{ code: 'zh-CN', name: 'Chinese (Simplified)' },
|
||||
{ code: 'zh-TW', name: 'Chinese (Traditional)' },
|
||||
{ code: 'pl', name: 'Polish' },
|
||||
{ code: 'nl', name: 'Dutch' },
|
||||
{ code: 'tr', name: 'Turkish' },
|
||||
{ code: 'ko', name: 'Korean' },
|
||||
{ code: 'ar', name: 'Arabic' },
|
||||
// Top 16-25
|
||||
{ code: 'fa', name: 'Persian (Farsi)' },
|
||||
{ code: 'vi', name: 'Vietnamese' },
|
||||
{ code: 'id', name: 'Indonesian' },
|
||||
{ code: 'uk', name: 'Ukrainian' },
|
||||
{ code: 'sv', name: 'Swedish' },
|
||||
{ code: 'cs', name: 'Czech' },
|
||||
{ code: 'el', name: 'Greek' },
|
||||
{ code: 'he', name: 'Hebrew' },
|
||||
{ code: 'hi', name: 'Hindi' },
|
||||
{ code: 'ro', name: 'Romanian' },
|
||||
// Others
|
||||
{ code: 'da', name: 'Danish' },
|
||||
{ code: 'fi', name: 'Finnish' },
|
||||
{ code: 'no', name: 'Norwegian' },
|
||||
{ code: 'hu', name: 'Hungarian' },
|
||||
{ code: 'th', name: 'Thai' },
|
||||
{ code: 'sk', name: 'Slovak' },
|
||||
{ code: 'bg', name: 'Bulgarian' },
|
||||
{ code: 'hr', name: 'Croatian' },
|
||||
{ code: 'ca', name: 'Catalan' },
|
||||
{ code: 'ms', name: 'Malay' },
|
||||
];
|
||||
|
||||
export function useTranslationConfig(hasFile: boolean): UseTranslationConfigReturn {
|
||||
const [sourceLang, setSourceLang] = useState('auto');
|
||||
const [targetLang, setTargetLang] = useState('');
|
||||
const [provider, setProvider] = useState<Provider | null>(null);
|
||||
const [availableProviders, setAvailableProviders] = useState<AvailableProvider[]>([]);
|
||||
const [isLoadingProviders, setIsLoadingProviders] = useState(false);
|
||||
const [languages, setLanguages] = useState<Language[]>([]);
|
||||
const [isPro, setIsPro] = useState(false);
|
||||
const [isLoadingLanguages, setIsLoadingLanguages] = useState(false);
|
||||
const [languagesError, setLanguagesError] = useState<string | null>(null);
|
||||
|
||||
// Fetch available (admin-configured) providers
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 8000);
|
||||
|
||||
const fetchProviders = async () => {
|
||||
setIsLoadingProviders(true);
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const headers: Record<string, string> = {};
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
|
||||
const response = await fetch(`${API_BASE}/api/v1/providers/available`, {
|
||||
headers,
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
const list = data.providers || [];
|
||||
setAvailableProviders(list.length > 0 ? list : FALLBACK_PROVIDERS);
|
||||
} else {
|
||||
setAvailableProviders(FALLBACK_PROVIDERS);
|
||||
}
|
||||
} catch {
|
||||
// Backend down or timeout — use fallback so user can still try
|
||||
setAvailableProviders(FALLBACK_PROVIDERS);
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
setIsLoadingProviders(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchProviders();
|
||||
return () => { controller.abort(); clearTimeout(timeoutId); };
|
||||
}, []);
|
||||
|
||||
// Fetch supported languages
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 8000);
|
||||
|
||||
const fetchLanguages = async () => {
|
||||
setIsLoadingLanguages(true);
|
||||
setLanguagesError(null);
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
|
||||
const response = await fetch(`${API_BASE}/api/v1/languages`, {
|
||||
headers,
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
|
||||
const data = await response.json();
|
||||
const langList: Language[] = Object.entries(data.supported_languages || {}).map(
|
||||
([code, name]) => ({ code, name: name as string })
|
||||
);
|
||||
setLanguages(langList.length > 0 ? langList : FALLBACK_LANGUAGES);
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === 'AbortError') {
|
||||
console.warn('Language fetch timed out, using fallback list');
|
||||
} else {
|
||||
setLanguagesError(error instanceof Error ? error.message : 'Failed to load languages');
|
||||
}
|
||||
setLanguages(FALLBACK_LANGUAGES);
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
setIsLoadingLanguages(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchLanguages();
|
||||
return () => { controller.abort(); clearTimeout(timeoutId); };
|
||||
}, []);
|
||||
|
||||
// Check user tier
|
||||
useEffect(() => {
|
||||
const checkTier = async () => {
|
||||
const userStr = localStorage.getItem('user');
|
||||
if (userStr) {
|
||||
try {
|
||||
const user = JSON.parse(userStr);
|
||||
if (user.tier) { setIsPro(user.tier === 'pro'); return; }
|
||||
} catch { /* continue */ }
|
||||
}
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) { setIsPro(false); return; }
|
||||
const response = await fetch(`${API_BASE}/api/v1/auth/me`, {
|
||||
headers: { 'Authorization': `Bearer ${token}` },
|
||||
});
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
const user = result.data;
|
||||
setIsPro(user.tier === 'pro');
|
||||
localStorage.setItem('user', JSON.stringify(user));
|
||||
} else {
|
||||
setIsPro(false);
|
||||
}
|
||||
} catch { setIsPro(false); }
|
||||
};
|
||||
checkTier();
|
||||
}, []);
|
||||
|
||||
// Mode is derived from the selected provider, never set manually.
|
||||
const mode = useMemo<TranslationMode>(() => {
|
||||
if (!provider) return 'classic';
|
||||
const p = availableProviders.find((ap) => ap.id === provider);
|
||||
return p?.mode === 'llm' ? 'llm' : 'classic';
|
||||
}, [provider, availableProviders]);
|
||||
|
||||
const isConfigValid = useMemo(() => {
|
||||
if (!hasFile || !targetLang) return false;
|
||||
if (!provider) return false;
|
||||
return true;
|
||||
}, [hasFile, targetLang, provider]);
|
||||
|
||||
const getConfig = useCallback((): TranslationConfig => ({
|
||||
sourceLang,
|
||||
targetLang,
|
||||
mode,
|
||||
provider: provider ?? undefined,
|
||||
}), [sourceLang, targetLang, mode, provider]);
|
||||
|
||||
return {
|
||||
sourceLang,
|
||||
targetLang,
|
||||
mode,
|
||||
provider,
|
||||
availableProviders,
|
||||
isLoadingProviders,
|
||||
languages,
|
||||
isPro,
|
||||
isConfigValid,
|
||||
isLoadingLanguages,
|
||||
languagesError,
|
||||
setSourceLang,
|
||||
setTargetLang,
|
||||
setProvider,
|
||||
getConfig,
|
||||
};
|
||||
}
|
||||
209
frontend/src/app/dashboard/translate/useTranslationSubmit.ts
Normal file
209
frontend/src/app/dashboard/translate/useTranslationSubmit.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import type {
|
||||
UseTranslationSubmitReturn,
|
||||
TranslationConfig,
|
||||
TranslationStatus,
|
||||
TranslationSubmitResponse,
|
||||
TranslationStatusResponse
|
||||
} from './types';
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
|
||||
const POLLING_INTERVAL_MS = 2000;
|
||||
const MAX_POLLING_FAILURES = 3;
|
||||
|
||||
export function useTranslationSubmit(): UseTranslationSubmitReturn {
|
||||
const [jobId, setJobId] = useState<string | null>(null);
|
||||
const [status, setStatus] = useState<TranslationStatus>('idle');
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [currentStep, setCurrentStep] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [estimatedRemaining, setEstimatedRemaining] = useState<number | null>(null);
|
||||
const [fileName, setFileName] = useState<string | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [pollingFailures, setPollingFailures] = useState(0);
|
||||
const [isPolling, setIsPolling] = useState(false);
|
||||
|
||||
const pollingIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const isPollingRef = useRef(false);
|
||||
// Use a ref for failure count to avoid stale closure in the interval callback.
|
||||
// If we relied on state, the setInterval callback would always read the initial
|
||||
// value of pollingFailures (0) and never reach MAX_POLLING_FAILURES.
|
||||
const pollingFailuresRef = useRef(0);
|
||||
|
||||
const stopPolling = useCallback(() => {
|
||||
if (pollingIntervalRef.current) {
|
||||
clearInterval(pollingIntervalRef.current);
|
||||
pollingIntervalRef.current = null;
|
||||
}
|
||||
isPollingRef.current = false;
|
||||
setIsPolling(false);
|
||||
}, []);
|
||||
|
||||
const pollProgress = useCallback(async (id: string) => {
|
||||
if (isPollingRef.current) return;
|
||||
|
||||
isPollingRef.current = true;
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const headers: Record<string, string> = {};
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE}/api/v1/translations/${id}`, { headers });
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
stopPolling();
|
||||
setStatus('failed');
|
||||
setError('Translation job not found');
|
||||
return;
|
||||
}
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data: TranslationStatusResponse = await response.json();
|
||||
const job = data.data;
|
||||
|
||||
setStatus(job.status as TranslationStatus);
|
||||
setProgress(job.progress_percent || 0);
|
||||
setCurrentStep(job.current_step || '');
|
||||
setEstimatedRemaining(data.meta.estimated_remaining_seconds ?? null);
|
||||
pollingFailuresRef.current = 0;
|
||||
setPollingFailures(0);
|
||||
|
||||
if (job.file_name) {
|
||||
setFileName(job.file_name);
|
||||
}
|
||||
|
||||
if (job.status === 'completed' || job.status === 'failed') {
|
||||
stopPolling();
|
||||
if (job.status === 'failed') {
|
||||
setError(job.error_message || 'Translation failed');
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Polling error:', err);
|
||||
pollingFailuresRef.current += 1;
|
||||
setPollingFailures(pollingFailuresRef.current);
|
||||
|
||||
if (pollingFailuresRef.current >= MAX_POLLING_FAILURES) {
|
||||
stopPolling();
|
||||
setStatus('failed');
|
||||
setError('Lost connection to translation service. Please check your internet connection and try again.');
|
||||
}
|
||||
} finally {
|
||||
isPollingRef.current = false;
|
||||
}
|
||||
}, [stopPolling]);
|
||||
|
||||
const startPolling = useCallback((id: string) => {
|
||||
stopPolling();
|
||||
pollingFailuresRef.current = 0;
|
||||
setIsPolling(true);
|
||||
setPollingFailures(0);
|
||||
|
||||
pollProgress(id);
|
||||
|
||||
pollingIntervalRef.current = setInterval(() => {
|
||||
pollProgress(id);
|
||||
}, POLLING_INTERVAL_MS);
|
||||
}, [pollProgress, stopPolling]);
|
||||
|
||||
const submitTranslation = useCallback(async (file: File, config: TranslationConfig) => {
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
setProgress(0);
|
||||
setCurrentStep('Uploading file...');
|
||||
setEstimatedRemaining(null);
|
||||
setStatus('processing'); // IMPORTANT: Set to 'processing' IMMEDIATELY so progress bar shows
|
||||
setFileName(file.name);
|
||||
setJobId(null);
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('source_lang', config.sourceLang);
|
||||
formData.append('target_lang', config.targetLang);
|
||||
formData.append('mode', config.mode);
|
||||
// Provider is configured server-side by admin — only send the provider name.
|
||||
if (config.mode === 'llm' && config.provider) {
|
||||
formData.append('provider', config.provider);
|
||||
}
|
||||
|
||||
const token = localStorage.getItem('token');
|
||||
const headers: Record<string, string> = {};
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE}/api/v1/translate`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let errorMessage = `Translation failed: ${response.status}`;
|
||||
try {
|
||||
const errorData = await response.json();
|
||||
errorMessage = errorData.message || errorData.error || errorMessage;
|
||||
} catch {
|
||||
// Response not JSON, use default message
|
||||
}
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
const data: TranslationSubmitResponse = await response.json();
|
||||
|
||||
setJobId(data.data.id);
|
||||
setFileName(data.data.file_name || file.name);
|
||||
setProgress(data.data.progress_percent || 5); // Start with at least 5%
|
||||
setCurrentStep(data.data.current_step || 'Translating...');
|
||||
|
||||
startPolling(data.data.id);
|
||||
} catch (err) {
|
||||
setStatus('failed');
|
||||
setError(err instanceof Error ? err.message : 'Translation failed');
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
// NOTE: Don't set isSubmitting(false) here - let polling handle the transition
|
||||
}, [startPolling]);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
stopPolling();
|
||||
setJobId(null);
|
||||
setStatus('idle');
|
||||
setProgress(0);
|
||||
setCurrentStep('');
|
||||
setError(null);
|
||||
setEstimatedRemaining(null);
|
||||
setFileName(null);
|
||||
setIsSubmitting(false);
|
||||
setPollingFailures(0);
|
||||
}, [stopPolling]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
stopPolling();
|
||||
};
|
||||
}, [stopPolling]);
|
||||
|
||||
return {
|
||||
submitTranslation,
|
||||
jobId,
|
||||
status,
|
||||
progress,
|
||||
currentStep,
|
||||
error,
|
||||
estimatedRemaining,
|
||||
fileName,
|
||||
reset,
|
||||
isSubmitting,
|
||||
isPolling,
|
||||
pollingFailures,
|
||||
};
|
||||
}
|
||||
7
frontend/src/app/dashboard/types.ts
Normal file
7
frontend/src/app/dashboard/types.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
tier: 'free' | 'pro';
|
||||
created_at: string;
|
||||
}
|
||||
16
frontend/src/app/dashboard/useLogout.ts
Normal file
16
frontend/src/app/dashboard/useLogout.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
export function useLogout() {
|
||||
const router = useRouter();
|
||||
|
||||
const logout = () => {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('refresh_token');
|
||||
localStorage.removeItem('user');
|
||||
router.push('/');
|
||||
};
|
||||
|
||||
return { logout };
|
||||
}
|
||||
29
frontend/src/app/dashboard/useUser.ts
Normal file
29
frontend/src/app/dashboard/useUser.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
'use client';
|
||||
|
||||
import { useQuery, UseQueryResult } from '@tanstack/react-query';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { apiClient, ApiClientError } from '@/lib/apiClient';
|
||||
import type { User } from './types';
|
||||
|
||||
export function useUser(): UseQueryResult<User, ApiClientError> {
|
||||
const router = useRouter();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['user', 'me'],
|
||||
queryFn: async (): Promise<User> => {
|
||||
const response = await apiClient.get<User>('/api/v1/auth/me');
|
||||
return response.data;
|
||||
},
|
||||
retry: (failureCount, error) => {
|
||||
if (error.status === 401) {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('refresh_token');
|
||||
localStorage.removeItem('user');
|
||||
router.push('/auth/login?redirect=/dashboard');
|
||||
return false;
|
||||
}
|
||||
return failureCount < 2;
|
||||
},
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
19
frontend/src/app/dashboard/utils.ts
Normal file
19
frontend/src/app/dashboard/utils.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Génère les initiales d'un nom (max 2 caractères)
|
||||
* @param name - Le nom complet
|
||||
* @returns Les initiales en majuscules
|
||||
* @example getInitials("John Doe") // "JD"
|
||||
* @example getInitials("Jane") // "J"
|
||||
*/
|
||||
export function getInitials(name: string): string {
|
||||
if (!name || typeof name !== 'string') {
|
||||
return '?';
|
||||
}
|
||||
|
||||
return name
|
||||
.split(' ')
|
||||
.map(n => n[0])
|
||||
.join('')
|
||||
.toUpperCase()
|
||||
.slice(0, 2);
|
||||
}
|
||||
Reference in New Issue
Block a user