427 lines
14 KiB
TypeScript
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>
|
|
);
|
|
}
|