[Feature] comfyui creator #12
@@ -15,3 +15,14 @@ SMTP_SECURE=false
|
||||
SMTP_USER=
|
||||
SMTP_PASS=
|
||||
SMTP_FROM="DrawIt <no-reply@example.com>"
|
||||
|
||||
# --- "Create" feature (AI-generated lessons via a self-hosted ComfyUI) ---
|
||||
# Leave COMFYUI_URL blank to disable the Create feature entirely.
|
||||
COMFYUI_URL=http://127.0.0.1:8188
|
||||
COMFYUI_API_KEY=
|
||||
# Path to a ComfyUI workflow exported in API format ("Save (API format)").
|
||||
COMFYUI_WORKFLOW=./comfyui/coloring-book.workflow_api.json
|
||||
# Node ids in that workflow to inject into: the positive prompt text, and the sampler seed.
|
||||
COMFYUI_PROMPT_NODE=6
|
||||
COMFYUI_SEED_NODE=3
|
||||
COMFYUI_TIMEOUT_MS=120000
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
# Normalize line endings: treat files as text and check out with LF.
|
||||
# Prevents CRLF/LF churn showing whole files as "modified".
|
||||
* text=auto eol=lf
|
||||
|
||||
# Binary assets — never normalize.
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.jpeg binary
|
||||
*.gif binary
|
||||
*.ico binary
|
||||
*.pdf binary
|
||||
*.woff binary
|
||||
*.woff2 binary
|
||||
@@ -0,0 +1,64 @@
|
||||
# CLAUDE.md — DrawIt
|
||||
|
||||
Guidance for Claude (and humans) working in this repo.
|
||||
|
||||
## What DrawIt is
|
||||
|
||||
A free, open-source, **privacy-first** web app that teaches kids to draw — mobile/iPad-first.
|
||||
**Non-negotiable principle: no data collection.** No analytics, no trackers, no third-party calls.
|
||||
All data stays in the operator's self-hosted SQLite. Any feature must preserve this.
|
||||
|
||||
## Stack
|
||||
|
||||
Next.js 15 (App Router) + TypeScript · `better-sqlite3` (WAL) · custom cookie-session auth (scrypt) ·
|
||||
`nodemailer` SMTP · `pdf-lib` (award certificates) · hand-written mobile-first CSS (no UI framework).
|
||||
Path alias `@/*` → `./src/*` (needs `baseUrl` in tsconfig for the Windows/Next build).
|
||||
|
||||
## How the curriculum works (read before touching lessons)
|
||||
|
||||
- **Levels:** Early Beginner → Beginner → Learner → Advanced Learner → Superb (`LEVELS` in
|
||||
`src/lib/curriculum.ts`).
|
||||
- **Subjects & phases:** each subject is a set of `Lesson` entries, one per phase.
|
||||
- Early Beginner = 3 phases: **Outline → Details → Color it!** (per-subject badge on Details).
|
||||
- Beginner = 4 phases: **Construct → Outline → Color → Light** (per-**pack** badge).
|
||||
- **Runners:** trace phases use per-step `lines: string[]` (SVG elements) revealed one at a time by
|
||||
`LineDraw` inside `TraceRunner`; color/light phases use `baseSvg` rasterized by `ColoringRunner`.
|
||||
- **Curriculum files:** static lessons live in `src/lib/curriculum.ts` and the generated
|
||||
`curriculum.beginner.ts`, `curriculum.eb2.ts`, `curriculum.animals.ts`. Art is produced by Python
|
||||
generators in the scratch outputs dir (cairosvg-rendered montages verify it before wiring).
|
||||
- **Grouping/gating:** `getGroupedSubjects` + `EARLY_BEGINNER_GROUPS` (EB themed groups);
|
||||
`src/lib/gating.ts` (Beginner unlocks after 5 EB lessons; pack completion). Per-subject vs per-pack
|
||||
badges via `badgeDefs()`.
|
||||
|
||||
## Build / verify
|
||||
|
||||
`npm run build` and `npm run typecheck` are the source of truth — **run them locally**. Note: the
|
||||
Cowork sandbox cannot reliably run them (typescript is a devDep often absent; native `better-sqlite3`
|
||||
won't compile; shell file reads of the mount can truncate). Verify edits via the file tools and the
|
||||
cairosvg art montages; do a real build on a normal machine before shipping.
|
||||
|
||||
## Design & research docs (`docs/`)
|
||||
|
||||
Read the relevant doc before building a feature it covers:
|
||||
|
||||
- `docs/RESEARCH-drawing-platforms-and-techniques.md` — competitor scan + drawing pedagogy; maps
|
||||
techniques to the five levels.
|
||||
- `docs/RESEARCH-beginner-level.md` — the Beginner level spec (4 phases, 3 packs, unlock gate,
|
||||
per-pack badges). **Built.**
|
||||
- `docs/RESEARCH-early-beginner-expansion.md` — the Early Beginner expansion (Simple Shapes, Fun
|
||||
Things, Clothes, Faces, ~19 animals; themed groups). **Built.**
|
||||
- **`docs/DESIGN-create-ai-lessons.md` — the "Create" feature + Creator role: type a subject →
|
||||
ComfyUI generates coloring-book line art → DrawIt turns it into a lesson. Covers the Creator role,
|
||||
ComfyUI API integration, the image→level pipeline, mandatory kids-safety/moderation, data model,
|
||||
API routes, and phased rollout. Design only (not built). Read this before implementing Create.**
|
||||
|
||||
Operational/setup docs also live in `docs/` (BUILDING, DOCKER, SYSTEMD, REVERSE_PROXIES, AUTO_STARTING).
|
||||
|
||||
## Conventions
|
||||
|
||||
- Preserve the privacy principle in every change.
|
||||
- First registered account is forced `admin` + active; everyone else defaults `learner` + `pending`
|
||||
until email confirmation. Roles: `admin | learner | creator` (creator = learner + the Create ability).
|
||||
- Adding a lesson subject = append `Lesson` entries (+ art); routes/runners are generic and handle the
|
||||
rest. Keep sublevels unique within a level (`subjectIndex*10 + phase` for static lessons).
|
||||
- Images (drawings, avatars, created line art) are stored as data URLs in SQLite for privacy.
|
||||
@@ -0,0 +1,28 @@
|
||||
# ComfyUI workflow for the "Create" feature
|
||||
|
||||
DrawIt's **Create** feature sends a fixed coloring-book prompt to a self-hosted [ComfyUI](https://github.com/comfyanonymous/ComfyUI)
|
||||
server and turns the result into a lesson. Nothing leaves your machine.
|
||||
|
||||
## Setup
|
||||
|
||||
1. Run ComfyUI (default: `http://127.0.0.1:8188`).
|
||||
2. Set `COMFYUI_URL` in `.env.local` (blank = feature disabled).
|
||||
3. Provide a workflow exported in **API format** (ComfyUI → Settings → enable dev mode →
|
||||
"Save (API format)") and point `COMFYUI_WORKFLOW` at it.
|
||||
|
||||
`coloring-book.workflow_api.json` here is the **stock SD1.5 text-to-image workflow** as a starting
|
||||
point. DrawIt injects its prompt into the positive-prompt node and a random seed into the sampler node:
|
||||
|
||||
- `COMFYUI_PROMPT_NODE` (default `6`) — the positive `CLIPTextEncode` node.
|
||||
- `COMFYUI_SEED_NODE` (default `3`) — the `KSampler` node.
|
||||
|
||||
## Tips for clean line art (matters — we vectorize it with potrace)
|
||||
|
||||
The cleaner and bolder the outlines, the better the trace lessons look. Recommended:
|
||||
|
||||
- Use a **line-art / coloring-book LoRA or model** if you have one, and bump `steps`.
|
||||
- Keep the negative prompt rejecting color/shading/background clutter (already set).
|
||||
- The image is auto-thresholded to pure black/white and fitted to the lesson canvas, so aim for
|
||||
**bold, fully-closed outlines on a white background** — thin or broken lines vectorize poorly.
|
||||
|
||||
Edit `ckpt_name` to a checkpoint you actually have installed.
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"3": {
|
||||
"inputs": {
|
||||
"seed": 0,
|
||||
"steps": 24,
|
||||
"cfg": 7,
|
||||
"sampler_name": "euler",
|
||||
"scheduler": "normal",
|
||||
"denoise": 1,
|
||||
"model": ["4", 0],
|
||||
"positive": ["6", 0],
|
||||
"negative": ["7", 0],
|
||||
"latent_image": ["5", 0]
|
||||
},
|
||||
"class_type": "KSampler"
|
||||
},
|
||||
"4": {
|
||||
"inputs": { "ckpt_name": "v1-5-pruned-emaonly.safetensors" },
|
||||
"class_type": "CheckpointLoaderSimple"
|
||||
},
|
||||
"5": {
|
||||
"inputs": { "width": 768, "height": 576, "batch_size": 1 },
|
||||
"class_type": "EmptyLatentImage"
|
||||
},
|
||||
"6": {
|
||||
"inputs": { "text": "placeholder prompt — replaced by DrawIt at runtime", "clip": ["4", 1] },
|
||||
"class_type": "CLIPTextEncode"
|
||||
},
|
||||
"7": {
|
||||
"inputs": { "text": "color, colored, shading, grayscale, gradient, photo, realistic, background clutter, watermark, text, signature", "clip": ["4", 1] },
|
||||
"class_type": "CLIPTextEncode"
|
||||
},
|
||||
"8": {
|
||||
"inputs": { "samples": ["3", 0], "vae": ["4", 2] },
|
||||
"class_type": "VAEDecode"
|
||||
},
|
||||
"9": {
|
||||
"inputs": { "filename_prefix": "DrawIt", "images": ["8", 0] },
|
||||
"class_type": "SaveImage"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
# DrawIt — Design: "Create" (AI-generated custom lessons) + the Creator role
|
||||
|
||||
_Designed June 2026. Purpose: let a privileged user type a subject (e.g. "Zebra"), have a self-hosted
|
||||
**ComfyUI** server generate a coloring-book line-art image, and turn that image into a real DrawIt
|
||||
lesson with the usual design elements. This is a design/research document — nothing is built yet. It
|
||||
assumes the current curriculum/runner architecture documented in `RESEARCH-beginner-level.md` and
|
||||
`RESEARCH-early-beginner-expansion.md`._
|
||||
|
||||
## 1. Goal & summary
|
||||
|
||||
A new **"Create"** option (visible only to a new **Creator** role) appears on the Early Beginner and
|
||||
Beginner levels. The Creator types a subject; the app sends a fixed prompt to a configurable ComfyUI
|
||||
endpoint; ComfyUI returns black-and-white line art; DrawIt processes that image into a playable lesson
|
||||
(trace + color phases, badge, progress, gallery — the same machinery every other lesson uses).
|
||||
|
||||
The fixed prompt (subject interpolated):
|
||||
|
||||
> A coloring book page of a **[subject]**, clean black-and-white line art, bold crisp outlines, simple
|
||||
> composition, large open areas to color, white background, no shading, no grayscale, no color, no
|
||||
> gradients, no heavy background detail, printable page.
|
||||
|
||||
Two hard problems drive the design: **(a)** an AI returns *one raster image*, but our lessons are
|
||||
multi-phase, SVG-step constructions; and **(b)** this is a children's app accepting free-text that
|
||||
feeds an image generator, so **safety/moderation is mandatory, not optional.**
|
||||
|
||||
## 2. The Creator role
|
||||
|
||||
Today `Role = "admin" | "learner"` (`src/lib/types.ts`); the DB `users.role` is already a free-text
|
||||
column defaulting to `learner`, and the first registered account is forced to `admin`. Adding a role is
|
||||
small and backward-compatible:
|
||||
|
||||
- **Type:** `Role = "admin" | "learner" | "creator"`. A Creator has **all Learner permissions plus the
|
||||
Create ability**. Admins implicitly have it too.
|
||||
- **No schema change** — `role` is already TEXT. Existing rows stay `learner`/`admin`.
|
||||
- **Assignment:** only an admin grants it, via the existing admin users API
|
||||
(`src/app/api/admin/users/[id]/route.ts`) — add `"creator"` to the allowed set (today it coerces to
|
||||
`admin`/`learner`). Signup is unchanged (first = admin, everyone else = learner).
|
||||
- **Server guard:** add `requireCreator()` in `src/lib/session.ts` (mirrors `requireAdmin()`):
|
||||
returns the user iff `role === "creator" || role === "admin"`. A tiny `canCreate(user)` helper feeds
|
||||
the UI. **Every Create endpoint must call this server-side** — never trust the client.
|
||||
|
||||
## 3. End-to-end user flow
|
||||
|
||||
1. Creator opens Early Beginner or Beginner → taps **✨ Create your own**.
|
||||
2. Types a subject ("Zebra"). Client does light validation; server does the real moderation (§5).
|
||||
3. Server creates a `created_lessons` row (`status: 'pending'`), interpolates the subject into the
|
||||
fixed prompt, and queues the ComfyUI workflow (`status: 'generating'`).
|
||||
4. Client polls status (or subscribes). ComfyUI renders (seconds to ~1 min on a GPU box).
|
||||
5. Server fetches the image, post-processes it (§6), derives the lesson assets, sets `status: 'ready'`
|
||||
(or `'needs_review'` if moderation requires admin approval).
|
||||
6. The lesson appears under a **"My Creations"** group on that level and plays through the normal
|
||||
runners (trace → color), earning a badge and saving to the gallery like any lesson.
|
||||
|
||||
## 4. ComfyUI integration
|
||||
|
||||
**Why ComfyUI / self-hosted:** it keeps DrawIt's privacy promise — the subject text and image never
|
||||
leave a server the operator runs. No third-party AI cloud. The endpoint is **configurable** so each
|
||||
self-hoster points at their own GPU box (or none, disabling Create).
|
||||
|
||||
**Config (env, mirroring the existing SMTP pattern in `.env.example`):**
|
||||
|
||||
```
|
||||
COMFYUI_URL= # e.g. http://127.0.0.1:8188 — blank disables the Create feature
|
||||
COMFYUI_API_KEY= # optional, if the box is behind an auth proxy
|
||||
COMFYUI_WORKFLOW=./comfyui/coloring-book.workflow_api.json # API-format workflow template
|
||||
COMFYUI_TIMEOUT_MS=120000
|
||||
COMFYUI_PROMPT_NODE=6 # id of the positive CLIP-text node to inject the prompt into
|
||||
```
|
||||
|
||||
**API calls** (ComfyUI's HTTP API):
|
||||
|
||||
1. `POST {COMFYUI_URL}/prompt` with `{ prompt: <workflow_api_json>, client_id }`. We load the saved
|
||||
**API-format workflow** (exported from ComfyUI's "Save (API format)"), and set the positive prompt
|
||||
node's `inputs.text` to our interpolated prompt (and randomize the seed node). Returns `prompt_id`.
|
||||
2. Poll `GET {COMFYUI_URL}/history/{prompt_id}` until the entry exists; read
|
||||
`outputs[node].images[]` → `{ filename, subfolder, type }`. (Optionally subscribe to
|
||||
`GET /ws?clientId=` for live progress to show a friendly "drawing your zebra…" bar.)
|
||||
3. `GET {COMFYUI_URL}/view?filename=…&subfolder=…&type=output` → the PNG bytes.
|
||||
|
||||
**Async model:** generation is slow, so treat it as a job. `created_lessons.status` is the state
|
||||
machine: `pending → generating → (needs_review) → ready | failed | blocked`. The POST route kicks off
|
||||
generation server-side and returns immediately; the client polls `GET /api/create/:id`. On a single
|
||||
self-hosted node an in-process async task is fine; document that horizontal scaling would want a real
|
||||
queue. Always set a timeout and a retry-once policy; surface `failed` with a kid-friendly message.
|
||||
|
||||
## 5. Safety & moderation (mandatory — this is a kids' app)
|
||||
|
||||
Free text → image generation for children is the riskiest part. Layered defenses, **input + output**:
|
||||
|
||||
**Input (the subject string):**
|
||||
- **Sanitize:** trim; cap length (~30 chars); collapse whitespace; allow only letters/spaces/hyphens;
|
||||
reject newlines and any prompt-control tokens. Because we only interpolate `[subject]` into a fixed
|
||||
prompt, prompt-injection surface is small — but still strip anything that could break out of the
|
||||
phrase.
|
||||
- **Allowlist-first (recommended for kids):** maintain a curated allowlist of safe, drawable subjects
|
||||
(animals, objects, food, vehicles, nature, shapes…). If the subject is on the allowlist → proceed
|
||||
automatically. This is the safest posture and the literature's recommendation for child contexts.
|
||||
- **Blocklist backstop:** a denylist of profanity, violence, weapons, drugs, sexual, hateful, and
|
||||
self-harm terms (and obfuscations) rejects anything obviously unsafe that slips past.
|
||||
- **Anything else → admin review queue** (`status: 'needs_review'`): not on the allowlist and not on
|
||||
the blocklist → a human approves before it generates or goes live. Keeps the door open to new
|
||||
subjects without exposing kids to raw generation.
|
||||
- **Rate-limit** per user (e.g. N creations/day) to bound abuse and GPU cost.
|
||||
|
||||
**Output (the generated image):** even safe prompts can misfire. Before a lesson is playable, require a
|
||||
**preview/approve step** — minimally the creating user sees it and confirms; for stricter operators, an
|
||||
admin approval queue. (Optional later: an automated NSFW image classifier as a pre-filter.)
|
||||
|
||||
**Logging vs. privacy:** store only what's needed to run/curate the feature (subject, status,
|
||||
moderation decision). Consistent with "no analytics, data stays local."
|
||||
|
||||
## 6. From image → level (the core pipeline)
|
||||
|
||||
**What a lesson needs today:** a `Lesson` with phases. Trace phases (`construct`/`outline`/`detail`)
|
||||
use per-step `lines: string[]` of SVG elements that `LineDraw` reveals one at a time; color/light
|
||||
phases use `baseSvg` which `ColoringRunner` **rasterizes** for the fill boundary and overlay. Badge,
|
||||
`sublevel`, `subjectKey`, and a group complete the level.
|
||||
|
||||
**The gap:** ComfyUI returns *one raster PNG*. It is not pre-split into outline-vs-detail phases, and
|
||||
it is pixels, not SVG strokes. So a Created lesson can't perfectly mirror a hand-authored 3–4-phase
|
||||
subject. **Chosen approach (per §13): Option C — vectorize the line art with potrace for a
|
||||
stroke-by-stroke Trace phase, and use the raster for the Color phase (2 phases total).** The three
|
||||
options below are kept for context; the build targets C.
|
||||
|
||||
- **Option A — Raster-native, 2-phase (recommended MVP).** Treat the cleaned PNG as the lesson art.
|
||||
- **Trace** phase: show the line art faintly as a guide to trace over (reuse `TraceRunner`'s base
|
||||
layer, fed a raster instead of SVG). Optional "watch it appear" = a left-to-right wipe reveal of
|
||||
the bitmap rather than true per-stroke animation.
|
||||
- **Color** phase: `ColoringRunner` already rasterizes its template to a boundary + overlay, so it
|
||||
can take the PNG **directly** — flood-fill and overlay work as-is. This phase is essentially free.
|
||||
- For Beginner-created lessons, optionally add the existing **Light** phase (sun overlay) on top.
|
||||
- Smallest change: add a `templateUrl?` (raster) alongside `baseSvg` to `TraceRunner`/`ColoringRunner`
|
||||
and the lesson-page meta; when present, use the bitmap path.
|
||||
|
||||
- **Option B — Vectorize for true line-by-line reveal.** Run the PNG through **potrace** (pure-JS npm,
|
||||
no native build — important given our better-sqlite3 build friction) to get SVG paths, then split the
|
||||
SVG into multiple `lines` (by `M` subpaths) so `LineDraw` animates it stroke-by-stroke like the
|
||||
hand-made lessons. Quality varies with image cleanliness; treat as an enhancement over A.
|
||||
|
||||
- **Option C — Hybrid (target end state).** Vectorize for the trace reveal (B) **and** keep the raster
|
||||
for coloring (A). Optionally heuristically split paths into a simpler "outline" (large/long paths)
|
||||
vs "detail" (small/short paths) pass to approximate the two trace phases — best-effort, not perfect.
|
||||
|
||||
**Image post-processing** (server-side, before storing) with a pure-JS lib like **jimp** (avoids native
|
||||
deps) or **sharp** (faster, native): grayscale → threshold to crisp black/white → auto-trim margins →
|
||||
fit to the lessons' aspect (the runners use a 4:3 / 400×300 viewBox and an 880×660 color canvas) →
|
||||
flatten on white → downscale. Store the cleaned PNG; if vectorizing, store the derived SVG too.
|
||||
|
||||
**Badge & metadata:** Created lessons get a generic medal — a default emoji (✏️ or 🎨, or a
|
||||
creator-picked one) plus a name like "**Zebra Artist**". Reuse the existing emoji-medal `BadgeArt` and
|
||||
award-PDF machinery untouched.
|
||||
|
||||
## 7. Data model
|
||||
|
||||
No destructive changes; one new table (plus the `Role` union value):
|
||||
|
||||
```sql
|
||||
CREATE TABLE created_lessons (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
level TEXT NOT NULL, -- 'early-beginner' | 'beginner'
|
||||
subject TEXT NOT NULL, -- sanitized, e.g. 'Zebra'
|
||||
slug TEXT NOT NULL UNIQUE, -- e.g. 'created-42-zebra'
|
||||
status TEXT NOT NULL DEFAULT 'pending', -- pending|generating|needs_review|ready|failed|blocked
|
||||
image TEXT, -- cleaned line-art (data URL, like drawings/avatars)
|
||||
template_svg TEXT, -- optional vectorized paths (Option B/C)
|
||||
prompt TEXT NOT NULL, -- full prompt sent (audit)
|
||||
moderation TEXT, -- 'allowlist' | 'review' | 'blocked:<reason>'
|
||||
emoji TEXT, -- badge emoji
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
```
|
||||
|
||||
**Integration with existing systems (reuse, don't fork):**
|
||||
- **Sublevel namespacing:** created lessons keep their `level` (so the unlock gate / grouping still
|
||||
apply) and use a high synthetic `sublevel = 900000 + id`, guaranteeing no collision with static
|
||||
lessons. Progress, completions, drawings, and badges then work through the *existing* tables keyed by
|
||||
`(user_id, level, sublevel)` with zero changes.
|
||||
- **Badge key:** `created-<id>`; `badgeDefs()` gains the user's ready created lessons at request time.
|
||||
- **Not in static `LESSONS`.** Add resolver helpers that the lesson page and learn page call:
|
||||
`getCreatedLesson(slug)` builds a `Lesson`-shaped object on the fly from the row (so the runners need
|
||||
no special-casing), and `listCreatedLessons(userId, level)` feeds the "My Creations" group.
|
||||
- **Images as data URLs in SQLite** matches the existing privacy-consistent storage for drawings and
|
||||
avatars (note: line-art PNGs are small once thresholded; watch row size, consider a files dir if big).
|
||||
- **Backups:** the new table rides along with the existing Litestream/SQLite backup automatically.
|
||||
|
||||
## 8. API routes (Next.js App Router)
|
||||
|
||||
- `POST /api/create` — `requireCreator`; body `{ level, subject }`. Validate + moderate (§5). On pass:
|
||||
insert row, interpolate prompt, kick off ComfyUI job, return `{ id, slug, status }`. On block: 422
|
||||
with a gentle message.
|
||||
- `GET /api/create/:id` — owner (or admin); returns `{ status, slug, image? }` for polling.
|
||||
- `GET /api/create/:id/image` — serves the stored line art (or inline data URL).
|
||||
- `POST /api/admin/create/:id` — admin approve/block for the review queue.
|
||||
- All Create routes are no-ops/404 when `COMFYUI_URL` is unset (feature disabled cleanly).
|
||||
|
||||
## 9. UI
|
||||
|
||||
- **Entry point:** a "✨ Create your own" card at the top of the Early Beginner and Beginner sections of
|
||||
the Learn page, **rendered only when `canCreate(user)`**. (Also a nav entry, optional.)
|
||||
- **Create view:** subject input + the fixed-prompt explainer ("we'll draw a coloring page of your
|
||||
subject"), a live status/progress state, then a preview with **"Start the lesson"** / **"Try again"**.
|
||||
- **My Creations group:** a `getGroupedSubjects`-style group on the level listing the user's ready
|
||||
creations, each opening into the normal trace/color runner.
|
||||
- **Gating:** non-creators never see Create (UI), and the API rejects them regardless (defense in depth).
|
||||
|
||||
## 10. Config & ops
|
||||
|
||||
- Requires a reachable ComfyUI with an image model + the saved API-format workflow JSON checked in at
|
||||
`comfyui/coloring-book.workflow_api.json` (with documented node ids for prompt + seed).
|
||||
- New deps (all optional/pure-JS preferred): `potrace` (vectorize), `jimp` or `sharp` (image cleanup).
|
||||
- Document GPU expectations, timeout, and that Create is **off by default** (blank `COMFYUI_URL`), so
|
||||
the core app and existing deploys are unaffected.
|
||||
|
||||
## 11. Privacy & principles (unchanged)
|
||||
|
||||
Self-hosted ComfyUI means the child's subject text and the image stay on the operator's own
|
||||
infrastructure — no third-party calls, consistent with "ABSOLUTELY NO DATA COLLECTED." Generated assets
|
||||
live in the operator's SQLite. The feature is fully optional and disabled unless configured.
|
||||
|
||||
## 12. Phased rollout
|
||||
|
||||
- **Phase 1 — Plumbing:** Creator role + `requireCreator` + admin can assign; env config; `created_lessons`
|
||||
table; ComfyUI client (queue → poll → fetch) behind a feature flag.
|
||||
- **Phase 2 — Lesson pipeline (Option C):** image cleanup + **potrace vectorization** → Trace (stroke
|
||||
reveal) + Color; subject **allowlist + admin review queue** + blocklist + sanitize; preview/approve;
|
||||
"My Creations" group (private to creator); badge + gallery via existing tables; rate limiting.
|
||||
- **Phase 3 — Curation:** admin **promote-to-global** (creation enters the shared curriculum with
|
||||
attribution; owner becomes system/global); review-queue UI polish.
|
||||
|
||||
## 13. Decisions (locked June 2026)
|
||||
|
||||
Settled with Dan; these drive the build:
|
||||
|
||||
1. **Phases:** a created lesson is **2-phase — Trace + Color** (one AI image isn't cleanly splittable
|
||||
into outline vs detail). Beginner creations may add the Light phase later, but the baseline is two.
|
||||
2. **Moderation:** **allowlist + admin review queue** (+ blocklist backstop + input sanitize). Curated
|
||||
safe subjects auto-pass; anything new is held for admin approval before it generates/goes live.
|
||||
3. **Line reveal:** **vectorize up front** — run the cleaned line art through **potrace** so the Trace
|
||||
phase animates **stroke-by-stroke** (via `LineDraw`) from day one, exactly like hand-made lessons.
|
||||
The raster is still used for the Color phase (`ColoringRunner` rasterizes anyway). So the pipeline is
|
||||
Option C (hybrid): **vectorized SVG for Trace + raster for Color**.
|
||||
4. **Visibility:** creations are **private to the creator by default, but admin-promotable to the global
|
||||
curriculum** (with attribution). So the data model must support a "promoted/global" flag and an
|
||||
owner of `null`/system once promoted.
|
||||
|
||||
Still open (smaller, decide at build time): **image storage** (data URLs in SQLite vs. a files dir for
|
||||
the larger line-art PNGs) and **which ComfyUI model/workflow** is the reference (affects line-art
|
||||
cleanliness, hence vectorization quality — pick a model that yields bold, closed outlines).
|
||||
|
||||
## Sources
|
||||
|
||||
- [ComfyUI API — Hosting a Workflow via API (9elements)](https://9elements.com/blog/hosting-a-comfyui-workflow-via-api/)
|
||||
- [ComfyUI API: The Complete Developer's Guide (Runflow)](https://www.runflow.io/blog/comfyui-api-developer-guide)
|
||||
- [ComfyUI API Endpoints — Complete Reference (Runflow)](https://www.runflow.io/blog/comfyui-api-endpoints)
|
||||
- [ComfyUI: Using the API, Part 1 (Medium)](https://medium.com/@yushantripleseven/comfyui-using-the-api-261293aa055a)
|
||||
- [potrace — npm (Node bitmap→SVG)](https://www.npmjs.com/package/potrace)
|
||||
- [imagetracerjs — npm (JS raster vectorizer)](https://github.com/jankovicsandras/imagetracerjs)
|
||||
- [Filtering Techniques: Blocklists and Allowlists for Safe AI Prompts (Learn Prompting)](https://learnprompting.org/docs/prompt_hacking/defensive_measures/filtering)
|
||||
- [Azure AI Content Safety — Filters and Best Practices](https://jannikreinhard.com/2026/02/18/what-azure-ai-content-safety-does-and-why-it-matters/)
|
||||
- [Safety Filtering in AI: How to Block Harmful Model Outputs](https://www.practical-devsecops.com/glossary/safety-filtering/)
|
||||
@@ -0,0 +1,266 @@
|
||||
# DrawIt — Research: Designing the "Beginner" Level
|
||||
|
||||
_Researched June 2026. Purpose: figure out **how a child moves from Early Beginner (simple shapes
|
||||
and confident lines) to genuinely more complex drawings**, and map that progression onto a concrete
|
||||
Beginner curriculum for DrawIt. This is a research/planning document only — nothing here is built
|
||||
yet. It builds directly on `RESEARCH-drawing-platforms-and-techniques.md`, which mapped each of our
|
||||
five ability levels to a technique. That doc said Beginner should teach **shape → form and base
|
||||
color**; this doc fleshes out exactly what that means, why, and how._
|
||||
|
||||
## 1. Where "Beginner" sits — the developmental anchor
|
||||
|
||||
DrawIt's five levels (Early Beginner → Beginner → Learner → Advanced Learner → Superb) line up
|
||||
surprisingly well with the way children's drawing actually develops. The most widely used framework
|
||||
is **Viktor Lowenfeld's stages of artistic development**:
|
||||
|
||||
| Lowenfeld stage | Rough age | What the child can do | DrawIt level |
|
||||
| --- | --- | --- | --- |
|
||||
| Pre-schematic | 4–6 | Circular figures, single objects floating in space, flat shapes | **Early Beginner** (where we are) |
|
||||
| Schematic | 7–9 | Understanding of space and proportion, baselines, repeatable "symbols" for things | **Beginner** (this doc) |
|
||||
| Dawning Realism | 9–11 | Strives for realistic look, more detail per part, overlapping objects, horizon line | Learner / Advanced |
|
||||
|
||||
Early Beginner lives in the **pre-schematic** world: a fish is a flat outline, an object on its own.
|
||||
The leap into **Beginner** mirrors the move into the **schematic stage** — the child starts to care
|
||||
about *space, proportion, and how parts relate*, and wants their drawings to look more like the real
|
||||
thing. That is the single most important framing for this level: **Beginner is the bridge from
|
||||
"flat symbol of a thing" to "a thing that has volume, parts, and believable size."**
|
||||
|
||||
Crucially, we are *not* trying to reach realism here. Lowenfeld's "Dawning Realism" (true light,
|
||||
shadow, overlap) is the Learner level. Beginner's job is the rung in between.
|
||||
|
||||
## 2. The leap: what actually changes from Early Beginner → Beginner
|
||||
|
||||
Four concrete shifts define the jump in complexity. Everything in this level should serve one of
|
||||
these four, and each is a well-documented teaching milestone, not something we invented:
|
||||
|
||||
1. **Flat shape → 3-D form.** A circle becomes a sphere; a square becomes a cube; a rectangle
|
||||
becomes a cylinder. With just one or two added ellipses/edges a 2-D shape reads as a solid object
|
||||
with volume. The four basic forms — **sphere, cube, cylinder, cone** — are described everywhere as
|
||||
"the building blocks of drawing complex objects" and the foundation for creating the illusion of
|
||||
depth and volume.
|
||||
|
||||
2. **Single shape → constructed object (combining shapes).** This is "construction": breaking a
|
||||
complex subject into basic shapes, stacking/overlapping them, and refining into the final outline.
|
||||
A mug is a cylinder with an ellipse rim; a house is a box with a prism roof; a snowman is stacked
|
||||
spheres. Early Beginner draws *one* shape per subject; Beginner draws subjects *made of several
|
||||
shapes fitted together*.
|
||||
|
||||
3. **Guessing size → simple proportion.** The schematic-stage child begins to care that the head is
|
||||
the right size for the body, that the handle fits the cup. The teachable beginner skill is
|
||||
**relative size / comparative measurement** — "nail the biggest proportions first, then refine"
|
||||
and "compare one part to another instead of measuring in isolation." For kids this is kept
|
||||
concrete: light guide marks, "the body is about two heads tall," a center line for symmetry.
|
||||
|
||||
4. **Line only → base color (and the first hint of light).** Early Beginner already added a separate
|
||||
"Color it!" coloring step. Beginner makes color *part of the drawing*: flat base color inside the
|
||||
lines, an intro to **primary → secondary** colors, and the very first idea of a **light side and a
|
||||
shadow side** of a form (one light source) — without full shading. This is deliberately the
|
||||
on-ramp to the Learner shading module, so the two levels connect.
|
||||
|
||||
## 3. The technique backbone for Beginner
|
||||
|
||||
Rather than "just more animals," each Beginner subject should teach one of four named techniques, in
|
||||
roughly this order of difficulty. This gives the level a real spine.
|
||||
|
||||
### 3a. Shape → Form (the four basic forms)
|
||||
|
||||
Teach the child to turn the flat shapes they already know into solids:
|
||||
|
||||
- **Circle → Sphere** (add a curved contour line + a round shadow): ball, orange, planet.
|
||||
- **Square → Cube** (add depth edges): box, dice, gift.
|
||||
- **Rectangle → Cylinder** (add an ellipse top/bottom): cup, can, tree trunk, log.
|
||||
- **Triangle → Cone** (add an ellipse base): ice-cream cone, party hat, traffic cone, tree.
|
||||
|
||||
Each form is introduced on its own (a short "make it 3-D" drill) and then immediately *used* in a
|
||||
real subject, so the technique never feels abstract. This is exactly how design-sketching courses
|
||||
introduce the "3 (or 4) basic forms" before any product is drawn.
|
||||
|
||||
### 3b. Construction — combining shapes into a subject
|
||||
|
||||
Once forms exist, teach assembly: **light construction shapes first → refine into the outline →
|
||||
erase/ignore the guides.** This is the professional "block-in" workflow simplified for kids. DrawIt's
|
||||
Early Beginner lessons already hint at this with the cumulative outline steps; Beginner should make
|
||||
it *explicit and visible*: step 1 is always "lay in the big shapes," and the guide shapes stay faint
|
||||
under the final line art (we already have a faint-guide + line-reveal mechanic in the trace runner).
|
||||
|
||||
Good "combine the shapes" subjects (well attested as kid-friendly construction exercises): house
|
||||
(box + prism), snowman (3 spheres), mug (cylinder + ellipse), ice-cream cone (cone + sphere),
|
||||
robot (cubes + cylinders), simple car (boxes + circles), cat curled up (circle + ovals), rocket
|
||||
(cylinder + cone + triangles).
|
||||
|
||||
### 3c. Proportion — relative size and placement
|
||||
|
||||
Keep it concrete and visual, never mathematical:
|
||||
|
||||
- **Big shapes first, details last** — the most repeated rule in proportion teaching.
|
||||
- **Compare part to part** — "the head is about as wide as the body is tall," "the handle is half
|
||||
the cup." Show this with on-canvas guide marks rather than a ruler.
|
||||
- **Center / symmetry lines** — a vertical guide for symmetric subjects (a vase, a face-on robot).
|
||||
- **Baseline / ground line** — the schematic-stage hallmark: objects sit *on* something, not float.
|
||||
|
||||
### 3d. Base color & first light
|
||||
|
||||
- **Flat color inside the lines** (we already have the coloring studio + fill bucket).
|
||||
- **Primary → secondary**: a tiny, playful color-mixing idea (red + yellow = orange) surfaced as a
|
||||
tip, since 5–9 year-olds are exactly the audience for first color theory.
|
||||
- **Light side / shadow side**: pick a light direction (a ☀️ marker), keep the side facing it lighter
|
||||
and the far side a touch darker — *one* darker tone, not a gradient. This is the gateway drug to the
|
||||
Learner shading module and keeps the levels continuous.
|
||||
|
||||
## 4. How step-by-step apps ramp difficulty (and what to copy)
|
||||
|
||||
The competitor scan reinforces the structure above:
|
||||
|
||||
- **Themed modules, each with ~5 lessons of gradually increasing difficulty** (e.g., "Let's Learn How
|
||||
to Draw!" modules; SimplyDraw's Animals/Nature/Characters paths). We should group Beginner into a
|
||||
few themed packs rather than one flat list.
|
||||
- **Skill-based ladder**: ArtWorkout's published progression is literally *shapes & lines → doodles &
|
||||
sketches → confident line work → shading, perspective, anatomy*. Beginner is the "confident line
|
||||
work + first form/color" rung — we're aligned.
|
||||
- **Self-check / "ready to move on?"**: some apps (Drawy) grade the attempt and tell the child to
|
||||
advance or practice more. We can do a lightweight, **fully on-device** version (e.g., a gentle
|
||||
self-rating or an optional shape-overlap check) without any data collection.
|
||||
- **"Draw on your own" fade-out**: apps gradually remove the prompts so the child eventually draws
|
||||
unaided. Beginner can start fading guides (e.g., the final lesson of a pack shows fewer
|
||||
construction hints), setting up the freer Learner/Superb levels.
|
||||
|
||||
Our differentiators stay intact: **free, open source, zero data collection, self-hostable,
|
||||
tablet-first** — all of the above is doable client-side with the stack we have.
|
||||
|
||||
## 5. Proposed Beginner curriculum
|
||||
|
||||
### 5a. Lesson shape — **4 phases (decided)**
|
||||
|
||||
Early Beginner uses **Outline → Detail → Color it!**. Beginner adopts a **4-phase** flow per subject:
|
||||
|
||||
1. **Construct** — lay in the basic forms/shapes (faint guides, the "make it 3-D" move). *New.*
|
||||
2. **Outline** — refine the guides into clean line art (as today).
|
||||
3. **Color** — flat base color inside the lines, with the primary/secondary tip (as today's "Color it!").
|
||||
4. **Light** — add the single light/shadow side using the ☀️ marker. *New, bridges to Learner.*
|
||||
|
||||
This is backward-compatible with our model: each phase is its own lesson with its own sublevel, and
|
||||
the construction guides reuse the existing faint-guide + line-reveal mechanics. (Badging is now
|
||||
**per-pack** rather than per-subject — see §5b/§8.)
|
||||
|
||||
### 5b. Subjects — **3 packs, all-new, shapes-focused (decided)**
|
||||
|
||||
Per Dan's direction: **3 packs to start, all-new subjects, focused on shapes/forms** (no reuse of the
|
||||
Early Beginner animals — more animals will be added to *Early Beginner* later, separately). Each pack
|
||||
ramps in difficulty and earns **one per-pack completion badge** when all its subjects are finished.
|
||||
|
||||
The three packs form a clean difficulty ladder: **single form → combined forms → multi-part objects.**
|
||||
|
||||
| Pack | Subject (form → themed object) | Teaches (form) | Skill focus |
|
||||
| --- | --- | --- | --- |
|
||||
| **Pack 1 — Make it 3-D** (single forms) | Beach ball | Sphere | First 2-D→3-D; round shadow |
|
||||
| | Gift box | Cube | Depth edges; cast shadow |
|
||||
| | Mug | Cylinder | Ellipse rim; handle proportion |
|
||||
| | Party hat | Cone | Ellipse base; first light side |
|
||||
| **Pack 2 — Put it together** (combine 2–3 forms) | Ice-cream cone | Cone + sphere | First *combined* form |
|
||||
| | Snowman | Stacked spheres | Proportion (3 sizes), baseline |
|
||||
| | House | Box + pyramid roof | Combining; symmetry line |
|
||||
| | Rocket | Cylinder + cone + fins | Combining + simple overlap |
|
||||
| **Pack 3 — Build a thing** (multi-part construction) | Robot | Cubes + cylinders | Multi-part construction |
|
||||
| | Car | Boxes + circles | Proportion, wheels on a baseline |
|
||||
| | Castle | Cubes + cones/pyramids | Repetition + symmetry |
|
||||
| | Sailboat | Triangles + curved hull | Combine + color + first light |
|
||||
|
||||
**Core-form choice:** the opening pack teaches the universal trio **sphere, cube, cylinder** (the most
|
||||
reusable primitives — nearly every later subject decomposes into them), with the **cone** added last as
|
||||
the bridge into combining. The **pyramid** is deliberately held for packs 2–3 (house roof, castle),
|
||||
where the lesson is combining/repeating forms rather than a first 2-D→3-D drill.
|
||||
|
||||
Sequencing within the level: **single form → two/three combined forms → multi-part construction**,
|
||||
each subject running the full Construct → Outline → Color → Light flow. Difficulty rises exactly the
|
||||
way the app research recommends (themed packs, ~4 lessons each, gradual ramp, guides fading toward the
|
||||
end of each pack).
|
||||
|
||||
**Badge model (decided): per-pack completion badge.** Finishing every subject in a pack earns that
|
||||
pack's badge (3 Beginner badges total), each with a shareable PNG + award PDF like the existing
|
||||
badges. (This differs from Early Beginner's per-subject badges.)
|
||||
|
||||
### 5c. Continuity — how it builds on Early Beginner and feeds Learner
|
||||
|
||||
- **Unlock gate (decided):** the Beginner level stays locked until the child completes **at least 5
|
||||
Early Beginner items**. The intent is that the Early Beginner lessons give the child the "look and
|
||||
feel" of how DrawIt works (the trace/line-reveal flow, coloring studio, badges, gallery) before
|
||||
stepping up in complexity. Implementation note for later: count completed Early Beginner subjects
|
||||
(or lessons) server-side, the same place we already gate phase progression, and grey out the
|
||||
Beginner level on the Learn page with an encouraging "finish 5 Early Beginner drawings to unlock"
|
||||
message until the threshold is met.
|
||||
- **Looks back:** Beginner subjects are **all-new and shape-focused** (no reprise of the Early
|
||||
Beginner animals). More *animals* will be added to **Early Beginner** later as a separate effort, so
|
||||
the two levels stay distinct: Early Beginner = recognizable subjects & confident line; Beginner =
|
||||
form, construction, proportion, base color.
|
||||
- **Looks forward:** the "light side / shadow side" phase is intentionally the simplest possible
|
||||
version of the Learner shading module (highlight / mid-tone / shadow, the sphere drill). A child who
|
||||
finishes Beginner has already met a light source and one shadow tone, so Learner is a deepening, not
|
||||
a brand-new idea.
|
||||
|
||||
## 6. Feature / data-model implications (for later — not building now)
|
||||
|
||||
Flagging what this level would *need* so the eventual build is scoped, consistent with the
|
||||
already-proposed extensions in the prior research doc:
|
||||
|
||||
- A **`construct` step kind** (faint, non-graded guide shapes) — we already have `kind` on steps and a
|
||||
faint-guide render path; this mostly reuses it.
|
||||
- A **base-color phase** — already covered by the coloring studio + fill bucket.
|
||||
- A **light-source overlay** (☀️ marker + light/shadow side hint) — a simpler cousin of the shading
|
||||
zone overlay proposed for Learner.
|
||||
- **Proportion guide marks** — light center/baseline lines and "this part = half that part" hints in
|
||||
the construct step.
|
||||
- **Optional on-device self-check** ("ready to move on?") — purely local, no scoring sent anywhere.
|
||||
- **Guide fade-out** toward the end of each pack — a per-lesson flag to show fewer hints.
|
||||
|
||||
All client-side, all bundled/generated locally. **No new data collection, no third-party services,
|
||||
still free and open source** — same constraints we've held throughout.
|
||||
|
||||
## 7. Suggested phasing for the Beginner build (when we get there)
|
||||
|
||||
- **Phase A — Forms first:** ship the four "make it 3-D" form drills + the four single-form subjects
|
||||
(ball, cup, box, cone) using the existing Outline→Color flow plus a new faint `construct` step.
|
||||
- **Phase B — Construction:** the "Build it" pack (snowman, house, rocket, robot) — combining shapes,
|
||||
proportion guides, baseline.
|
||||
- **Phase C — Form + color + first light:** the living-things pack and the optional light/shadow
|
||||
phase; this is the explicit hand-off into the Learner shading module.
|
||||
|
||||
## 8. Decisions (locked June 2026)
|
||||
|
||||
These are settled and drive the eventual build:
|
||||
|
||||
1. **Phases:** **4 phases** per subject — **Construct → Outline → Color → Light**.
|
||||
2. **Packs:** **3 packs** to start (§5b): *Make it 3-D* → *Put it together* → *Build a thing*, ~4
|
||||
subjects each, ramping single form → combined forms → multi-part construction.
|
||||
3. **All-new, shapes-focused subjects** — no reprise of the Early Beginner animals. (More animals will
|
||||
be added to *Early Beginner* later, as a separate effort.)
|
||||
4. **Unlock gate:** Beginner opens only after the child completes **≥ 5 Early Beginner items**, so they
|
||||
get the DrawIt "look and feel" first. Beginner is greyed out with an encouraging unlock prompt until
|
||||
then.
|
||||
5. **Badges:** **per-pack completion badge** (3 Beginner badges total), each with shareable PNG +
|
||||
award PDF — not per-subject.
|
||||
|
||||
Still open (smaller, can be decided at build time): whether to include a lightweight, fully on-device
|
||||
"ready to move on?" self-check, and the exact art direction for the 3 pack badges.
|
||||
|
||||
## Sources
|
||||
|
||||
- [Lowenfeld's Stages of Artistic Development — The Virtual Instructor](https://thevirtualinstructor.com/blog/the-stages-of-artistic-development)
|
||||
- [Schematic Stage of Drawing for Children — Learning For A Purpose](https://learningforapurpose.com/schematic-stage/)
|
||||
- [Stages of Artistic Development — Where Creativity Works](https://wherecreativityworks.com/stages-of-artistic-development/)
|
||||
- [Basic forms (cube, sphere, cylinder, cone) — Fiveable, Drawing I](https://fiveable.me/drawing-foundations/unit-5/basic-forms-cube-sphere-cylinder-cone/study-guide/OGG3MWqfJGt1tDn6)
|
||||
- [The 3 Basic 3D Forms (Cube, Sphere, Cylinder) — The Design Sketchbook](https://www.thedesignsketchbook.com/how-to-draw-the-3-basic-forms-of-design-sketching-cube-sphere-cylinder/)
|
||||
- [How to Draw the 4 Basic Forms in 3D — The Design Sketchbook](https://www.thedesignsketchbook.com/how-to-draw-the-4-basic-forms-in-art-and-design/)
|
||||
- [Drawing Basics — Construction — The Virtual Instructor](https://thevirtualinstructor.com/blog/drawing-basics-construction)
|
||||
- [How to draw basic shapes — Creative Bloq](https://www.creativebloq.com/illustration/how-draw-basic-shapes-31619534)
|
||||
- [How to Teach Kids to Draw Using Shapes — HubPages](https://discover.hubpages.com/art/How-to-Teach-Kids-to-Draw-Using-Shapes)
|
||||
- [Easy Drawing Pictures from Shapes for kids — KidsTut](https://kidstut.com/easy-drawing-pictures-from-shapes/)
|
||||
- [How to Measure Relative Proportions with a Pencil — Life Drawing Academy](https://lifedrawing.academy/life-drawing-academy-news/how-to-measure-relative-proportions-with-a-pencil)
|
||||
- [Teaching Proportions in Art to Younger Students — Kymberli Grant](https://kymberligrant.com/2022/01/10/teaching-proportions-in-art-to-younger-students/)
|
||||
- [Lesson 4: How to Draw with Accurate Proportions — RapidFireArt](https://rapidfireart.com/2017/05/17/lesson-4-how-to-draw-with-accurate-proportions/)
|
||||
- [How to Teach Color — Deep Space Sparkle](https://www.deepspacesparkle.com/how-to-teach-color/)
|
||||
- [Color Theory for Kids — Monkey Pen](https://monkeypen.com/blogs/news/teaching-color-theory-to-kids)
|
||||
- [Colour Theory for Kids — Little Change Creators](https://littlechangecreators.com/blogs/blog/colour-theory-for-kids)
|
||||
- [Simply Draw: Learn to Draw — App Store](https://apps.apple.com/us/app/simply-draw-learn-to-draw/id1639875485)
|
||||
- [ArtWorkout: Learn How to Draw — App Store](https://apps.apple.com/us/app/artworkout-learn-how-to-draw/id1564657118)
|
||||
- [11 Best Apps to Draw Step By Step (2026) — Freeappsforme](https://freeappsforme.com/apps-to-draw-step-by-step/)
|
||||
- [The 11 Best Drawing Apps for Kids — PureWow](https://www.purewow.com/family/best-drawing-apps-for-kids)
|
||||
@@ -0,0 +1,173 @@
|
||||
# DrawIt — Research: Teaching Platforms & Techniques (Shading, Layering, and More)
|
||||
|
||||
_Researched June 2026. Purpose: see what SimplyDraw and similar apps do well, understand how
|
||||
drawing is actually taught (especially shading and layering), and lay out concrete ways to fold
|
||||
those ideas into DrawIt while staying free, open-source, and privacy-first._
|
||||
|
||||
## 1. What the competition does
|
||||
|
||||
**Simply Draw** (by Simply / JoyTunes, the Simply Piano people) is the closest match to our vision
|
||||
and the most polished. Its pitch is "draw like you always wanted," and the teaching model has three
|
||||
pillars:
|
||||
|
||||
- **Guided every step of the way** — each drawing is broken into clear, easy-to-follow steps so
|
||||
"anyone can create something they're proud of." This is the part DrawIt already does.
|
||||
- **Bring drawings to life with shading** — they explicitly frame shading as the thing that "adds
|
||||
depth, dimension, and brings sketches to life," and walk learners from studying a reference image
|
||||
all the way through to shading, with smart tips on proportion, shading, and texture.
|
||||
- **Spark imagination** — every tutorial pushes creative thinking and personal expression, not just
|
||||
copying. They also use video sessions led by professional artists that you can pause, rewind, and
|
||||
draw along with, plus a "personalized path" and weekly new content.
|
||||
|
||||
Other platforms worth borrowing from:
|
||||
|
||||
| Platform | Audience | What it does well | What we can borrow | Gap we can beat |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| **Simply Draw** | Kids→adult | Step-by-step + shading + texture + reference study; pausable artist videos; personalized path | Shading module, reference-image study, replayable demos | Paid subscription; not open; collects data |
|
||||
| **Drawing Desk** | Kids→advanced | Step lessons with on-screen guides + **text and voice** guides; themed tracks (anime, kawaii, doodle); beginner→refine | Multi-modal guidance (visual + voice + text); themed lesson packs | Ads / IAP; not private |
|
||||
| **Project Aqua** (Adobe) | Ages 5–12 | Completely **free, ad-free, family-focused**; color premade pages or your own sketch; step-by-step tutorials | Free/ad-free family stance (matches us); color-the-sketch mode | Closed source; Adobe account/ecosystem |
|
||||
| **Kids Doodle** | Ages 2–8 | Very simple; themed coloring pages; **video replay** of how the art was made | Stroke **replay** is a delightful, low-cost feature; big simple UI | Little real skill progression |
|
||||
| **Draw.ai** | Kids | Pick artwork, recreate it with **animated** step-by-step instructions | Animated (not just static) step guides | Thin on fundamentals |
|
||||
|
||||
The recurring winning ingredients: guided steps (we have it), **shading taught explicitly**, **reference
|
||||
study**, **replayable/animated demos**, multi-modal cues (show + tell), themed packs, and a creative
|
||||
"make it your own" finish. Our differentiators stay strong: **free, open source, zero data
|
||||
collection, self-hostable, tablet-first.**
|
||||
|
||||
## 2. How drawing is actually taught (the pedagogy)
|
||||
|
||||
Art instruction is remarkably consistent about the order things should be learned. DrawIt's five
|
||||
ability levels line up well with it; we just need to attach the right *techniques* to each level.
|
||||
|
||||
**Work broad → specific ("blocking-in").** Every strong drawing starts by establishing big basic
|
||||
shapes to nail proportion, placement, and composition *before* any detail. Construction shapes
|
||||
(ovals, boxes, cylinders) come first; refinement comes later. DrawIt's fish/panda/etc. already teach
|
||||
this implicitly — we should make it explicit and name it.
|
||||
|
||||
**The fundamentals, in rough teaching order:**
|
||||
|
||||
1. **Line & mark-making** — confident lines, contours, and the different marks (crisp outline vs.
|
||||
light construction line vs. hatching stroke). Each mark has a purpose.
|
||||
2. **Shape → Form** — turning flat shapes into 3-D volumes (circle → sphere, square → cube).
|
||||
3. **Value & shading** — light and shadow. This is the big one (see below).
|
||||
4. **Texture** — implying surface (fur, scales, bark) with marks.
|
||||
5. **Proportion & perspective** — relative sizes, foreshortening, simple 1-point perspective.
|
||||
6. **Color** — basic color theory, then color + shading together.
|
||||
|
||||
**Shading, specifically** (what SimplyDraw leans on). You teach it by introducing a **light source**
|
||||
and the three value zones every form has: **highlight, mid-tone, shadow** (plus cast shadow). The
|
||||
core techniques, easiest to hardest:
|
||||
|
||||
- **Tonal/blended shading** — smooth gradients from light to dark.
|
||||
- **Hatching** — parallel lines; closer together = darker, farther apart = lighter.
|
||||
- **Cross-hatching** — overlapping sets of lines; denser crossings = darker.
|
||||
- **Stippling** — dots; denser = darker.
|
||||
|
||||
The pro move taught everywhere: **curve your hatching to follow the form's surface**, space lines
|
||||
wider in light areas and tighter in shadow, and **build up gradually** in passes. The classic
|
||||
practice object is a sphere lit from one side — exactly the kind of single-shape drill we can ship.
|
||||
|
||||
**Layering** — this word has two useful meanings, and we want both:
|
||||
|
||||
- **Pedagogical layering (process):** a drawing is built up in stages —
|
||||
**construction/under-drawing → refined line art → base color → shading/details.** This is the
|
||||
professional workflow (underdrawing → underpainting → overpainting → glazes), simplified for kids.
|
||||
Teaching the *process* of layers is as important as teaching shapes.
|
||||
- **Technical layering (tool):** in digital art, **layers** are stacked transparent sheets — sketch
|
||||
on one, line art on another, color below the lines, shading on top. Being able to draw the
|
||||
construction loosely and then ink/color *without destroying it* is what makes the process above
|
||||
actually learnable. This is a feature we can build into the canvas.
|
||||
|
||||
## 3. How to incorporate this into DrawIt
|
||||
|
||||
### 3a. Map techniques onto our five levels
|
||||
|
||||
Our levels already describe the learner; here's the technique each one should introduce, so the
|
||||
curriculum has a real backbone instead of just "more lessons."
|
||||
|
||||
- **Early Beginner** — simple shapes & confident lines (what we have today: fish, panda, flower,
|
||||
unicorn, tree, dinosaur). Add one idea: **construction first** (draw the light guide shapes, then
|
||||
the outline).
|
||||
- **Beginner** — **shape → form** and **base color**: turn the same shapes into 3-D (sphere, cube,
|
||||
cylinder), introduce the idea of a light source, color-the-sketch lessons.
|
||||
- **Learner** — **shading & value**: the sphere drill, highlight/mid-tone/shadow, intro hatching;
|
||||
this is the "SimplyDraw moment" where drawings start to look real.
|
||||
- **Advanced Learned** — **layering, texture, proportion**: full construction → line → color →
|
||||
shade workflow on a multi-part subject; cross-hatching; simple perspective.
|
||||
- **Superb** — **composition & personal style**: multi-object scenes, free-draw challenges with
|
||||
light-touch guidance, "make it your own."
|
||||
|
||||
### 3b. Extend the lesson data model (small, backward-compatible)
|
||||
|
||||
Today a lesson is steps with a single cumulative SVG. Add optional fields so a step can declare what
|
||||
kind of step it is and what it teaches:
|
||||
|
||||
- `kind: "construct" | "outline" | "color" | "shade" | "detail"` — drives UI hints and which canvas
|
||||
layer the child should be drawing on.
|
||||
- `targetLayer` — which layer this step belongs to (sketch / line / color / shade).
|
||||
- `shadeGuide` — optional overlay showing the light source ☀️ and the highlight/mid-tone/shadow
|
||||
zones for the step (so we can *show* where it gets dark).
|
||||
- `palette` — for color/shade steps, a small kid-safe swatch set (and a **grayscale value strip**
|
||||
for shading lessons, since value is taught before hue).
|
||||
|
||||
Because these are all optional, the six existing lessons keep working untouched.
|
||||
|
||||
### 3c. Canvas / tool features to build (in priority order)
|
||||
|
||||
1. **Layers in the drawing stage** (highest leverage — unlocks "layering" properly). Start with a
|
||||
fixed set — *Sketch, Lines, Color, Shading* — each a transparent canvas with show/hide and
|
||||
opacity. The construction guide lives on Sketch at low opacity; kids ink on Lines; the lesson can
|
||||
auto-advance the active layer per step. This is the single feature that most closes the gap with
|
||||
SimplyDraw and teaches the pro workflow at the same time.
|
||||
2. **A shading brush** — a soft, low-opacity, pressure-/speed-sensitive brush (stack passes to go
|
||||
darker), plus a **hatching helper** that snaps stroke direction to follow the form. Add a
|
||||
light-source marker and faint highlight/shadow zones as a guide overlay.
|
||||
3. **Value-first palette** — a grayscale strip for value drills and a small color palette for color
|
||||
lessons; teach value before color, as the books do.
|
||||
4. **Reference panel** — an optional side-by-side reference image (or the finished cumulative SVG)
|
||||
the child studies before/while drawing, mirroring SimplyDraw's "study the reference → draw →
|
||||
shade" flow.
|
||||
5. **Stroke replay / animated steps** — record the lesson's guide as a timeline and play it back as
|
||||
an animation ("watch it drawn, then you try"). Cheap to do with our SVG steps and matches
|
||||
Draw.ai / Kids Doodle's most-loved feature. Bonus: kids can replay *their own* drawing.
|
||||
6. **Multi-modal cues** — we already have text instructions and tips; add optional short audio
|
||||
narration per step (Drawing Desk's voice guides) using the browser's built-in speech synthesis,
|
||||
so no files and nothing leaves the device.
|
||||
7. **"Make it your own" finish** — after the guided steps, a free-draw canvas seeded with their
|
||||
drawing, so every lesson ends in creative expression (and still earns the badge/award).
|
||||
|
||||
### 3d. Keep our principles
|
||||
|
||||
Everything above can be done client-side with the same stack: extra canvases for layers,
|
||||
`SpeechSynthesis` for narration, SVG timelines for replay/animation. **No new data collection, no
|
||||
third-party services, still free and open source.** Reference images and audio are generated locally
|
||||
or bundled, so nothing about the child leaves the server you run.
|
||||
|
||||
## 4. Suggested roadmap
|
||||
|
||||
- **Phase 1 (quick wins):** name "construction first" in Early Beginner; add per-step `kind`/tip
|
||||
copy; add stroke **replay** of the guide; add optional speech narration. Small code, big feel.
|
||||
- **Phase 2 (shading module):** value strip + soft shading brush + light-source/zone overlays; ship
|
||||
the **sphere** drill and a shaded version of an existing subject (e.g., a shaded fish) as the first
|
||||
Learner lessons.
|
||||
- **Phase 3 (layering engine):** the Sketch/Lines/Color/Shading layer system in the canvas, with the
|
||||
lesson auto-selecting the active layer per step; convert one subject to the full
|
||||
construct→outline→color→shade workflow as the flagship Advanced lesson.
|
||||
- **Phase 4 (color + scenes + polish):** basic color theory lessons, reference panel, multi-object
|
||||
Superb challenges, and a personalized "what do you want to draw?" path.
|
||||
|
||||
## Sources
|
||||
|
||||
- [Simply Draw — official](https://www.hellosimply.com/simply-draw)
|
||||
- [Simply Draw on the App Store](https://apps.apple.com/us/app/simply-draw-learn-to-draw/id1639875485)
|
||||
- [Simply Draw review — Research.com](https://research.com/software/reviews/simplydraw-review)
|
||||
- [7 Best Drawing Apps for Kids — EducationalAppStore](https://www.educationalappstore.com/best-apps/best-drawing-apps-for-kids)
|
||||
- [Best Drawing Apps for Kids — PureWow](https://www.purewow.com/family/best-drawing-apps-for-kids)
|
||||
- [Drawing Desk — Google Play](https://play.google.com/store/apps/details?id=com.axis.drawingdesk.v3)
|
||||
- [Best Drawing Apps for Kids — Adobe Aqua](https://aqua.adobe.com/learn/drawing-apps-for-kids)
|
||||
- [What are the Drawing Fundamentals? — Fine Art Tutorials](https://finearttutorials.com/guide/drawing-fundamentals/)
|
||||
- [Shading Techniques — The Virtual Instructor](https://thevirtualinstructor.com/shading-techniques-basics.html)
|
||||
- [Hatching and cross-hatching — Fiveable](https://fiveable.me/drawing-foundations/unit-6/hatching-cross-hatching/study-guide/SBjr8djW1TCdGeXY)
|
||||
- [Working in layers — Wikipedia](https://en.wikipedia.org/wiki/Working_in_layers)
|
||||
- [Underdrawing — Wikipedia](https://en.wikipedia.org/wiki/Underdrawing)
|
||||
- [Layers (digital image editing) — Wikipedia](https://en.wikipedia.org/wiki/Layers_(digital_image_editing))
|
||||
@@ -0,0 +1,167 @@
|
||||
# DrawIt — Research: Expanding the Early Beginner Level
|
||||
|
||||
_Researched June 2026. Purpose: figure out the best way to fold a batch of new subjects —
|
||||
**hearts, triangles, squares, bow ties, football, basketball, clothes (shirts, dresses, socks),
|
||||
trophy, eyes, faces, cups** — into the existing Early Beginner level without breaking its structure or
|
||||
duplicating the new Beginner level. Research/planning only; nothing is built here. Builds on
|
||||
`RESEARCH-drawing-platforms-and-techniques.md` and `RESEARCH-beginner-level.md`._
|
||||
|
||||
## 1. Where these fit — and the one rule that keeps it coherent
|
||||
|
||||
Early Beginner lives in Lowenfeld's **pre-schematic** world: flat, single, recognizable subjects drawn
|
||||
with confident lines. Every requested subject fits that world *as long as we keep it flat and simple*.
|
||||
The pedagogy backs our existing approach almost word-for-word: teach **one shape at a time**, have the
|
||||
child **trace the outline with a finger, then a pencil** (exactly our line-by-line trace runner), and
|
||||
then **"think in shapes" to build objects**. So the new subjects don't need a new mechanic — they need
|
||||
to be slotted into the existing **Outline → Details → Color it!** format.
|
||||
|
||||
**The one rule:** keep Early Beginner versions *flat 2-D, front-on, no ellipses or depth*. This is what
|
||||
separates them from the Beginner level, which deliberately teaches the same forms *with* 3-D volume.
|
||||
Three of the requested subjects overlap with Beginner and must be drawn differently:
|
||||
|
||||
| Subject | Early Beginner (flat) | Beginner (form) — already built |
|
||||
| --- | --- | --- |
|
||||
| **Cup** | A flat front-on cup: tapered sides + a handle, straight top line | **Mug** = cylinder with an elliptical rim |
|
||||
| **Basketball** | A flat circle + curved seam lines | **Beach ball** = sphere with shaded panels |
|
||||
| **Football** | A flat pointed oval + laces (American football) | (n/a — no conflict if we keep it American football, not a round soccer ball) |
|
||||
|
||||
Keeping the EB versions flat means a child *feels* the level-up when the same object returns in Beginner
|
||||
with real volume. That's a feature, not redundancy — worth saying so in the lesson copy.
|
||||
|
||||
## 2. The new subjects, grouped and sequenced
|
||||
|
||||
With the new objects **and** a batch of ~19 animals (below), Early Beginner grows from six subjects to
|
||||
nearly forty. The pedagogy ("introduce one shape, then the next, then build objects from them") and every
|
||||
competitor app point to the same answer: **group Early Beginner into themed groups with a gentle
|
||||
difficulty ramp.** Proposed groups, easiest → hardest:
|
||||
|
||||
| Group | Subjects | Why here / teaches |
|
||||
| --- | --- | --- |
|
||||
| **1. Simple Shapes** (new, foundational) | Square, Triangle, Heart | The literal building blocks; pure confident-line practice. The natural *first* thing a brand-new artist draws. |
|
||||
| **2. Animals & Nature** (existing classics) | Fish, Panda, Flower, Unicorn, Tree, Dinosaur | The current core — recognizable subjects from combined shapes. |
|
||||
| **3. Safari Animals** (new) | Lion, Elephant, Giraffe, Zebra, Rhino, Leopard, Tiger, Monkey, Crocodile | Bigger animals from ovals + legs; pattern practice (stripes, spots). |
|
||||
| **4. Woodland Critters** (new) | Fox, Bear, Deer, Rabbit, Hedgehog, Owl, Cat, Turtle | Small rounded animals; cozy, high-appeal, very simple silhouettes. |
|
||||
| **5. Up in the Air** (new) | Bird, Butterfly | Tiny, symmetric, great confident-curve practice. |
|
||||
| **6. Fun Things** (new, objects) | Bow tie, Football, Basketball, Cup, Trophy | "Things are made of shapes": two triangles = bow tie, circle + seams = basketball, etc. |
|
||||
| **7. Things to Wear** (new, clothes) | Shirt, Dress, Socks | Flat front-on garments; simple symmetric outlines + one detail (collar/hem/stripe). |
|
||||
| **8. Faces & Features** (new, capstone) | Eyes, Face | The hardest EB skill — symmetry, placement, proportion. Do **Eyes first**, then **Face**. |
|
||||
|
||||
### About the ~19 animals (from Dan's reference picture)
|
||||
|
||||
The reference image is a stock/commercial illustration, so we will **not trace or copy it** — that would
|
||||
lift someone else's copyrighted artwork. Instead we draw our **own original** simple line-art versions of
|
||||
the same animals in DrawIt's existing rounded doodle style (poses of our choosing). The animal *subjects*
|
||||
themselves aren't protectable; the specific drawing is. New animals (none overlap with the existing six):
|
||||
**owl, monkey, bird, butterfly, leopard, fox, rabbit, tiger, turtle, bear, deer, zebra, crocodile, cat,
|
||||
hedgehog, lion, giraffe, rhino, elephant.** They use the same 3-phase format and per-subject badge as
|
||||
every other EB subject; they're split across the Safari / Woodland / Up-in-the-Air groups above purely so
|
||||
the list stays browsable.
|
||||
|
||||
This keeps **Fish → Outline as the free flagship lesson** (unchanged) while giving newcomers an even
|
||||
gentler on-ramp (Simple Shapes) and a satisfying capstone (Faces). Within Faces, eyes are taught before
|
||||
the face because the face lesson reuses them, mirroring how face tutorials build the eyes on an eye-line.
|
||||
|
||||
### Per-subject sketch (shapes → what each phase does)
|
||||
|
||||
Each subject keeps the **3-phase** EB format. "Detail" is where we make a plain shape *fun*.
|
||||
|
||||
- **Square / Triangle / Heart** — Outline: draw the shape (trace it). Details: give it a cute face
|
||||
(two eye dots + a smile) so even a square has personality. Color: fill it in. _(This is how we justify
|
||||
3 phases for a trivial shape — see §3.)_
|
||||
- **Bow tie** — Outline: two triangles meeting at a center knot. Details: a small square knot + fold
|
||||
lines. Color: pick a color + polka dots.
|
||||
- **Football (American)** — Outline: a pointed oval. Details: center lace stripe + stitches. Color: brown.
|
||||
- **Basketball** — Outline: a circle. Details: one vertical + one horizontal seam, two side curves.
|
||||
Color: orange.
|
||||
- **Cup** — Outline: flat tapered cup body + straight top line + a handle. Details: a rim line + a
|
||||
steam swirl. Color. _(Flat — contrast with Beginner's cylinder mug.)_
|
||||
- **Trophy** — Outline: a cup bowl on a stem + base. Details: two handles + a "★/#1" + shine marks.
|
||||
Color: gold.
|
||||
- **Shirt** — Outline: center line → shoulder line → trapezoid body → two sleeves → neckline (the exact
|
||||
beginner method from the research). Details: collar + a pocket or stripe. Color.
|
||||
- **Dress** — Outline: bodice + A-line skirt (triangle-ish). Details: waistline + neckline + hem. Color.
|
||||
- **Socks** — Outline: an L-shaped tube. Details: cuff line + heel/toe + a stripe. Color.
|
||||
- **Eyes** — Outline: two almond shapes on a line, one eye-width apart. Details: iris + pupil + a white
|
||||
highlight + lashes/brows. Color. _(Teaches the "eyes sit halfway, spaced one eye apart" rule.)_
|
||||
- **Face** — Outline: a round head + faint center line + eye line. Details: eyes (on the line), nose
|
||||
(halfway to chin), mouth (a third below nose), ears, hair. Color. _(The EB capstone.)_
|
||||
|
||||
## 3. Phase handling — the one real design question
|
||||
|
||||
For animals the 3-phase format is obviously right. For a **square or a heart**, "Outline → Details →
|
||||
Color" risks feeling padded. Two options:
|
||||
|
||||
- **(A) Keep all subjects 3-phase (recommended).** Make the *Details* phase "make it cute" — add a tiny
|
||||
face or pattern. This keeps the data model, gating, badges, and UI perfectly uniform, and turns a
|
||||
boring shape into something a kid wants to finish and color. Pedagogically fine: the outline phase is
|
||||
pure shape practice; details add confident small marks.
|
||||
- **(B) Add a one-step "warm-up" lesson type** for pure shapes (single phase, no detail/color). More
|
||||
faithful to "it's just a square," but it's a new lesson shape to special-case in the runner, learn
|
||||
page, gating, and badges — more code for little gain.
|
||||
|
||||
Recommendation: **(A)**. It's zero new mechanics and more fun. Flagged as a decision in §6.
|
||||
|
||||
## 4. How to implement it in the codebase (no schema change)
|
||||
|
||||
Everything reuses the patterns the Beginner build just established.
|
||||
|
||||
- **Data model:** each new subject = **three `Lesson` entries** (outline/detail/extra) in
|
||||
`src/lib/curriculum.ts`, identical in shape to the existing Fish/Panda/etc. Per-subject badge on the
|
||||
**Detail** lesson (`badgeKey: "early-beginner-<n>-<name>"`), exactly as today. **Early Beginner keeps
|
||||
per-subject badges** (not the per-pack badges we used for Beginner) — these are bite-size wins for the
|
||||
youngest users.
|
||||
- **Sublevels:** continue the existing `subjectIndex*10 + phase` scheme (phase 1/2/3). New subjects get
|
||||
indices 7+ → sublevels **71/72/73, 81/82/83, …**. Sublevels are namespaced by `level`, so an EB sublevel
|
||||
never collides with a Beginner one (they differ by `level`). Assign indices by group order from §2.
|
||||
- **Grouping (the only new UI work):** rather than tag every lesson, add a small **groups config** keyed
|
||||
by `subjectKey` (e.g. `EARLY_BEGINNER_GROUPS = [{ key, name, emoji, subjectKeys[] }]`) plus a
|
||||
`getGroupedSubjects(level)` helper that buckets `getSubjects()` output into ordered groups (anything
|
||||
unlisted falls into a default group). The Learn page then renders Early Beginner as `<details>` group
|
||||
sections — the same accordion treatment Beginner packs use, **minus** the pack badge (EB keeps
|
||||
per-subject badges). No per-lesson field and no edits to the existing six lessons required.
|
||||
- **Art:** extend the existing generator approach (`outputs/gen_beginner.py` / the earlier `gen4`
|
||||
pattern) — define each subject's `lines` as simple geometric SVG, render a **cairosvg montage** to
|
||||
verify, then emit the curriculum entries. These subjects are mostly primitives (circle, oval, triangle,
|
||||
trapezoid, almond) so the art is quick and low-risk.
|
||||
- **Badges/awards:** no changes — the emoji-medal badge art and award PDF already work for any
|
||||
per-subject badge.
|
||||
- **Unlock gate:** unaffected. Beginner still opens after 5 completed EB lessons; more EB content just
|
||||
gives kids more ways to get there. (Optionally bump the threshold later, but no need.)
|
||||
- **Free lesson:** unchanged — `fish-outline` stays the one free, no-account lesson.
|
||||
- **Landing page:** today it shows every EB subject as a teaser grid. With ~19 subjects that's a wall —
|
||||
recommend the landing show only **Group 1 (Simple Shapes)** plus an "…and lots more" link, so the
|
||||
marketing page stays tight. Small change in `src/app/page.tsx`.
|
||||
|
||||
## 5. Suggested rollout phasing (when we build)
|
||||
|
||||
- **Phase A — Grouping UI + Simple Shapes** (square, triangle, heart). Proves the group treatment.
|
||||
- **Phase B — Fun Things** (bow tie, football, basketball, cup, trophy).
|
||||
- **Phase C — Things to Wear** (shirt, dress, socks).
|
||||
- **Phase D — Faces & Features** (eyes → face), the capstone.
|
||||
- **Phase E — Animals** (the ~19 originals, across Safari / Woodland / Up-in-the-Air). Largest art effort;
|
||||
done last because each animal is an organic doodle (more drawing work than the geometric subjects).
|
||||
|
||||
## 6. Open decisions for Dan
|
||||
|
||||
1. **Phase handling for pure shapes** — go with option (A): keep all subjects 3-phase, with a "make it
|
||||
cute" Details step? (Recommended.)
|
||||
2. **Grouping** — add the lightweight EB group UI (Simple Shapes / Animals & Nature / Fun Things / Things
|
||||
to Wear / Faces), or keep Early Beginner as one flat list?
|
||||
3. **Football** — American football (pointed oval + laces, no overlap with basketball), or a round soccer
|
||||
ball (which would duplicate the basketball circle)? (Recommend American football.)
|
||||
4. **Group order** — put **Simple Shapes first** (before the existing Animals), as recommended, or keep
|
||||
the existing animals first and append the new groups after?
|
||||
5. **Clothes scope** — three garments (shirt, dress, socks) to start, or add more later (hat, shoes)?
|
||||
|
||||
## Sources
|
||||
|
||||
- [How to Draw a Face for Kids — Art Projects for Kids](https://artprojectsforkids.org/how-to-draw-a-face/)
|
||||
- [How to Draw a Face: Facial Proportions — The Virtual Instructor](https://thevirtualinstructor.com/facialproportions.html)
|
||||
- [How to Draw a Face — How to Draw for Kids](https://howtodrawforkids.com/how-to-draw-a-face/)
|
||||
- [How to Draw a Nose and Eyes — How to Draw for Kids](https://howtodrawforkids.com/how-to-draw-a-nose-and-eyes/)
|
||||
- [Teaching Shapes to Preschoolers — ABCJesusLovesMe](https://www.abcjesuslovesme.com/ideas/teaching-shapes)
|
||||
- [Discovering Shapes and Space in Preschool — NAEYC](https://www.naeyc.org/resources/pubs/tyc/apr2014/discovering-shapes-and-space-preschool)
|
||||
- [Exploring Shapes — Mensa for Kids](https://www.mensaforkids.org/teach/lesson-plans/exploring-shapes/)
|
||||
- [How to Draw a Dress — How to Draw for Kids](https://howtodrawforkids.com/how-to-draw-a-dress/)
|
||||
- [How To Draw Clothes Step By Step (For Kids & Beginners)](https://how-to-drawa.com/clothes/)
|
||||
- [Easy Clothes Drawing Tutorials — Easy Drawing Guides](https://easydrawingguides.com/easy-clothes-drawing-tutorials/)
|
||||
+2
-3
@@ -16,6 +16,7 @@
|
||||
"next": "15.5.19",
|
||||
"nodemailer": "^9.0.1",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"potrace": "^2.1.8",
|
||||
"react": "19.2.7",
|
||||
"react-dom": "19.2.7"
|
||||
},
|
||||
@@ -29,6 +30,4 @@
|
||||
},
|
||||
"allowScripts": {
|
||||
"better-sqlite3@12.11.1": true,
|
||||
"sharp@0.34.5": true
|
||||
}
|
||||
}
|
||||
"sha
|
||||
@@ -0,0 +1,71 @@
|
||||
"use client";
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
interface Row {
|
||||
id: number;
|
||||
subject: string;
|
||||
level: string;
|
||||
status: string;
|
||||
slug: string;
|
||||
emoji: string;
|
||||
}
|
||||
|
||||
export default function AdminCreations({ review, ready }: { review: Row[]; ready: Row[] }) {
|
||||
const router = useRouter();
|
||||
const [busy, setBusy] = useState<number | null>(null);
|
||||
|
||||
async function act(id: number, action: "approve" | "block" | "promote") {
|
||||
setBusy(id);
|
||||
try {
|
||||
await fetch(`/api/admin/create/${id}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action }),
|
||||
});
|
||||
router.refresh();
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
}
|
||||
|
||||
if (review.length === 0 && ready.length === 0) {
|
||||
return <p className="muted" style={{ padding: 16, margin: 0 }}>No creations awaiting review.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: 12 }}>
|
||||
{review.length > 0 && (
|
||||
<>
|
||||
<strong>Awaiting review</strong>
|
||||
<div className="stack" style={{ marginTop: 8, marginBottom: 16 }}>
|
||||
{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 && (
|
||||
<>
|
||||
<strong>Ready — promote to everyone?</strong>
|
||||
<div className="stack" style={{ marginTop: 8 }}>
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
const mini: React.CSSProperties = { minHeight: 34, padding: "6px 12px", fontSize: "0.85rem", boxShadow: "none" };
|
||||
@@ -90,11 +90,23 @@ export default function AdminUsers({
|
||||
Unsuspend
|
||||
</button>
|
||||
) : null}
|
||||
{u.role === "learner" ? (
|
||||
{u.role !== "admin" && (
|
||||
<button className="btn ghost" style={btn} disabled={busy} onClick={() => act(u.id, "role", "admin")}>
|
||||
Make admin
|
||||
</button>
|
||||
) : (
|
||||
)}
|
||||
{u.role !== "creator" && (
|
||||
<button
|
||||
className="btn ghost"
|
||||
style={btn}
|
||||
disabled={busy || isLastAdmin}
|
||||
title={isLastAdmin ? "Can't demote the only admin" : undefined}
|
||||
onClick={() => act(u.id, "role", "creator")}
|
||||
>
|
||||
Make creator
|
||||
</button>
|
||||
)}
|
||||
{u.role !== "learner" && (
|
||||
<button
|
||||
className="btn ghost"
|
||||
style={btn}
|
||||
|
||||
@@ -7,6 +7,8 @@ import { toPublicUser } from "@/lib/types";
|
||||
import AddUserForm from "./AddUserForm";
|
||||
import AdminUsers from "./AdminUsers";
|
||||
import AdminReports from "./AdminReports";
|
||||
import AdminCreations from "./AdminCreations";
|
||||
import { listNeedsReview, listReadyUnpromoted } from "@/lib/createdLessons";
|
||||
|
||||
export const metadata = { title: "Admin · DrawIt" };
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -19,6 +21,9 @@ export default async function AdminPage() {
|
||||
const pending = users.filter((u) => u.status === "pending");
|
||||
const reports = listReports();
|
||||
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 reviewCreations = listNeedsReview().map(pick);
|
||||
const readyCreations = listReadyUnpromoted().map(pick);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -44,6 +49,13 @@ export default async function AdminPage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section style={{ marginTop: 28 }}>
|
||||
<h2>Created lessons {reviewCreations.length > 0 && <span className="pill pending">{reviewCreations.length} to review</span>}</h2>
|
||||
<div className="card" style={{ padding: 0 }}>
|
||||
<AdminCreations review={reviewCreations} ready={readyCreations} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section style={{ marginTop: 28 }}>
|
||||
<h2>Issue reports</h2>
|
||||
<div className="card" style={{ padding: 0 }}>
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireAdmin } from "@/lib/session";
|
||||
import { getCreatedById, updateCreatedStatus, promoteCreated, processCreation } from "@/lib/createdLessons";
|
||||
|
||||
// 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 }> }) {
|
||||
const admin = await requireAdmin();
|
||||
if (!admin) return NextResponse.json({ error: "Not authorized." }, { status: 403 });
|
||||
|
||||
const { id } = await ctx.params;
|
||||
const row = getCreatedById(Number(id));
|
||||
if (!row) return NextResponse.json({ error: "Not found." }, { status: 404 });
|
||||
|
||||
let body: { action?: string };
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid request." }, { status: 400 });
|
||||
}
|
||||
|
||||
switch (body.action) {
|
||||
case "approve":
|
||||
if (row.status !== "needs_review") return NextResponse.json({ error: "Not awaiting review." }, { status: 400 });
|
||||
updateCreatedStatus(row.id, "pending");
|
||||
void processCreation(row.id);
|
||||
return NextResponse.json({ ok: true, status: "generating" });
|
||||
case "block":
|
||||
updateCreatedStatus(row.id, "blocked");
|
||||
return NextResponse.json({ ok: true, status: "blocked" });
|
||||
case "promote":
|
||||
if (row.status !== "ready") return NextResponse.json({ error: "Only ready lessons can be promoted." }, { status: 400 });
|
||||
promoteCreated(row.id);
|
||||
return NextResponse.json({ ok: true });
|
||||
default:
|
||||
return NextResponse.json({ error: "Unknown action." }, { status: 400 });
|
||||
}
|
||||
}
|
||||
@@ -39,7 +39,7 @@ export async function POST(req: Request, ctx: { params: Promise<{ id: string }>
|
||||
}
|
||||
case "role": {
|
||||
const r = body.value as Role;
|
||||
if (!["admin", "learner"].includes(r))
|
||||
if (!["admin", "learner", "creator"].includes(r))
|
||||
return NextResponse.json({ error: "Invalid role." }, { status: 400 });
|
||||
// Guard: don't let an admin demote the last remaining admin (themselves included).
|
||||
if (target.role === "admin" && r !== "admin") {
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getCurrentUser } from "@/lib/session";
|
||||
import { getCreatedById } from "@/lib/createdLessons";
|
||||
|
||||
// Poll the status of a creation (owner or admin).
|
||||
export async function GET(_req: Request, ctx: { params: Promise<{ id: string }> }) {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) return NextResponse.json({ error: "Log in." }, { status: 401 });
|
||||
|
||||
const { id } = await ctx.params;
|
||||
const row = getCreatedById(Number(id));
|
||||
if (!row) return NextResponse.json({ error: "Not found." }, { status: 404 });
|
||||
if (row.user_id !== user.id && user.role !== "admin")
|
||||
return NextResponse.json({ error: "Not authorized." }, { status: 403 });
|
||||
|
||||
return NextResponse.json({ id: row.id, slug: row.slug, status: row.status, error: row.error, subject: row.subject });
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireCreator } from "@/lib/session";
|
||||
import { isCreateEnabled, buildPrompt } from "@/lib/comfyui";
|
||||
import { moderateSubject } from "@/lib/moderation";
|
||||
import { createCreatedLesson, processCreation, listCreatedForUser } from "@/lib/createdLessons";
|
||||
|
||||
const LEVELS = new Set(["early-beginner", "beginner"]);
|
||||
const DAILY_LIMIT = 20;
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const user = await requireCreator();
|
||||
if (!user) return NextResponse.json({ error: "Not authorized." }, { status: 403 });
|
||||
if (!isCreateEnabled()) return NextResponse.json({ error: "Create is not available right now." }, { status: 503 });
|
||||
|
||||
let body: { level?: string; subject?: string };
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid request." }, { status: 400 });
|
||||
}
|
||||
|
||||
const level = body.level || "";
|
||||
if (!LEVELS.has(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));
|
||||
if (todays.length >= DAILY_LIMIT) return NextResponse.json({ error: "You've created a lot today — try again tomorrow!" }, { status: 429 });
|
||||
|
||||
const mod = moderateSubject(body.subject || "");
|
||||
if (mod.decision === "blocked") {
|
||||
return NextResponse.json({ error: "Let's pick a different, friendly thing to draw!" }, { status: 422 });
|
||||
}
|
||||
|
||||
const prompt = buildPrompt(mod.subject);
|
||||
const status = mod.decision === "allowlist" ? "pending" : "needs_review";
|
||||
const row = createCreatedLesson({ userId: user.id, level, subject: mod.subject, prompt, moderation: mod.decision, status });
|
||||
|
||||
// Allowlisted → generate now (async). Review → wait for an admin to approve.
|
||||
if (mod.decision === "allowlist") void processCreation(row.id);
|
||||
|
||||
return NextResponse.json({
|
||||
id: row.id,
|
||||
slug: row.slug,
|
||||
status: mod.decision === "allowlist" ? "generating" : "needs_review",
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
"use client";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
|
||||
type Phase = "idle" | "submitting" | "generating" | "review" | "ready" | "failed";
|
||||
|
||||
export default function CreateForm({ level: initialLevel, enabled }: { level: string; enabled: boolean }) {
|
||||
const [level, setLevel] = useState(initialLevel);
|
||||
const [subject, setSubject] = useState("");
|
||||
const [phase, setPhase] = useState<Phase>("idle");
|
||||
const [msg, setMsg] = useState("");
|
||||
const [slug, setSlug] = useState("");
|
||||
const poll = useRef<ReturnType<typeof setInterval> | undefined>(undefined);
|
||||
|
||||
useEffect(() => () => clearInterval(poll.current), []);
|
||||
|
||||
async function start(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setMsg("");
|
||||
setSlug("");
|
||||
setPhase("submitting");
|
||||
try {
|
||||
const res = await fetch("/api/create", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ level, subject }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
setPhase("failed");
|
||||
setMsg(data.error || "Something went wrong.");
|
||||
return;
|
||||
}
|
||||
if (data.status === "needs_review") {
|
||||
setPhase("review");
|
||||
return;
|
||||
}
|
||||
setSlug(data.slug);
|
||||
setPhase("generating");
|
||||
watch(data.id, data.slug);
|
||||
} catch {
|
||||
setPhase("failed");
|
||||
setMsg("Couldn't reach the server.");
|
||||
}
|
||||
}
|
||||
|
||||
function watch(id: number, baseSlug: string) {
|
||||
clearInterval(poll.current);
|
||||
poll.current = setInterval(async () => {
|
||||
try {
|
||||
const r = await fetch(`/api/create/${id}`);
|
||||
const d = await r.json();
|
||||
if (d.status === "ready") {
|
||||
clearInterval(poll.current);
|
||||
setSlug(baseSlug);
|
||||
setPhase("ready");
|
||||
} else if (d.status === "failed" || d.status === "blocked") {
|
||||
clearInterval(poll.current);
|
||||
setPhase("failed");
|
||||
setMsg(d.error || "The drawing couldn't be made. Try a different subject.");
|
||||
}
|
||||
} catch {
|
||||
/* keep polling */
|
||||
}
|
||||
}, 2500);
|
||||
}
|
||||
|
||||
if (!enabled) {
|
||||
return (
|
||||
<div className="card" style={{ marginTop: 16 }}>
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
The Create feature isn't turned on for this DrawIt instance yet. Ask your administrator to configure a
|
||||
drawing server (ComfyUI).
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const busy = phase === "submitting" || phase === "generating";
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: 16, maxWidth: 560 }}>
|
||||
<form onSubmit={start} className="card" style={{ display: "grid", gap: 12 }}>
|
||||
<label className="field">
|
||||
<span style={{ fontWeight: 700 }}>What do you want to draw?</span>
|
||||
<input
|
||||
value={subject}
|
||||
onChange={(e) => setSubject(e.target.value)}
|
||||
placeholder="e.g. Zebra"
|
||||
maxLength={30}
|
||||
disabled={busy}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<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>
|
||||
</select>
|
||||
</label>
|
||||
<button className="btn big" type="submit" disabled={busy || subject.trim().length < 2}>
|
||||
{busy ? "Drawing…" : "✨ Make my lesson"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{phase === "generating" && (
|
||||
<div className="card" style={{ marginTop: 12, textAlign: "center" }}>
|
||||
<p style={{ margin: 0 }}>🎨 Drawing your {subject || "picture"}… this can take a little while.</p>
|
||||
<p className="muted" style={{ marginTop: 6, fontSize: "0.9rem" }}>You can stay on this page.</p>
|
||||
</div>
|
||||
)}
|
||||
{phase === "review" && (
|
||||
<div className="card" style={{ marginTop: 12 }}>
|
||||
<p style={{ margin: 0 }}>📨 Thanks! "{subject}" was sent to a grown-up to approve. Check back soon.</p>
|
||||
</div>
|
||||
)}
|
||||
{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>
|
||||
</div>
|
||||
)}
|
||||
{phase === "failed" && (
|
||||
<div className="notice" style={{ marginTop: 12 }}>
|
||||
<p style={{ margin: 0 }}>{msg || "Something went wrong. Try a different subject."}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import SiteNav from "@/components/SiteNav";
|
||||
import CreateForm from "./CreateForm";
|
||||
import { getCurrentUser, canCreate } from "@/lib/session";
|
||||
import { isCreateEnabled } from "@/lib/comfyui";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const metadata = { title: "Create · DrawIt" };
|
||||
|
||||
export default async function CreatePage({ searchParams }: { searchParams: Promise<{ level?: string }> }) {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) redirect("/login");
|
||||
if (!canCreate(user)) redirect("/learn");
|
||||
// Hide the Create page entirely when ComfyUI isn't configured (Creator role alone isn't enough).
|
||||
if (!isCreateEnabled()) redirect("/learn");
|
||||
|
||||
const { level } = await searchParams;
|
||||
const lvl = level === "beginner" ? "beginner" : "early-beginner";
|
||||
|
||||
return (
|
||||
<>
|
||||
<SiteNav />
|
||||
<main className="container page">
|
||||
<h1>✨ Create your own lesson</h1>
|
||||
<p className="muted" style={{ maxWidth: 640 }}>
|
||||
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()} />
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { getCurrentUser } from "@/lib/session";
|
||||
import { getCompletedSteps, hasBadge } from "@/lib/progress";
|
||||
import { isLevelUnlocked } from "@/lib/gating";
|
||||
import { getLatestDrawing } from "@/lib/drawings";
|
||||
import { resolveCreatedLesson } from "@/lib/createdLessons";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -28,7 +29,8 @@ 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);
|
||||
if (!lesson || lesson.level !== level) notFound();
|
||||
// Not a static lesson? It may be an AI-created lesson (…-trace / …-color).
|
||||
if (!lesson || lesson.level !== level) return renderCreated(level, slug);
|
||||
|
||||
const user = await getCurrentUser();
|
||||
if (!user && lesson.slug !== FREE_SLUG) redirect("/signup");
|
||||
@@ -119,3 +121,54 @@ export default async function LessonPage({ params }: { params: Promise<{ level:
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Render an AI-created lesson (Trace → Color), built on the fly from its DB row.
|
||||
async function renderCreated(level: string, slug: string) {
|
||||
const resolved = resolveCreatedLesson(slug);
|
||||
if (!resolved || resolved.row.level !== level) notFound();
|
||||
const { lessons, index, row } = resolved;
|
||||
|
||||
const user = await getCurrentUser();
|
||||
if (!user) redirect("/signup");
|
||||
// Visible to the owner, anyone if promoted to global, or an admin.
|
||||
const canView = row.user_id === user.id || row.promoted === 1 || user.role === "admin";
|
||||
if (!canView) notFound();
|
||||
if (!isLevelUnlocked(user.id, level)) redirect("/learn");
|
||||
|
||||
const lesson = lessons[index];
|
||||
const prev = index > 0 ? lessons[index - 1] : undefined;
|
||||
const stepCount = (l: Lesson) => l.steps.length || 1;
|
||||
// Gate Color behind a completed Trace.
|
||||
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 common = { level: lesson.level, levelName, sublevel: lesson.sublevel, title: lesson.title, emoji: lesson.emoji, intro: lesson.intro };
|
||||
|
||||
if (lesson.phase === "outline") {
|
||||
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" };
|
||||
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" />
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Color phase
|
||||
const userSketch = prev ? (getLatestDrawing(user.id, level, prev.sublevel) ?? "") : "";
|
||||
const meta = { ...common, baseSvg: lesson.baseSvg ?? "", phaseLabel: "Color" };
|
||||
return (
|
||||
<>
|
||||
<SiteNav />
|
||||
<main className="container page">
|
||||
<ColoringRunner key={lesson.slug} meta={meta} loggedIn={true} username={user.username} alreadyEarned={false} userSketch={userSketch} />
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,9 @@ import { LEVELS, getSubjects, getGroupedSubjects, BEGINNER_PACKS, lessonStepCoun
|
||||
import { getCurrentUser } from "@/lib/session";
|
||||
import { getCompletedSteps, hasBadge } from "@/lib/progress";
|
||||
import { isLevelUnlocked, countCompletedLessons, isPackComplete, BEGINNER_UNLOCK_THRESHOLD } from "@/lib/gating";
|
||||
import { canCreate } from "@/lib/session";
|
||||
import { isCreateEnabled } from "@/lib/comfyui";
|
||||
import { listCreatedForUser, listPromoted, buildCreatedLessons, type CreatedLessonRow } from "@/lib/createdLessons";
|
||||
|
||||
export const metadata = { title: "Learn · DrawIt" };
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -70,6 +73,47 @@ export default async function LearnPage() {
|
||||
);
|
||||
};
|
||||
|
||||
const creator = canCreate(user);
|
||||
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);
|
||||
const ready = mine.filter((r) => r.status === "ready");
|
||||
const pending = mine.filter((r) => ["pending", "generating", "needs_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;
|
||||
return (
|
||||
<div style={{ border: "2px dashed var(--primary)", borderRadius: 16, padding: "12px 14px" }}>
|
||||
<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>}
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
))}
|
||||
{canCreateNow && 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>
|
||||
{featured.map((r) => subjectAccordion(toCreatedSubject(r), false))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<SiteNav />
|
||||
@@ -116,6 +160,7 @@ 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);
|
||||
@@ -136,6 +181,7 @@ 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 (
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import "server-only";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
/**
|
||||
* Minimal ComfyUI HTTP client. Self-hosted endpoint only (privacy-first): the subject text and the
|
||||
* generated image never leave the operator's own machine. Disabled unless COMFYUI_URL is set.
|
||||
*
|
||||
* Flow: POST /prompt (workflow JSON) -> poll /history/{id} -> GET /view (PNG bytes).
|
||||
*/
|
||||
|
||||
const URL_BASE = (process.env.COMFYUI_URL || "").replace(/\/$/, "");
|
||||
const API_KEY = process.env.COMFYUI_API_KEY || "";
|
||||
const WORKFLOW_PATH = process.env.COMFYUI_WORKFLOW || "./comfyui/coloring-book.workflow_api.json";
|
||||
const TIMEOUT_MS = Number(process.env.COMFYUI_TIMEOUT_MS || 120000);
|
||||
const PROMPT_NODE = process.env.COMFYUI_PROMPT_NODE || "6"; // positive CLIPTextEncode in the default workflow
|
||||
const SEED_NODE = process.env.COMFYUI_SEED_NODE || "3"; // KSampler in the default workflow
|
||||
|
||||
export function isCreateEnabled(): boolean {
|
||||
return URL_BASE.length > 0;
|
||||
}
|
||||
|
||||
/** The fixed coloring-book prompt with the subject interpolated. */
|
||||
export function buildPrompt(subject: string): string {
|
||||
return `A coloring book page of a ${subject}, clean black-and-white line art, bold crisp outlines, simple composition, large open areas to color, white background, no shading, no grayscale, no color, no gradients, no heavy background detail, printable page.`;
|
||||
}
|
||||
|
||||
function authHeaders(): Record<string, string> {
|
||||
return API_KEY ? { Authorization: `Bearer ${API_KEY}` } : {};
|
||||
}
|
||||
|
||||
function loadWorkflow(): Record<string, { inputs: Record<string, unknown>; class_type?: string }> {
|
||||
const p = path.isAbsolute(WORKFLOW_PATH) ? WORKFLOW_PATH : path.join(process.cwd(), WORKFLOW_PATH);
|
||||
if (!fs.existsSync(p)) throw new Error(`ComfyUI workflow not found at ${p} (set COMFYUI_WORKFLOW).`);
|
||||
return JSON.parse(fs.readFileSync(p, "utf-8"));
|
||||
}
|
||||
|
||||
async function sleep(ms: number) {
|
||||
return new Promise((r) => setTimeout(r, ms));
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate coloring-book line art for the given prompt. Returns the PNG bytes.
|
||||
* Throws on timeout / ComfyUI errors (caller marks the lesson 'failed').
|
||||
*/
|
||||
export async function generateLineArt(prompt: string): Promise<Buffer> {
|
||||
if (!isCreateEnabled()) throw new Error("Create is disabled (COMFYUI_URL not set).");
|
||||
|
||||
const workflow = loadWorkflow();
|
||||
// Inject our prompt + a fresh seed into the configured nodes.
|
||||
if (workflow[PROMPT_NODE]?.inputs) workflow[PROMPT_NODE].inputs.text = prompt;
|
||||
if (workflow[SEED_NODE]?.inputs) workflow[SEED_NODE].inputs.seed = Math.floor(Math.random() * 1e15);
|
||||
|
||||
const clientId = `drawit-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
// 1) Queue the workflow.
|
||||
const queueRes = await fetch(`${URL_BASE}/prompt`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...authHeaders() },
|
||||
body: JSON.stringify({ prompt: workflow, client_id: clientId }),
|
||||
});
|
||||
if (!queueRes.ok) throw new Error(`ComfyUI /prompt failed: ${queueRes.status} ${await queueRes.text()}`);
|
||||
const queued = (await queueRes.json()) as { prompt_id?: string; error?: unknown };
|
||||
const promptId = queued.prompt_id;
|
||||
if (!promptId) throw new Error(`ComfyUI did not return a prompt_id: ${JSON.stringify(queued)}`);
|
||||
|
||||
// 2) Poll history until the outputs appear (or timeout).
|
||||
const deadline = Date.now() + TIMEOUT_MS;
|
||||
let image: { filename: string; subfolder: string; type: string } | undefined;
|
||||
while (Date.now() < deadline) {
|
||||
await sleep(1500);
|
||||
const hRes = await fetch(`${URL_BASE}/history/${promptId}`, { headers: authHeaders() });
|
||||
if (!hRes.ok) continue;
|
||||
const hist = (await hRes.json()) as Record<string, { outputs?: Record<string, { images?: { filename: string; subfolder: string; type: string }[] }> }>;
|
||||
const entry = hist[promptId];
|
||||
if (!entry?.outputs) continue;
|
||||
for (const node of Object.values(entry.outputs)) {
|
||||
const img = node.images?.[0];
|
||||
if (img) { image = img; break; }
|
||||
}
|
||||
if (image) break;
|
||||
}
|
||||
if (!image) throw new Error("ComfyUI timed out before producing an image.");
|
||||
|
||||
// 3) Fetch the image bytes.
|
||||
const viewUrl = `${URL_BASE}/view?filename=${encodeURIComponent(image.filename)}&subfolder=${encodeURIComponent(image.subfolder || "")}&type=${encodeURIComponent(image.type || "output")}`;
|
||||
const vRes = await fetch(viewUrl, { headers: authHeaders() });
|
||||
if (!vRes.ok) throw new Error(`ComfyUI /view failed: ${vRes.status}`);
|
||||
return Buffer.from(await vRes.arrayBuffer());
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
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.
|
||||
*/
|
||||
|
||||
export type CreatedStatus = "pending" | "generating" | "needs_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: things awaiting review. */
|
||||
export function listNeedsReview(): CreatedLessonRow[] {
|
||||
return getDb()
|
||||
.prepare("SELECT * FROM created_lessons WHERE status = 'needs_review' ORDER BY created_at")
|
||||
.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 setReady(id: number, templateSvg: string, image: string) {
|
||||
getDb()
|
||||
.prepare("UPDATE created_lessons SET status = 'ready', template_svg = ?, image = ?, error = NULL, updated_at = datetime('now') WHERE id = ?")
|
||||
.run(templateSvg, image, 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.prompt);
|
||||
const templateSvg = await pngToTemplateSvg(png);
|
||||
setReady(id, templateSvg, `data:image/png;base64,${png.toString("base64")}`);
|
||||
} 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]);
|
||||
if (!row || row.status !== "ready") return null;
|
||||
const lessons = buildCreatedLessons(row);
|
||||
const index = lessons.findIndex((l) => l.slug === slug);
|
||||
if (index < 0) return null;
|
||||
return { lessons, index, row };
|
||||
}
|
||||
@@ -133,6 +133,27 @@ function migrate(db: Database.Database) {
|
||||
CREATE INDEX IF NOT EXISTS idx_drawings_user ON drawings(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_drawings_progress ON drawings(user_id, level, sublevel, status);
|
||||
CREATE INDEX IF NOT EXISTS idx_completions_user ON completions(user_id);
|
||||
|
||||
-- AI "Create" feature: a Creator types a subject; ComfyUI makes line art; we vectorize it into a lesson.
|
||||
CREATE TABLE IF NOT EXISTS created_lessons (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, -- null once promoted to global
|
||||
level TEXT NOT NULL, -- 'early-beginner' | 'beginner'
|
||||
subject TEXT NOT NULL, -- sanitized, e.g. 'Zebra'
|
||||
slug TEXT NOT NULL UNIQUE, -- e.g. 'created-42-zebra'
|
||||
status TEXT NOT NULL DEFAULT 'pending', -- pending|generating|needs_review|ready|failed|blocked
|
||||
template_svg TEXT, -- vectorized line art (potrace), fitted to 0 0 400 300
|
||||
image TEXT, -- cleaned raster line art (data URL) — optional/debug
|
||||
prompt TEXT NOT NULL, -- full prompt sent to ComfyUI (audit)
|
||||
moderation TEXT, -- 'allowlist' | 'review' | 'blocked:<reason>' | 'approved'
|
||||
emoji TEXT NOT NULL DEFAULT '🎨',
|
||||
error TEXT, -- failure reason if status='failed'
|
||||
promoted INTEGER NOT NULL DEFAULT 0, -- 1 = part of the shared/global curriculum
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_created_user ON created_lessons(user_id, level);
|
||||
CREATE INDEX IF NOT EXISTS idx_created_status ON created_lessons(status);
|
||||
`);
|
||||
|
||||
// Profile fields (added later — safe for existing databases)
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Subject moderation for the "Create" feature. This is a children's app and the subject text feeds an
|
||||
* image generator, so we are deliberately conservative: sanitize → allowlist (auto-approve) →
|
||||
* blocklist (reject) → otherwise hold for admin review.
|
||||
*/
|
||||
|
||||
export type ModerationDecision =
|
||||
| { decision: "allowlist"; subject: string }
|
||||
| { decision: "review"; subject: string }
|
||||
| { decision: "blocked"; subject: string; reason: string };
|
||||
|
||||
/** Clean raw input to a single short, plain phrase. Returns null if nothing usable remains. */
|
||||
export function sanitizeSubject(raw: string): string | null {
|
||||
if (typeof raw !== "string") return null;
|
||||
const cleaned = raw
|
||||
.replace(/[\r\n\t]+/g, " ")
|
||||
.replace(/[^a-zA-Z\- ]+/g, "") // letters, spaces, hyphens only — strips digits/punctuation/prompt tokens
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
.slice(0, 30);
|
||||
if (cleaned.length < 2) return null;
|
||||
// Title-case for display ("zebra" -> "Zebra").
|
||||
return cleaned.replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
}
|
||||
|
||||
// Curated safe subjects (lowercase). Auto-approved. Extend freely.
|
||||
const ALLOWLIST = new Set<string>([
|
||||
// animals
|
||||
"cat", "dog", "puppy", "kitten", "rabbit", "bunny", "fox", "bear", "panda", "lion", "tiger",
|
||||
"elephant", "giraffe", "zebra", "monkey", "deer", "horse", "pony", "cow", "pig", "sheep", "goat",
|
||||
"duck", "chicken", "owl", "bird", "penguin", "fish", "shark", "whale", "dolphin", "octopus", "crab",
|
||||
"turtle", "frog", "snail", "bee", "butterfly", "ladybug", "snake", "dinosaur", "dragon", "unicorn",
|
||||
"hedgehog", "squirrel", "mouse", "koala", "kangaroo", "crocodile", "leopard", "snail", "starfish",
|
||||
// nature
|
||||
"tree", "flower", "rose", "sunflower", "leaf", "mushroom", "cactus", "cloud", "sun", "moon", "star",
|
||||
"rainbow", "mountain", "apple", "banana", "strawberry", "cherry", "grapes", "carrot", "pumpkin",
|
||||
// objects / vehicles / fun
|
||||
"house", "castle", "boat", "sailboat", "car", "truck", "train", "bus", "plane", "rocket", "robot",
|
||||
"balloon", "kite", "umbrella", "cup", "mug", "hat", "shirt", "dress", "sock", "shoe", "bowtie",
|
||||
"bow tie", "gift", "present", "ball", "football", "basketball", "trophy", "crown", "heart", "star",
|
||||
"square", "triangle", "circle", "cake", "cupcake", "cookie", "ice cream", "lollipop", "snowman",
|
||||
"teddy bear", "ghost", "pumpkin", "guitar", "drum", "book", "pencil", "key", "clock", "lamp",
|
||||
]);
|
||||
|
||||
// Obvious unsafe terms (substring match, after sanitize). Backstop, not exhaustive.
|
||||
const BLOCKLIST: string[] = [
|
||||
"gun", "knife", "weapon", "sword", "blood", "gore", "kill", "dead", "death", "corpse", "drug",
|
||||
"beer", "wine", "alcohol", "cigarette", "smoke", "nude", "naked", "sex", "sexy", "porn", "kiss",
|
||||
"hate", "nazi", "racist", "suicide", "hang", "noose", "bomb", "grenade", "war", "shoot", "knive",
|
||||
];
|
||||
|
||||
export function moderateSubject(raw: string): ModerationDecision {
|
||||
const subject = sanitizeSubject(raw);
|
||||
if (!subject) return { decision: "blocked", subject: "", reason: "empty" };
|
||||
const lower = subject.toLowerCase();
|
||||
|
||||
const bad = BLOCKLIST.find((w) => lower.includes(w));
|
||||
if (bad) return { decision: "blocked", subject, reason: `term "${bad}"` };
|
||||
|
||||
if (ALLOWLIST.has(lower)) return { decision: "allowlist", subject };
|
||||
|
||||
// Not obviously safe and not obviously bad → let an admin decide.
|
||||
return { decision: "review", subject };
|
||||
}
|
||||
@@ -59,3 +59,14 @@ export async function requireAdmin(): Promise<User | null> {
|
||||
if (!user || user.role !== "admin") return null;
|
||||
return user;
|
||||
}
|
||||
|
||||
/** True if the user may use the "Create" feature (Creator or Admin). */
|
||||
export function canCreate(user: { role: string } | null | undefined): boolean {
|
||||
return !!user && (user.role === "creator" || user.role === "admin");
|
||||
}
|
||||
|
||||
/** Guard for Create endpoints — returns the user iff they can create, else null. */
|
||||
export async function requireCreator(): Promise<User | null> {
|
||||
const user = await getCurrentUser();
|
||||
return canCreate(user) ? user : null;
|
||||
}
|
||||
|
||||
+2
-1
@@ -1,4 +1,5 @@
|
||||
export type Role = "admin" | "learner";
|
||||
// creator = learner permissions + the ability to create AI-generated lessons.
|
||||
export type Role = "admin" | "learner" | "creator";
|
||||
export type Status = "pending" | "active" | "suspended";
|
||||
|
||||
export interface User {
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
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.
|
||||
*
|
||||
* - `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.
|
||||
*/
|
||||
|
||||
const STROKE = "#2b2440";
|
||||
const VW = 400;
|
||||
const VH = 300;
|
||||
|
||||
function traceToSvg(png: Buffer): 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)),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function round(n: number): number {
|
||||
return Math.round(n * 100) / 100;
|
||||
}
|
||||
|
||||
export async function pngToTemplateSvg(png: Buffer): Promise<string> {
|
||||
const svg = await traceToSvg(png);
|
||||
|
||||
// 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;
|
||||
|
||||
// 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(" ");
|
||||
|
||||
// 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);
|
||||
const transform = `translate(${tx} ${ty}) scale(${round(s)})`;
|
||||
|
||||
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;
|
||||
}
|
||||
Vendored
+8
@@ -0,0 +1,8 @@
|
||||
// Minimal types for the `potrace` package (no official @types).
|
||||
declare module "potrace" {
|
||||
type TraceCallback = (err: Error | null, svg: string) => void;
|
||||
export function trace(file: Buffer | string, options: Record<string, unknown>, cb: TraceCallback): void;
|
||||
export function trace(file: Buffer | string, cb: TraceCallback): void;
|
||||
export function posterize(file: Buffer | string, options: Record<string, unknown>, cb: TraceCallback): void;
|
||||
export function posterize(file: Buffer | string, cb: TraceCallback): void;
|
||||
}
|
||||
Reference in New Issue
Block a user