Files
drawit/src/lib/createdLessons.ts
T

202 lines
7.6 KiB
TypeScript

import "server-only";
import { getDb } from "./db";
import { svgToRevealSteps } from "./vectorize";
import type { Lesson } from "./curriculum";
/**
* Data + lifecycle for AI-created lessons. A row is generated asynchronously (ComfyUI), vectorized
* (potrace), and then rendered through the normal runners by building Lesson objects on the fly.
*/
// 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;
user_id: number | null;
level: string;
subject: string;
slug: string;
status: CreatedStatus;
template_svg: string | null;
image: string | null;
prompt: string;
moderation: string | null;
emoji: string;
error: string | null;
promoted: number;
created_at: string;
updated_at: string;
}
function kebab(s: string): string {
return s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
}
/** Insert a created-lesson row and return it (slug includes the row id for uniqueness). */
export function createCreatedLesson(opts: {
userId: number;
level: string;
subject: string;
prompt: string;
moderation: string;
status: CreatedStatus;
emoji?: string;
}): CreatedLessonRow {
const db = getDb();
const info = db
.prepare(
`INSERT INTO created_lessons (user_id, level, subject, slug, status, prompt, moderation, emoji)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
)
.run(opts.userId, opts.level, opts.subject, `pending-${Date.now()}`, opts.status, opts.prompt, opts.moderation, opts.emoji || "🎨");
const id = Number(info.lastInsertRowid);
const slug = `created-${id}-${kebab(opts.subject)}`;
db.prepare("UPDATE created_lessons SET slug = ? WHERE id = ?").run(slug, id);
return getCreatedById(id)!;
}
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. */
export function getCreatedBySlug(baseSlug: string): CreatedLessonRow | undefined {
return getDb().prepare("SELECT * FROM created_lessons WHERE slug = ?").get(baseSlug) as CreatedLessonRow | undefined;
}
export function listCreatedForUser(userId: number, level: string): CreatedLessonRow[] {
return getDb()
.prepare("SELECT * FROM created_lessons WHERE user_id = ? AND level = ? ORDER BY created_at DESC")
.all(userId, level) as CreatedLessonRow[];
}
/** Promoted (global) creations, visible to everyone on a level. */
export function listPromoted(level: string): CreatedLessonRow[] {
return getDb()
.prepare("SELECT * FROM created_lessons WHERE promoted = 1 AND status = 'ready' AND level = ? ORDER BY created_at DESC")
.all(level) as 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 IN ('pending','generating','needs_review','review','failed') ORDER BY created_at DESC")
.all() as CreatedLessonRow[];
}
/** Admin queue: ready creations not yet promoted to the global curriculum. */
export function listReadyUnpromoted(): CreatedLessonRow[] {
return getDb()
.prepare("SELECT * FROM created_lessons WHERE status = 'ready' AND promoted = 0 ORDER BY created_at DESC")
.all() as CreatedLessonRow[];
}
export function updateCreatedStatus(id: number, status: CreatedStatus, error?: string) {
getDb()
.prepare("UPDATE created_lessons SET status = ?, error = ?, updated_at = datetime('now') WHERE id = ?")
.run(status, error ?? null, id);
}
function setGenerated(id: number, templateSvg: string, image: string, status: CreatedStatus) {
getDb()
.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) {
getDb().prepare("UPDATE created_lessons SET promoted = 1, updated_at = datetime('now') WHERE id = ?").run(id);
}
/**
* Run generation for a row: ComfyUI -> vectorize -> ready (or failed). Fire-and-forget from the API
* route; safe on a long-lived self-hosted node. Heavy deps are imported lazily so the data layer stays
* light for normal page renders.
*/
export async function processCreation(id: number): Promise<void> {
const row = getCreatedById(id);
if (!row) return;
try {
updateCreatedStatus(id, "generating");
const { generateLineArt } = await import("./comfyui");
const { pngToTemplateSvg } = await import("./vectorize");
const png = await generateLineArt(row.subject);
const templateSvg = await pngToTemplateSvg(png);
// 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));
}
}
/** Build the playable lessons (Trace + Color) from a ready row. */
export function buildCreatedLessons(row: CreatedLessonRow): Lesson[] {
const base = 900000 + row.id * 10;
const tpl = row.template_svg || "";
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,
subjectKey: row.slug,
subjectName: row.subject,
subjectEmoji: row.emoji,
order: 0,
badgeName: `${row.subject} Artist`,
};
const trace: Lesson = {
...common,
sublevel: base + 1,
slug: `${row.slug}-trace`,
title: `${row.subject} · Trace`,
subject: `${lower} trace`,
intro: `Watch your ${lower} appear, then trace it!`,
badgeKey: "",
phase: "outline",
baseSvg: "",
steps,
};
const color: Lesson = {
...common,
sublevel: base + 2,
slug: `${row.slug}-color`,
title: `${row.subject} · Color`,
subject: `color the ${lower}`,
intro: `Bring your ${lower} to life! Color it in.`,
badgeKey: "",
phase: "color",
baseSvg: tpl,
steps: [],
};
return [trace, color];
}
/** Resolve a lesson-page slug (…-trace / …-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)$/);
if (!m) return null;
const row = getCreatedBySlug(m[1]);
// 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;
return { lessons, index, row };
}