Skip to content

Commit 41b11b1

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

13 files changed

Lines changed: 1218 additions & 690 deletions

File tree

app/admin/create/page.tsx

Lines changed: 92 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
"use client";
22

3-
import { useState } from "react";
3+
import { useEffect, useRef, useState } from "react";
44
import Link from "next/link";
5-
import { useRouter } from "next/navigation";
5+
import { useRouter, useSearchParams } from "next/navigation";
66
import {
77
ArrowLeft,
88
Plus,
@@ -19,6 +19,11 @@ import {
1919
Upload,
2020
} from "lucide-react";
2121
import { CANONICAL_TAGS, normalizeProblemTag, normalizeTagList } from "@/lib/problem-tags";
22+
import {
23+
clearJsonImportDraft,
24+
loadJsonImportDraft,
25+
type ImportIssue,
26+
} from "@/lib/import/json-draft-storage";
2227
import { LatexStatement } from "@/app/problem-sets/[slug]/latex-statement";
2328

2429
/* ── Types ─────────────────────────────────────────────── */
@@ -125,6 +130,9 @@ function hasTag(csv: string, tag: string): boolean {
125130

126131
export default function CreateSetPage() {
127132
const router = useRouter();
133+
const searchParams = useSearchParams();
134+
const importDraftKey = searchParams.get("importDraft");
135+
const draftLoaded = useRef(false);
128136

129137
// Set metadata
130138
const [title, setTitle] = useState("");
@@ -133,7 +141,7 @@ export default function CreateSetPage() {
133141
const [description, setDescription] = useState("");
134142
const [order, setOrder] = useState("1");
135143
const [difficulty, setDifficulty] = useState(1);
136-
const [status, setStatus] = useState<"DRAFT" | "PUBLISHED">("DRAFT");
144+
const [status, setStatus] = useState<"DRAFT" | "PUBLISHED" | "ARCHIVED">("DRAFT");
137145
const [topicTags, setTopicTags] = useState("");
138146
const [videoUrl, setVideoUrl] = useState("");
139147
const [problemPdf, setProblemPdf] = useState<{ name: string; dataUrl: string } | null>(null);
@@ -146,8 +154,55 @@ export default function CreateSetPage() {
146154
const [saving, setSaving] = useState(false);
147155
const [error, setError] = useState<string | null>(null);
148156
const [success, setSuccess] = useState<string | null>(null);
157+
const [draftIssues, setDraftIssues] = useState<ImportIssue[]>([]);
158+
const [draftFileName, setDraftFileName] = useState<string | null>(null);
149159
const effectiveSlug = slugManual ? slug : slugify(title);
150160

161+
/* eslint-disable react-hooks/set-state-in-effect */
162+
useEffect(() => {
163+
if (!importDraftKey || draftLoaded.current) {
164+
return;
165+
}
166+
167+
const draft = loadJsonImportDraft(importDraftKey);
168+
draftLoaded.current = true;
169+
170+
if (!draft) {
171+
queueMicrotask(() => {
172+
setError("That import draft is missing or expired. Run the dry run again and reopen it.");
173+
});
174+
return;
175+
}
176+
177+
setTitle(draft.title);
178+
setSlug(draft.slug);
179+
setSlugManual(true);
180+
setDescription(draft.description);
181+
setOrder(draft.order);
182+
setDifficulty(draft.difficulty);
183+
setStatus(draft.status);
184+
setTopicTags(draft.topicTags.join(", "));
185+
setVideoUrl(draft.videoUrl ?? "");
186+
setProblems(
187+
draft.problems.length > 0
188+
? draft.problems.map((problem) => ({
189+
id: uid(),
190+
number: problem.number,
191+
statement: problem.statement,
192+
contentFormat: problem.contentFormat,
193+
answerType: problem.answerType,
194+
answerKey: problem.answerKey,
195+
topicTags: problem.topicTags.join(", "),
196+
points: problem.points,
197+
explanationNote: problem.explanationNote ?? "",
198+
}))
199+
: [emptyProblem(1)],
200+
);
201+
setDraftIssues(draft.issues);
202+
setDraftFileName(draft.fileName);
203+
}, [importDraftKey]);
204+
/* eslint-enable react-hooks/set-state-in-effect */
205+
151206
function addProblem() {
152207
setProblems((prev) => [...prev, emptyProblem(prev.length + 1)]);
153208
}
@@ -269,7 +324,10 @@ export default function CreateSetPage() {
269324
}
270325

271326
setSuccess(`Problem set "${title}" created successfully!`);
272-
setTimeout(() => router.push("/admin/sets"), 1500);
327+
if (importDraftKey) {
328+
clearJsonImportDraft(importDraftKey);
329+
}
330+
setTimeout(() => router.push(`/admin/sets/${data.problemSet.id}`), 1500);
273331
} catch {
274332
setError("Network error. Please try again.");
275333
} finally {
@@ -284,7 +342,7 @@ export default function CreateSetPage() {
284342
<header className="create-set-header">
285343
<div>
286344
<p className="eyebrow">Admin</p>
287-
<h1>Create Problem Set</h1>
345+
<h1>{importDraftKey ? "Fix JSON Draft" : "Create Problem Set"}</h1>
288346
</div>
289347
<div className="create-set-header-actions">
290348
<Link className="secondary-action" href="/dashboard">
@@ -314,6 +372,33 @@ export default function CreateSetPage() {
314372
<span>{success}</span>
315373
</div>
316374
)}
375+
{importDraftKey && draftFileName ? (
376+
<div className="create-set-alert">
377+
<CheckCircle2 size={18} />
378+
<span>Loaded import draft from {draftFileName}. Fix the issues below, then save.</span>
379+
</div>
380+
) : null}
381+
{draftIssues.length > 0 ? (
382+
<div className="dry-run-result" aria-live="polite">
383+
<div className="preview-card">
384+
<div className="preview-heading">
385+
<span className="status-dot status-review" />
386+
<div>
387+
<strong>Import issues to fix</strong>
388+
<small>These came from the original JSON dry run.</small>
389+
</div>
390+
</div>
391+
</div>
392+
<div className="issue-list">
393+
{draftIssues.map((issue) => (
394+
<div className={`issue-row ${issue.level}`} key={issue.message}>
395+
{issue.level === "error" ? <AlertCircle size={16} /> : <CheckCircle2 size={16} />}
396+
<span>{issue.message}</span>
397+
</div>
398+
))}
399+
</div>
400+
</div>
401+
) : null}
317402

318403
{/* ── Metadata section ──────────────────────────── */}
319404
<section className="create-set-meta">
@@ -399,10 +484,11 @@ export default function CreateSetPage() {
399484
<select
400485
id="set-status"
401486
value={status}
402-
onChange={(e) => setStatus(e.target.value as "DRAFT" | "PUBLISHED")}
487+
onChange={(e) => setStatus(e.target.value as "DRAFT" | "PUBLISHED" | "ARCHIVED")}
403488
>
404489
<option value="DRAFT">Draft</option>
405490
<option value="PUBLISHED">Published</option>
491+
<option value="ARCHIVED">Archived</option>
406492
</select>
407493
</div>
408494

app/admin/import/json-zip-import-panel.tsx

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,23 @@
22

33
import JSZip from "jszip";
44
import Link from "next/link";
5+
import { useRouter } from "next/navigation";
56
import { ChangeEvent, useMemo, useState } from "react";
67
import {
78
CheckCircle2,
89
ExternalLink,
910
FileArchive,
11+
FilePenLine,
1012
Loader2,
1113
ShieldCheck,
1214
UploadCloud,
1315
XCircle,
1416
} from "lucide-react";
17+
import {
18+
createJsonImportDraftKey,
19+
saveJsonImportDraft,
20+
type JsonImportEditorDraft,
21+
} from "@/lib/import/json-draft-storage";
1522

1623
type DryRunResult = {
1724
ok: boolean;
@@ -53,6 +60,15 @@ type ImportResult = {
5360
};
5461
};
5562

63+
type DraftResult = {
64+
ok: boolean;
65+
issues: Array<{
66+
level: "error" | "warning";
67+
message: string;
68+
}>;
69+
draft: JsonImportEditorDraft | null;
70+
};
71+
5672
type JsonZipEntry = {
5773
name: string;
5874
file: File;
@@ -65,6 +81,7 @@ function isIgnoredZipEntry(path: string) {
6581
}
6682

6783
export function JsonZipImportPanel() {
84+
const router = useRouter();
6885
const [zipFile, setZipFile] = useState<File | null>(null);
6986
const [entries, setEntries] = useState<JsonZipEntry[]>([]);
7087
const [zipError, setZipError] = useState<string | null>(null);
@@ -215,6 +232,35 @@ export function JsonZipImportPanel() {
215232
}
216233
}
217234

235+
async function onOpenInEditor(entry: JsonZipEntry) {
236+
const formData = new FormData();
237+
formData.append("file", entry.file);
238+
239+
try {
240+
const response = await fetch("/api/admin/import/draft", {
241+
method: "POST",
242+
body: formData,
243+
});
244+
const result = (await response.json()) as DraftResult;
245+
if (!response.ok || !result.draft) {
246+
setDryRunErrors((current) => ({
247+
...current,
248+
[entry.name]: result.issues[0]?.message ?? "Could not prepare this draft for editing.",
249+
}));
250+
return;
251+
}
252+
253+
const draftKey = createJsonImportDraftKey();
254+
saveJsonImportDraft(draftKey, result.draft);
255+
router.push(`/admin/create?importDraft=${encodeURIComponent(draftKey)}`);
256+
} catch {
257+
setDryRunErrors((current) => ({
258+
...current,
259+
[entry.name]: "Could not open this JSON in the editor.",
260+
}));
261+
}
262+
}
263+
218264
return (
219265
<section className="panel import-panel">
220266
<div className="panel-header">
@@ -388,6 +434,18 @@ export function JsonZipImportPanel() {
388434
<span>Dry run passed. Ready to import this JSON file.</span>
389435
</div>
390436
) : null}
437+
{dryRunResult ? (
438+
<div className="result-links">
439+
<button
440+
className="secondary-action compact"
441+
type="button"
442+
onClick={() => onOpenInEditor(entry)}
443+
>
444+
<FilePenLine size={16} />
445+
{dryRunResult.ok ? "Edit before import" : "Fix in editor"}
446+
</button>
447+
</div>
448+
) : null}
391449
</div>
392450
) : null}
393451

app/admin/import/zip-import-panel.tsx

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,23 @@
11
"use client";
22

33
import Link from "next/link";
4+
import { useRouter } from "next/navigation";
45
import { ChangeEvent, useMemo, useState } from "react";
56
import {
67
CheckCircle2,
78
ExternalLink,
9+
FilePenLine,
810
FileJson,
911
Loader2,
1012
ShieldCheck,
1113
UploadCloud,
1214
XCircle,
1315
} from "lucide-react";
16+
import {
17+
createJsonImportDraftKey,
18+
saveJsonImportDraft,
19+
type JsonImportEditorDraft,
20+
} from "@/lib/import/json-draft-storage";
1421

1522
type ValidationItem = {
1623
label: string;
@@ -57,7 +64,17 @@ type ImportResult = {
5764
};
5865
};
5966

67+
type DraftResult = {
68+
ok: boolean;
69+
issues: Array<{
70+
level: "error" | "warning";
71+
message: string;
72+
}>;
73+
draft: JsonImportEditorDraft | null;
74+
};
75+
6076
export function ZipImportPanel() {
77+
const router = useRouter();
6178
const [file, setFile] = useState<File | null>(null);
6279
const [dryRunResult, setDryRunResult] = useState<DryRunResult | null>(null);
6380
const [dryRunError, setDryRunError] = useState<string | null>(null);
@@ -158,6 +175,33 @@ export function ZipImportPanel() {
158175
}
159176
}
160177

178+
async function onOpenInEditor() {
179+
if (!file) {
180+
return;
181+
}
182+
183+
const formData = new FormData();
184+
formData.append("file", file);
185+
186+
try {
187+
const response = await fetch("/api/admin/import/draft", {
188+
method: "POST",
189+
body: formData,
190+
});
191+
const result = (await response.json()) as DraftResult;
192+
if (!response.ok || !result.draft) {
193+
setDryRunError(result.issues[0]?.message ?? "Could not prepare this draft for editing.");
194+
return;
195+
}
196+
197+
const draftKey = createJsonImportDraftKey();
198+
saveJsonImportDraft(draftKey, result.draft);
199+
router.push(`/admin/create?importDraft=${encodeURIComponent(draftKey)}`);
200+
} catch {
201+
setDryRunError("Could not open this JSON in the editor.");
202+
}
203+
}
204+
161205
return (
162206
<section className="panel import-panel">
163207
<div className="panel-header">
@@ -339,6 +383,14 @@ export function ZipImportPanel() {
339383
<span>Dry run passed. Ready to import from JSON.</span>
340384
</div>
341385
) : null}
386+
{file && dryRunResult ? (
387+
<div className="result-links">
388+
<button className="secondary-action compact" type="button" onClick={onOpenInEditor}>
389+
<FilePenLine size={16} />
390+
{dryRunResult.ok ? "Edit before import" : "Fix in editor"}
391+
</button>
392+
</div>
393+
) : null}
342394
</div>
343395
)}
344396
</section>

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

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@ 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";
2524

2625
type ProblemData = {
2726
id: string;
@@ -557,8 +556,6 @@ export function SetEditForm({ set }: { set: SetData }) {
557556
</div>
558557
</div>
559558

560-
<SetJsonReplacePanel setId={set.id} setTitle={set.title} />
561-
562559
<div className="set-editor-problem-list">
563560
{problems.map((problem) => (
564561
<article className="problem-card" key={problem.id}>

0 commit comments

Comments
 (0)