Skip to content

Commit 28f8bb1

Browse files
Edwin ChanEdwin Chan
authored andcommitted
fixes
1 parent e8970f9 commit 28f8bb1

9 files changed

Lines changed: 615 additions & 44 deletions

File tree

app/admin/sets/[id]/set-edit-form.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import Link from "next/link";
2121
import { statusLabel, statusColor } from "@/lib/visibility";
2222
import { CANONICAL_TAGS, normalizeProblemTag, normalizeTagList } from "@/lib/problem-tags";
2323
import { DeleteSetButton } from "../delete-set-button";
24+
import { SetJsonReplacePanel } from "./set-json-replace-panel";
2425

2526
type ProblemData = {
2627
id: string;
@@ -556,6 +557,8 @@ export function SetEditForm({ set }: { set: SetData }) {
556557
</div>
557558
</div>
558559

560+
<SetJsonReplacePanel setId={set.id} setTitle={set.title} />
561+
559562
<div className="set-editor-problem-list">
560563
{problems.map((problem) => (
561564
<article className="problem-card" key={problem.id}>
Lines changed: 326 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,326 @@
1+
"use client";
2+
3+
import Link from "next/link";
4+
import { useRouter } from "next/navigation";
5+
import { ChangeEvent, useState } from "react";
6+
import {
7+
CheckCircle2,
8+
ExternalLink,
9+
FileJson,
10+
Loader2,
11+
ShieldCheck,
12+
UploadCloud,
13+
XCircle,
14+
} from "lucide-react";
15+
16+
type DryRunResult = {
17+
ok: boolean;
18+
issues: Array<{
19+
level: "error" | "warning";
20+
message: string;
21+
}>;
22+
preview: null | {
23+
slug: string;
24+
title: string;
25+
status: string;
26+
problemCount: number;
27+
totalPoints: number;
28+
difficulty: number;
29+
topicTags: string[];
30+
videoUrl: string | null;
31+
statementFormat?: string;
32+
answerTypeCounts: Record<string, number>;
33+
solutionCount: number;
34+
};
35+
};
36+
37+
type ImportResult = {
38+
ok: boolean;
39+
issues: Array<{
40+
level: "error" | "warning";
41+
message: string;
42+
}>;
43+
created: null | {
44+
problemSetId: string;
45+
slug: string;
46+
title: string;
47+
status: string;
48+
problemCount: number;
49+
problemFileKey: string | null;
50+
solutionFileKey: string | null;
51+
videoUrl: string | null;
52+
warnings: string[];
53+
};
54+
};
55+
56+
export function SetJsonReplacePanel({ setId, setTitle }: { setId: string; setTitle: string }) {
57+
const router = useRouter();
58+
const [file, setFile] = useState<File | null>(null);
59+
const [dryRunResult, setDryRunResult] = useState<DryRunResult | null>(null);
60+
const [dryRunError, setDryRunError] = useState<string | null>(null);
61+
const [importResult, setImportResult] = useState<ImportResult | null>(null);
62+
const [importError, setImportError] = useState<string | null>(null);
63+
const [isDryRunning, setIsDryRunning] = useState(false);
64+
const [isImporting, setIsImporting] = useState(false);
65+
66+
function onFileChange(event: ChangeEvent<HTMLInputElement>) {
67+
const nextFile = event.target.files?.[0] ?? null;
68+
setFile(nextFile);
69+
setDryRunResult(null);
70+
setDryRunError(null);
71+
setImportResult(null);
72+
setImportError(null);
73+
}
74+
75+
async function submit(intent: "dry-run" | "replace") {
76+
if (!file) {
77+
return;
78+
}
79+
80+
const formData = new FormData();
81+
formData.append("file", file);
82+
formData.append("intent", intent);
83+
84+
if (intent === "dry-run") {
85+
setIsDryRunning(true);
86+
setDryRunError(null);
87+
setDryRunResult(null);
88+
setImportResult(null);
89+
setImportError(null);
90+
} else {
91+
setIsImporting(true);
92+
setImportError(null);
93+
setImportResult(null);
94+
}
95+
96+
try {
97+
const response = await fetch(`/api/admin/sets/${setId}/replace-json`, {
98+
method: "POST",
99+
body: formData,
100+
});
101+
const result = (await response.json()) as DryRunResult | ImportResult;
102+
103+
if (intent === "dry-run") {
104+
setDryRunResult(result as DryRunResult);
105+
if (!response.ok) {
106+
setDryRunError(
107+
(result as DryRunResult).issues[0]?.message ?? "Dry run failed for this JSON file.",
108+
);
109+
}
110+
} else {
111+
setImportResult(result as ImportResult);
112+
if (!response.ok) {
113+
setImportError(
114+
(result as ImportResult).issues[0]?.message ?? "Replacement failed for this JSON file.",
115+
);
116+
} else {
117+
router.refresh();
118+
}
119+
}
120+
} catch {
121+
if (intent === "dry-run") {
122+
setDryRunError("Dry run request failed.");
123+
} else {
124+
setImportError("Replacement request failed.");
125+
}
126+
} finally {
127+
if (intent === "dry-run") {
128+
setIsDryRunning(false);
129+
} else {
130+
setIsImporting(false);
131+
}
132+
}
133+
}
134+
135+
async function onReplace() {
136+
if (!file || !dryRunResult?.ok) {
137+
return;
138+
}
139+
140+
const confirmed = window.confirm(
141+
`Replace "${setTitle}" with "${file.name}"?\n\nThis deletes the current set, attempts, responses, bookmarks, and feedback before importing the new JSON.`,
142+
);
143+
144+
if (!confirmed) {
145+
return;
146+
}
147+
148+
await submit("replace");
149+
}
150+
151+
const readyToReplace = dryRunResult?.ok === true && !importResult?.ok;
152+
153+
return (
154+
<section className="json-replace-section">
155+
<div className="panel-header">
156+
<div>
157+
<p className="eyebrow">Direct replace</p>
158+
<h2>Import JSON over this set</h2>
159+
</div>
160+
<FileJson size={20} />
161+
</div>
162+
163+
<label className="dropzone">
164+
<input
165+
type="file"
166+
accept=".json,application/json"
167+
onChange={onFileChange}
168+
data-testid="replace-set-json-input"
169+
/>
170+
<UploadCloud size={34} />
171+
<strong>{file ? file.name : "Choose JSON file"}</strong>
172+
<span>
173+
{file
174+
? `${formatBytes(file.size)} selected`
175+
: "Dry run first, then replace this set in place if the JSON passes"}
176+
</span>
177+
</label>
178+
179+
<div className="validation-list" aria-label="Replacement validation preview">
180+
<div className={`validation-row ${file ? "ok" : "fail"}`}>
181+
{file ? <CheckCircle2 size={18} /> : <XCircle size={18} />}
182+
<span>JSON selected</span>
183+
</div>
184+
<div className={`validation-row ${dryRunResult?.ok ? "ok" : "fail"}`}>
185+
{dryRunResult?.ok ? <CheckCircle2 size={18} /> : <XCircle size={18} />}
186+
<span>Dry run passes</span>
187+
</div>
188+
<div className={`validation-row ${readyToReplace ? "ok" : "fail"}`}>
189+
{readyToReplace ? <CheckCircle2 size={18} /> : <XCircle size={18} />}
190+
<span>Ready to replace current set</span>
191+
</div>
192+
</div>
193+
194+
<div className="zip-json-list" style={{ marginTop: 0 }}>
195+
<article className="zip-json-item">
196+
<div className="zip-json-row">
197+
<div className="zip-json-meta">
198+
<strong>{setTitle}</strong>
199+
<small>Current set will be cleared before the replacement import commits.</small>
200+
</div>
201+
<div className="topbar-actions">
202+
<button
203+
className="secondary-action compact"
204+
type="button"
205+
disabled={!file || isDryRunning}
206+
onClick={() => void submit("dry-run")}
207+
>
208+
<ShieldCheck size={16} />
209+
{isDryRunning ? "Checking..." : "Dry run"}
210+
</button>
211+
<button
212+
className="primary-action compact"
213+
type="button"
214+
disabled={!readyToReplace || isImporting}
215+
onClick={() => void onReplace()}
216+
>
217+
{isImporting ? <Loader2 size={16} className="spin-icon" /> : <UploadCloud size={16} />}
218+
{isImporting ? "Replacing..." : "Replace set"}
219+
</button>
220+
</div>
221+
</div>
222+
223+
{importResult?.ok && importResult.created ? (
224+
<div className="dry-run-result" aria-live="polite">
225+
<div className="import-result-card">
226+
<div className="result-header">
227+
<CheckCircle2 size={22} />
228+
<div>
229+
<strong>Replacement successful</strong>
230+
<small>{importResult.created.title}</small>
231+
</div>
232+
</div>
233+
<div className="result-links">
234+
<Link
235+
className="secondary-action compact"
236+
href={`/admin/sets/${importResult.created.problemSetId}`}
237+
>
238+
<ExternalLink size={16} />
239+
Refresh editor
240+
</Link>
241+
<Link
242+
className="secondary-action compact"
243+
href={`/problem-sets/${importResult.created.slug}`}
244+
>
245+
<ExternalLink size={16} />
246+
Open set
247+
</Link>
248+
</div>
249+
</div>
250+
</div>
251+
) : null}
252+
253+
{!importResult && (dryRunError || dryRunResult) ? (
254+
<div className="dry-run-result" aria-live="polite">
255+
{dryRunError ? <p className="result-error">{dryRunError}</p> : null}
256+
{dryRunResult?.preview ? (
257+
<div className="preview-card">
258+
<div className="preview-heading">
259+
<span
260+
className={`status-dot ${dryRunResult.ok ? "status-solved" : "status-review"}`}
261+
/>
262+
<div>
263+
<strong>{dryRunResult.preview.title}</strong>
264+
<small>{dryRunResult.preview.slug}</small>
265+
</div>
266+
</div>
267+
<dl className="preview-grid">
268+
<div>
269+
<dt>Problems</dt>
270+
<dd>{dryRunResult.preview.problemCount}</dd>
271+
</div>
272+
<div>
273+
<dt>Points</dt>
274+
<dd>{dryRunResult.preview.totalPoints}</dd>
275+
</div>
276+
<div>
277+
<dt>Status</dt>
278+
<dd>{dryRunResult.preview.status}</dd>
279+
</div>
280+
<div>
281+
<dt>Solutions</dt>
282+
<dd>{dryRunResult.preview.solutionCount}</dd>
283+
</div>
284+
</dl>
285+
</div>
286+
) : null}
287+
288+
{dryRunResult?.issues.length ? (
289+
<div className="issue-list">
290+
{dryRunResult.issues.map((issue) => (
291+
<div className={`issue-row ${issue.level}`} key={issue.message}>
292+
{issue.level === "error" ? <XCircle size={16} /> : <ShieldCheck size={16} />}
293+
<span>{issue.message}</span>
294+
</div>
295+
))}
296+
</div>
297+
) : dryRunResult ? (
298+
<div className="issue-row ok">
299+
<CheckCircle2 size={16} />
300+
<span>Dry run passed. Replacing this set will now use the uploaded JSON.</span>
301+
</div>
302+
) : null}
303+
</div>
304+
) : null}
305+
306+
{importError ? (
307+
<div className="dry-run-result" aria-live="polite">
308+
<p className="result-error">{importError}</p>
309+
</div>
310+
) : null}
311+
</article>
312+
</div>
313+
</section>
314+
);
315+
}
316+
317+
function formatBytes(bytes: number) {
318+
if (bytes === 0) {
319+
return "0 B";
320+
}
321+
322+
const units = ["B", "KB", "MB", "GB"];
323+
const index = Math.floor(Math.log(bytes) / Math.log(1024));
324+
const value = bytes / 1024 ** index;
325+
return `${value.toFixed(value >= 10 || index === 0 ? 0 : 1)} ${units[index]}`;
326+
}

0 commit comments

Comments
 (0)