202 lines
7.6 KiB
TypeScript
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 };
|
|
}
|