- DocumentTranslator: replace Chakra Progress (broken types in 2.2.1 with fresh installs) with a simple Box-based progress bar — no type issues, same visual result - Dockerfile: switch from npm install to npm ci so Docker uses exact locked versions from package-lock.json, preventing type discrepancies between local and Docker builds Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
264 lines
10 KiB
TypeScript
264 lines
10 KiB
TypeScript
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;
|