427 lines
14 KiB
TypeScript

"use client";
import { useState, useCallback, useEffect } from "react";
import { useDropzone } from "react-dropzone";
import { Upload, FileText, FileSpreadsheet, Presentation, X, Download, Loader2, Cpu, AlertTriangle } from "lucide-react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Progress } from "@/components/ui/progress";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { useTranslationStore } from "@/lib/store";
import { translateDocument, languages, providers, extractTextsFromDocument, reconstructDocument, TranslatedText } from "@/lib/api";
import { useWebLLM } from "@/lib/webllm";
import { cn } from "@/lib/utils";
const fileIcons: Record<string, React.ElementType> = {
xlsx: FileSpreadsheet,
xls: FileSpreadsheet,
docx: FileText,
doc: FileText,
pptx: Presentation,
ppt: Presentation,
};
type ProviderType = "google" | "ollama" | "deepl" | "libre" | "webllm" | "openai" | "openrouter";
export function FileUploader() {
const { settings, isTranslating, progress, setTranslating, setProgress } = useTranslationStore();
const webllm = useWebLLM();
const [file, setFile] = useState<File | null>(null);
const [targetLanguage, setTargetLanguage] = useState(settings.defaultTargetLanguage);
const [provider, setProvider] = useState<ProviderType>(settings.defaultProvider);
const [translateImages, setTranslateImages] = useState(settings.translateImages);
const [downloadUrl, setDownloadUrl] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [translationStatus, setTranslationStatus] = useState<string>("");
// Sync with store settings when they change
useEffect(() => {
setTargetLanguage(settings.defaultTargetLanguage);
setProvider(settings.defaultProvider);
setTranslateImages(settings.translateImages);
}, [settings.defaultTargetLanguage, settings.defaultProvider, settings.translateImages]);
const onDrop = useCallback((acceptedFiles: File[]) => {
if (acceptedFiles.length > 0) {
setFile(acceptedFiles[0]);
setDownloadUrl(null);
setError(null);
}
}, []);
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
accept: {
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": [".xlsx"],
"application/vnd.ms-excel": [".xls"],
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": [".docx"],
"application/msword": [".doc"],
"application/vnd.openxmlformats-officedocument.presentationml.presentation": [".pptx"],
"application/vnd.ms-powerpoint": [".ppt"],
},
multiple: false,
});
const getFileExtension = (filename: string) => {
return filename.split(".").pop()?.toLowerCase() || "";
};
const getFileIcon = (filename: string) => {
const ext = getFileExtension(filename);
return fileIcons[ext] || FileText;
};
const formatFileSize = (bytes: number) => {
if (bytes < 1024) return bytes + " B";
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB";
return (bytes / (1024 * 1024)).toFixed(1) + " MB";
};
const handleTranslate = async () => {
if (!file) return;
// Validate provider-specific requirements
if (provider === "openai" && !settings.openaiApiKey) {
setError("OpenAI API key not configured. Go to Settings > Translation Services to add your API key.");
return;
}
if (provider === "deepl" && !settings.deeplApiKey) {
setError("DeepL API key not configured. Go to Settings > Translation Services to add your API key.");
return;
}
// WebLLM specific validation
if (provider === "webllm") {
if (!webllm.isWebGPUSupported()) {
setError("WebGPU is not supported in this browser. Please use Chrome 113+ or Edge 113+.");
return;
}
if (!webllm.isLoaded) {
setError("WebLLM model not loaded. Go to Settings > Translation Services to load a model first.");
return;
}
}
setTranslating(true);
setProgress(0);
setError(null);
setDownloadUrl(null);
setTranslationStatus("");
try {
// For WebLLM, use client-side translation
if (provider === "webllm") {
await handleWebLLMTranslation();
} else {
await handleServerTranslation();
}
} catch (err) {
setError(err instanceof Error ? err.message : "Translation failed");
} finally {
setTranslating(false);
setTranslationStatus("");
}
};
// Get language name from code
const getLanguageName = (code: string): string => {
const lang = languages.find(l => l.code === code);
return lang ? lang.name : code;
};
// WebLLM client-side translation
const handleWebLLMTranslation = async () => {
if (!file) return;
try {
// Step 1: Extract texts from document
setTranslationStatus("Extracting texts from document...");
setProgress(5);
const extractResult = await extractTextsFromDocument(file);
if (extractResult.texts.length === 0) {
throw new Error("No translatable text found in document");
}
setTranslationStatus(`Found ${extractResult.texts.length} texts to translate`);
setProgress(10);
// Step 2: Translate each text using WebLLM
const translations: TranslatedText[] = [];
const totalTexts = extractResult.texts.length;
const langName = getLanguageName(targetLanguage);
for (let i = 0; i < totalTexts; i++) {
const item = extractResult.texts[i];
setTranslationStatus(`Translating ${i + 1}/${totalTexts}: "${item.text.substring(0, 30)}..."`);
const translatedText = await webllm.translate(
item.text,
langName,
settings.systemPrompt || undefined,
settings.glossary || undefined
);
translations.push({
id: item.id,
translated_text: translatedText,
});
// Update progress (10% for extraction, 80% for translation, 10% for reconstruction)
const translationProgress = 10 + (80 * (i + 1) / totalTexts);
setProgress(translationProgress);
}
// Step 3: Reconstruct document with translations
setTranslationStatus("Reconstructing document...");
setProgress(92);
const blob = await reconstructDocument(
extractResult.session_id,
translations,
targetLanguage
);
setProgress(100);
setTranslationStatus("Translation complete!");
const url = URL.createObjectURL(blob);
setDownloadUrl(url);
} catch (err) {
throw err;
}
};
// Server-side translation (existing logic)
const handleServerTranslation = async () => {
if (!file) return;
// Simulate progress for UX
let currentProgress = 0;
const progressInterval = setInterval(() => {
currentProgress = Math.min(currentProgress + Math.random() * 10, 90);
setProgress(currentProgress);
}, 500);
try {
const blob = await translateDocument({
file,
targetLanguage,
provider,
ollamaModel: settings.ollamaModel,
translateImages: translateImages || settings.translateImages,
systemPrompt: settings.systemPrompt,
glossary: settings.glossary,
libreUrl: settings.libreTranslateUrl,
openaiApiKey: settings.openaiApiKey,
openaiModel: settings.openaiModel,
openrouterApiKey: settings.openrouterApiKey,
openrouterModel: settings.openrouterModel,
});
clearInterval(progressInterval);
setProgress(100);
const url = URL.createObjectURL(blob);
setDownloadUrl(url);
} catch (err) {
clearInterval(progressInterval);
throw err;
}
};
const handleDownload = () => {
if (!downloadUrl || !file) return;
const a = document.createElement("a");
a.href = downloadUrl;
const ext = getFileExtension(file.name);
const baseName = file.name.replace(`.${ext}`, "");
a.download = `${baseName}_translated.${ext}`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
};
const removeFile = () => {
setFile(null);
setDownloadUrl(null);
setError(null);
setProgress(0);
};
const FileIcon = file ? getFileIcon(file.name) : FileText;
return (
<div className="space-y-6">
{/* File Drop Zone */}
<Card className="border-zinc-800 bg-zinc-900/50">
<CardHeader>
<CardTitle className="text-white">Upload Document</CardTitle>
<CardDescription>
Drag and drop or click to select a file (Excel, Word, PowerPoint)
</CardDescription>
</CardHeader>
<CardContent>
{!file ? (
<div
{...getRootProps()}
className={cn(
"border-2 border-dashed rounded-xl p-12 text-center cursor-pointer transition-all",
isDragActive
? "border-teal-500 bg-teal-500/10"
: "border-zinc-700 hover:border-zinc-600 hover:bg-zinc-800/50"
)}
>
<input {...getInputProps()} />
<Upload className="h-12 w-12 mx-auto mb-4 text-zinc-500" />
<p className="text-zinc-400 mb-2">
{isDragActive
? "Drop the file here..."
: "Drag & drop a document here, or click to select"}
</p>
<p className="text-xs text-zinc-600">
Supports: .xlsx, .docx, .pptx
</p>
</div>
) : (
<div className="flex items-center gap-4 p-4 bg-zinc-800/50 rounded-lg">
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-zinc-700">
<FileIcon className="h-6 w-6 text-teal-400" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-white truncate">
{file.name}
</p>
<p className="text-xs text-zinc-500">
{formatFileSize(file.size)}
</p>
</div>
<Badge variant="outline" className="border-zinc-700 text-zinc-400">
{getFileExtension(file.name).toUpperCase()}
</Badge>
<Button
variant="ghost"
size="icon"
onClick={removeFile}
className="text-zinc-500 hover:text-red-400"
>
<X className="h-4 w-4" />
</Button>
</div>
)}
</CardContent>
</Card>
{/* Translation Options */}
<Card className="border-zinc-800 bg-zinc-900/50">
<CardHeader>
<CardTitle className="text-white">Translation Options</CardTitle>
<CardDescription>
Select your target language and start translating
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Target Language */}
<div className="space-y-2">
<Label htmlFor="language" className="text-zinc-300">Target Language</Label>
<Select value={targetLanguage} onValueChange={setTargetLanguage}>
<SelectTrigger id="language" className="bg-zinc-800 border-zinc-700 text-white">
<SelectValue placeholder="Select language" />
</SelectTrigger>
<SelectContent className="bg-zinc-800 border-zinc-700 max-h-80">
{languages.map((lang) => (
<SelectItem
key={lang.code}
value={lang.code}
className="text-white hover:bg-zinc-700"
>
<span className="flex items-center gap-2">
<span>{lang.flag}</span>
<span>{lang.name}</span>
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Translate Button */}
<Button
onClick={handleTranslate}
disabled={!file || isTranslating}
className="w-full bg-teal-600 hover:bg-teal-700 text-white h-12"
>
{isTranslating ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Translating...
</>
) : (
<>
<Upload className="mr-2 h-4 w-4" />
Translate Document
</>
)}
</Button>
{/* Progress Bar */}
{isTranslating && (
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-zinc-400">
{translationStatus || "Processing..."}
</span>
<span className="text-teal-400">{Math.round(progress)}%</span>
</div>
<Progress value={progress} className="h-2" />
{provider === "webllm" && (
<p className="text-xs text-zinc-500 flex items-center gap-1">
<Cpu className="h-3 w-3" />
Translating locally with WebLLM...
</p>
)}
</div>
)}
{/* Error */}
{error && (
<div className="rounded-lg bg-red-500/10 border border-red-500/30 p-4">
<p className="text-sm text-red-400">{error}</p>
</div>
)}
</CardContent>
</Card>
{/* Download Section */}
{downloadUrl && (
<Card className="border-teal-500/30 bg-teal-500/5">
<CardHeader>
<CardTitle className="text-teal-400 flex items-center gap-2">
<Download className="h-5 w-5" />
Translation Complete
</CardTitle>
<CardDescription>
Your document has been translated successfully
</CardDescription>
</CardHeader>
<CardContent>
<Button
onClick={handleDownload}
className="w-full bg-teal-600 hover:bg-teal-700 text-white h-12"
>
<Download className="mr-2 h-4 w-4" />
Download Translated Document
</Button>
</CardContent>
</Card>
)}
</div>
);
}