diff --git a/next.config.mjs b/next.config.mjs index 9d7c4df..cdcfaee 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -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, }; diff --git a/src/app/admin/AdminCreations.tsx b/src/app/admin/AdminCreations.tsx index 6b84c92..608c787 100644 --- a/src/app/admin/AdminCreations.tsx +++ b/src/app/admin/AdminCreations.tsx @@ -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(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

No creations awaiting review.

; + return

No created lessons yet.

; } + const thumb = (r: Row) => + r.image ? ( + {`${r.subject} + ) : ( +
{r.emoji}
+ ); + + 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 ( +
+ {thumb(r)} +
+ {r.emoji} {r.subject} Β· {r.level} +
+ {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} +
+
+ + {generated && πŸ‘ Preview} + {kind === "queue" && (r.status === "review" || r.status === "needs_review") && ( + + )} + {(generated || r.status === "failed") && ( + + )} + {kind === "ready" && ( + + )} + {r.status !== "blocked" && ( + + )} + +
+ ); + }; + return ( -
+
{review.length > 0 && ( <> - Awaiting review -
- {review.map((r) => ( -
- {r.emoji} {r.subject} Β· {r.level} - - - - -
- ))} -
+ In review / in progress + {review.map((r) => card(r, "queue"))} )} {ready.length > 0 && ( <> - Ready β€” promote to everyone? -
- {ready.map((r) => ( -
- {r.emoji} {r.subject} Β· {r.level} - -
- ))} -
+ Live β€” promote to everyone? + {ready.map((r) => card(r, "ready"))} )}
diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index abdd5f3..c037aea 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -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 ( diff --git a/src/app/api/admin/create/[id]/route.ts b/src/app/api/admin/create/[id]/route.ts index 9efbc06..6b4215f 100644 --- a/src/app/api/admin/create/[id]/route.ts +++ b/src/app/api/admin/create/[id]/route.ts @@ -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" }); diff --git a/src/app/api/create/route.ts b/src/app/api/create/route.ts index c366583..a19afd7 100644 --- a/src/app/api/create/route.ts +++ b/src/app/api/create/route.ts @@ -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" }); } diff --git a/src/app/learn/[level]/[slug]/page.tsx b/src/app/learn/[level]/[slug]/page.tsx index d20f0c8..ec8a4b3 100644 --- a/src/app/learn/[level]/[slug]/page.tsx +++ b/src/app/learn/[level]/[slug]/page.tsx @@ -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"); diff --git a/src/app/learn/page.tsx b/src/app/learn/page.tsx index c034bfc..f160816 100644 --- a/src/app/learn/page.tsx +++ b/src/app/learn/page.tsx @@ -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 ( diff --git a/src/lib/createdLessons.ts b/src/lib/createdLessons.ts index a1bc7e4..afffd6a 100644 --- a/src/lib/createdLessons.ts +++ b/src/lib/createdLessons.ts @@ -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 { 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;