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:
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user