Call ComfyUI and show preview of lesson to admin to approve

This commit is contained in:
iamdoubz
2026-06-29 10:05:30 -05:00
parent aa78b135e3
commit 09a0c7e504
8 changed files with 101 additions and 53 deletions
+3 -2
View File
@@ -1,8 +1,9 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
// better-sqlite3 is a native module; keep it external to the server bundle.
serverExternalPackages: ["better-sqlite3"],
// Keep these out of the server bundle: better-sqlite3 is native; potrace/jimp are CommonJS and
// break when webpack-bundled ("Right-hand side of 'instanceof' is not callable" from `x instanceof Jimp`).
serverExternalPackages: ["better-sqlite3", "potrace", "jimp"],
// Privacy: no telemetry headers, no powered-by header.
poweredByHeader: false,
};
+49 -24
View File
@@ -9,13 +9,15 @@ interface Row {
status: string;
slug: string;
emoji: string;
image: string | null;
error: string | null;
}
export default function AdminCreations({ review, ready }: { review: Row[]; ready: Row[] }) {
const router = useRouter();
const [busy, setBusy] = useState<number | null>(null);
async function act(id: number, action: "approve" | "block" | "promote") {
async function act(id: number, action: "approve" | "block" | "promote" | "regenerate") {
setBusy(id);
try {
await fetch(`/api/admin/create/${id}`, {
@@ -30,38 +32,61 @@ export default function AdminCreations({ review, ready }: { review: Row[]; ready
}
if (review.length === 0 && ready.length === 0) {
return <p className="muted" style={{ padding: 16, margin: 0 }}>No creations awaiting review.</p>;
return <p className="muted" style={{ padding: 16, margin: 0 }}>No created lessons yet.</p>;
}
const thumb = (r: Row) =>
r.image ? (
<img src={r.image} alt={`${r.subject} line art`} style={{ width: 96, height: 72, objectFit: "contain", background: "#fff", border: "1px solid var(--line)", borderRadius: 8, flex: "0 0 auto" }} />
) : (
<div style={{ width: 96, height: 72, display: "grid", placeItems: "center", border: "1px dashed var(--line)", borderRadius: 8, flex: "0 0 auto", fontSize: "1.4rem" }}>{r.emoji}</div>
);
const previewHref = (r: Row) => `/learn/${r.level}/${r.slug}-trace`;
const card = (r: Row, kind: "queue" | "ready") => {
const generated = !!r.image && (r.status === "review" || r.status === "ready");
const working = r.status === "pending" || r.status === "generating";
return (
<div key={r.id} className="row" style={{ gap: 12, alignItems: "center", border: "1px solid var(--line)", borderRadius: 12, padding: 10, flexWrap: "wrap" }}>
{thumb(r)}
<div style={{ flex: 1, minWidth: 160 }}>
<strong>{r.emoji} {r.subject}</strong> <span className="muted" style={{ fontSize: "0.82rem" }}>· {r.level}</span>
<div className="muted" style={{ fontSize: "0.8rem", marginTop: 2 }}>
{working ? "🎨 Drawing… (refresh to update)" : r.status === "review" ? "🔎 Awaiting approval" : r.status === "needs_review" ? "⏳ Needs generation" : r.status === "failed" ? `⚠️ Failed: ${r.error || "unknown error"}` : r.status === "ready" ? "✓ Ready" : r.status}
</div>
</div>
<span className="row" style={{ gap: 6, flexWrap: "wrap" }}>
{generated && <a className="btn ghost" style={mini} href={previewHref(r)} target="_blank" rel="noopener">👁 Preview</a>}
{kind === "queue" && (r.status === "review" || r.status === "needs_review") && (
<button className="btn" style={mini} disabled={busy === r.id} onClick={() => act(r.id, "approve")}>✓ Approve</button>
)}
{(generated || r.status === "failed") && (
<button className="btn secondary" style={mini} disabled={busy === r.id} onClick={() => act(r.id, "regenerate")}>↻ Regenerate</button>
)}
{kind === "ready" && (
<button className="btn secondary" style={mini} disabled={busy === r.id} onClick={() => act(r.id, "promote")}>🌟 Promote</button>
)}
{r.status !== "blocked" && (
<button className="btn danger" style={mini} disabled={busy === r.id} onClick={() => act(r.id, "block")}>Block</button>
)}
</span>
</div>
);
};
return (
<div style={{ padding: 12 }}>
<div className="stack" style={{ padding: 12 }}>
{review.length > 0 && (
<>
<strong>Awaiting review</strong>
<div className="stack" style={{ marginTop: 8, marginBottom: 16 }}>
{review.map((r) => (
<div key={r.id} className="row" style={{ justifyContent: "space-between", alignItems: "center", border: "1px solid var(--line)", borderRadius: 10, padding: "8px 12px" }}>
<span>{r.emoji} <strong>{r.subject}</strong> <span className="muted" style={{ fontSize: "0.82rem" }}>· {r.level}</span></span>
<span className="row" style={{ gap: 6 }}>
<button className="btn" style={mini} disabled={busy === r.id} onClick={() => act(r.id, "approve")}>✓ Approve</button>
<button className="btn danger" style={mini} disabled={busy === r.id} onClick={() => act(r.id, "block")}>Block</button>
</span>
</div>
))}
</div>
<strong>In review / in progress</strong>
{review.map((r) => card(r, "queue"))}
</>
)}
{ready.length > 0 && (
<>
<strong>Ready — promote to everyone?</strong>
<div className="stack" style={{ marginTop: 8 }}>
{ready.map((r) => (
<div key={r.id} className="row" style={{ justifyContent: "space-between", alignItems: "center", border: "1px solid var(--line)", borderRadius: 10, padding: "8px 12px" }}>
<span>{r.emoji} <strong>{r.subject}</strong> <span className="muted" style={{ fontSize: "0.82rem" }}>· {r.level}</span></span>
<button className="btn secondary" style={mini} disabled={busy === r.id} onClick={() => act(r.id, "promote")}>🌟 Promote to global</button>
</div>
))}
</div>
<strong style={{ marginTop: 8 }}>Live — promote to everyone?</strong>
{ready.map((r) => card(r, "ready"))}
</>
)}
</div>
+4 -3
View File
@@ -8,7 +8,7 @@ import AddUserForm from "./AddUserForm";
import AdminUsers from "./AdminUsers";
import AdminReports from "./AdminReports";
import AdminCreations from "./AdminCreations";
import { listNeedsReview, listReadyUnpromoted } from "@/lib/createdLessons";
import { listAdminQueue, listReadyUnpromoted } from "@/lib/createdLessons";
export const metadata = { title: "Admin · DrawIt" };
export const dynamic = "force-dynamic";
@@ -21,8 +21,9 @@ export default async function AdminPage() {
const pending = users.filter((u) => u.status === "pending");
const reports = listReports();
const openReports = reports.filter((r) => r.status === "open").length;
const pick = (r: { id: number; subject: string; level: string; status: string; slug: string; emoji: string }) => ({ id: r.id, subject: r.subject, level: r.level, status: r.status, slug: r.slug, emoji: r.emoji });
const reviewCreations = listNeedsReview().map(pick);
const pick = (r: { id: number; subject: string; level: string; status: string; slug: string; emoji: string; image: string | null; error: string | null }) =>
({ id: r.id, subject: r.subject, level: r.level, status: r.status, slug: r.slug, emoji: r.emoji, image: r.image, error: r.error });
const reviewCreations = listAdminQueue().map(pick);
const readyCreations = listReadyUnpromoted().map(pick);
return (
+13 -2
View File
@@ -1,6 +1,6 @@
import { NextResponse } from "next/server";
import { requireAdmin } from "@/lib/session";
import { getCreatedById, updateCreatedStatus, promoteCreated, processCreation } from "@/lib/createdLessons";
import { getCreatedById, updateCreatedStatus, promoteCreated, processCreation, approveCreated } from "@/lib/createdLessons";
// Admin review queue: approve (generate), block, or promote a creation to the global curriculum.
export async function POST(req: Request, ctx: { params: Promise<{ id: string }> }) {
@@ -20,7 +20,18 @@ export async function POST(req: Request, ctx: { params: Promise<{ id: string }>
switch (body.action) {
case "approve":
if (row.status !== "needs_review") return NextResponse.json({ error: "Not awaiting review." }, { status: 400 });
if (row.template_svg) {
// Already generated and previewed → make it live immediately (no re-generation).
approveCreated(row.id);
return NextResponse.json({ ok: true, status: "ready" });
}
// Legacy/not-generated → mark approved then generate; it will finish as 'ready'.
approveCreated(row.id);
updateCreatedStatus(row.id, "pending");
void processCreation(row.id);
return NextResponse.json({ ok: true, status: "generating" });
case "regenerate":
// Re-run ComfyUI + vectorize; keeps its moderation, so a review item returns to 'review'.
updateCreatedStatus(row.id, "pending");
void processCreation(row.id);
return NextResponse.json({ ok: true, status: "generating" });
+5 -10
View File
@@ -32,15 +32,10 @@ export async function POST(req: Request) {
}
const prompt = resolvePrompt(mod.subject);
const status = mod.decision === "allowlist" ? "pending" : "needs_review";
const row = createCreatedLesson({ userId: user.id, level, subject: mod.subject, prompt, moderation: mod.decision, status });
// Both allowlisted and review subjects generate now; review subjects finish in a quarantined
// 'review' state (hidden from kids) so an admin can preview before approving. Blocked never reaches here.
const row = createCreatedLesson({ userId: user.id, level, subject: mod.subject, prompt, moderation: mod.decision, status: "pending" });
void processCreation(row.id);
// Allowlisted → generate now (async). Review → wait for an admin to approve.
if (mod.decision === "allowlist") void processCreation(row.id);
return NextResponse.json({
id: row.id,
slug: row.slug,
status: mod.decision === "allowlist" ? "generating" : "needs_review",
});
return NextResponse.json({ id: row.id, slug: row.slug, status: "generating" });
}
+4 -2
View File
@@ -130,8 +130,10 @@ async function renderCreated(level: string, slug: string) {
const user = await getCurrentUser();
if (!user) redirect("/signup");
// Visible to the owner, anyone if promoted to global, or an admin.
const canView = row.user_id === user.id || row.promoted === 1 || user.role === "admin";
// Admins can preview any generated lesson (incl. 'review'). Everyone else only sees 'ready' ones
// they own or that were promoted to global — a 'review' item stays hidden until approved.
const isReady = row.status === "ready";
const canView = user.role === "admin" || (isReady && (row.user_id === user.id || row.promoted === 1));
if (!canView) notFound();
if (!isLevelUnlocked(user.id, level)) redirect("/learn");
+1 -1
View File
@@ -85,7 +85,7 @@ export default async function LearnPage() {
const mine: CreatedLessonRow[] = user && creator ? listCreatedForUser(user.id, lvlKey) : [];
const featured: CreatedLessonRow[] = listPromoted(lvlKey);
const ready = mine.filter((r) => r.status === "ready");
const pending = mine.filter((r) => ["pending", "generating", "needs_review"].includes(r.status));
const pending = mine.filter((r) => ["pending", "generating", "needs_review", "review"].includes(r.status));
// Nothing to show: Create disabled and no existing or featured creations.
if (!canCreateNow && ready.length === 0 && pending.length === 0 && featured.length === 0) return null;
return (
+22 -9
View File
@@ -8,7 +8,9 @@ import type { Lesson } from "./curriculum";
* (potrace), and then rendered through the normal runners by building Lesson objects on the fly.
*/
export type CreatedStatus = "pending" | "generating" | "needs_review" | "ready" | "failed" | "blocked";
// pending/generating = working; review = generated but awaiting admin approval (hidden from kids);
// ready = approved/auto-approved and visible; failed/blocked = terminal.
export type CreatedStatus = "pending" | "generating" | "needs_review" | "review" | "ready" | "failed" | "blocked";
export interface CreatedLessonRow {
id: number;
@@ -77,10 +79,10 @@ export function listPromoted(level: string): CreatedLessonRow[] {
.all(level) as CreatedLessonRow[];
}
/** Admin queue: things awaiting review. */
export function listNeedsReview(): CreatedLessonRow[] {
/** Admin queue: everything not yet live (working, awaiting review, or failed). */
export function listAdminQueue(): CreatedLessonRow[] {
return getDb()
.prepare("SELECT * FROM created_lessons WHERE status = 'needs_review' ORDER BY created_at")
.prepare("SELECT * FROM created_lessons WHERE status IN ('pending','generating','needs_review','review','failed') ORDER BY created_at DESC")
.all() as CreatedLessonRow[];
}
@@ -97,10 +99,17 @@ export function updateCreatedStatus(id: number, status: CreatedStatus, error?: s
.run(status, error ?? null, id);
}
function setReady(id: number, templateSvg: string, image: string) {
function setGenerated(id: number, templateSvg: string, image: string, status: CreatedStatus) {
getDb()
.prepare("UPDATE created_lessons SET status = 'ready', template_svg = ?, image = ?, error = NULL, updated_at = datetime('now') WHERE id = ?")
.run(templateSvg, image, id);
.prepare("UPDATE created_lessons SET status = ?, template_svg = ?, image = ?, error = NULL, updated_at = datetime('now') WHERE id = ?")
.run(status, templateSvg, image, id);
}
/** Approve a creation: make it live (ready) and mark it approved. */
export function approveCreated(id: number) {
getDb()
.prepare("UPDATE created_lessons SET status = 'ready', moderation = 'approved', updated_at = datetime('now') WHERE id = ?")
.run(id);
}
export function promoteCreated(id: number) {
@@ -121,7 +130,10 @@ export async function processCreation(id: number): Promise<void> {
const { pngToTemplateSvg } = await import("./vectorize");
const png = await generateLineArt(row.subject);
const templateSvg = await pngToTemplateSvg(png);
setReady(id, templateSvg, `data:image/png;base64,${png.toString("base64")}`);
// Auto-approved (allowlist) or admin-approved → live; anything else is quarantined for review.
const fresh = getCreatedById(id);
const needsReview = fresh?.moderation === "review" || fresh?.moderation === "needs_review";
setGenerated(id, templateSvg, `data:image/png;base64,${png.toString("base64")}`, needsReview ? "review" : "ready");
} catch (e) {
updateCreatedStatus(id, "failed", e instanceof Error ? e.message : String(e));
}
@@ -180,7 +192,8 @@ export function resolveCreatedLesson(slug: string): { lessons: Lesson[]; index:
const m = slug.match(/^(.*)-(trace|color)$/);
if (!m) return null;
const row = getCreatedBySlug(m[1]);
if (!row || row.status !== "ready") return null;
// Built once generated: 'ready' (live) or 'review' (admin preview before approval).
if (!row || !row.template_svg || (row.status !== "ready" && row.status !== "review")) return null;
const lessons = buildCreatedLessons(row);
const index = lessons.findIndex((l) => l.slug === slug);
if (index < 0) return null;