Add import/export functionality

This commit is contained in:
2026-07-03 08:24:28 +03:00
parent 250c3b0156
commit 2eb0a3ffa7
3 changed files with 351 additions and 37 deletions
+77 -10
View File
@@ -7,6 +7,7 @@ import { javascript } from "@codemirror/lang-javascript";
import ReactMarkdown from "react-markdown";
import {
CheckCircle2,
Download,
Eye,
FileText,
History,
@@ -698,6 +699,8 @@ function AdminApp({ refreshAuth }: { refreshAuth: () => void }) {
if (!selectedTask.groupId && groupData[0]) {
setSelectedTask((task) => ({ ...task, groupId: groupData[0].id }));
}
return { groupData, submissionData };
}
useEffect(() => {
@@ -802,6 +805,53 @@ function AdminApp({ refreshAuth }: { refreshAuth: () => void }) {
});
}
async function exportTasks() {
setError("");
setMessage("");
const response = await fetch("/api/admin/export");
if (!response.ok) {
const payload = await response.json().catch(() => ({ error: response.statusText }));
throw new ApiError(typeof payload.error === "string" ? payload.error : "Could not export tasks.", collectErrorDetails(payload));
}
const blob = await response.blob();
const disposition = response.headers.get("content-disposition") ?? "";
const fileNameMatch = disposition.match(/filename="([^"]+)"/);
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = fileNameMatch?.[1] ?? `frontend-judge-tasks-${new Date().toISOString().slice(0, 10)}.json`;
document.body.append(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
setMessage("Task export downloaded.");
}
async function importTasks(file: File) {
setError("");
setMessage("");
const shouldImport = window.confirm("Importing will replace all current groups, tasks, and submissions. Continue?");
if (!shouldImport) {
return;
}
const parsed = JSON.parse(await file.text()) as unknown;
const result = await api<{ groups: number; tasks: number; warnings: string[] }>("/api/admin/import", {
method: "POST",
body: JSON.stringify(parsed)
});
const { groupData } = await loadAdminData();
setSelectedTask({ ...emptyTask, groupId: groupData[0]?.id ?? "" });
setSelectedSubmission(null);
setMessage(
[
`Imported ${result.groups} group(s) and ${result.tasks} task(s).`,
...(result.warnings ?? [])
].join("\n")
);
}
return (
<main className="app-shell">
<header className="topbar">
@@ -809,16 +859,33 @@ function AdminApp({ refreshAuth }: { refreshAuth: () => void }) {
<h1>Admin dashboard</h1>
<p>Create day groups, tasks, checks, and reference screenshots.</p>
</div>
<button
className="ghost"
onClick={async () => {
await api("/api/auth/logout", { method: "POST" });
refreshAuth();
}}
title="Log out"
>
<LogOut size={18} />
</button>
<div className="topbar-actions">
<button className="secondary" onClick={() => exportTasks().catch((err) => setError(errorMessage(err, "Could not export tasks.")))}>
<Download size={17} /> Export
</button>
<label className="upload-button">
<Upload size={17} /> Import
<input
type="file"
accept="application/json,.json"
onChange={(event) => {
const file = event.target.files?.[0];
event.target.value = "";
if (file) importTasks(file).catch((err) => setError(errorMessage(err, "Could not import tasks.")));
}}
/>
</label>
<button
className="ghost"
onClick={async () => {
await api("/api/auth/logout", { method: "POST" });
refreshAuth();
}}
title="Log out"
>
<LogOut size={18} />
</button>
</div>
</header>
<div className="admin-layout">
+9
View File
@@ -102,6 +102,14 @@ p {
color: #5b6b79;
}
.topbar-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 0.6rem;
flex-wrap: wrap;
}
.student-layout,
.admin-layout {
display: grid;
@@ -645,6 +653,7 @@ p {
.success {
color: #167142;
font-weight: 750;
white-space: pre-line;
}
.cm-editor {