Files
LingvAI/pages/admin/index.tsx
Malin 0799101da3 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>
2026-03-10 07:43:54 +01:00

373 lines
16 KiB
TypeScript

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;