feat: add admin panel, Replicate AI translation, and document translation
- Admin panel (/admin) with JWT auth: configure Replicate API token, JigsawStack API key, model version, enable/disable AI translation, change admin password. Settings persisted in data/settings.json. - Replicate AI translation: POST /api/translate/replicate uses JigsawStack text-translate model via Replicate API. Main page switches to client-side AI translation when enabled. - Document translation tab: supports PDF, DOCX, XLSX, XLS, CSV. Excel/Word formatting fully preserved (SheetJS + JSZip XML manipulation). PDF uses pdf-parse extraction + pdf-lib reconstruction. Column selector UI for tabular data (per-sheet, All/None toggles). - Updated README with full implementation documentation. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
107
components/ColumnSelector.tsx
Normal file
107
components/ColumnSelector.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import { FC, useState, useEffect } from "react";
|
||||
import {
|
||||
Box, Checkbox, CheckboxGroup, VStack, HStack, Text, Button,
|
||||
Accordion, AccordionItem, AccordionButton, AccordionPanel, AccordionIcon,
|
||||
Badge
|
||||
} from "@chakra-ui/react";
|
||||
|
||||
export type SheetColumnInfo = {
|
||||
sheetName: string;
|
||||
columns: string[];
|
||||
};
|
||||
|
||||
export type ColumnSelection = {
|
||||
sheetName: string;
|
||||
columnIndices: number[];
|
||||
};
|
||||
|
||||
type Props = {
|
||||
sheetColumns: SheetColumnInfo[];
|
||||
onChange: (selections: ColumnSelection[]) => void;
|
||||
};
|
||||
|
||||
const ColumnSelector: FC<Props> = ({ sheetColumns, onChange }) => {
|
||||
const [selections, setSelections] = useState<Record<string, Set<number>>>(() => {
|
||||
const init: Record<string, Set<number>> = {};
|
||||
sheetColumns.forEach(s => {
|
||||
init[s.sheetName] = new Set(s.columns.map((_, i) => i));
|
||||
});
|
||||
return init;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const result: ColumnSelection[] = sheetColumns.map(s => ({
|
||||
sheetName: s.sheetName,
|
||||
columnIndices: Array.from(selections[s.sheetName] ?? [])
|
||||
}));
|
||||
onChange(result);
|
||||
}, [selections, sheetColumns, onChange]);
|
||||
|
||||
const toggleColumn = (sheetName: string, colIdx: number) => {
|
||||
setSelections(prev => {
|
||||
const set = new Set(prev[sheetName] ?? []);
|
||||
if (set.has(colIdx)) set.delete(colIdx);
|
||||
else set.add(colIdx);
|
||||
return { ...prev, [sheetName]: set };
|
||||
});
|
||||
};
|
||||
|
||||
const selectAll = (sheetName: string, cols: string[]) => {
|
||||
setSelections(prev => ({
|
||||
...prev,
|
||||
[sheetName]: new Set(cols.map((_, i) => i))
|
||||
}));
|
||||
};
|
||||
|
||||
const selectNone = (sheetName: string) => {
|
||||
setSelections(prev => ({ ...prev, [sheetName]: new Set() }));
|
||||
};
|
||||
|
||||
return (
|
||||
<Box w="full" borderWidth={1} borderRadius="md" p={3}>
|
||||
<Text fontWeight="bold" mb={2} fontSize="sm">Select Columns to Translate</Text>
|
||||
<Accordion allowMultiple defaultIndex={sheetColumns.map((_, i) => i)}>
|
||||
{sheetColumns.map(sheet => (
|
||||
<AccordionItem key={sheet.sheetName}>
|
||||
<AccordionButton>
|
||||
<Box flex="1" textAlign="left" fontSize="sm" fontWeight="semibold">
|
||||
{sheet.sheetName}
|
||||
<Badge ml={2} colorScheme="lingva">
|
||||
{selections[sheet.sheetName]?.size ?? 0}/{sheet.columns.length}
|
||||
</Badge>
|
||||
</Box>
|
||||
<AccordionIcon />
|
||||
</AccordionButton>
|
||||
<AccordionPanel pb={3}>
|
||||
<HStack mb={2} spacing={2}>
|
||||
<Button size="xs" variant="outline" colorScheme="lingva"
|
||||
onClick={() => selectAll(sheet.sheetName, sheet.columns)}>
|
||||
All
|
||||
</Button>
|
||||
<Button size="xs" variant="outline"
|
||||
onClick={() => selectNone(sheet.sheetName)}>
|
||||
None
|
||||
</Button>
|
||||
</HStack>
|
||||
<VStack align="start" spacing={1} maxH="150px" overflowY="auto">
|
||||
{sheet.columns.map((col, idx) => (
|
||||
<Checkbox
|
||||
key={idx}
|
||||
size="sm"
|
||||
isChecked={selections[sheet.sheetName]?.has(idx) ?? false}
|
||||
onChange={() => toggleColumn(sheet.sheetName, idx)}
|
||||
colorScheme="lingva"
|
||||
>
|
||||
<Text fontSize="xs">{col}</Text>
|
||||
</Checkbox>
|
||||
))}
|
||||
</VStack>
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
))}
|
||||
</Accordion>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ColumnSelector;
|
||||
261
components/DocumentTranslator.tsx
Normal file
261
components/DocumentTranslator.tsx
Normal file
@@ -0,0 +1,261 @@
|
||||
import { FC, useState, useCallback, useRef } from "react";
|
||||
import {
|
||||
Box, Button, VStack, HStack, Text, Input, Alert, AlertIcon,
|
||||
Progress, 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 => 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 => { 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 => 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">
|
||||
<Progress value={progress} colorScheme="lingva" borderRadius="full" hasStripe isAnimated />
|
||||
<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;
|
||||
@@ -3,6 +3,7 @@ import Head from "next/head";
|
||||
import NextLink from "next/link";
|
||||
import { Flex, HStack, IconButton, Link, useColorModeValue } from "@chakra-ui/react";
|
||||
import { FaGithub } from "react-icons/fa";
|
||||
import { FiSettings } from "react-icons/fi";
|
||||
import Image from "next/image";
|
||||
import { ColorModeToggler } from ".";
|
||||
|
||||
@@ -40,6 +41,15 @@ const Header: FC<Props> = (props) => (
|
||||
<ColorModeToggler
|
||||
variant={useColorModeValue("outline", "solid")}
|
||||
/>
|
||||
<NextLink href="/admin" passHref={true}>
|
||||
<IconButton
|
||||
as={Link}
|
||||
aria-label="Admin settings"
|
||||
icon={<FiSettings />}
|
||||
colorScheme="lingva"
|
||||
variant={useColorModeValue("outline", "solid")}
|
||||
/>
|
||||
</NextLink>
|
||||
<IconButton
|
||||
as={Link}
|
||||
href="https://github.com/thedaviddelta/lingva-translate"
|
||||
|
||||
@@ -7,3 +7,5 @@ export { default as ColorModeToggler } from "./ColorModeToggler";
|
||||
export { default as LangSelect } from "./LangSelect";
|
||||
export { default as TranslationArea } from "./TranslationArea";
|
||||
export { default as AutoTranslateButton } from "./AutoTranslateButton";
|
||||
export { default as DocumentTranslator } from "./DocumentTranslator";
|
||||
export { default as ColumnSelector } from "./ColumnSelector";
|
||||
|
||||
Reference in New Issue
Block a user