AI Creator improvements and admin functions #15
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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" && (
|
||||
|
||||
@@ -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'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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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
@@ -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
@@ -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).
|
||||
|
||||
@@ -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
@@ -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) };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user