- Header: add "+ AI enhancements" italic subtitle below logo - Footer: update copyright year to 2021–current, add "AI enhancements by Cloud Host" link - Admin page: wrap all states in pageBg Box so dark-mode background shows correctly Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
397 lines
19 KiB
TypeScript
397 lines
19 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, Alert, AlertIcon,
|
|
InputGroup, InputRightElement, IconButton, Badge, Link,
|
|
useColorModeValue, Spinner, Textarea, RadioGroup, Radio, Code
|
|
} from "@chakra-ui/react";
|
|
import { FiEye, FiEyeOff, FiSave, FiLogOut, FiArrowLeft } from "react-icons/fi";
|
|
import { CustomHead } from "@components";
|
|
|
|
type ReplicateMode = "cloud" | "local";
|
|
|
|
type Settings = {
|
|
replicateApiToken: string;
|
|
jigsawApiKey: string;
|
|
modelVersion: string;
|
|
replicateEnabled: boolean;
|
|
replicateMode: ReplicateMode;
|
|
localEndpoint: string;
|
|
};
|
|
|
|
const DEFAULT_MODEL = "jigsawstack/text-translate:454df4c49941c05dea05175bd37686d0872c73c1f9366d1c2505db32ade52a89";
|
|
const DEFAULT_LOCAL_ENDPOINT = "http://localhost:5030/predictions";
|
|
|
|
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,
|
|
replicateMode: "cloud",
|
|
localEndpoint: DEFAULT_LOCAL_ENDPOINT
|
|
});
|
|
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 pageBg = useColorModeValue("gray.50", "gray.900");
|
|
const cardBg = useColorModeValue("white", "gray.800");
|
|
const borderCol = useColorModeValue("gray.200", "gray.600");
|
|
const codeBg = useColorModeValue("gray.100", "gray.700");
|
|
|
|
useEffect(() => {
|
|
fetch("/api/admin/auth")
|
|
.then(r => r.json())
|
|
.then(d => setAuthed(d.authenticated))
|
|
.catch(() => setAuthed(false));
|
|
}, []);
|
|
|
|
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();
|
|
setTestResult(res.ok ? `✓ "${d.translation}"` : `✗ ${d.error}`);
|
|
} catch {
|
|
setTestResult("✗ Network error");
|
|
} finally {
|
|
setTestLoading(false);
|
|
}
|
|
};
|
|
|
|
if (authed === null) {
|
|
return (
|
|
<Box w="full" minH="100%" bg={pageBg} display="flex" justifyContent="center" pt={20}>
|
|
<Spinner size="xl" color="lingva.400" />
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
if (!authed) {
|
|
return (
|
|
<>
|
|
<CustomHead home={false} />
|
|
<Box w="full" minH="100%" bg={pageBg}>
|
|
<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>
|
|
</Box>
|
|
</>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<CustomHead home={false} />
|
|
<Box w="full" minH="100%" bg={pageBg}>
|
|
<Box w="full" maxW="700px" mx="auto" px={4} pb={10} pt={2}>
|
|
<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>
|
|
|
|
{/* Mode selector */}
|
|
<FormControl>
|
|
<FormLabel>Translation Backend</FormLabel>
|
|
<RadioGroup
|
|
value={settings.replicateMode}
|
|
onChange={v => setSettings(s => ({ ...s, replicateMode: v as ReplicateMode }))}
|
|
>
|
|
<HStack spacing={6}>
|
|
<Radio value="cloud" colorScheme="lingva">
|
|
<VStack align="start" spacing={0}>
|
|
<Text fontSize="sm" fontWeight="semibold">Replicate Cloud</Text>
|
|
<Text fontSize="xs" color="gray.500">Uses replicate.com API</Text>
|
|
</VStack>
|
|
</Radio>
|
|
<Radio value="local" colorScheme="lingva">
|
|
<VStack align="start" spacing={0}>
|
|
<Text fontSize="sm" fontWeight="semibold">Local Docker (Cog)</Text>
|
|
<Text fontSize="xs" color="gray.500">Uses local container</Text>
|
|
</VStack>
|
|
</Radio>
|
|
</HStack>
|
|
</RadioGroup>
|
|
</FormControl>
|
|
|
|
{/* Cloud fields */}
|
|
{settings.replicateMode === "cloud" && (
|
|
<>
|
|
<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>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>
|
|
Format: <Code fontSize="xs">owner/model:hash</Code> — the hash after the colon is extracted automatically.
|
|
</FormHelperText>
|
|
</FormControl>
|
|
</>
|
|
)}
|
|
|
|
{/* Local fields */}
|
|
{settings.replicateMode === "local" && (
|
|
<>
|
|
<Box bg={codeBg} p={4} borderRadius="md" fontSize="sm">
|
|
<Text fontWeight="semibold" mb={2}>Start the local model container:</Text>
|
|
<Code display="block" fontSize="xs" whiteSpace="pre" overflowX="auto">
|
|
{`docker run -d -p 5030:5000 \\\n r8.im/jigsawstack/text-translate@sha256:454df4c49941c05dea05175bd37686d0872c73c1f9366d1c2505db32ade52a89`}
|
|
</Code>
|
|
</Box>
|
|
<FormControl>
|
|
<FormLabel>Local Model Endpoint</FormLabel>
|
|
<Input
|
|
value={settings.localEndpoint}
|
|
onChange={e => setSettings(s => ({ ...s, localEndpoint: e.target.value }))}
|
|
placeholder={DEFAULT_LOCAL_ENDPOINT}
|
|
fontFamily="mono"
|
|
fontSize="sm"
|
|
/>
|
|
<FormHelperText>
|
|
Default: <Code fontSize="xs">{DEFAULT_LOCAL_ENDPOINT}</Code> (container port 5000 mapped to host 5030)
|
|
</FormHelperText>
|
|
</FormControl>
|
|
</>
|
|
)}
|
|
|
|
{/* JigsawStack key — needed in both modes */}
|
|
<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 by the JigsawStack model in both cloud and local modes.{" "}
|
|
<Link href="https://jigsawstack.com" isExternal color="lingva.400">Get yours →</Link>
|
|
</FormHelperText>
|
|
</FormControl>
|
|
|
|
<HStack>
|
|
<Button
|
|
size="sm" variant="outline" colorScheme="lingva"
|
|
onClick={testTranslation} isLoading={testLoading}
|
|
isDisabled={!settings.replicateEnabled || !settings.jigsawApiKey}
|
|
>
|
|
Test Translation
|
|
</Button>
|
|
{testResult && (
|
|
<Text fontSize="sm" color={testResult.startsWith("✓") ? "green.500" : "red.500"}>
|
|
{testResult}
|
|
</Text>
|
|
)}
|
|
</HStack>
|
|
</VStack>
|
|
</Box>
|
|
|
|
{/* ── Security ── */}
|
|
<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.</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>
|
|
</Box>
|
|
</>
|
|
);
|
|
};
|
|
|
|
export default AdminPage;
|