diff --git a/src/app/learn/[level]/[slug]/page.tsx b/src/app/learn/[level]/[slug]/page.tsx index 5fe28a9..2579a47 100644 --- a/src/app/learn/[level]/[slug]/page.tsx +++ b/src/app/learn/[level]/[slug]/page.tsx @@ -2,13 +2,15 @@ import { notFound, redirect } from "next/navigation"; import SiteNav from "@/components/SiteNav"; import LessonRunner from "@/components/LessonRunner"; import ShadingRunner from "@/components/ShadingRunner"; -import { getLessonBySlug, getLevel, cumulativeSvg } from "@/lib/curriculum"; +import TraceRunner from "@/components/TraceRunner"; +import ColoringRunner from "@/components/ColoringRunner"; +import { getLessonBySlug, getLevel, getSubjects, cumulativeSvg, type Lesson } from "@/lib/curriculum"; import { getCurrentUser } from "@/lib/session"; import { getCompletedSteps, hasBadge } from "@/lib/progress"; export const dynamic = "force-dynamic"; -const FREE_SLUG = "fish"; // the only lesson playable without an account +const FREE_SLUG = "fish-outline"; // the only lesson playable without an account export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }) { const { slug } = await params; @@ -16,67 +18,75 @@ export async function generateMetadata({ params }: { params: Promise<{ slug: str return { title: lesson ? `${lesson.title} Β· DrawIt` : "Lesson Β· DrawIt" }; } -export default async function LessonPage({ - params, -}: { - params: Promise<{ level: string; slug: string }>; -}) { +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(); const user = await getCurrentUser(); - // Server-side lockdown: guests may only open the free lesson; everything else needs an account. if (!user && lesson.slug !== FREE_SLUG) redirect("/signup"); - const completed = user ? getCompletedSteps(user.id, lesson.level, lesson.sublevel) : []; - const alreadyEarned = user ? hasBadge(user.id, lesson.badgeKey) : false; const levelName = getLevel(lesson.level)?.name ?? ""; + const stepCount = (l: Lesson) => l.steps.length || 1; + const isComplete = (l: Lesson | undefined) => + !!l && !!user && getCompletedSteps(user.id, l.level, l.sublevel).length >= stepCount(l); + + // Phase gating: Detail needs Outline done; Extra needs Detail done. + if (user && lesson.phase) { + const subj = getSubjects(level).find((s) => s.key === lesson.subjectKey); + const find = (ph: string) => subj?.lessons.find((l) => l.phase === ph); + if (lesson.phase === "detail") { + const o = find("outline"); + if (o && !isComplete(o)) redirect(`/learn/${level}/${o.slug}`); + } else if (lesson.phase === "extra") { + const d = find("detail"); + if (d && !isComplete(d) && !(d.badgeKey && hasBadge(user.id, d.badgeKey))) redirect(`/learn/${level}/${d.slug}`); + } + } + + const completed = user ? getCompletedSteps(user.id, lesson.level, lesson.sublevel) : []; + const alreadyEarned = !!user && !!lesson.badgeKey && hasBadge(user.id, lesson.badgeKey); + const common = { level: lesson.level, levelName, sublevel: lesson.sublevel, title: lesson.title, emoji: lesson.emoji, intro: lesson.intro }; + + // Coloring-book trace lessons (outline / detail) + if (lesson.phase === "outline" || lesson.phase === "detail") { + const steps = lesson.steps.map((s) => ({ n: s.n, title: s.title, instruction: s.instruction, tip: s.tip ?? "", lines: s.lines ?? [] })); + const meta = { ...common, badgeKey: lesson.badgeKey, badgeName: lesson.badgeName, baseSvg: lesson.baseSvg ?? "", phaseLabel: lesson.phase === "outline" ? "Outline" : "Details" }; + return ( + <> + +
+ +
+ + ); + } + + // Coloring studio (extra) + if (lesson.phase === "extra") { + const meta = { ...common, baseSvg: lesson.baseSvg ?? "" }; + return ( + <> + +
+ +
+ + ); + } + + // Legacy colored / shading lessons const isShade = lesson.mode === "shade"; - - const steps = lesson.steps.map((s) => ({ - n: s.n, - title: s.title, - instruction: s.instruction, - tip: s.tip ?? "", - kind: s.kind ?? "", - // Draw lessons stack their fragments; shade lessons use each step's full guide. - guideSvg: isShade ? s.svg : cumulativeSvg(lesson, s.n), - })); - - const meta = { - level: lesson.level, - levelName, - sublevel: lesson.sublevel, - title: lesson.title, - emoji: lesson.emoji, - intro: lesson.intro, - badgeKey: lesson.badgeKey, - badgeName: lesson.badgeName, - }; - + const steps = lesson.steps.map((s) => ({ n: s.n, title: s.title, instruction: s.instruction, tip: s.tip ?? "", kind: s.kind ?? "", guideSvg: isShade ? (s.svg ?? "") : cumulativeSvg(lesson, s.n) })); + const meta = { ...common, badgeKey: lesson.badgeKey, badgeName: lesson.badgeName }; return ( <>
{isShade ? ( - + ) : ( - + )}
diff --git a/src/app/learn/page.tsx b/src/app/learn/page.tsx index 867fa86..cd8b86b 100644 --- a/src/app/learn/page.tsx +++ b/src/app/learn/page.tsx @@ -1,23 +1,24 @@ import Link from "next/link"; import SiteNav from "@/components/SiteNav"; -import { LEVELS, LESSONS } from "@/lib/curriculum"; +import { LEVELS, getSubjects, type Lesson } from "@/lib/curriculum"; import { getCurrentUser } from "@/lib/session"; import { getCompletedSteps, hasBadge } from "@/lib/progress"; export const metadata = { title: "Learn Β· DrawIt" }; export const dynamic = "force-dynamic"; +const PHASE_LABEL: Record = { outline: "Outline", detail: "Details", extra: "Color it!" }; + export default async function LearnPage() { const user = await getCurrentUser(); - // Per-lesson progress for the signed-in user (guests see everything as "not started"). - const progress = new Map(); - for (const lesson of LESSONS) { - const total = lesson.steps.length; - const completed = user ? getCompletedSteps(user.id, lesson.level, lesson.sublevel).length : 0; - const done = user ? hasBadge(user.id, lesson.badgeKey) || completed >= total : false; - progress.set(lesson.slug, { done, completed: Math.min(completed, total), total }); - } + const stepCount = (l: Lesson) => l.steps.length || 1; + const doneCount = (l: Lesson) => (user ? getCompletedSteps(user.id, l.level, l.sublevel).length : 0); + const isDone = (l: Lesson) => { + if (!user) return false; + if (l.badgeKey && hasBadge(user.id, l.badgeKey)) return true; + return l.phase === "extra" ? false : doneCount(l) >= stepCount(l); + }; return ( <> @@ -25,140 +26,75 @@ export default async function LearnPage() {

Learn to draw 🎨

- Pick your level and start a lesson. New lessons are added over time. - {!user && " Log in to track your progress and earn badges."} + Each picture has an Outline lesson and a Details lesson (earn the badge), plus an optional Color it! lesson. + {!user && " Log in to track progress and earn badges."}

{LEVELS.map((lvl, idx) => { - const lessons = LESSONS.filter((l) => l.level === lvl.key); + const subjects = getSubjects(lvl.key); return (
-

- {lvl.emoji} {idx + 1}. {lvl.name} -

- {lessons.length === 0 && Coming soon} +

{lvl.emoji} {idx + 1}. {lvl.name}

+ {subjects.length === 0 && Coming soon}

{lvl.blurb}

- {lessons.length > 0 && ( -
- {lessons.map((lesson) => { - const p = progress.get(lesson.slug)!; - const inProgress = !p.done && p.completed > 0; - const currentStep = Math.min(p.completed + 1, p.total); - const pct = p.done ? 100 : Math.round((p.completed / p.total) * 100); + +
+ {subjects.map((subj) => { + const phased = subj.lessons.length > 1 || !!subj.lessons[0].phase; + if (!phased) { + // legacy single lesson + const l = subj.lessons[0]; + const done = isDone(l); + const inProg = !done && doneCount(l) > 0; return ( - - {/* Status indicator (top-right) */} -
- {p.done ? ( - - βœ“ - - ) : inProgress ? ( - - Step {currentStep}/{p.total} - - ) : ( - - )} + +
+ {subj.emoji} {l.title} + {done ? βœ“ Done : inProg ? Step {Math.min(doneCount(l) + 1, stepCount(l))}/{stepCount(l)} : {stepCount(l)} steps}
- -
{lesson.emoji}
- - Level {lesson.sublevel}: {lesson.title} - -

- {p.done - ? "Completed πŸŽ‰" - : inProgress - ? `${p.completed} of ${p.total} steps done` - : `${p.total} simple steps`} -

- - {/* Progress bar */} - {(inProgress || p.done) && ( -
-
-
- )} ); - })} -
- )} + } + // phased subject (Outline / Details / Color it) + const byPhase = (p: string) => subj.lessons.find((l) => l.phase === p); + const outline = byPhase("outline"); + const detail = byPhase("detail"); + const extra = byPhase("extra"); + const outlineDone = outline ? isDone(outline) : false; + const detailDone = detail ? isDone(detail) : false; + const phaseCard = (l: Lesson | undefined, locked: boolean, optional = false) => { + if (!l) return null; + const done = isDone(l); + const inProg = !done && !locked && doneCount(l) > 0; + const inner = ( +
+ {PHASE_LABEL[l.phase ?? ""]}{optional ? " (optional)" : ""} + + {locked ? "πŸ”’ Locked" : done ? "βœ“ Done" : inProg ? `Step ${Math.min(doneCount(l) + 1, stepCount(l))}/${stepCount(l)}` : l.phase === "extra" ? "Add color" : `${stepCount(l)} steps`} + +
+ ); + return locked ? ( +
{inner}
+ ) : ( + {inner} + ); + }; + return ( +
+ {subj.emoji} {subj.name} +
+ {phaseCard(outline, false)} + {phaseCard(detail, !outlineDone)} + {phaseCard(extra, !detailDone, true)} +
+
+ ); + })} +
); })} @@ -167,3 +103,6 @@ export default async function LearnPage() { ); } + +const subCard = (done: boolean): React.CSSProperties => ({ textDecoration: "none", color: "inherit", border: done ? "2px solid var(--leaf)" : "2px solid var(--line)", boxShadow: "none", padding: 14 }); +const phaseBox: React.CSSProperties = { display: "block", border: "2px solid var(--line)", borderRadius: 12, padding: "12px 14px", background: "#fff" }; diff --git a/src/app/page.tsx b/src/app/page.tsx index 2de8a2c..fa1ac7d 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,193 +1,121 @@ import Link from "next/link"; import SiteNav from "@/components/SiteNav"; import FishSlideshow from "@/components/FishSlideshow"; -import { FISH_LESSON, LEVELS, LESSONS, cumulativeSvg } from "@/lib/curriculum"; +import { LEVELS, getSubjects, FISH_OUTLINE_LESSON, FISH_DETAIL_LESSON } from "@/lib/curriculum"; -const FREE_SLUG = "fish"; // the one lesson playable without an account +const FREE_SLUG = "fish-outline"; export default function Home() { - const slides = FISH_LESSON.steps.map((s) => ({ - n: s.n, - title: s.title, - instruction: s.instruction, - cumulativeSvg: cumulativeSvg(FISH_LESSON, s.n), - })); + // Build a coloring-book "video" preview from the fish outline + detail lines + const fishSteps = [...FISH_OUTLINE_LESSON.steps, ...FISH_DETAIL_LESSON.steps]; + let cum = ""; + const slides = fishSteps.map((s, i) => { + cum += (s.lines || []).join("\n") + "\n"; + return { n: i + 1, title: s.title, instruction: s.instruction, cumulativeSvg: cum }; + }); return ( <>
- {/* Hero */}
-
- πŸ†“ Free & open source Β· πŸ”’ No data collected, ever -
-

Learn to draw, one shape at a time

-

- DrawIt teaches kids to draw with simple, friendly steps β€” built for iPads and tablets. - Level 1 is free to try right now β€” create a free account to unlock every - lesson, save progress, and earn badges. +

πŸ†“ Free & open source Β· πŸ”’ No data collected, ever
+

Learn to draw, one line at a time

+

+ DrawIt teaches kids to draw with simple, friendly steps β€” built for iPads and tablets. Each + picture has an Outline lesson, a Details lesson, and a fun + Color it! lesson. The first lesson is free β€” create an account to unlock the rest.

- - 🐟 Try Level 1 free - - - Create a free account - + 🐟 Try the first lesson free + Create a free account
- {/* Slideshow of Early Beginner Level 1 */}
-

Peek at Level 1: Draw a Fish 🐟

+

Watch it drawn, line by line 🐟

- This is the one lesson you can try for free. Watch it come together, step by step, from a - few simple shapes. + Lessons draw each line slowly so kids can follow along, like a coloring book that builds itself.

- {/* Curriculum preview: Level 1 free, everything else locked */}

Start free β€” unlock everything with an account

- Level 1 (Draw a Fish) is free to try. A free account unlocks all the other lessons and - levels, saves your progress, and lets you earn badges and printable awards. + The first lesson (Fish β†’ Outline) is free to try. A free account unlocks the rest, saves your + progress, and lets you earn badges and keep your drawings.

-
{LEVELS.map((lvl, idx) => { - const lessons = LESSONS.filter((l) => l.level === lvl.key); - const isFirstLevel = lvl.key === "early-beginner"; + const subjects = getSubjects(lvl.key); + const isFirst = lvl.key === "early-beginner"; return (
-

- {lvl.emoji} {idx + 1}. {lvl.name} -

- {isFirstLevel ? ( - Level 1 free - ) : ( - πŸ”’ Account needed - )} +

{lvl.emoji} {idx + 1}. {lvl.name}

+ {!isFirst && πŸ”’ Account needed}

{lvl.blurb}

- - {isFirstLevel && lessons.length > 0 ? ( + {isFirst && subjects.length > 0 && (
- {lessons.map((lesson) => { - const free = lesson.slug === FREE_SLUG; - return free ? ( - - Free -
{lesson.emoji}
- - Level {lesson.sublevel}: {lesson.title} - -

- {lesson.steps.length} simple steps Β· try it now -

- - ) : ( - - πŸ”’ -
{lesson.emoji}
- - Level {lesson.sublevel}: {lesson.title} - -

- Create an account to unlock + {subjects.map((subj) => { + const first = subj.lessons[0]; + const free = first.slug === FREE_SLUG; + return ( + + {free ? "Free" : "πŸ”’"} +

{subj.emoji}
+ {subj.name} +

+ {free ? "Outline Β· free to try" : "Create an account to unlock"}

); })}
- ) : ( - - πŸ”’ + )} + {!isFirst && ( + + πŸ”’ Unlock {lvl.name} -

- More lessons inside β€” create a free account to explore. -

+

Create a free account to explore.

)}
); })}
-
- Create a free account to unlock everything + Create a free account
- {/* How it works */}

How DrawIt works

-
-
-

πŸ‘£ Step by step

-

Each lesson breaks a picture into tiny, doable steps using only simple shapes.

-
-
-

πŸ… Badges & awards

-

Finish a lesson to earn a shareable badge and a printable award certificate (PDF).

-
-
-

πŸ”’ Private by design

-

No analytics, no trackers, no data sold or shared. Just drawing.

-
+
+

✏️ Outline β†’ Details

Trace the coloring-book outline, then add the details that bring it to life.

+

🎨 Color it in

Finish with the coloring studio β€” color, shade, and make it your own.

+

πŸ”’ Private by design

No analytics, no trackers, no data sold or shared. Just drawing.

- -
- DrawIt · Free & open source · No data collected. Made for young artists. 🎨 -
+
DrawIt · Free & open source · No data collected. Made for young artists. 🎨
); } -function lessonCard(locked: boolean): React.CSSProperties { - return { - position: "relative", - textDecoration: "none", - color: "inherit", - background: "var(--surface)", - borderRadius: "var(--radius)", - border: "2px solid var(--line)", - padding: 18, - opacity: locked ? 0.6 : 1, - filter: locked ? "grayscale(0.7)" : "none", - }; +function card(locked: boolean): React.CSSProperties { + return { position: "relative", textDecoration: "none", color: "inherit", background: "var(--surface)", borderRadius: "var(--radius)", border: "2px solid var(--line)", padding: 18, opacity: locked ? 0.6 : 1, filter: locked ? "grayscale(0.7)" : "none" }; } - -function cornerTag(kind: "free" | "lock"): React.CSSProperties { - return { - position: "absolute", - top: 12, - right: 12, - fontWeight: 800, - fontSize: "0.8rem", - padding: kind === "free" ? "3px 10px" : "3px 8px", - borderRadius: 999, - background: kind === "free" ? "#e8f7ee" : "#f0ebf7", - color: kind === "free" ? "#1f7a45" : "var(--ink-soft)", - }; +function tag(free: boolean): React.CSSProperties { + return { position: "absolute", top: 12, right: 12, fontWeight: 800, fontSize: "0.8rem", padding: "3px 10px", borderRadius: 999, background: free ? "#e8f7ee" : "#f0ebf7", color: free ? "#1f7a45" : "var(--ink-soft)" }; } diff --git a/src/components/ColoringRunner.tsx b/src/components/ColoringRunner.tsx new file mode 100644 index 0000000..ccbd405 --- /dev/null +++ b/src/components/ColoringRunner.tsx @@ -0,0 +1,180 @@ +"use client"; +import { useCallback, useEffect, useRef, useState } from "react"; +import Link from "next/link"; +import { loadProgressDrawing, saveProgressDrawing, saveFinalDrawing, exportCanvasToDataURL, fileToDataURL } from "./drawingPersistence"; + +interface Meta { + level: string; levelName: string; sublevel: number; title: string; emoji: string; intro: string; baseSvg: string; +} +const COLORS = ["#2b2440", "#4f7cff", "#34c6c6", "#58c777", "#ffc843", "#ff7eb6", "#e85a5a", "#a06bff", "#ffffff", "#c4c8cc"]; +const SIZES = [10, 22, 40]; +interface Point { x: number; y: number } +interface Stroke { color: string; size: number; alpha: number; erase: boolean; points: Point[] } + +export default function ColoringRunner({ meta, loggedIn, username, alreadyEarned }: { + meta: Meta; loggedIn: boolean; username: string; alreadyEarned: boolean; +}) { + const [done, setDone] = useState(false); + const [finalImage, setFinalImage] = useState(null); + const [uploadMsg, setUploadMsg] = useState(""); + const uploadRef = useRef(null); + + const canvasRef = useRef(null); + const stageRef = useRef(null); + const strokesRef = useRef([]); + const drawingRef = useRef(null); + const baseImgRef = useRef(null); + const saveTimer = useRef | undefined>(undefined); + const [color, setColor] = useState(COLORS[1]); + const [size, setSize] = useState(SIZES[1]); + const [opacity, setOpacity] = useState(1); + const [erasing, setErasing] = useState(false); + const [, force] = useState(0); + + const redraw = useCallback(() => { + const c = canvasRef.current; if (!c) return; + const ctx = c.getContext("2d"); if (!ctx) return; + ctx.clearRect(0, 0, c.width, c.height); + ctx.globalCompositeOperation = "source-over"; ctx.globalAlpha = 1; + if (baseImgRef.current) ctx.drawImage(baseImgRef.current, 0, 0, c.width, c.height); + ctx.lineCap = "round"; ctx.lineJoin = "round"; + const dpr = Math.min(window.devicePixelRatio || 1, 2); + for (const s of strokesRef.current) { + if (!s.points.length) continue; + ctx.globalCompositeOperation = s.erase ? "destination-out" : "source-over"; + ctx.globalAlpha = s.erase ? 1 : s.alpha; + ctx.strokeStyle = s.color; ctx.lineWidth = s.size * dpr; + ctx.beginPath(); ctx.moveTo(s.points[0].x * c.width, s.points[0].y * c.height); + if (s.points.length === 1) ctx.lineTo(s.points[0].x * c.width + 0.1, s.points[0].y * c.height + 0.1); + else for (let i = 1; i < s.points.length; i++) ctx.lineTo(s.points[i].x * c.width, s.points[i].y * c.height); + ctx.stroke(); + } + ctx.globalCompositeOperation = "source-over"; ctx.globalAlpha = 1; + }, []); + const resize = useCallback(() => { + const c = canvasRef.current, st = stageRef.current; if (!c || !st) return; + const r = st.getBoundingClientRect(); const dpr = Math.min(window.devicePixelRatio || 1, 2); + c.width = Math.round(r.width * dpr); c.height = Math.round(r.height * dpr); redraw(); + }, [redraw]); + useEffect(() => { resize(); window.addEventListener("resize", resize); return () => window.removeEventListener("resize", resize); }, [resize]); + + useEffect(() => { + if (!loggedIn) return; let alive = true; + loadProgressDrawing(meta.level, meta.sublevel).then((img) => { if (!img || !alive) return; const im = new Image(); im.onload = () => { baseImgRef.current = im; redraw(); }; im.src = img; }); + return () => { alive = false; }; + }, [loggedIn, meta.level, meta.sublevel, redraw]); + + function hasContent() { return strokesRef.current.length > 0 || !!baseImgRef.current; } + function flushSave() { if (!loggedIn) return; const c = canvasRef.current; if (!c || !hasContent()) return; saveProgressDrawing(meta.level, meta.sublevel, exportCanvasToDataURL(c)); } + function scheduleSave() { if (!loggedIn) return; clearTimeout(saveTimer.current); saveTimer.current = setTimeout(flushSave, 1200); } + + function pe(e: React.PointerEvent): Point { const r = canvasRef.current!.getBoundingClientRect(); return { x: (e.clientX - r.left) / r.width, y: (e.clientY - r.top) / r.height }; } + function onDown(e: React.PointerEvent) { + (e.target as Element).setPointerCapture?.(e.pointerId); + const pressure = e.pressure && e.pressure > 0 ? e.pressure : 1; + const s: Stroke = { color, size, alpha: Math.min(1, opacity * (0.6 + pressure * 0.4)), erase: erasing, points: [pe(e)] }; + drawingRef.current = s; strokesRef.current.push(s); + } + function onMove(e: React.PointerEvent) { const s = drawingRef.current; if (!s) return; s.points.push(pe(e)); redraw(); } + function onUp() { drawingRef.current = null; force((n) => n + 1); scheduleSave(); } + function clearCanvas() { strokesRef.current = []; baseImgRef.current = null; redraw(); force((n) => n + 1); if (loggedIn) saveProgressDrawing(meta.level, meta.sublevel, exportCanvasToDataURL(canvasRef.current!)); } + function undo() { strokesRef.current.pop(); redraw(); force((n) => n + 1); scheduleSave(); } + + async function finish() { + const c = canvasRef.current; + if (c && hasContent()) { const data = exportCanvasToDataURL(c); setFinalImage(data); if (loggedIn) await saveFinalDrawing(meta.level, meta.sublevel, "canvas", data); } + setDone(true); + } + async function onUpload(e: React.ChangeEvent) { + const file = e.target.files?.[0]; if (!file) return; + if (!loggedIn) { setUploadMsg("Log in to save to your gallery."); return; } + setUploadMsg("Uploading…"); + try { const data = await fileToDataURL(file); const ok = await saveFinalDrawing(meta.level, meta.sublevel, "upload", data); setUploadMsg(ok ? "Saved to your gallery! πŸ–ΌοΈ" : "Couldn't save that file."); } + catch { setUploadMsg("Couldn't read that image."); } + finally { if (uploadRef.current) uploadRef.current.value = ""; } + } + + if (done) { + return ( +
+
+

🎨 Beautiful!

+

You colored your {meta.title.replace(" Β· Color it!", "")}.

+ {finalImage && ( + + )} +
+ + + {uploadMsg &&

{uploadMsg}

} +
+
+ {loggedIn && πŸ–ΌοΈ My drawings} + + Back to lessons +
+
+
+ ); + } + + return ( +
+
+
+
{meta.levelName} Β· Color it!
+

{meta.emoji} {meta.title}

+
+ Report a problem +
+

{meta.intro}

+
More tools (pencil, marker, fill bucket, layers, custom colors) are coming in the next update β€” for now, color away!
+ +
+
+
+ + {/* coloring-book lines on top so colors fill underneath */} + +
+ +
+ {COLORS.map((c) => ( + +
+
+ {SIZES.map((s) => ( + + ))} + + + + +
+ {loggedIn &&

Lower the opacity and go over an area a few times to shade and layer. Saved automatically.

} +
+ +
+

Color your picture however you like β€” then save it to your gallery.

+ + {!loggedIn &&

Log in to keep your drawing.

} +
+
+
+ ); +} + +const toolBtn: React.CSSProperties = { minHeight: 34, padding: "6px 12px", fontSize: "0.85rem", boxShadow: "none" }; diff --git a/src/components/LineDraw.tsx b/src/components/LineDraw.tsx new file mode 100644 index 0000000..7cfb486 --- /dev/null +++ b/src/components/LineDraw.tsx @@ -0,0 +1,91 @@ +"use client"; +import { useEffect, useRef } from "react"; + +/** + * Draws a set of SVG line elements one at a time (stroke reveal), slowly, for the "video". + * Re-runs whenever `playKey` changes. Filled bits (e.g. a pupil dot) just fade in on their turn. + */ +export default function LineDraw({ + lines, + playKey, + onDone, +}: { + lines: string[]; + playKey: number; + onDone?: () => void; +}) { + const ref = useRef(null); + + useEffect(() => { + const g = ref.current; + if (!g) return; + const els = Array.from( + g.querySelectorAll("path,line,polyline,polygon,circle,ellipse") + ) as SVGGeometryElement[]; + let cancelled = false; + const timers: ReturnType[] = []; + const len = (el: SVGGeometryElement) => { + try { + return el.getTotalLength(); + } catch { + return 0; + } + }; + const isFilled = (el: SVGGeometryElement) => { + const f = el.getAttribute("fill"); + return !!f && f !== "none"; + }; + + // Reset: stroked lines hidden via dash offset; filled bits hidden via opacity. + els.forEach((el) => { + const s = el as unknown as { style: CSSStyleDeclaration }; + s.style.transition = "none"; + const L = len(el); + if (!isFilled(el) && L) { + s.style.strokeDasharray = String(L); + s.style.strokeDashoffset = String(L); + s.style.opacity = "1"; + } else { + s.style.opacity = "0"; + } + }); + + // Play sequentially + let acc = 0; + els.forEach((el) => { + const s = el as unknown as { style: CSSStyleDeclaration }; + const filled = isFilled(el); + const L = len(el); + const dur = filled ? 280 : Math.max(900, Math.min(2600, Math.round(L * 7))); + timers.push( + setTimeout(() => { + if (cancelled) return; + if (!filled && L) { + s.style.transition = `stroke-dashoffset ${dur}ms ease-in-out`; + s.style.strokeDashoffset = "0"; + } else { + s.style.transition = "opacity 280ms ease"; + s.style.opacity = "1"; + } + }, acc) + ); + acc += dur + 220; + }); + timers.push(setTimeout(() => { if (!cancelled) onDone?.(); }, acc + 60)); + + return () => { + cancelled = true; + timers.forEach(clearTimeout); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [playKey]); + + return ( + + + + ); +} diff --git a/src/components/TraceRunner.tsx b/src/components/TraceRunner.tsx new file mode 100644 index 0000000..e06339f --- /dev/null +++ b/src/components/TraceRunner.tsx @@ -0,0 +1,237 @@ +"use client"; +import { useCallback, useEffect, useRef, useState } from "react"; +import Link from "next/link"; +import { badgeSvgMarkup, badgeFullSvg } from "./BadgeArt"; +import LineDraw from "./LineDraw"; +import { loadProgressDrawing, saveProgressDrawing, saveFinalDrawing, exportCanvasToDataURL, fileToDataURL } from "./drawingPersistence"; + +interface StepData { n: number; title: string; instruction: string; tip: string; lines: string[] } +interface Meta { + level: string; levelName: string; sublevel: number; title: string; emoji: string; intro: string; + badgeKey: string; badgeName: string; baseSvg: string; phaseLabel: string; +} + +const COLORS = ["#2b2440", "#4f7cff", "#34c6c6", "#58c777", "#ffc843", "#ff7eb6", "#e85a5a", "#ffffff", "#c4c8cc"]; +const SIZES = [4, 8, 16]; +interface Point { x: number; y: number } +interface Stroke { color: string; size: number; points: Point[] } + +export default function TraceRunner({ meta, steps, loggedIn, username, completedSteps, alreadyEarned }: { + meta: Meta; steps: StepData[]; loggedIn: boolean; username: string; completedSteps: number[]; alreadyEarned: boolean; +}) { + const lastIndex = steps.length - 1; + const [idx, setIdx] = useState(Math.min(completedSteps.length, lastIndex)); + const [finished, setFinished] = useState(alreadyEarned); + const [earned, setEarned] = useState(alreadyEarned); + const [playKey, setPlayKey] = useState(0); + const [playing, setPlaying] = useState(true); + const [finalImage, setFinalImage] = useState(null); + const [uploadMsg, setUploadMsg] = useState(""); + const uploadRef = useRef(null); + const step = steps[idx]; + + // faint guide = base outline + every line up to & including the current step + const guideUpTo = steps.slice(0, idx + 1).flatMap((s) => s.lines).join("\n"); + + const canvasRef = useRef(null); + const stageRef = useRef(null); + const strokesRef = useRef([]); + const drawingRef = useRef(null); + const baseImgRef = useRef(null); + const saveTimer = useRef | undefined>(undefined); + const [color, setColor] = useState(COLORS[0]); + const [size, setSize] = useState(SIZES[1]); + const [, force] = useState(0); + + const redraw = useCallback(() => { + const c = canvasRef.current; if (!c) return; + const ctx = c.getContext("2d"); if (!ctx) return; + ctx.clearRect(0, 0, c.width, c.height); + if (baseImgRef.current) ctx.drawImage(baseImgRef.current, 0, 0, c.width, c.height); + ctx.lineCap = "round"; ctx.lineJoin = "round"; + const dpr = Math.min(window.devicePixelRatio || 1, 2); + for (const s of strokesRef.current) { + if (!s.points.length) continue; + ctx.strokeStyle = s.color; ctx.lineWidth = s.size * dpr; + ctx.beginPath(); ctx.moveTo(s.points[0].x * c.width, s.points[0].y * c.height); + if (s.points.length === 1) ctx.lineTo(s.points[0].x * c.width + 0.1, s.points[0].y * c.height + 0.1); + else for (let i = 1; i < s.points.length; i++) ctx.lineTo(s.points[i].x * c.width, s.points[i].y * c.height); + ctx.stroke(); + } + }, []); + const resize = useCallback(() => { + const c = canvasRef.current, st = stageRef.current; if (!c || !st) return; + const r = st.getBoundingClientRect(); const dpr = Math.min(window.devicePixelRatio || 1, 2); + c.width = Math.round(r.width * dpr); c.height = Math.round(r.height * dpr); redraw(); + }, [redraw]); + useEffect(() => { resize(); window.addEventListener("resize", resize); return () => window.removeEventListener("resize", resize); }, [resize]); + + useEffect(() => { + if (!loggedIn || alreadyEarned) return; let alive = true; + loadProgressDrawing(meta.level, meta.sublevel).then((img) => { if (!img || !alive) return; const im = new Image(); im.onload = () => { baseImgRef.current = im; redraw(); }; im.src = img; }); + return () => { alive = false; }; + }, [loggedIn, alreadyEarned, meta.level, meta.sublevel, redraw]); + + // Auto-play the line video whenever the step changes + useEffect(() => { setPlaying(true); setPlayKey((k) => k + 1); }, [idx]); + + function hasContent() { return strokesRef.current.length > 0 || !!baseImgRef.current; } + function flushSave() { if (!loggedIn) return; const c = canvasRef.current; if (!c || !hasContent()) return; saveProgressDrawing(meta.level, meta.sublevel, exportCanvasToDataURL(c)); } + function scheduleSave() { if (!loggedIn) return; clearTimeout(saveTimer.current); saveTimer.current = setTimeout(flushSave, 1200); } + + function pe(e: React.PointerEvent): Point { const r = canvasRef.current!.getBoundingClientRect(); return { x: (e.clientX - r.left) / r.width, y: (e.clientY - r.top) / r.height }; } + function onDown(e: React.PointerEvent) { if (finished) return; (e.target as Element).setPointerCapture?.(e.pointerId); const s: Stroke = { color, size, points: [pe(e)] }; drawingRef.current = s; strokesRef.current.push(s); } + function onMove(e: React.PointerEvent) { const s = drawingRef.current; if (!s) return; s.points.push(pe(e)); redraw(); } + function onUp() { drawingRef.current = null; force((n) => n + 1); scheduleSave(); } + function clearCanvas() { strokesRef.current = []; baseImgRef.current = null; redraw(); force((n) => n + 1); if (loggedIn) saveProgressDrawing(meta.level, meta.sublevel, exportCanvasToDataURL(canvasRef.current!)); } + function undo() { strokesRef.current.pop(); redraw(); force((n) => n + 1); scheduleSave(); } + + async function saveStep(stepN: number, badgeKey?: string) { + if (!loggedIn) return; + try { await fetch("/api/progress", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ level: meta.level, sublevel: meta.sublevel, step: stepN, badgeKey }) }); } catch { /* */ } + } + function next() { saveStep(step.n); flushSave(); if (idx < lastIndex) setIdx(idx + 1); else finish(); } + function prev() { flushSave(); if (idx > 0) setIdx(idx - 1); } + async function finish() { + await saveStep(step.n, meta.badgeKey || undefined); + const c = canvasRef.current; + if (c && hasContent()) { const data = exportCanvasToDataURL(c); setFinalImage(data); if (loggedIn) await saveFinalDrawing(meta.level, meta.sublevel, "canvas", data); } + setEarned(loggedIn && !!meta.badgeKey); + setFinished(true); + } + async function onUpload(e: React.ChangeEvent) { + const file = e.target.files?.[0]; if (!file) return; + if (!loggedIn) { setUploadMsg("Log in to save your drawing to your gallery."); return; } + setUploadMsg("Uploading…"); + try { const data = await fileToDataURL(file); const ok = await saveFinalDrawing(meta.level, meta.sublevel, "upload", data); setUploadMsg(ok ? "Saved to your gallery! πŸ–ΌοΈ" : "Couldn't save that file."); } + catch { setUploadMsg("Couldn't read that image."); } + finally { if (uploadRef.current) uploadRef.current.value = ""; } + } + + async function rasterizeBadge(): Promise { + const blob = new Blob([badgeFullSvg(meta.badgeName, meta.emoji)], { type: "image/svg+xml" }); const url = URL.createObjectURL(blob); + try { const img = await loadImage(url); const c = document.createElement("canvas"); c.width = 600; c.height = 600; c.getContext("2d")!.drawImage(img, 0, 0, 600, 600); return await new Promise((res) => c.toBlob((b) => res(b), "image/png")); } + finally { URL.revokeObjectURL(url); } + } + async function shareBadge() { + const blob = await rasterizeBadge(); if (!blob) return; const file = new File([blob], "drawit-badge.png", { type: "image/png" }); + const nav = navigator as Navigator & { canShare?: (d: ShareData) => boolean }; + if (nav.share && nav.canShare && nav.canShare({ files: [file] })) { try { await nav.share({ files: [file], title: "My DrawIt badge!", text: `I earned the ${meta.badgeName} badge on DrawIt!` }); return; } catch { /* */ } } + downloadBlob(blob, "drawit-badge.png"); + } + async function downloadBadge() { const b = await rasterizeBadge(); if (b) downloadBlob(b, "drawit-badge.png"); } + + if (finished) { + return ( +
+
+

πŸŽ‰ Great work!

+

You finished {meta.title}{earned ? <> and earned the {meta.badgeName} badge! : "."}

+ {earned &&
} + {earned && ( +
+
+ + +
+ πŸ† Download award (PDF) +
+ )} + {finalImage && ( +
+ Your drawing + Your drawing + ⬇ Download my drawing +
+ )} +
+ Drew it on paper? +

Take a photo and add it to your gallery.

+ + + {uploadMsg &&

{uploadMsg}

} +
+
+ {loggedIn && πŸ–ΌοΈ My drawings} + + Back to lessons +
+
+
+ ); + } + + return ( +
+
+
+
{meta.levelName} Β· {meta.phaseLabel}
+

{meta.emoji} {meta.title}

+
+ Report a problem +
+ {idx === 0 &&

{meta.intro}

} + +
+ {steps.map((s, i) => ( + + ))} +
+ +
+
+
+ {/* faint trace target (outline base + lines so far) */} + + {/* the slowly-drawn video for the current step */} + {playing && setPlaying(false)} />} + +
+ +
+ +
+ +
+ {COLORS.map((c) => ( + + ))} +
+
+ + +
+ {loggedIn &&

Your drawing is saved automatically.

} +
+ +
+ Step {step.n}: {step.title} +

{step.instruction}

+ {step.tip &&

πŸ’‘ {step.tip}

} +
+ + +
+ {!loggedIn &&

Log in to save your progress.

} +
+
+
+ ); +} + +const toolBtn: React.CSSProperties = { minHeight: 40, padding: "8px 14px", fontSize: "0.9rem", boxShadow: "none" }; +function loadImage(src: string): Promise { return new Promise((res, rej) => { const i = new Image(); i.onload = () => res(i); i.onerror = rej; i.src = src; }); } +function downloadBlob(blob: Blob, name: string) { const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = name; a.click(); setTimeout(() => URL.revokeObjectURL(url), 1000); } diff --git a/src/lib/curriculum.ts b/src/lib/curriculum.ts index 8dfb1b0..0c5adfc 100644 --- a/src/lib/curriculum.ts +++ b/src/lib/curriculum.ts @@ -17,31 +17,43 @@ export const LEVELS: LevelMeta[] = [ export function getLevel(key: string): LevelMeta | undefined { return LEVELS.find((l) => l.key === key); } export type StepKind = "construct" | "outline" | "color" | "shade" | "detail"; +export type Phase = "outline" | "detail" | "extra"; -export interface LessonStep { n: number; title: string; instruction: string; tip?: string; kind?: StepKind; svg: string; } +export interface LessonStep { n: number; title: string; instruction: string; tip?: string; kind?: StepKind; svg?: string; lines?: string[]; } -export interface Lesson { level: string; sublevel: number; slug: string; title: string; subject: string; emoji: string; intro: string; badgeKey: string; badgeName: string; mode?: "draw" | "shade"; steps: LessonStep[]; } +export interface Lesson { level: string; sublevel: number; slug: string; title: string; subject: string; emoji: string; intro: string; badgeKey: string; badgeName: string; mode?: "draw" | "shade"; phase?: Phase; baseSvg?: string; subjectKey?: string; subjectName?: string; subjectEmoji?: string; order?: number; steps: LessonStep[]; } -const FISH_STEPS: LessonStep[] = [ - { n: 1, kind: "construct", title: "The body", instruction: "Draw a big rounded body β€” fatter in the middle and tapering toward the tail.", - tip: "Like a soft egg lying on its side.", - svg: `` }, - { n: 2, kind: "outline", title: "The tail", instruction: "Add a fan-shaped tail on the right with a little notch.", - svg: `` }, - { n: 3, kind: "outline", title: "The fins", instruction: "Add a small fin on top (the back) and one underneath the belly.", - svg: `` }, - { n: 4, kind: "detail", title: "The eye", instruction: "Draw a circle near the front with a dark pupil and a tiny white sparkle.", - tip: "The sparkle makes it look alive!", - svg: `` }, - { n: 5, kind: "detail", title: "Mouth and gill", instruction: "Add a little smile at the front and a curved gill line behind the head.", - svg: `` }, - { n: 6, kind: "detail", title: "Stripes", instruction: "Add a couple of curved stripes, then color your fish any way you like!", - tip: "πŸŽ‰", - svg: `` }, +// Fish is the pilot for the 3-lesson model: Outline β†’ Detail β†’ Color it. +// Outline/Detail use per-step `lines` (coloring-book strokes drawn one at a time). +const LN = (d: string) => ``; + +const FISH_OUTLINE_STEPS: LessonStep[] = [ + { n: 1, title: "The body", instruction: "Draw the big rounded body β€” loop all the way around.", tip: "Slow and smooth, like an egg.", + lines: [LN("M85 150 C 95 95, 180 78, 252 96 C 292 107, 306 128, 306 150 C 306 172, 292 193, 252 204 C 180 222, 95 205, 85 150 Z")] }, + { n: 2, title: "The tail", instruction: "Add the fan-shaped tail at the back.", + lines: [LN("M300 150 C 332 130, 360 112, 390 100 C 374 130, 374 170, 390 200 C 360 188, 332 170, 300 150 Z")] }, + { n: 3, title: "The fins", instruction: "Draw a fin on the back, then one under the belly.", + lines: [LN("M150 92 C 168 62, 212 60, 240 84"), LN("M150 178 C 168 208, 206 210, 228 196")] }, ]; -export const FISH_LESSON: Lesson = { level: "early-beginner", sublevel: 1, slug: "fish", title: "Draw a Fish", subject: "a happy fish", emoji: "🐟", intro: "We'll build a friendly fish from a few simple shapes. Six easy steps!", badgeKey: "early-beginner-1-fish", badgeName: "Fish Friend", mode: "draw", steps: FISH_STEPS }; +const FISH_DETAIL_STEPS: LessonStep[] = [ + { n: 1, title: "The eye", instruction: "Draw a round eye, then a little dot inside.", + lines: [LN("M147 134 a15 15 0 1 0 0.1 0"), ``] }, + { n: 2, title: "The mouth", instruction: "Add a small smile at the front.", + lines: [LN("M90 162 q15 13 32 4")] }, + { n: 3, title: "The gill", instruction: "Add a curved gill line behind the head.", + lines: [LN("M160 104 q-16 46 0 92")] }, + { n: 4, title: "Stripes", instruction: "Add a couple of curved stripes across the body.", tip: "Now it's ready to color!", + lines: [LN("M205 96 q-20 54 4 110"), LN("M240 100 q-16 50 4 100")] }, +]; + +const FISH_OUTLINE_SVG = FISH_OUTLINE_STEPS.flatMap((s) => s.lines || []).join("\n"); +const FISH_DETAIL_SVG = FISH_OUTLINE_SVG + "\n" + FISH_DETAIL_STEPS.flatMap((s) => s.lines || []).join("\n"); + +export const FISH_OUTLINE_LESSON: Lesson = { level: "early-beginner", sublevel: 11, slug: "fish-outline", title: "Fish Β· Outline", subject: "a fish outline", emoji: "🐟", intro: "Let's draw the outline of a fish, one line at a time. Watch each line, then trace it!", badgeKey: "", badgeName: "Fish Friend", phase: "outline", subjectKey: "fish", subjectName: "Fish", subjectEmoji: "🐟", order: 1, steps: FISH_OUTLINE_STEPS }; +export const FISH_DETAIL_LESSON: Lesson = { level: "early-beginner", sublevel: 12, slug: "fish-detail", title: "Fish Β· Details", subject: "fish details", emoji: "🐟", intro: "Now add the details that make it a fish β€” the eye, mouth, gill, and stripes.", badgeKey: "early-beginner-1-fish", badgeName: "Fish Friend", phase: "detail", baseSvg: FISH_OUTLINE_SVG, subjectKey: "fish", subjectName: "Fish", subjectEmoji: "🐟", order: 1, steps: FISH_DETAIL_STEPS }; +export const FISH_EXTRA_LESSON: Lesson = { level: "early-beginner", sublevel: 13, slug: "fish-extra", title: "Fish Β· Color it!", subject: "color the fish", emoji: "🎨", intro: "Bring your fish to life! Color it in, and try shading and layering.", badgeKey: "", badgeName: "Fish Friend", phase: "extra", baseSvg: FISH_DETAIL_SVG, subjectKey: "fish", subjectName: "Fish", subjectEmoji: "🐟", order: 1, steps: [] }; const PANDA_STEPS: LessonStep[] = [ { n: 1, kind: "construct", title: "The head", instruction: "Draw one big round face.", @@ -170,8 +182,23 @@ const SHADE_STEPS: LessonStep[] = [ export const SHADE_BALL_LESSON: Lesson = { level: "learner", sublevel: 1, slug: "shade-ball", title: "Light & Shadow: Shade a Ball", subject: "a shaded ball", emoji: "\u{1F317}", intro: "Time to make a flat circle look like a round ball β€” using light and shadow! First, learn how to hold your pencil.", badgeKey: "learner-1-shading", badgeName: "Shading Star", mode: "shade", steps: SHADE_STEPS }; -export const LESSONS: Lesson[] = [FISH_LESSON, PANDA_LESSON, FLOWER_LESSON, UNICORN_LESSON, TREE_LESSON, DINO_LESSON, SHADE_BALL_LESSON]; +export const LESSONS: Lesson[] = [FISH_OUTLINE_LESSON, FISH_DETAIL_LESSON, FISH_EXTRA_LESSON, PANDA_LESSON, FLOWER_LESSON, UNICORN_LESSON, TREE_LESSON, DINO_LESSON, SHADE_BALL_LESSON]; export function getLesson(level: string, sublevel: number): Lesson | undefined { return LESSONS.find((l) => l.level === level && l.sublevel === sublevel); } export function getLessonBySlug(slug: string): Lesson | undefined { return LESSONS.find((l) => l.slug === slug); } -export function cumulativeSvg(lesson: Lesson, upTo: number): string { return lesson.steps.filter((s) => s.n <= upTo).map((s) => s.svg).join("\n"); } +export function cumulativeSvg(lesson: Lesson, upTo: number): string { return lesson.steps.filter((s) => s.n <= upTo).map((s) => s.svg || "").join("\n"); } +export function cumulativeLines(lesson: Lesson, upTo: number): string { return lesson.steps.filter((s) => s.n <= upTo).flatMap((s) => s.lines || []).join("\n"); } + +/** Subjects in a level, in order, each with its phase lessons (or a single legacy lesson). */ +export function getSubjects(level: string): { key: string; name: string; emoji: string; order: number; lessons: Lesson[] }[] { + const inLevel = LESSONS.filter((l) => l.level === level); + const map = new Map(); + for (const l of inLevel) { + const key = l.subjectKey || l.slug; + if (!map.has(key)) map.set(key, { key, name: l.subjectName || l.title, emoji: l.subjectEmoji || l.emoji, order: l.order ?? l.sublevel, lessons: [] }); + map.get(key)!.lessons.push(l); + } + const phaseRank: Record = { outline: 0, detail: 1, extra: 2 }; + for (const s of map.values()) s.lessons.sort((a, b) => (phaseRank[a.phase ?? ""] ?? 0) - (phaseRank[b.phase ?? ""] ?? 0)); + return [...map.values()].sort((a, b) => a.order - b.order); +}