Call ComfyUI and show preview of lesson to admin to approve
This commit is contained in:
+3
-2
@@ -1,8 +1,9 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
// better-sqlite3 is a native module; keep it external to the server bundle.
|
// Keep these out of the server bundle: better-sqlite3 is native; potrace/jimp are CommonJS and
|
||||||
serverExternalPackages: ["better-sqlite3"],
|
// 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.
|
// Privacy: no telemetry headers, no powered-by header.
|
||||||
poweredByHeader: false,
|
poweredByHeader: false,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,13 +9,15 @@ interface Row {
|
|||||||
status: string;
|
status: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
emoji: string;
|
emoji: string;
|
||||||
|
image: string | null;
|
||||||
|
error: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AdminCreations({ review, ready }: { review: Row[]; ready: Row[] }) {
|
export default function AdminCreations({ review, ready }: { review: Row[]; ready: Row[] }) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [busy, setBusy] = useState<number | null>(null);
|
const [busy, setBusy] = useState<number | null>(null);
|
||||||
|
|
||||||
async function act(id: number, action: "approve" | "block" | "promote") {
|
async function act(id: number, action: "approve" | "block" | "promote" | "regenerate") {
|
||||||
setBusy(id);
|
setBusy(id);
|
||||||
try {
|
try {
|
||||||
await fetch(`/api/admin/create/${id}`, {
|
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) {
|
if (review.length === 0 && ready.length === 0) {
|
||||||
return <p className="muted" style={{ padding: 16, margin: 0 }}>No creations awaiting review.</p>;
|
return <p className="muted" style={{ padding: 16, margin: 0 }}>No created lessons yet.</p>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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" }} />
|
||||||
|
) : (
|
||||||
|
<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 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 (
|
||||||
|
<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 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}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="row" style={{ gap: 6, flexWrap: "wrap" }}>
|
||||||
|
{generated && <a className="btn ghost" style={mini} href={previewHref(r)} target="_blank" rel="noopener">👁 Preview</a>}
|
||||||
|
{kind === "queue" && (r.status === "review" || r.status === "needs_review") && (
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
{kind === "ready" && (
|
||||||
|
<button className="btn secondary" style={mini} disabled={busy === r.id} onClick={() => act(r.id, "promote")}>🌟 Promote</button>
|
||||||
|
)}
|
||||||
|
{r.status !== "blocked" && (
|
||||||
|
<button className="btn danger" style={mini} disabled={busy === r.id} onClick={() => act(r.id, "block")}>Block</button>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: 12 }}>
|
<div className="stack" style={{ padding: 12 }}>
|
||||||
{review.length > 0 && (
|
{review.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<strong>Awaiting review</strong>
|
<strong>In review / in progress</strong>
|
||||||
<div className="stack" style={{ marginTop: 8, marginBottom: 16 }}>
|
{review.map((r) => card(r, "queue"))}
|
||||||
{review.map((r) => (
|
|
||||||
<div key={r.id} className="row" style={{ justifyContent: "space-between", alignItems: "center", border: "1px solid var(--line)", borderRadius: 10, padding: "8px 12px" }}>
|
|
||||||
<span>{r.emoji} <strong>{r.subject}</strong> <span className="muted" style={{ fontSize: "0.82rem" }}>· {r.level}</span></span>
|
|
||||||
<span className="row" style={{ gap: 6 }}>
|
|
||||||
<button className="btn" style={mini} disabled={busy === r.id} onClick={() => act(r.id, "approve")}>✓ Approve</button>
|
|
||||||
<button className="btn danger" style={mini} disabled={busy === r.id} onClick={() => act(r.id, "block")}>Block</button>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{ready.length > 0 && (
|
{ready.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<strong>Ready — promote to everyone?</strong>
|
<strong style={{ marginTop: 8 }}>Live — promote to everyone?</strong>
|
||||||
<div className="stack" style={{ marginTop: 8 }}>
|
{ready.map((r) => card(r, "ready"))}
|
||||||
{ready.map((r) => (
|
|
||||||
<div key={r.id} className="row" style={{ justifyContent: "space-between", alignItems: "center", border: "1px solid var(--line)", borderRadius: 10, padding: "8px 12px" }}>
|
|
||||||
<span>{r.emoji} <strong>{r.subject}</strong> <span className="muted" style={{ fontSize: "0.82rem" }}>· {r.level}</span></span>
|
|
||||||
<button className="btn secondary" style={mini} disabled={busy === r.id} onClick={() => act(r.id, "promote")}>🌟 Promote to global</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import AddUserForm from "./AddUserForm";
|
|||||||
import AdminUsers from "./AdminUsers";
|
import AdminUsers from "./AdminUsers";
|
||||||
import AdminReports from "./AdminReports";
|
import AdminReports from "./AdminReports";
|
||||||
import AdminCreations from "./AdminCreations";
|
import AdminCreations from "./AdminCreations";
|
||||||
import { listNeedsReview, listReadyUnpromoted } from "@/lib/createdLessons";
|
import { listAdminQueue, listReadyUnpromoted } from "@/lib/createdLessons";
|
||||||
|
|
||||||
export const metadata = { title: "Admin · DrawIt" };
|
export const metadata = { title: "Admin · DrawIt" };
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -21,8 +21,9 @@ export default async function AdminPage() {
|
|||||||
const pending = users.filter((u) => u.status === "pending");
|
const pending = users.filter((u) => u.status === "pending");
|
||||||
const reports = listReports();
|
const reports = listReports();
|
||||||
const openReports = reports.filter((r) => r.status === "open").length;
|
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 pick = (r: { id: number; subject: string; level: string; status: string; slug: string; emoji: string; image: string | null; error: string | null }) =>
|
||||||
const reviewCreations = listNeedsReview().map(pick);
|
({ 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);
|
const readyCreations = listReadyUnpromoted().map(pick);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { requireAdmin } from "@/lib/session";
|
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.
|
// 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 }> }) {
|
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) {
|
switch (body.action) {
|
||||||
case "approve":
|
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");
|
updateCreatedStatus(row.id, "pending");
|
||||||
void processCreation(row.id);
|
void processCreation(row.id);
|
||||||
return NextResponse.json({ ok: true, status: "generating" });
|
return NextResponse.json({ ok: true, status: "generating" });
|
||||||
|
|||||||
@@ -32,15 +32,10 @@ export async function POST(req: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const prompt = resolvePrompt(mod.subject);
|
const prompt = resolvePrompt(mod.subject);
|
||||||
const status = mod.decision === "allowlist" ? "pending" : "needs_review";
|
// Both allowlisted and review subjects generate now; review subjects finish in a quarantined
|
||||||
const row = createCreatedLesson({ userId: user.id, level, subject: mod.subject, prompt, moderation: mod.decision, status });
|
// '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.
|
return NextResponse.json({ id: row.id, slug: row.slug, status: "generating" });
|
||||||
if (mod.decision === "allowlist") void processCreation(row.id);
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
id: row.id,
|
|
||||||
slug: row.slug,
|
|
||||||
status: mod.decision === "allowlist" ? "generating" : "needs_review",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -130,8 +130,10 @@ async function renderCreated(level: string, slug: string) {
|
|||||||
|
|
||||||
const user = await getCurrentUser();
|
const user = await getCurrentUser();
|
||||||
if (!user) redirect("/signup");
|
if (!user) redirect("/signup");
|
||||||
// Visible to the owner, anyone if promoted to global, or an admin.
|
// Admins can preview any generated lesson (incl. 'review'). Everyone else only sees 'ready' ones
|
||||||
const canView = row.user_id === user.id || row.promoted === 1 || user.role === "admin";
|
// 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 (!canView) notFound();
|
||||||
if (!isLevelUnlocked(user.id, level)) redirect("/learn");
|
if (!isLevelUnlocked(user.id, level)) redirect("/learn");
|
||||||
|
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ export default async function LearnPage() {
|
|||||||
const mine: CreatedLessonRow[] = user && creator ? listCreatedForUser(user.id, lvlKey) : [];
|
const mine: CreatedLessonRow[] = user && creator ? listCreatedForUser(user.id, lvlKey) : [];
|
||||||
const featured: CreatedLessonRow[] = listPromoted(lvlKey);
|
const featured: CreatedLessonRow[] = listPromoted(lvlKey);
|
||||||
const ready = mine.filter((r) => r.status === "ready");
|
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.
|
// Nothing to show: Create disabled and no existing or featured creations.
|
||||||
if (!canCreateNow && ready.length === 0 && pending.length === 0 && featured.length === 0) return null;
|
if (!canCreateNow && ready.length === 0 && pending.length === 0 && featured.length === 0) return null;
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ import type { Lesson } from "./curriculum";
|
|||||||
* (potrace), and then rendered through the normal runners by building Lesson objects on the fly.
|
* (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 {
|
export interface CreatedLessonRow {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -77,10 +79,10 @@ export function listPromoted(level: string): CreatedLessonRow[] {
|
|||||||
.all(level) as CreatedLessonRow[];
|
.all(level) as CreatedLessonRow[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Admin queue: things awaiting review. */
|
/** Admin queue: everything not yet live (working, awaiting review, or failed). */
|
||||||
export function listNeedsReview(): CreatedLessonRow[] {
|
export function listAdminQueue(): CreatedLessonRow[] {
|
||||||
return getDb()
|
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[];
|
.all() as CreatedLessonRow[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,10 +99,17 @@ export function updateCreatedStatus(id: number, status: CreatedStatus, error?: s
|
|||||||
.run(status, error ?? null, id);
|
.run(status, error ?? null, id);
|
||||||
}
|
}
|
||||||
|
|
||||||
function setReady(id: number, templateSvg: string, image: string) {
|
function setGenerated(id: number, templateSvg: string, image: string, status: CreatedStatus) {
|
||||||
getDb()
|
getDb()
|
||||||
.prepare("UPDATE created_lessons SET status = 'ready', template_svg = ?, image = ?, error = NULL, updated_at = datetime('now') WHERE id = ?")
|
.prepare("UPDATE created_lessons SET status = ?, template_svg = ?, image = ?, error = NULL, updated_at = datetime('now') WHERE id = ?")
|
||||||
.run(templateSvg, image, 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) {
|
export function promoteCreated(id: number) {
|
||||||
@@ -121,7 +130,10 @@ export async function processCreation(id: number): Promise<void> {
|
|||||||
const { pngToTemplateSvg } = await import("./vectorize");
|
const { pngToTemplateSvg } = await import("./vectorize");
|
||||||
const png = await generateLineArt(row.subject);
|
const png = await generateLineArt(row.subject);
|
||||||
const templateSvg = await pngToTemplateSvg(png);
|
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) {
|
} catch (e) {
|
||||||
updateCreatedStatus(id, "failed", e instanceof Error ? e.message : String(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)$/);
|
const m = slug.match(/^(.*)-(trace|color)$/);
|
||||||
if (!m) return null;
|
if (!m) return null;
|
||||||
const row = getCreatedBySlug(m[1]);
|
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 lessons = buildCreatedLessons(row);
|
||||||
const index = lessons.findIndex((l) => l.slug === slug);
|
const index = lessons.findIndex((l) => l.slug === slug);
|
||||||
if (index < 0) return null;
|
if (index < 0) return null;
|
||||||
|
|||||||
Reference in New Issue
Block a user