feat: revue de code, doc CODE_REVIEW, forfaits 2026, traduction LLM, providers avec modèle
Made-with: Cursor
This commit is contained in:
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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user