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:
2026-03-10 07:43:54 +01:00
parent 0190ea5da9
commit 0799101da3
23 changed files with 18595 additions and 261 deletions

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useReducer } from "react";
import { useCallback, useEffect, useReducer, useState } from "react";
import { GetStaticPaths, GetStaticProps, NextPage } from "next";
import { useRouter } from "next/router";
import dynamic from "next/dynamic";
@@ -13,9 +13,10 @@ import {
TranslationInfo,
LangCode
} from "lingva-scraper";
import { HStack, IconButton, Stack, VStack } from "@chakra-ui/react";
import { HStack, IconButton, Stack, VStack, Tabs, TabList, Tab, TabPanels, TabPanel } from "@chakra-ui/react";
import { FaExchangeAlt } from "react-icons/fa";
import { HiTranslate } from "react-icons/hi";
import { FiFileText } from "react-icons/fi";
import { useHotkeys } from "react-hotkeys-hook";
import { CustomHead, LangSelect, TranslationArea } from "@components";
import { useToastOnLoad } from "@hooks";
@@ -24,6 +25,7 @@ import langReducer, { Actions, initialState, State } from "@utils/reducer";
import { localGetItem, localSetItem } from "@utils/storage";
const AutoTranslateButton = dynamic(() => import("@components/AutoTranslateButton"), { ssr: false });
const DocumentTranslator = dynamic(() => import("@components/DocumentTranslator"), { ssr: false });
export enum ResponseType {
SUCCESS,
@@ -63,6 +65,17 @@ const Page: NextPage<Props> = (props) => {
] = useReducer(langReducer, initialState);
const router = useRouter();
const [replicateEnabled, setReplicateEnabled] = useState(false);
const [replicateTranslation, setReplicateTranslation] = useState("");
const [replicateLoading, setReplicateLoading] = useState(false);
// Check if Replicate is enabled
useEffect(() => {
fetch("/api/admin/settings")
.then(r => r.ok ? r.json() : null)
.then(d => { if (d?.replicateEnabled) setReplicateEnabled(true); })
.catch(() => {});
}, []);
const setField = useCallback(<T extends keyof State,>(key: T, value: State[T]) => (
dispatch({ type: Actions.SET_FIELD, payload: { key, value }})
@@ -101,6 +114,30 @@ const Page: NextPage<Props> = (props) => {
router.push(`/${source}/${target}/${encodeURIComponent(customQuery)}`);
}, [isLoading, source, target, props, router, setField]);
// Replicate-powered translation
const translateWithReplicate = useCallback(async (text: string) => {
if (!text.trim()) return;
setReplicateLoading(true);
setReplicateTranslation("");
try {
const res = await fetch("/api/translate/replicate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text, targetLanguage: target })
});
const data = await res.json();
if (res.ok) {
setReplicateTranslation(data.translation ?? "");
} else {
setReplicateTranslation(`[Error: ${data.error}]`);
}
} catch {
setReplicateTranslation("[Error: Network failure]");
} finally {
setReplicateLoading(false);
}
}, [target]);
useEffect(() => {
if (router.isFallback)
return;
@@ -183,78 +220,107 @@ const Page: NextPage<Props> = (props) => {
: replaceExceptedCode(LanguageType.TARGET, source);
const transLang = replaceExceptedCode(LanguageType.SOURCE, target);
const LangControls = (
<HStack px={[1, null, 3, 4]} w="full">
<LangSelect
id="source"
aria-label="Source language"
value={source}
detectedSource={detectedSource}
onChange={e => setLanguage(LanguageType.SOURCE, e.target.value)}
langs={languageList.source}
/>
<IconButton
aria-label="Switch languages"
icon={<FaExchangeAlt />}
colorScheme="lingva"
variant="ghost"
onClick={() => switchLanguages(detectedSource)}
isDisabled={!canSwitch}
/>
<LangSelect
id="target"
aria-label="Target language"
value={target}
onChange={e => setLanguage(LanguageType.TARGET, e.target.value)}
langs={languageList.target}
/>
</HStack>
);
return (
<>
<CustomHead home={props.type === ResponseType.HOME} />
<VStack px={[8, null, 24, 40]} w="full">
<HStack px={[1, null, 3, 4]} w="full">
<LangSelect
id="source"
aria-label="Source language"
value={source}
detectedSource={detectedSource}
onChange={e => setLanguage(LanguageType.SOURCE, e.target.value)}
langs={languageList.source}
/>
<IconButton
aria-label="Switch languages"
icon={<FaExchangeAlt />}
colorScheme="lingva"
variant="ghost"
onClick={() => switchLanguages(detectedSource)}
isDisabled={!canSwitch}
/>
<LangSelect
id="target"
aria-label="Target language"
value={target}
onChange={e => setLanguage(LanguageType.TARGET, e.target.value)}
langs={languageList.target}
/>
</HStack>
<Stack direction={["column", null, "row"]} w="full">
<TranslationArea
id="query"
aria-label="Translation query"
placeholder="Text"
value={query}
onChange={e => isLoading || setField("query", e.target.value)}
onSubmit={() => changeRoute(query)}
lang={queryLang}
audio={audio.query}
pronunciation={pronunciation.query}
/>
<Stack direction={["row", null, "column"]} justify="center" spacing={3} px={[2, null, "initial"]}>
<IconButton
aria-label="Translate"
icon={<HiTranslate />}
colorScheme="lingva"
variant="outline"
onClick={() => changeRoute(query)}
isDisabled={isLoading}
w={["full", null, "auto"]}
/>
<AutoTranslateButton
isDisabled={isLoading}
// runs on effect update
onAuto={useCallback(() => changeRoute(delayedQuery), [delayedQuery, changeRoute])}
w={["full", null, "auto"]}
/>
</Stack>
<TranslationArea
id="translation"
aria-label="Translation result"
placeholder="Translation"
value={translation ?? ""}
readOnly={true}
lang={transLang}
audio={audio.translation}
canCopy={true}
isLoading={isLoading}
pronunciation={pronunciation.translation}
/>
</Stack>
<Tabs w="full" colorScheme="lingva" variant="soft-rounded" size="sm">
<TabList mb={4} justifyContent="center">
<Tab><HiTranslate style={{ marginRight: 6 }} />Text</Tab>
<Tab><FiFileText style={{ marginRight: 6 }} />Document</Tab>
</TabList>
<TabPanels>
{/* Text Translation Tab */}
<TabPanel p={0}>
<VStack w="full" spacing={4}>
{LangControls}
<Stack direction={["column", null, "row"]} w="full">
<TranslationArea
id="query"
aria-label="Translation query"
placeholder="Text"
value={query}
onChange={e => isLoading || setField("query", e.target.value)}
onSubmit={() => replicateEnabled ? translateWithReplicate(query) : changeRoute(query)}
lang={queryLang}
audio={audio.query}
pronunciation={pronunciation.query}
/>
<Stack direction={["row", null, "column"]} justify="center" spacing={3} px={[2, null, "initial"]}>
<IconButton
aria-label="Translate"
icon={<HiTranslate />}
colorScheme="lingva"
variant="outline"
onClick={() => replicateEnabled ? translateWithReplicate(query) : changeRoute(query)}
isDisabled={isLoading || replicateLoading}
isLoading={replicateLoading}
w={["full", null, "auto"]}
/>
<AutoTranslateButton
isDisabled={isLoading || replicateLoading}
onAuto={useCallback(() => {
if (replicateEnabled) {
translateWithReplicate(delayedQuery);
} else {
changeRoute(delayedQuery);
}
}, [delayedQuery, replicateEnabled, translateWithReplicate, changeRoute])}
w={["full", null, "auto"]}
/>
</Stack>
<TranslationArea
id="translation"
aria-label="Translation result"
placeholder="Translation"
value={replicateEnabled ? replicateTranslation : (translation ?? "")}
readOnly={true}
lang={transLang}
audio={audio.translation}
canCopy={true}
isLoading={replicateEnabled ? replicateLoading : isLoading}
pronunciation={replicateEnabled ? undefined : pronunciation.translation}
/>
</Stack>
</VStack>
</TabPanel>
{/* Document Translation Tab */}
<TabPanel p={0}>
<DocumentTranslator />
</TabPanel>
</TabPanels>
</Tabs>
</VStack>
</>
);