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:
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