Files
LingvAI/components/DocumentTranslator.tsx

264 lines
10 KiB
TypeScript
Raw Normal View History

import { FC, useState, useCallback, useRef } from "react";
import {
Box, Button, VStack, HStack, Text, Input, Alert, AlertIcon,
Select, Badge, Icon, useColorModeValue
} from "@chakra-ui/react";
import { FiUpload, FiDownload, FiFile } from "react-icons/fi";
import { languageList, LangCode } from "lingva-scraper";
import dynamic from "next/dynamic";
import type { ColumnSelection, SheetColumnInfo } from "./ColumnSelector";
const ColumnSelector = dynamic(() => import("./ColumnSelector"), { ssr: false });
const SUPPORTED_TYPES = [".pdf", ".docx", ".xlsx", ".xls", ".csv"];
const TABULAR_TYPES = [".xlsx", ".xls", ".csv"];
type Stage = "idle" | "columns" | "translating" | "done" | "error";
const DocumentTranslator: FC = () => {
const [file, setFile] = useState<File | null>(null);
const [target, setTarget] = useState<string>("en");
const [stage, setStage] = useState<Stage>("idle");
const [progress, setProgress] = useState(0);
const [errorMsg, setErrorMsg] = useState("");
const [sheetColumns, setSheetColumns] = useState<SheetColumnInfo[]>([]);
const [columnSelections, setColumnSelections] = useState<ColumnSelection[]>([]);
const [downloadUrl, setDownloadUrl] = useState<string | null>(null);
const [downloadName, setDownloadName] = useState("");
const fileInputRef = useRef<HTMLInputElement>(null);
const borderColor = useColorModeValue("gray.200", "gray.600");
const dropBg = useColorModeValue("gray.50", "gray.700");
const isTabular = file
? TABULAR_TYPES.some(e => file.name.toLowerCase().endsWith(e))
: false;
const handleFile = useCallback(async (f: File) => {
setFile(f);
setStage("idle");
setErrorMsg("");
setDownloadUrl(null);
setSheetColumns([]);
const ext = "." + f.name.split(".").pop()?.toLowerCase();
if (!SUPPORTED_TYPES.includes(ext)) {
setErrorMsg(`Unsupported file type. Supported: ${SUPPORTED_TYPES.join(", ")}`);
setStage("error");
return;
}
if (TABULAR_TYPES.includes(ext)) {
// Fetch column info
const fd = new FormData();
fd.append("file", f);
fd.append("action", "getColumns");
try {
const res = await fetch("/api/translate/document", { method: "POST", body: fd });
if (res.ok) {
const data = await res.json();
setSheetColumns(data.columns ?? []);
setStage("columns");
} else {
const e = await res.json();
setErrorMsg(e.error ?? "Failed to read columns");
setStage("error");
}
} catch {
setErrorMsg("Network error reading file columns");
setStage("error");
}
}
}, []);
const onDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
const f = e.dataTransfer.files[0];
if (f) handleFile(f);
}, [handleFile]);
const translate = useCallback(async () => {
if (!file) return;
setStage("translating");
setProgress(10);
setErrorMsg("");
const fd = new FormData();
fd.append("file", file);
fd.append("targetLanguage", target);
fd.append("action", "translate");
if (columnSelections.length > 0) {
fd.append("columnSelections", JSON.stringify(columnSelections));
}
try {
setProgress(30);
const res = await fetch("/api/translate/document", { method: "POST", body: fd });
setProgress(90);
if (!res.ok) {
const e = await res.json().catch(() => ({ error: "Translation failed" }));
setErrorMsg(e.error ?? "Translation failed");
setStage("error");
return;
}
const blob = await res.blob();
const ext = "." + file.name.split(".").pop()!;
const outName = file.name.replace(ext, `_${target}${ext}`);
const url = URL.createObjectURL(blob);
setDownloadUrl(url);
setDownloadName(outName);
setProgress(100);
setStage("done");
} catch (err) {
setErrorMsg("Network error during translation");
setStage("error");
}
}, [file, target, columnSelections]);
const reset = () => {
setFile(null);
setStage("idle");
setErrorMsg("");
setDownloadUrl(null);
setSheetColumns([]);
if (fileInputRef.current) fileInputRef.current.value = "";
};
const targetLangs = Object.entries(languageList.target) as [LangCode<"target">, string][];
return (
<VStack w="full" spacing={4} px={[8, null, 24, 40]}>
{/* Drop Zone */}
<Box
w="full"
border="2px dashed"
borderColor={file ? "lingva.400" : borderColor}
borderRadius="xl"
p={8}
bg={dropBg}
textAlign="center"
cursor="pointer"
onDrop={onDrop}
onDragOver={(e: React.DragEvent) => e.preventDefault()}
onClick={() => fileInputRef.current?.click()}
_hover={{ borderColor: "lingva.400" }}
transition="border-color 0.2s"
>
<Input
ref={fileInputRef}
type="file"
display="none"
accept=".pdf,.docx,.xlsx,.xls,.csv"
onChange={(e: React.ChangeEvent<HTMLInputElement>) => { const f = e.target.files?.[0]; if (f) handleFile(f); }}
/>
{file ? (
<VStack spacing={1}>
<Icon as={FiFile} boxSize={8} color="lingva.400" />
<Text fontWeight="semibold">{file.name}</Text>
<Text fontSize="sm" color="gray.500">
{(file.size / 1024 / 1024).toFixed(2)} MB
</Text>
</VStack>
) : (
<VStack spacing={2}>
<Icon as={FiUpload} boxSize={10} color="gray.400" />
<Text fontWeight="semibold">Drop file here or click to upload</Text>
<Text fontSize="sm" color="gray.500">
Supported: PDF, Word (.docx), Excel (.xlsx, .xls), CSV
</Text>
</VStack>
)}
</Box>
{/* Target language + controls */}
{file && stage !== "error" && (
<HStack w="full" spacing={3}>
<Box flex={1}>
<Select
value={target}
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => setTarget(e.target.value)}
size="md"
aria-label="Target language"
>
{targetLangs.map(([code, name]) => (
<option key={code} value={code}>{name}</option>
))}
</Select>
</Box>
<Button
colorScheme="lingva"
leftIcon={<Icon as={FiFile} />}
onClick={translate}
isLoading={stage === "translating"}
loadingText="Translating…"
isDisabled={stage === "translating" || (isTabular && stage !== "columns" && stage !== "idle")}
>
Translate
</Button>
<Button variant="ghost" size="sm" onClick={reset}>Reset</Button>
</HStack>
)}
{/* Column selector for Excel/CSV */}
{stage === "columns" && sheetColumns.length > 0 && (
<ColumnSelector
sheetColumns={sheetColumns}
onChange={setColumnSelections}
/>
)}
{/* Progress */}
{stage === "translating" && (
<Box w="full">
<Box w="full" h="8px" bg="gray.200" borderRadius="full" overflow="hidden">
<Box h="full" w={`${progress}%`} bg="lingva.400" borderRadius="full" transition="width 0.4s ease" />
</Box>
<Text fontSize="sm" textAlign="center" mt={1} color="gray.500">
Translating this may take a moment for large documents.
</Text>
</Box>
)}
{/* Download */}
{stage === "done" && downloadUrl && (
<Alert status="success" borderRadius="md">
<AlertIcon />
<HStack justify="space-between" w="full">
<Text fontSize="sm">Translation complete!</Text>
<Button
as="a"
href={downloadUrl}
download={downloadName}
size="sm"
colorScheme="lingva"
leftIcon={<Icon as={FiDownload} />}
>
Download
</Button>
</HStack>
</Alert>
)}
{/* Error */}
{stage === "error" && errorMsg && (
<Alert status="error" borderRadius="md">
<AlertIcon />
<VStack align="start" spacing={1}>
<Text fontSize="sm">{errorMsg}</Text>
<Button size="xs" variant="link" colorScheme="red" onClick={reset}>Try again</Button>
</VStack>
</Alert>
)}
<Text fontSize="xs" color="gray.400" textAlign="center">
Document translation requires Replicate AI to be enabled in the admin settings.
<br />
PDF formatting is best-effort; Excel and Word formatting is fully preserved.
</Text>
</VStack>
);
};
export default DocumentTranslator;