AI Creator improvements and admin functions #15

Merged
iamdoubz merged 1 commits from feature_aicreator_unified into main 2026-06-29 10:58:37 -05:00
10 changed files with 247 additions and 129 deletions
+65 -9
View File
@@ -1,5 +1,5 @@
"use client";
import { useState } from "react";
import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
interface Row {
@@ -16,15 +16,41 @@ interface Row {
export default function AdminCreations({ review, ready }: { review: Row[]; ready: Row[] }) {
const router = useRouter();
const [busy, setBusy] = useState<number | null>(null);
const [lightbox, setLightbox] = useState<string | null>(null);
const [editing, setEditing] = useState<number | null>(null);
const editVal = useRef("");
async function act(id: number, action: "approve" | "block" | "promote" | "regenerate") {
// Auto-update: while anything is generating, poll its status and refresh when it changes
// (so a Regenerate's new image appears on screen without a manual reload).
useEffect(() => {
const working = review.filter((r) => r.status === "pending" || r.status === "generating");
if (working.length === 0) return;
const iv = setInterval(async () => {
for (const r of working) {
try {
const res = await fetch(`/api/create/${r.id}`);
const d = await res.json();
if (d.status && d.status !== r.status) {
router.refresh();
break;
}
} catch {
/* keep polling */
}
}
}, 3000);
return () => clearInterval(iv);
}, [review, router]);
async function act(id: number, action: "approve" | "block" | "promote" | "regenerate" | "rename", subject?: string) {
setBusy(id);
try {
await fetch(`/api/admin/create/${id}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action }),
body: JSON.stringify(subject !== undefined ? { action, subject } : { action }),
});
setEditing(null);
router.refresh();
} finally {
setBusy(null);
@@ -37,12 +63,18 @@ export default function AdminCreations({ review, ready }: { review: Row[]; ready
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" }} />
<img
src={r.image}
alt={`${r.subject} line art`}
onClick={() => setLightbox(r.image)}
title="Click to view full screen"
style={{ width: 96, height: 72, objectFit: "contain", background: "#fff", border: "1px solid var(--line)", borderRadius: 8, flex: "0 0 auto", cursor: "zoom-in" }}
/>
) : (
<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 previewHref = (r: Row) => `/learn/${r.level}/${r.slug}-outline`;
const card = (r: Row, kind: "queue" | "ready") => {
const generated = !!r.image && (r.status === "review" || r.status === "ready");
@@ -50,10 +82,22 @@ export default function AdminCreations({ review, ready }: { review: Row[]; ready
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 style={{ flex: 1, minWidth: 180 }}>
{editing === r.id ? (
<div className="row" style={{ gap: 6, alignItems: "center" }}>
<input defaultValue={r.subject} onChange={(e) => (editVal.current = e.target.value)} maxLength={30} style={{ flex: 1, minWidth: 120 }} />
<button className="btn" style={mini} disabled={busy === r.id} onClick={() => act(r.id, "rename", editVal.current || r.subject)}>Save</button>
<button className="btn ghost" style={mini} onClick={() => setEditing(null)}>Cancel</button>
</div>
) : (
<strong>
{r.emoji} {r.subject}{" "}
<button className="btn ghost" style={{ ...mini, minHeight: 26, padding: "2px 8px" }} onClick={() => { editVal.current = r.subject; setEditing(r.id); }} title="Rename subject"></button>
<span className="muted" style={{ fontSize: "0.82rem", fontWeight: 400 }}> · {r.level}</span>
</strong>
)}
<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}
{working ? "🎨 Drawing… (updates automatically)" : 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" }}>
@@ -62,7 +106,7 @@ export default function AdminCreations({ review, ready }: { review: Row[]; ready
<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>
<button className="btn secondary" style={mini} disabled={busy === r.id || working} 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>
@@ -89,6 +133,18 @@ export default function AdminCreations({ review, ready }: { review: Row[]; ready
{ready.map((r) => card(r, "ready"))}
</>
)}
{lightbox && (
<div
onClick={() => setLightbox(null)}
role="dialog"
aria-modal="true"
style={{ position: "fixed", inset: 0, background: "rgba(20,16,30,0.82)", display: "grid", placeItems: "center", padding: 20, zIndex: 100, cursor: "zoom-out" }}
>
<img src={lightbox} alt="Generated line art, full size" style={{ maxWidth: "100%", maxHeight: "90vh", borderRadius: 12, background: "#fff", boxShadow: "0 12px 40px rgba(0,0,0,0.5)" }} />
<button onClick={(e) => { e.stopPropagation(); setLightbox(null); }} aria-label="Close" style={{ position: "fixed", top: 16, right: 20, fontSize: "1.8rem", lineHeight: 1, background: "rgba(255,255,255,0.9)", border: "none", borderRadius: "50%", width: 44, height: 44, cursor: "pointer", fontWeight: 800 }}>×</button>
</div>
)}
</div>
);
}
+12 -3
View File
@@ -1,8 +1,10 @@
import { NextResponse } from "next/server";
import { requireAdmin } from "@/lib/session";
import { getCreatedById, updateCreatedStatus, promoteCreated, processCreation, approveCreated } from "@/lib/createdLessons";
import { getCreatedById, updateCreatedStatus, promoteCreated, processCreation, approveCreated, updateCreatedSubject } from "@/lib/createdLessons";
import { sanitizeSubject } from "@/lib/moderation";
import { resolvePrompt } from "@/lib/comfyui";
// Admin review queue: approve (generate), block, or promote a creation to the global curriculum.
// Admin review queue: approve, regenerate, rename, block, or promote a creation.
export async function POST(req: Request, ctx: { params: Promise<{ id: string }> }) {
const admin = await requireAdmin();
if (!admin) return NextResponse.json({ error: "Not authorized." }, { status: 403 });
@@ -11,7 +13,7 @@ export async function POST(req: Request, ctx: { params: Promise<{ id: string }>
const row = getCreatedById(Number(id));
if (!row) return NextResponse.json({ error: "Not found." }, { status: 404 });
let body: { action?: string };
let body: { action?: string; subject?: string };
try {
body = await req.json();
} catch {
@@ -35,6 +37,13 @@ export async function POST(req: Request, ctx: { params: Promise<{ id: string }>
updateCreatedStatus(row.id, "pending");
void processCreation(row.id);
return NextResponse.json({ ok: true, status: "generating" });
case "rename": {
// Admins can fix the subject wording; the new prompt is used on the next regenerate.
const subject = sanitizeSubject(body.subject || "");
if (!subject) return NextResponse.json({ error: "Enter a subject." }, { status: 400 });
updateCreatedSubject(row.id, subject, resolvePrompt(subject));
return NextResponse.json({ ok: true, subject });
}
case "block":
updateCreatedStatus(row.id, "blocked");
return NextResponse.json({ ok: true, status: "blocked" });
+2 -2
View File
@@ -3,8 +3,8 @@ import { requireCreator } from "@/lib/session";
import { isCreateEnabled, resolvePrompt } from "@/lib/comfyui";
import { moderateSubject } from "@/lib/moderation";
import { createCreatedLesson, processCreation, listCreatedForUser } from "@/lib/createdLessons";
import { CREATABLE_LEVELS } from "@/lib/curriculum";
const LEVELS = new Set(["early-beginner", "beginner"]);
const DAILY_LIMIT = 20;
export async function POST(req: Request) {
@@ -20,7 +20,7 @@ export async function POST(req: Request) {
}
const level = body.level || "";
if (!LEVELS.has(level)) return NextResponse.json({ error: "Pick a level." }, { status: 400 });
if (!CREATABLE_LEVELS.includes(level)) return NextResponse.json({ error: "Pick a level." }, { status: 400 });
// Simple per-user daily cap (bounds abuse + GPU cost).
const todays = listCreatedForUser(user.id, level).filter((r) => r.created_at.slice(0, 10) === new Date().toISOString().slice(0, 10));
+5 -4
View File
@@ -4,7 +4,7 @@ import Link from "next/link";
type Phase = "idle" | "submitting" | "generating" | "review" | "ready" | "failed";
export default function CreateForm({ level: initialLevel, enabled }: { level: string; enabled: boolean }) {
export default function CreateForm({ level: initialLevel, levels, enabled }: { level: string; levels: { key: string; name: string }[]; enabled: boolean }) {
const [level, setLevel] = useState(initialLevel);
const [subject, setSubject] = useState("");
const [phase, setPhase] = useState<Phase>("idle");
@@ -95,8 +95,9 @@ export default function CreateForm({ level: initialLevel, enabled }: { level: st
<label className="field">
<span style={{ fontWeight: 700 }}>Level</span>
<select value={level} onChange={(e) => setLevel(e.target.value)} disabled={busy}>
<option value="early-beginner">Early Beginner</option>
<option value="beginner">Beginner</option>
{levels.map((l) => (
<option key={l.key} value={l.key}>{l.name}</option>
))}
</select>
</label>
<button className="btn big" type="submit" disabled={busy || subject.trim().length < 2}>
@@ -118,7 +119,7 @@ export default function CreateForm({ level: initialLevel, enabled }: { level: st
{phase === "ready" && (
<div className="card" style={{ marginTop: 12, textAlign: "center" }}>
<p style={{ marginTop: 0 }}>🎉 Your lesson is ready!</p>
<Link className="btn big" href={`/learn/${level}/${slug}-trace`}>Start drawing</Link>
<Link className="btn big" href={`/learn/${level}/${slug}-outline`}>Start drawing</Link>
</div>
)}
{phase === "failed" && (
+4 -2
View File
@@ -3,6 +3,7 @@ import SiteNav from "@/components/SiteNav";
import CreateForm from "./CreateForm";
import { getCurrentUser, canCreate } from "@/lib/session";
import { isCreateEnabled } from "@/lib/comfyui";
import { creatableLevels } from "@/lib/curriculum";
export const dynamic = "force-dynamic";
export const metadata = { title: "Create · DrawIt" };
@@ -14,8 +15,9 @@ export default async function CreatePage({ searchParams }: { searchParams: Promi
// Hide the Create page entirely when ComfyUI isn't configured (Creator role alone isn't enough).
if (!isCreateEnabled()) redirect("/learn");
const levels = creatableLevels().map((l) => ({ key: l.key, name: l.name }));
const { level } = await searchParams;
const lvl = level === "beginner" ? "beginner" : "early-beginner";
const lvl = levels.some((l) => l.key === level) ? (level as string) : levels[0]?.key ?? "early-beginner";
return (
<>
@@ -26,7 +28,7 @@ export default async function CreatePage({ searchParams }: { searchParams: Promi
Type something you&apos;d love to draw and DrawIt will make a brand-new coloring-book lesson for it
trace the lines, then color it in.
</p>
<CreateForm level={lvl} enabled={isCreateEnabled()} />
<CreateForm level={lvl} levels={levels} enabled={isCreateEnabled()} />
</main>
</>
);
+18 -10
View File
@@ -29,7 +29,7 @@ export async function generateMetadata({ params }: { params: Promise<{ slug: str
export default async function LessonPage({ params }: { params: Promise<{ level: string; slug: string }> }) {
const { level, slug } = await params;
const lesson = getLessonBySlug(slug);
// Not a static lesson? It may be an AI-created lesson (…-trace / …-color).
// Not a static lesson? It may be an AI-created lesson (…-outline / …-detail / …-color).
if (!lesson || lesson.level !== level) return renderCreated(level, slug);
const user = await getCurrentUser();
@@ -122,7 +122,9 @@ export default async function LessonPage({ params }: { params: Promise<{ level:
);
}
// Render an AI-created lesson (Trace → Color), built on the fly from its DB row.
// Render an AI-created lesson (Outline → Details → Color it!), built on the fly from its DB row.
const CREATED_PHASE_LABEL: Record<string, string> = { outline: "Outline", detail: "Details", color: "Color it!" };
async function renderCreated(level: string, slug: string) {
const resolved = resolveCreatedLesson(slug);
if (!resolved || resolved.row.level !== level) notFound();
@@ -139,37 +141,43 @@ async function renderCreated(level: string, slug: string) {
const lesson = lessons[index];
const prev = index > 0 ? lessons[index - 1] : undefined;
const next = index < lessons.length - 1 ? lessons[index + 1] : undefined;
const stepCount = (l: Lesson) => l.steps.length || 1;
// Gate Color behind a completed Trace.
// Gate each phase behind the previous one.
if (prev && getCompletedSteps(user.id, prev.level, prev.sublevel).length < stepCount(prev)) {
redirect(`/learn/${level}/${prev.slug}`);
}
const levelName = getLevel(level)?.name ?? "";
const completed = getCompletedSteps(user.id, lesson.level, lesson.sublevel);
const phaseLabel = CREATED_PHASE_LABEL[lesson.phase ?? ""] ?? "";
const common = { level: lesson.level, levelName, sublevel: lesson.sublevel, title: lesson.title, emoji: lesson.emoji, intro: lesson.intro };
// The child's drawing carried from the previous phase (their traced outline → details → color).
const prevDrawing = prev ? (getLatestDrawing(user.id, level, prev.sublevel) ?? "") : "";
if (lesson.phase === "outline") {
// Trace phases: outline / details.
if (lesson.phase === "outline" || lesson.phase === "detail") {
const steps = lesson.steps.map((s) => ({ n: s.n, title: s.title, instruction: s.instruction, tip: s.tip ?? "", lines: s.lines ?? [] }));
const meta = { ...common, badgeKey: "", badgeName: lesson.badgeName, baseSvg: lesson.baseSvg ?? "", phaseLabel: "Trace" };
const meta = { ...common, badgeKey: "", badgeName: lesson.badgeName, baseSvg: lesson.baseSvg ?? "", phaseLabel };
const nextHref = next ? `/learn/${level}/${next.slug}` : "";
const nextLabel = next ? `Next: ${CREATED_PHASE_LABEL[next.phase ?? ""] ?? "Continue"}` : "";
return (
<>
<SiteNav />
<main className="container page">
<TraceRunner key={lesson.slug} meta={meta} steps={steps} loggedIn={true} username={user.username} completedSteps={completed} alreadyEarned={false} nextHref={`/learn/${level}/${lessons[1].slug}`} nextLabel="Next: Color" />
<TraceRunner key={lesson.slug} meta={meta} steps={steps} loggedIn={true} username={user.username} completedSteps={completed} alreadyEarned={false} carryImage={prevDrawing} nextHref={nextHref} nextLabel={nextLabel} />
</main>
</>
);
}
// Color phase
const userSketch = prev ? (getLatestDrawing(user.id, level, prev.sublevel) ?? "") : "";
const meta = { ...common, baseSvg: lesson.baseSvg ?? "", phaseLabel: "Color" };
// Color phase — "use my sketch" offers their traced drawing; "use template" uses the full line art.
const meta = { ...common, baseSvg: lesson.baseSvg ?? "", phaseLabel };
return (
<>
<SiteNav />
<main className="container page">
<ColoringRunner key={lesson.slug} meta={meta} loggedIn={true} username={user.username} alreadyEarned={false} userSketch={userSketch} />
<ColoringRunner key={lesson.slug} meta={meta} loggedIn={true} username={user.username} alreadyEarned={false} userSketch={prevDrawing} />
</main>
</>
);
+18 -17
View File
@@ -1,6 +1,6 @@
import Link from "next/link";
import SiteNav from "@/components/SiteNav";
import { LEVELS, getSubjects, getGroupedSubjects, BEGINNER_PACKS, lessonStepCount, type Lesson } from "@/lib/curriculum";
import { LEVELS, getSubjects, getGroupedSubjects, BEGINNER_PACKS, CREATABLE_LEVELS, lessonStepCount, type Lesson } from "@/lib/curriculum";
import { getCurrentUser } from "@/lib/session";
import { getCompletedSteps, hasBadge } from "@/lib/progress";
import { isLevelUnlocked, countCompletedLessons, isPackComplete, BEGINNER_UNLOCK_THRESHOLD } from "@/lib/gating";
@@ -77,32 +77,32 @@ export default async function LearnPage() {
const createEnabled = isCreateEnabled();
const toCreatedSubject = (row: CreatedLessonRow) => ({ key: row.slug, name: row.subject, emoji: row.emoji, order: 0, lessons: buildCreatedLessons(row) });
// "Create your own" entry + the user's creations + any promoted (global) ones, for EB/Beginner.
const creationsSection = (lvlKey: string) => {
if (lvlKey !== "early-beginner" && lvlKey !== "beginner") return null;
// Create UI is hidden entirely unless ComfyUI is configured (being a Creator alone isn't enough).
const canCreateNow = creator && createEnabled;
const mine: CreatedLessonRow[] = user && creator ? listCreatedForUser(user.id, lvlKey) : [];
const featured: CreatedLessonRow[] = listPromoted(lvlKey);
// A single "Create your own" section for admins/creators, shown once at the bottom of the page.
// Gathers the user's creations across every creatable level, plus any promoted (global) ones.
const creationsSection = () => {
if (!creator) return null; // only admins/creators see this section
const mine: CreatedLessonRow[] = user ? CREATABLE_LEVELS.flatMap((lvl) => listCreatedForUser(user.id, lvl)) : [];
const featured: CreatedLessonRow[] = CREATABLE_LEVELS.flatMap((lvl) => listPromoted(lvl));
const ready = mine.filter((r) => r.status === "ready");
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;
// Hide entirely if Create isn't configured and there's nothing to show.
if (!createEnabled && ready.length === 0 && pending.length === 0 && featured.length === 0) return null;
return (
<div style={{ border: "2px dashed var(--primary)", borderRadius: 16, padding: "12px 14px" }}>
<div className="card" style={{ marginTop: 18, border: "2px dashed var(--primary)" }}>
<div className="row" style={{ justifyContent: "space-between", alignItems: "center", flexWrap: "wrap", gap: 8 }}>
<strong style={{ fontSize: "1.05rem" }}>{canCreateNow ? "✨ Create your own" : "✨ My Creations"}</strong>
{canCreateNow && <Link className="btn" style={{ minHeight: 36, padding: "6px 12px" }} href={`/create?level=${lvlKey}`}>+ New lesson</Link>}
<h2 style={{ margin: 0 }}> Create your own</h2>
{createEnabled && <Link className="btn" style={{ minHeight: 36, padding: "6px 12px" }} href="/create">+ New lesson</Link>}
</div>
<p className="muted" style={{ marginTop: 4 }}>Make a brand-new lesson from any subject you can imagine.</p>
<div className="stack" style={{ marginTop: 10 }}>
{ready.map((r) => subjectAccordion(toCreatedSubject(r), false))}
{pending.map((r) => (
<div key={r.slug} style={phaseBox}>
<strong>{r.emoji} {r.subject}</strong>{" "}
<span className="muted" style={{ fontSize: "0.82rem" }}>{r.status === "needs_review" ? "⏳ Waiting for approval" : "🎨 Drawing…"}</span>
<span className="muted" style={{ fontSize: "0.82rem" }}>{r.status === "review" || r.status === "needs_review" ? "⏳ Waiting for approval" : "🎨 Drawing…"}</span>
</div>
))}
{canCreateNow && ready.length === 0 && pending.length === 0 && <p className="muted" style={{ fontSize: "0.9rem", margin: 0 }}>No creations yet tap + New lesson.</p>}
{createEnabled && ready.length === 0 && pending.length === 0 && <p className="muted" style={{ fontSize: "0.9rem", margin: 0 }}>No creations yet tap + New lesson.</p>}
{featured.length > 0 && (
<>
<strong style={{ fontSize: "0.95rem", marginTop: 6 }}>🌟 Featured creations</strong>
@@ -160,7 +160,6 @@ export default async function LearnPage() {
) : lvl.key === "beginner" ? (
// Beginner: group subjects into packs, each with a per-pack badge.
<div className="stack" style={{ marginTop: 6 }}>
{creationsSection(lvl.key)}
{BEGINNER_PACKS.map((pack) => {
const packSubjects = subjects.filter((s) => pack.subjectKeys.includes(s.key));
const packDone = !!user && isPackComplete(user.id, pack);
@@ -181,7 +180,6 @@ export default async function LearnPage() {
) : lvl.key === "early-beginner" ? (
// Early Beginner: themed groups, each holding its subjects.
<div className="stack" style={{ marginTop: 6 }}>
{creationsSection(lvl.key)}
{getGroupedSubjects(lvl.key).map((group) => {
const groupDone = group.subjects.every((s) => subjectComplete(s));
return (
@@ -223,6 +221,9 @@ export default async function LearnPage() {
);
})}
</div>
{/* Single "Create your own" section at the very bottom (admins/creators only). */}
{creationsSection()}
</main>
</>
);
+59 -31
View File
@@ -1,6 +1,5 @@
import "server-only";
import { getDb } from "./db";
import { svgToRevealSteps } from "./vectorize";
import type { Lesson } from "./curriculum";
/**
@@ -61,7 +60,7 @@ export function getCreatedById(id: number): CreatedLessonRow | undefined {
return getDb().prepare("SELECT * FROM created_lessons WHERE id = ?").get(id) as CreatedLessonRow | undefined;
}
/** Look up by the base slug (e.g. 'created-42-zebra'), ignoring any '-trace'/'-color' phase suffix. */
/** Look up by the base slug (e.g. 'created-42-zebra'), ignoring any '-outline'/'-detail'/'-color' suffix. */
export function getCreatedBySlug(baseSlug: string): CreatedLessonRow | undefined {
return getDb().prepare("SELECT * FROM created_lessons WHERE slug = ?").get(baseSlug) as CreatedLessonRow | undefined;
}
@@ -105,6 +104,13 @@ function setGenerated(id: number, templateSvg: string, image: string, status: Cr
.run(status, templateSvg, image, id);
}
/** Change a creation's subject (and the prompt that will be sent on the next generate). */
export function updateCreatedSubject(id: number, subject: string, prompt: string) {
getDb()
.prepare("UPDATE created_lessons SET subject = ?, prompt = ?, updated_at = datetime('now') WHERE id = ?")
.run(subject, prompt, id);
}
/** Approve a creation: make it live (ready) and mark it approved. */
export function approveCreated(id: number) {
getDb()
@@ -127,30 +133,39 @@ export async function processCreation(id: number): Promise<void> {
try {
updateCreatedStatus(id, "generating");
const { generateLineArt } = await import("./comfyui");
const { pngToTemplateSvg } = await import("./vectorize");
const { pngToLessonArt } = await import("./vectorize");
const png = await generateLineArt(row.subject);
const templateSvg = await pngToTemplateSvg(png);
const art = await pngToLessonArt(png); // { outline, details, full }
// 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");
setGenerated(id, JSON.stringify(art), `data:image/png;base64,${png.toString("base64")}`, needsReview ? "review" : "ready");
} catch (e) {
updateCreatedStatus(id, "failed", e instanceof Error ? e.message : String(e));
}
}
/** Build the playable lessons (Trace + Color) from a ready row. */
/** The stored line art: { outline, details, full }. Tolerates legacy rows that stored a bare SVG string. */
function lessonArt(row: CreatedLessonRow): { outline: string; details: string; full: string } {
const raw = row.template_svg || "";
try {
const a = JSON.parse(raw);
if (a && typeof a === "object" && "full" in a) return { outline: a.outline || a.full || "", details: a.details || "", full: a.full || "" };
} catch {
/* legacy: a plain SVG string */
}
return { outline: raw, details: "", full: raw };
}
/**
* Build the playable lessons from a ready row, mirroring Early Beginner's 3-phase shape:
* Outline (trace the main shape) → Details (add the inner lines) → Color it! (full template).
* The Details phase is skipped when the art has no separable interior detail.
*/
export function buildCreatedLessons(row: CreatedLessonRow): Lesson[] {
const base = 900000 + row.id * 10;
const tpl = row.template_svg || "";
const { outline, details, full } = lessonArt(row);
const lower = row.subject.toLowerCase();
const steps = svgToRevealSteps(tpl).map((els, i) => ({
n: i + 1,
title: `Lines ${i + 1}`,
instruction: "Trace the lines, one part at a time.",
tip: "",
lines: els,
}));
const common = {
level: row.level,
emoji: row.emoji,
@@ -159,37 +174,50 @@ export function buildCreatedLessons(row: CreatedLessonRow): Lesson[] {
subjectEmoji: row.emoji,
order: 0,
badgeName: `${row.subject} Artist`,
badgeKey: "",
};
const trace: Lesson = {
const lessons: Lesson[] = [];
lessons.push({
...common,
sublevel: base + 1,
slug: `${row.slug}-trace`,
title: `${row.subject} · Trace`,
subject: `${lower} trace`,
intro: `Watch your ${lower} appear, then trace it!`,
badgeKey: "",
slug: `${row.slug}-outline`,
title: `${row.subject} · Outline`,
subject: `${lower} outline`,
intro: `Trace the outline of your ${lower}, one line at a time.`,
phase: "outline",
baseSvg: "",
steps,
};
const color: Lesson = {
steps: [{ n: 1, title: "The outline", instruction: `Trace the main outline of your ${lower}.`, tip: "", lines: [outline] }],
});
if (details) {
lessons.push({
...common,
sublevel: base + 2,
slug: `${row.slug}-detail`,
title: `${row.subject} · Details`,
subject: `${lower} details`,
intro: `Now add the details inside your ${lower}.`,
phase: "detail",
baseSvg: outline, // faint outline guide under the new detail lines
steps: [{ n: 1, title: "The details", instruction: "Add the inside details.", tip: "", lines: [details] }],
});
}
lessons.push({
...common,
sublevel: base + 2,
sublevel: base + 3,
slug: `${row.slug}-color`,
title: `${row.subject} · Color`,
title: `${row.subject} · Color it!`,
subject: `color the ${lower}`,
intro: `Bring your ${lower} to life! Color it in.`,
badgeKey: "",
phase: "color",
baseSvg: tpl,
baseSvg: full,
steps: [],
};
return [trace, color];
});
return lessons;
}
/** Resolve a lesson-page slug (…-trace / …-color) to its created lesson + its phase siblings. */
/** Resolve a lesson-page slug (…-outline / …-detail / …-color) to its created lesson + its phase siblings. */
export function resolveCreatedLesson(slug: string): { lessons: Lesson[]; index: number; row: CreatedLessonRow } | null {
const m = slug.match(/^(.*)-(trace|color)$/);
const m = slug.match(/^(.*)-(outline|detail|color)$/);
if (!m) return null;
const row = getCreatedBySlug(m[1]);
// Built once generated: 'ready' (live) or 'review' (admin preview before approval).
+5
View File
@@ -12,6 +12,11 @@ export const LEVELS: LevelMeta[] = [
{ key: "superb", name: "Superb", emoji: "\u{1F31F}", blurb: "Confident, expressive drawing in your own style." },
];
export function getLevel(key: string): LevelMeta | undefined { return LEVELS.find((l) => l.key === key); }
// Levels the "Create" feature can target. Add a level key here once it's ready for AI-created lessons;
// it will then appear in the Create level picker automatically. (Only Early Beginner + Beginner today.)
export const CREATABLE_LEVELS = ["early-beginner", "beginner"];
export function creatableLevels(): LevelMeta[] { return LEVELS.filter((l) => CREATABLE_LEVELS.includes(l.key)); }
export type StepKind = "construct" | "outline" | "color" | "shade" | "detail";
// Early Beginner uses outline/detail/extra; Beginner uses construct/outline/color/light.
export type Phase = "construct" | "outline" | "detail" | "color" | "light" | "extra";
+59 -51
View File
@@ -2,75 +2,83 @@ import "server-only";
import { trace } from "potrace";
/**
* Turn a raster coloring-book PNG (black lines on white) into SVG line art fitted to the lessons'
* 0 0 400 300 viewBox. potrace traces the dark pixels into filled paths; we recolor them with our ink
* and wrap with a transform so they sit in the standard lesson coordinate space.
* Turn a raster coloring-book PNG (black lines on white) into simple line art split into the
* Early-Beginner phases: a big-shapes OUTLINE, the leftover DETAILS, and the FULL image (color template).
*
* - `pngToTemplateSvg` returns one fitted <path> (holes preserved via fill-rule) — used as the Color
* phase `baseSvg` and the final overlay.
* - `svgToRevealSteps` splits that path into chunks for the Trace phase's stroke-by-stroke reveal.
* Key correctness points:
* - potrace traces dark pixels into one filled path that uses `fill-rule: evenodd` so the thin lines
* read correctly (interior "holes" are subtracted). We must keep each phase as ONE path — splitting
* a path into separate filled subpaths makes the outer contour fill in solid (the old "solid blob").
* - Outline vs details comes from two passes: a high `turdSize` pass drops small features (doors,
* windows…) leaving the main outline; details = everything in the full pass that isn't in the outline.
*/
const STROKE = "#2b2440";
const VW = 400;
const VH = 300;
function traceToSvg(png: Buffer): Promise<string> {
interface Parsed {
subs: string[]; // subpath "d" strings, each starting with M/m
w: number;
h: number;
}
function traceToSvg(png: Buffer, options: Record<string, unknown>): Promise<string> {
return new Promise((resolve, reject) => {
trace(
png,
{ color: STROKE, background: "transparent", threshold: 170, turdSize: 80, optTolerance: 0.4 },
(err: Error | null, svg: string) => (err ? reject(err) : resolve(svg)),
);
trace(png, { threshold: 128, ...options }, (err: Error | null, svg: string) => (err ? reject(err) : resolve(svg)));
});
}
function parse(svg: string): Parsed {
const wM = svg.match(/width="(\d+(?:\.\d+)?)"/);
const hM = svg.match(/height="(\d+(?:\.\d+)?)"/);
const w = wM ? parseFloat(wM[1]) : VW;
const h = hM ? parseFloat(hM[1]) : VH;
const ds = [...svg.matchAll(/\bd="([^"]+)"/g)].map((m) => m[1]);
const combined = ds.join(" ");
const subs = combined
.split(/(?=[Mm])/)
.map((s) => s.trim())
.filter(Boolean);
return { subs, w, h };
}
function round(n: number): number {
return Math.round(n * 100) / 100;
}
export async function pngToTemplateSvg(png: Buffer): Promise<string> {
const svg = await traceToSvg(png);
export interface LessonArt {
outline: string; // one <path> — the main outline (big shapes)
details: string; // one <path> — interior details (may be "")
full: string; // one <path> — the whole line art (color template)
}
// Source dimensions (potrace emits width/height + viewBox on the <svg>).
const wM = svg.match(/width="(\d+(?:\.\d+)?)"/);
const hM = svg.match(/height="(\d+(?:\.\d+)?)"/);
const srcW = wM ? parseFloat(wM[1]) : VW;
const srcH = hM ? parseFloat(hM[1]) : VH;
export async function pngToLessonArt(png: Buffer): Promise<LessonArt> {
// Full pass: keep almost everything (drop only tiny speckles).
const full = parse(await traceToSvg(png, { turdSize: 12 }));
if (full.subs.length === 0) throw new Error("Vectorize produced no paths (image may be blank or solid).");
// All path data, combined into one path (keeps fill-rule holes correct).
const ds = [...svg.matchAll(/\bd="([^"]+)"/g)].map((m) => m[1]);
if (ds.length === 0) throw new Error("Vectorize produced no paths (image may be blank).");
const combined = ds.join(" ");
// Outline pass: a turdSize scaled to the image drops small interior features, leaving the big outline.
const outlineTurd = Math.max(200, Math.round((full.w * full.h) / 400));
const outline = parse(await traceToSvg(png, { turdSize: outlineTurd }));
// Fit srcW×srcH into 400×300, centered.
const s = Math.min(VW / srcW, VH / srcH);
const tx = round((VW - srcW * s) / 2);
const ty = round((VH - srcH * s) / 2);
// Fit the source image into the 400×300 lesson canvas, centered (same transform for every phase so they align).
const s = Math.min(VW / full.w, VH / full.h);
const tx = round((VW - full.w * s) / 2);
const ty = round((VH - full.h * s) / 2);
const transform = `translate(${tx} ${ty}) scale(${round(s)})`;
const wrap = (subs: string[]) =>
subs.length ? `<path d="${subs.join(" ")}" transform="${transform}" fill="${STROKE}" fill-rule="evenodd"/>` : "";
return `<path d="${combined}" transform="${transform}" fill="${STROKE}" fill-rule="evenodd"/>`;
}
/** Split the template path into up to `maxSteps` chunks of subpaths for the trace reveal. */
export function svgToRevealSteps(templateSvg: string, maxSteps = 6): string[][] {
const dM = templateSvg.match(/\bd="([^"]+)"/);
const tM = templateSvg.match(/transform="([^"]+)"/);
if (!dM) return [[templateSvg]];
const transform = tM ? ` transform="${tM[1]}"` : "";
// Split into subpaths at each move command.
const subs = dM[1]
.split(/(?=[Mm])/)
.map((s) => s.trim())
.filter(Boolean);
const elements = subs.map((d) => `<path d="${d}"${transform} fill="${STROKE}" fill-rule="evenodd"/>`);
if (elements.length === 0) return [[templateSvg]];
// Chunk into steps so the reveal has a few stages.
const steps = Math.min(maxSteps, elements.length);
const per = Math.ceil(elements.length / steps);
const out: string[][] = [];
for (let i = 0; i < elements.length; i += per) out.push(elements.slice(i, i + per));
return out;
let outlineSubs = outline.subs;
const outlineSet = new Set(outlineSubs);
let detailSubs = full.subs.filter((d) => !outlineSet.has(d));
// If the outline pass kept nothing (or everything), fall back so we always have a usable outline.
if (outlineSubs.length === 0 || detailSubs.length === 0) {
outlineSubs = full.subs;
detailSubs = [];
}
return { outline: wrap(outlineSubs), details: wrap(detailSubs), full: wrap(full.subs) };
}