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>
|
||||
</>
|
||||
);
|
||||
|
||||
372
pages/admin/index.tsx
Normal file
372
pages/admin/index.tsx
Normal file
@@ -0,0 +1,372 @@
|
||||
import { useState, useEffect, FormEvent } from "react";
|
||||
import { NextPage } from "next";
|
||||
import NextLink from "next/link";
|
||||
import {
|
||||
Box, VStack, HStack, Heading, Text, Input, Button, Switch,
|
||||
FormControl, FormLabel, FormHelperText, Divider, Alert, AlertIcon,
|
||||
InputGroup, InputRightElement, IconButton, Badge, Link,
|
||||
useColorModeValue, Spinner, Textarea
|
||||
} from "@chakra-ui/react";
|
||||
import { FiEye, FiEyeOff, FiSave, FiLogOut, FiArrowLeft } from "react-icons/fi";
|
||||
import { CustomHead } from "@components";
|
||||
|
||||
type Settings = {
|
||||
replicateApiToken: string;
|
||||
jigsawApiKey: string;
|
||||
modelVersion: string;
|
||||
replicateEnabled: boolean;
|
||||
};
|
||||
|
||||
const DEFAULT_MODEL = "jigsawstack/text-translate:454df4c49941c05dea05175bd37686d0872c73c1f9366d1c2505db32ade52a89";
|
||||
|
||||
const AdminPage: NextPage = () => {
|
||||
const [authed, setAuthed] = useState<boolean | null>(null);
|
||||
const [password, setPassword] = useState("");
|
||||
const [loginError, setLoginError] = useState("");
|
||||
const [loginLoading, setLoginLoading] = useState(false);
|
||||
|
||||
const [settings, setSettings] = useState<Settings>({
|
||||
replicateApiToken: "",
|
||||
jigsawApiKey: "",
|
||||
modelVersion: DEFAULT_MODEL,
|
||||
replicateEnabled: false
|
||||
});
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [saveMsg, setSaveMsg] = useState<{ type: "success" | "error"; text: string } | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [showToken, setShowToken] = useState(false);
|
||||
const [showJigsawKey, setShowJigsawKey] = useState(false);
|
||||
const [testResult, setTestResult] = useState<string | null>(null);
|
||||
const [testLoading, setTestLoading] = useState(false);
|
||||
|
||||
const cardBg = useColorModeValue("white", "gray.800");
|
||||
const borderCol = useColorModeValue("gray.200", "gray.600");
|
||||
|
||||
// Check auth on load
|
||||
useEffect(() => {
|
||||
fetch("/api/admin/auth")
|
||||
.then(r => r.json())
|
||||
.then(d => setAuthed(d.authenticated))
|
||||
.catch(() => setAuthed(false));
|
||||
}, []);
|
||||
|
||||
// Load settings when authed
|
||||
useEffect(() => {
|
||||
if (!authed) return;
|
||||
fetch("/api/admin/settings")
|
||||
.then(r => r.json())
|
||||
.then(d => setSettings(s => ({ ...s, ...d })))
|
||||
.catch(() => {});
|
||||
}, [authed]);
|
||||
|
||||
const login = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoginLoading(true);
|
||||
setLoginError("");
|
||||
try {
|
||||
const res = await fetch("/api/admin/auth", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ password })
|
||||
});
|
||||
if (res.ok) {
|
||||
setAuthed(true);
|
||||
} else {
|
||||
const d = await res.json();
|
||||
setLoginError(d.error ?? "Login failed");
|
||||
}
|
||||
} catch {
|
||||
setLoginError("Network error");
|
||||
} finally {
|
||||
setLoginLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const logout = async () => {
|
||||
await fetch("/api/admin/auth", { method: "DELETE" });
|
||||
setAuthed(false);
|
||||
setPassword("");
|
||||
};
|
||||
|
||||
const save = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
setSaveMsg(null);
|
||||
try {
|
||||
const body: Record<string, unknown> = { ...settings };
|
||||
if (newPassword.length >= 6) body.newPassword = newPassword;
|
||||
const res = await fetch("/api/admin/settings", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
const d = await res.json();
|
||||
if (res.ok) {
|
||||
setSettings(s => ({ ...s, ...d }));
|
||||
setSaveMsg({ type: "success", text: "Settings saved successfully" });
|
||||
setNewPassword("");
|
||||
} else {
|
||||
setSaveMsg({ type: "error", text: d.error ?? "Save failed" });
|
||||
}
|
||||
} catch {
|
||||
setSaveMsg({ type: "error", text: "Network error" });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const testTranslation = async () => {
|
||||
setTestLoading(true);
|
||||
setTestResult(null);
|
||||
try {
|
||||
const res = await fetch("/api/translate/replicate", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ text: "Hello, world!", targetLanguage: "es" })
|
||||
});
|
||||
const d = await res.json();
|
||||
if (res.ok) {
|
||||
setTestResult(`✓ Success: "${d.translation}"`);
|
||||
} else {
|
||||
setTestResult(`✗ Error: ${d.error}`);
|
||||
}
|
||||
} catch {
|
||||
setTestResult("✗ Network error");
|
||||
} finally {
|
||||
setTestLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Loading state
|
||||
if (authed === null) {
|
||||
return (
|
||||
<Box w="full" display="flex" justifyContent="center" pt={20}>
|
||||
<Spinner size="xl" color="lingva.400" />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Login form
|
||||
if (!authed) {
|
||||
return (
|
||||
<>
|
||||
<CustomHead home={false} />
|
||||
<Box
|
||||
w="full"
|
||||
maxW="400px"
|
||||
mx="auto"
|
||||
mt={10}
|
||||
p={8}
|
||||
bg={cardBg}
|
||||
borderWidth={1}
|
||||
borderColor={borderCol}
|
||||
borderRadius="xl"
|
||||
boxShadow="md"
|
||||
>
|
||||
<VStack spacing={6} as="form" onSubmit={login}>
|
||||
<Heading size="md">Admin Login</Heading>
|
||||
{loginError && (
|
||||
<Alert status="error" borderRadius="md">
|
||||
<AlertIcon />{loginError}
|
||||
</Alert>
|
||||
)}
|
||||
<FormControl isRequired>
|
||||
<FormLabel>Password</FormLabel>
|
||||
<Input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
autoFocus
|
||||
placeholder="Admin password"
|
||||
/>
|
||||
<FormHelperText>Default: admin (set ADMIN_PASSWORD env var)</FormHelperText>
|
||||
</FormControl>
|
||||
<Button
|
||||
type="submit"
|
||||
colorScheme="lingva"
|
||||
w="full"
|
||||
isLoading={loginLoading}
|
||||
>
|
||||
Login
|
||||
</Button>
|
||||
<NextLink href="/" passHref>
|
||||
<Link fontSize="sm">← Back to translator</Link>
|
||||
</NextLink>
|
||||
</VStack>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Admin panel
|
||||
return (
|
||||
<>
|
||||
<CustomHead home={false} />
|
||||
<Box w="full" maxW="700px" mx="auto" px={4} pb={10}>
|
||||
<HStack justify="space-between" mb={6}>
|
||||
<NextLink href="/" passHref>
|
||||
<Button as={Link} leftIcon={<FiArrowLeft />} variant="ghost" size="sm">
|
||||
Back
|
||||
</Button>
|
||||
</NextLink>
|
||||
<Heading size="md">Admin Settings</Heading>
|
||||
<Button leftIcon={<FiLogOut />} variant="ghost" size="sm" onClick={logout}>
|
||||
Logout
|
||||
</Button>
|
||||
</HStack>
|
||||
|
||||
<VStack spacing={6} as="form" onSubmit={save}>
|
||||
{/* Replicate Section */}
|
||||
<Box w="full" p={6} bg={cardBg} borderWidth={1} borderColor={borderCol} borderRadius="xl">
|
||||
<HStack mb={4}>
|
||||
<Heading size="sm">Replicate AI Translation</Heading>
|
||||
<Badge colorScheme={settings.replicateEnabled ? "green" : "gray"}>
|
||||
{settings.replicateEnabled ? "Enabled" : "Disabled"}
|
||||
</Badge>
|
||||
</HStack>
|
||||
|
||||
<VStack spacing={4} align="stretch">
|
||||
<FormControl display="flex" alignItems="center">
|
||||
<FormLabel mb={0}>Enable Replicate Translation</FormLabel>
|
||||
<Switch
|
||||
colorScheme="lingva"
|
||||
isChecked={settings.replicateEnabled}
|
||||
onChange={e => setSettings(s => ({ ...s, replicateEnabled: e.target.checked }))}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>Replicate API Token</FormLabel>
|
||||
<InputGroup>
|
||||
<Input
|
||||
type={showToken ? "text" : "password"}
|
||||
value={settings.replicateApiToken}
|
||||
onChange={e => setSettings(s => ({ ...s, replicateApiToken: e.target.value }))}
|
||||
placeholder="r8_..."
|
||||
fontFamily="mono"
|
||||
fontSize="sm"
|
||||
/>
|
||||
<InputRightElement>
|
||||
<IconButton
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
aria-label="Toggle visibility"
|
||||
icon={showToken ? <FiEyeOff /> : <FiEye />}
|
||||
onClick={() => setShowToken(v => !v)}
|
||||
/>
|
||||
</InputRightElement>
|
||||
</InputGroup>
|
||||
<FormHelperText>
|
||||
Get your token at{" "}
|
||||
<Link href="https://replicate.com/account/api-tokens" isExternal color="lingva.400">
|
||||
replicate.com/account/api-tokens
|
||||
</Link>
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>JigsawStack API Key</FormLabel>
|
||||
<InputGroup>
|
||||
<Input
|
||||
type={showJigsawKey ? "text" : "password"}
|
||||
value={settings.jigsawApiKey}
|
||||
onChange={e => setSettings(s => ({ ...s, jigsawApiKey: e.target.value }))}
|
||||
placeholder="sk_..."
|
||||
fontFamily="mono"
|
||||
fontSize="sm"
|
||||
/>
|
||||
<InputRightElement>
|
||||
<IconButton
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
aria-label="Toggle visibility"
|
||||
icon={showJigsawKey ? <FiEyeOff /> : <FiEye />}
|
||||
onClick={() => setShowJigsawKey(v => !v)}
|
||||
/>
|
||||
</InputRightElement>
|
||||
</InputGroup>
|
||||
<FormHelperText>
|
||||
Required for the JigsawStack translation model. Get yours at{" "}
|
||||
<Link href="https://jigsawstack.com" isExternal color="lingva.400">
|
||||
jigsawstack.com
|
||||
</Link>
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>Model Version</FormLabel>
|
||||
<Textarea
|
||||
value={settings.modelVersion}
|
||||
onChange={e => setSettings(s => ({ ...s, modelVersion: e.target.value }))}
|
||||
fontFamily="mono"
|
||||
fontSize="xs"
|
||||
rows={2}
|
||||
placeholder={DEFAULT_MODEL}
|
||||
/>
|
||||
<FormHelperText>
|
||||
Replicate model version string. Default uses the JigsawStack text-translate model.
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
|
||||
<HStack>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
colorScheme="lingva"
|
||||
onClick={testTranslation}
|
||||
isLoading={testLoading}
|
||||
isDisabled={!settings.replicateEnabled || !settings.replicateApiToken}
|
||||
>
|
||||
Test Translation
|
||||
</Button>
|
||||
{testResult && (
|
||||
<Text
|
||||
fontSize="sm"
|
||||
color={testResult.startsWith("✓") ? "green.500" : "red.500"}
|
||||
>
|
||||
{testResult}
|
||||
</Text>
|
||||
)}
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Box>
|
||||
|
||||
{/* Security Section */}
|
||||
<Box w="full" p={6} bg={cardBg} borderWidth={1} borderColor={borderCol} borderRadius="xl">
|
||||
<Heading size="sm" mb={4}>Security</Heading>
|
||||
<FormControl>
|
||||
<FormLabel>New Admin Password</FormLabel>
|
||||
<Input
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={e => setNewPassword(e.target.value)}
|
||||
placeholder="Leave blank to keep current"
|
||||
minLength={6}
|
||||
/>
|
||||
<FormHelperText>Minimum 6 characters. Leave blank to keep current password.</FormHelperText>
|
||||
</FormControl>
|
||||
</Box>
|
||||
|
||||
{saveMsg && (
|
||||
<Alert status={saveMsg.type} borderRadius="md">
|
||||
<AlertIcon />{saveMsg.text}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
colorScheme="lingva"
|
||||
leftIcon={<FiSave />}
|
||||
isLoading={saving}
|
||||
size="lg"
|
||||
w="full"
|
||||
>
|
||||
Save Settings
|
||||
</Button>
|
||||
</VStack>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminPage;
|
||||
33
pages/api/admin/auth.ts
Normal file
33
pages/api/admin/auth.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { NextApiHandler } from "next";
|
||||
import { signAdminToken, checkPassword, COOKIE_NAME, getTokenFromRequest, verifyAdminToken } from "@utils/admin-auth";
|
||||
|
||||
const handler: NextApiHandler = async (req, res) => {
|
||||
if (req.method === "POST") {
|
||||
const { password } = req.body ?? {};
|
||||
if (!password || typeof password !== "string") {
|
||||
return res.status(400).json({ error: "Password required" });
|
||||
}
|
||||
if (!checkPassword(password)) {
|
||||
return res.status(401).json({ error: "Invalid password" });
|
||||
}
|
||||
const token = await signAdminToken();
|
||||
res.setHeader("Set-Cookie", `${COOKIE_NAME}=${token}; HttpOnly; Path=/; SameSite=Lax; Max-Age=28800`);
|
||||
return res.status(200).json({ ok: true });
|
||||
}
|
||||
|
||||
if (req.method === "DELETE") {
|
||||
res.setHeader("Set-Cookie", `${COOKIE_NAME}=; HttpOnly; Path=/; Max-Age=0`);
|
||||
return res.status(200).json({ ok: true });
|
||||
}
|
||||
|
||||
if (req.method === "GET") {
|
||||
const token = getTokenFromRequest(req);
|
||||
const valid = token ? await verifyAdminToken(token) : false;
|
||||
return res.status(200).json({ authenticated: valid });
|
||||
}
|
||||
|
||||
res.setHeader("Allow", ["GET", "POST", "DELETE"]);
|
||||
return res.status(405).json({ error: "Method Not Allowed" });
|
||||
};
|
||||
|
||||
export default handler;
|
||||
42
pages/api/admin/settings.ts
Normal file
42
pages/api/admin/settings.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { NextApiHandler } from "next";
|
||||
import { requireAdmin } from "@utils/admin-auth";
|
||||
import { readSettings, writeSettings } from "@utils/settings-store";
|
||||
|
||||
const handler: NextApiHandler = async (req, res) => {
|
||||
if (!(await requireAdmin(req, res))) return;
|
||||
|
||||
if (req.method === "GET") {
|
||||
const settings = readSettings();
|
||||
// Never expose the adminPasswordHash
|
||||
const { adminPasswordHash: _omit, ...safe } = settings;
|
||||
return res.status(200).json(safe);
|
||||
}
|
||||
|
||||
if (req.method === "POST") {
|
||||
const {
|
||||
replicateApiToken,
|
||||
jigsawApiKey,
|
||||
modelVersion,
|
||||
replicateEnabled,
|
||||
newPassword
|
||||
} = req.body ?? {};
|
||||
|
||||
const updates: Parameters<typeof writeSettings>[0] = {};
|
||||
if (replicateApiToken !== undefined) updates.replicateApiToken = replicateApiToken;
|
||||
if (jigsawApiKey !== undefined) updates.jigsawApiKey = jigsawApiKey;
|
||||
if (modelVersion !== undefined) updates.modelVersion = modelVersion;
|
||||
if (replicateEnabled !== undefined) updates.replicateEnabled = Boolean(replicateEnabled);
|
||||
if (newPassword && typeof newPassword === "string" && newPassword.length >= 6) {
|
||||
updates.adminPasswordHash = newPassword;
|
||||
}
|
||||
|
||||
const saved = writeSettings(updates);
|
||||
const { adminPasswordHash: _omit, ...safe } = saved;
|
||||
return res.status(200).json(safe);
|
||||
}
|
||||
|
||||
res.setHeader("Allow", ["GET", "POST"]);
|
||||
return res.status(405).json({ error: "Method Not Allowed" });
|
||||
};
|
||||
|
||||
export default handler;
|
||||
136
pages/api/translate/document.ts
Normal file
136
pages/api/translate/document.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { NextApiHandler } from "next";
|
||||
import formidable, { File, Fields, Files } from "formidable";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { translateExcel, getExcelColumns, ColumnSelection } from "@utils/document-processors/excel";
|
||||
import { translateDocx } from "@utils/document-processors/docx";
|
||||
import { translatePdf } from "@utils/document-processors/pdf";
|
||||
import { readSettings } from "@utils/settings-store";
|
||||
|
||||
export const config = {
|
||||
api: {
|
||||
bodyParser: false,
|
||||
responseLimit: "50mb"
|
||||
}
|
||||
};
|
||||
|
||||
type ParsedForm = {
|
||||
fields: Fields;
|
||||
files: Files;
|
||||
};
|
||||
|
||||
function parseForm(req: Parameters<NextApiHandler>[0]): Promise<ParsedForm> {
|
||||
const form = formidable({ maxFileSize: 50 * 1024 * 1024 }); // 50 MB
|
||||
return new Promise((resolve, reject) => {
|
||||
form.parse(req, (err, fields, files) => {
|
||||
if (err) reject(err);
|
||||
else resolve({ fields, files });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function getFileBuffer(file: File): Buffer {
|
||||
return fs.readFileSync(file.filepath);
|
||||
}
|
||||
|
||||
function getField(fields: Fields, key: string): string | undefined {
|
||||
const val = fields[key];
|
||||
return Array.isArray(val) ? val[0] : (val as string | undefined);
|
||||
}
|
||||
|
||||
const handler: NextApiHandler = async (req, res) => {
|
||||
if (req.method !== "POST") {
|
||||
res.setHeader("Allow", ["POST"]);
|
||||
return res.status(405).json({ error: "Method Not Allowed" });
|
||||
}
|
||||
|
||||
const settings = readSettings();
|
||||
if (!settings.replicateEnabled) {
|
||||
return res.status(503).json({ error: "Replicate translation is not enabled. Configure it in the admin panel." });
|
||||
}
|
||||
|
||||
let parsed: ParsedForm;
|
||||
try {
|
||||
parsed = await parseForm(req);
|
||||
} catch (err) {
|
||||
return res.status(400).json({ error: "Failed to parse upload" });
|
||||
}
|
||||
|
||||
const { fields, files } = parsed;
|
||||
const fileEntry = files["file"];
|
||||
const file = Array.isArray(fileEntry) ? fileEntry[0] : fileEntry;
|
||||
|
||||
if (!file) {
|
||||
return res.status(400).json({ error: "No file uploaded" });
|
||||
}
|
||||
|
||||
const targetLanguage = getField(fields, "targetLanguage") ?? "en";
|
||||
const sourceLanguage = getField(fields, "sourceLanguage");
|
||||
const action = getField(fields, "action") ?? "translate";
|
||||
const columnSelectionsRaw = getField(fields, "columnSelections");
|
||||
|
||||
const filename = file.originalFilename ?? file.newFilename ?? "file";
|
||||
const ext = path.extname(filename).toLowerCase();
|
||||
const buffer = getFileBuffer(file);
|
||||
|
||||
try {
|
||||
// Action: getColumns - return column info for Excel files
|
||||
if (action === "getColumns") {
|
||||
if (![".xlsx", ".xls", ".csv"].includes(ext)) {
|
||||
return res.status(400).json({ error: "Column selection only supported for Excel/CSV files" });
|
||||
}
|
||||
const columns = getExcelColumns(buffer, filename);
|
||||
return res.status(200).json({ columns });
|
||||
}
|
||||
|
||||
// Action: translate
|
||||
let outBuffer: Buffer;
|
||||
let outMime: string;
|
||||
let outFilename: string;
|
||||
|
||||
if ([".xlsx", ".xls"].includes(ext)) {
|
||||
let columnSelections: ColumnSelection[] = [];
|
||||
if (columnSelectionsRaw) {
|
||||
try {
|
||||
columnSelections = JSON.parse(columnSelectionsRaw);
|
||||
} catch { /* use empty = translate all */ }
|
||||
}
|
||||
outBuffer = await translateExcel(buffer, targetLanguage, columnSelections);
|
||||
outMime = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
|
||||
outFilename = filename.replace(ext, `_${targetLanguage}${ext}`);
|
||||
} else if (ext === ".csv") {
|
||||
// Treat CSV as Excel
|
||||
let columnSelections: ColumnSelection[] = [];
|
||||
if (columnSelectionsRaw) {
|
||||
try {
|
||||
columnSelections = JSON.parse(columnSelectionsRaw);
|
||||
} catch { /* use empty = translate all */ }
|
||||
}
|
||||
outBuffer = await translateExcel(buffer, targetLanguage, columnSelections);
|
||||
outMime = "text/csv";
|
||||
outFilename = filename.replace(ext, `_${targetLanguage}${ext}`);
|
||||
} else if (ext === ".docx") {
|
||||
outBuffer = await translateDocx(buffer, targetLanguage);
|
||||
outMime = "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
|
||||
outFilename = filename.replace(ext, `_${targetLanguage}${ext}`);
|
||||
} else if (ext === ".pdf") {
|
||||
outBuffer = await translatePdf(buffer, targetLanguage, sourceLanguage);
|
||||
outMime = "application/pdf";
|
||||
outFilename = filename.replace(ext, `_${targetLanguage}${ext}`);
|
||||
} else {
|
||||
return res.status(400).json({
|
||||
error: `Unsupported file type: ${ext}. Supported: .pdf, .docx, .xlsx, .xls, .csv`
|
||||
});
|
||||
}
|
||||
|
||||
res.setHeader("Content-Type", outMime);
|
||||
res.setHeader("Content-Disposition", `attachment; filename="${outFilename}"`);
|
||||
res.setHeader("Content-Length", outBuffer.length);
|
||||
return res.status(200).send(outBuffer);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : "Translation failed";
|
||||
return res.status(500).json({ error: msg });
|
||||
}
|
||||
};
|
||||
|
||||
export default handler;
|
||||
38
pages/api/translate/replicate.ts
Normal file
38
pages/api/translate/replicate.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { NextApiHandler } from "next";
|
||||
import NextCors from "nextjs-cors";
|
||||
import { replicateTranslate } from "@utils/replicate-translate";
|
||||
import { readSettings } from "@utils/settings-store";
|
||||
|
||||
type Data = { translation: string } | { error: string };
|
||||
|
||||
const handler: NextApiHandler<Data> = async (req, res) => {
|
||||
await NextCors(req, res, { methods: ["POST"], origin: "*" });
|
||||
|
||||
if (req.method !== "POST") {
|
||||
res.setHeader("Allow", ["POST"]);
|
||||
return res.status(405).json({ error: "Method Not Allowed" });
|
||||
}
|
||||
|
||||
const settings = readSettings();
|
||||
if (!settings.replicateEnabled) {
|
||||
return res.status(503).json({ error: "Replicate translation is not enabled" });
|
||||
}
|
||||
|
||||
const { text, targetLanguage } = req.body ?? {};
|
||||
if (!text || typeof text !== "string") {
|
||||
return res.status(400).json({ error: "text is required" });
|
||||
}
|
||||
if (!targetLanguage || typeof targetLanguage !== "string") {
|
||||
return res.status(400).json({ error: "targetLanguage is required" });
|
||||
}
|
||||
|
||||
try {
|
||||
const translation = await replicateTranslate(text, targetLanguage);
|
||||
return res.status(200).json({ translation });
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : "Translation failed";
|
||||
return res.status(500).json({ error: msg });
|
||||
}
|
||||
};
|
||||
|
||||
export default handler;
|
||||
Reference in New Issue
Block a user