- 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>
410 lines
15 KiB
TypeScript
410 lines
15 KiB
TypeScript
import { useCallback, useEffect, useReducer, useState } from "react";
|
|
import { GetStaticPaths, GetStaticProps, NextPage } from "next";
|
|
import { useRouter } from "next/router";
|
|
import dynamic from "next/dynamic";
|
|
import {
|
|
getTranslationInfo,
|
|
getTranslationText,
|
|
getAudio,
|
|
languageList,
|
|
LanguageType,
|
|
replaceExceptedCode,
|
|
isValidCode,
|
|
TranslationInfo,
|
|
LangCode
|
|
} from "lingva-scraper";
|
|
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";
|
|
import { extractSlug } from "@utils/slug";
|
|
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,
|
|
ERROR,
|
|
HOME
|
|
}
|
|
|
|
type Props = {
|
|
type: ResponseType.SUCCESS,
|
|
translation: string,
|
|
info: TranslationInfo | null,
|
|
audio: {
|
|
query: number[] | null,
|
|
translation: number[] | null
|
|
},
|
|
initial: {
|
|
source: LangCode<"source">,
|
|
target: LangCode<"target">,
|
|
query: string
|
|
}
|
|
} | {
|
|
type: ResponseType.ERROR,
|
|
errorMsg: string,
|
|
initial: {
|
|
source: LangCode<"source">,
|
|
target: LangCode<"target">,
|
|
query: string
|
|
}
|
|
} | {
|
|
type: ResponseType.HOME
|
|
};
|
|
|
|
const Page: NextPage<Props> = (props) => {
|
|
const [
|
|
{ source, target, query, delayedQuery, translation, isLoading, pronunciation, audio },
|
|
dispatch
|
|
] = 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 }})
|
|
), []);
|
|
|
|
const setAllFields = useCallback((state: State) => (
|
|
dispatch({ type: Actions.SET_ALL, payload: { state }})
|
|
), []);
|
|
|
|
const setLanguage = useCallback((type: typeof LanguageType[keyof typeof LanguageType], code: string) => (
|
|
dispatch({
|
|
type: type === LanguageType.SOURCE
|
|
? Actions.SET_SOURCE
|
|
: Actions.SET_TARGET,
|
|
payload: { code }
|
|
})
|
|
), []);
|
|
|
|
const switchLanguages = useCallback((detectedSource?: LangCode<"source">) => (
|
|
dispatch({ type: Actions.SWITCH_LANGS, payload: { detectedSource } })
|
|
), []);
|
|
|
|
const changeRoute = useCallback((customQuery: string) => {
|
|
if (isLoading || router.isFallback)
|
|
return;
|
|
if (!customQuery || customQuery === initialState.query)
|
|
return;
|
|
if (props.type === ResponseType.SUCCESS && customQuery === props.initial.query
|
|
&& source === props.initial.source && target === props.initial.target)
|
|
return;
|
|
|
|
localSetItem(LanguageType.SOURCE, source);
|
|
localSetItem(LanguageType.TARGET, target);
|
|
|
|
setField("isLoading", true);
|
|
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;
|
|
|
|
if (props.type === ResponseType.HOME) {
|
|
const localSource = localGetItem(LanguageType.SOURCE);
|
|
const localTarget = localGetItem(LanguageType.TARGET);
|
|
|
|
return setAllFields({
|
|
...initialState,
|
|
source: isValidCode(localSource, LanguageType.SOURCE)
|
|
? localSource
|
|
: initialState.source,
|
|
target: isValidCode(localTarget, LanguageType.TARGET)
|
|
? localTarget
|
|
: initialState.target,
|
|
isLoading: false
|
|
});
|
|
}
|
|
|
|
if (props.type === ResponseType.ERROR)
|
|
return setAllFields({
|
|
...initialState,
|
|
...props.initial,
|
|
delayedQuery: props.initial.query,
|
|
isLoading: false
|
|
});
|
|
|
|
setAllFields({
|
|
...props.initial,
|
|
delayedQuery: props.initial.query,
|
|
translation: props.translation,
|
|
isLoading: false,
|
|
pronunciation: props.info?.pronunciation ?? {},
|
|
audio: {
|
|
query: props.audio.query ?? undefined,
|
|
translation: props.audio.translation ?? undefined
|
|
}
|
|
});
|
|
}, [props, router, setAllFields]);
|
|
|
|
useEffect(() => {
|
|
const timeoutId = setTimeout(() => setField("delayedQuery", query), 1000);
|
|
return () => clearTimeout(timeoutId);
|
|
}, [query, setField]);
|
|
|
|
useEffect(() => {
|
|
const handler = (url: string) => {
|
|
url === router.asPath || setField("isLoading", true);
|
|
|
|
if (url !== "/")
|
|
return;
|
|
setLanguage(LanguageType.SOURCE, initialState.source);
|
|
localSetItem(LanguageType.SOURCE, initialState.source);
|
|
setLanguage(LanguageType.TARGET, initialState.target);
|
|
localSetItem(LanguageType.TARGET, initialState.target);
|
|
};
|
|
router.events.on("beforeHistoryChange", handler);
|
|
return () => router.events.off("beforeHistoryChange", handler);
|
|
}, [router, setLanguage, setField]);
|
|
|
|
useToastOnLoad({
|
|
status: "error",
|
|
title: "Unexpected error",
|
|
description: props.type === ResponseType.ERROR ? props.errorMsg : undefined,
|
|
updateDeps: props.type === ResponseType.ERROR ? props.initial : undefined
|
|
});
|
|
|
|
const detectedSource = props.type === ResponseType.SUCCESS ? props.info?.detectedSource : undefined;
|
|
|
|
const canSwitch = !isLoading && (source !== "auto" || !!detectedSource);
|
|
|
|
useHotkeys("ctrl+shift+s, command+shift+s, ctrl+shift+f, command+shift+f", () => (
|
|
canSwitch && switchLanguages(detectedSource)
|
|
), [canSwitch, detectedSource, switchLanguages]);
|
|
|
|
// parse existing code with opposite exceptions in order to flatten to the standards
|
|
const queryLang = source === "auto" && !!detectedSource
|
|
? detectedSource
|
|
: 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">
|
|
<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>
|
|
</>
|
|
);
|
|
};
|
|
|
|
export default Page;
|
|
|
|
export const getStaticPaths: GetStaticPaths = async () => ({
|
|
paths: [
|
|
{
|
|
params: { slug: [] }
|
|
}
|
|
],
|
|
fallback: true
|
|
});
|
|
|
|
export const getStaticProps: GetStaticProps<Props> = async ({ params }) => {
|
|
if (!params?.slug || !Array.isArray(params.slug))
|
|
return {
|
|
props: {
|
|
type: ResponseType.HOME
|
|
}
|
|
};
|
|
|
|
const { source, target, query } = extractSlug(params.slug);
|
|
|
|
if (!query)
|
|
return {
|
|
notFound: true
|
|
};
|
|
|
|
if (!source || !target)
|
|
return {
|
|
redirect: {
|
|
destination: `/${source ?? "auto"}/${target ?? "en"}/${query}`,
|
|
permanent: true
|
|
}
|
|
};
|
|
|
|
if (!isValidCode(source, LanguageType.SOURCE) || !isValidCode(target, LanguageType.TARGET))
|
|
return {
|
|
notFound: true
|
|
};
|
|
|
|
const initial = { source, target, query };
|
|
|
|
const translation = await getTranslationText(source, target, query);
|
|
|
|
if (!translation)
|
|
return {
|
|
props: {
|
|
type: ResponseType.ERROR,
|
|
errorMsg: "An error occurred while retrieving the translation",
|
|
initial
|
|
},
|
|
revalidate: 1
|
|
};
|
|
|
|
const info = await getTranslationInfo(source, target, query);
|
|
|
|
const audioSource = source === "auto" && info?.detectedSource
|
|
? info.detectedSource
|
|
: source;
|
|
const parsedAudioSource = replaceExceptedCode(LanguageType.TARGET, audioSource);
|
|
|
|
const [audioQuery, audioTranslation] = await Promise.all([
|
|
getAudio(parsedAudioSource, query),
|
|
getAudio(target, translation)
|
|
]);
|
|
|
|
const audio = {
|
|
query: audioQuery,
|
|
translation: audioTranslation
|
|
};
|
|
|
|
return {
|
|
props: {
|
|
type: ResponseType.SUCCESS,
|
|
translation,
|
|
info,
|
|
audio,
|
|
initial
|
|
},
|
|
revalidate: 2 * 30 * 24 * 60 * 60 // 2 months
|
|
};
|
|
};
|