[Feature] Add lessons in each level: outline, detail, extra #1
No files matched your search
@@ -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 (
|
||||
<>
|
||||
<SiteNav />
|
||||
<main className="container page">
|
||||
<TraceRunner meta={meta} steps={steps} loggedIn={!!user} username={user?.username ?? ""} completedSteps={completed} alreadyEarned={alreadyEarned} />
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Coloring studio (extra)
|
||||
if (lesson.phase === "extra") {
|
||||
const meta = { ...common, baseSvg: lesson.baseSvg ?? "" };
|
||||
return (
|
||||
<>
|
||||
<SiteNav />
|
||||
<main className="container page">
|
||||
<ColoringRunner meta={meta} loggedIn={!!user} username={user?.username ?? ""} alreadyEarned={alreadyEarned} />
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<>
|
||||
<SiteNav />
|
||||
<main className="container page">
|
||||
{isShade ? (
|
||||
<ShadingRunner
|
||||
meta={meta}
|
||||
steps={steps}
|
||||
loggedIn={!!user}
|
||||
username={user?.username ?? ""}
|
||||
completedSteps={completed}
|
||||
alreadyEarned={alreadyEarned}
|
||||
/>
|
||||
<ShadingRunner meta={meta} steps={steps} loggedIn={!!user} username={user?.username ?? ""} completedSteps={completed} alreadyEarned={alreadyEarned} />
|
||||
) : (
|
||||
<LessonRunner
|
||||
meta={meta}
|
||||
steps={steps}
|
||||
loggedIn={!!user}
|
||||
username={user?.username ?? ""}
|
||||
completedSteps={completed}
|
||||
alreadyEarned={alreadyEarned}
|
||||
/>
|
||||
<LessonRunner meta={meta} steps={steps} loggedIn={!!user} username={user?.username ?? ""} completedSteps={completed} alreadyEarned={alreadyEarned} />
|
||||
)}
|
||||
</main>
|
||||
</>
|
||||
|
||||
+69
-130
@@ -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<string, string> = { 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<string, { done: boolean; completed: number; total: number }>();
|
||||
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() {
|
||||
<main className="container page">
|
||||
<h1>Learn to draw 🎨</h1>
|
||||
<p className="muted">
|
||||
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 <strong>Outline</strong> lesson and a <strong>Details</strong> lesson (earn the badge), plus an optional <strong>Color it!</strong> lesson.
|
||||
{!user && " Log in to track progress and earn badges."}
|
||||
</p>
|
||||
|
||||
<div className="stack" style={{ marginTop: 18 }}>
|
||||
{LEVELS.map((lvl, idx) => {
|
||||
const lessons = LESSONS.filter((l) => l.level === lvl.key);
|
||||
const subjects = getSubjects(lvl.key);
|
||||
return (
|
||||
<div className="card" key={lvl.key}>
|
||||
<div className="row" style={{ justifyContent: "space-between" }}>
|
||||
<h2 style={{ margin: 0 }}>
|
||||
{lvl.emoji} {idx + 1}. {lvl.name}
|
||||
</h2>
|
||||
{lessons.length === 0 && <span className="pill pending">Coming soon</span>}
|
||||
<h2 style={{ margin: 0 }}>{lvl.emoji} {idx + 1}. {lvl.name}</h2>
|
||||
{subjects.length === 0 && <span className="pill pending">Coming soon</span>}
|
||||
</div>
|
||||
<p className="muted">{lvl.blurb}</p>
|
||||
{lessons.length > 0 && (
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gap: 12,
|
||||
gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))",
|
||||
marginTop: 8,
|
||||
}}
|
||||
>
|
||||
{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);
|
||||
|
||||
<div className="stack" style={{ marginTop: 6 }}>
|
||||
{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 (
|
||||
<Link
|
||||
key={lesson.slug}
|
||||
href={`/learn/${lesson.level}/${lesson.slug}`}
|
||||
className="card"
|
||||
style={{
|
||||
position: "relative",
|
||||
textDecoration: "none",
|
||||
color: "inherit",
|
||||
border: p.done ? "2px solid var(--leaf)" : "2px solid var(--line)",
|
||||
boxShadow: "none",
|
||||
}}
|
||||
>
|
||||
{/* Status indicator (top-right) */}
|
||||
<div style={{ position: "absolute", top: 12, right: 12 }}>
|
||||
{p.done ? (
|
||||
<span
|
||||
aria-label="Completed"
|
||||
title="Completed"
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: 30,
|
||||
height: 30,
|
||||
borderRadius: 9,
|
||||
background: "var(--leaf)",
|
||||
color: "#fff",
|
||||
fontWeight: 900,
|
||||
fontSize: "1rem",
|
||||
boxShadow: "0 2px 6px rgba(88,199,119,0.5)",
|
||||
}}
|
||||
>
|
||||
✓
|
||||
</span>
|
||||
) : inProgress ? (
|
||||
<span
|
||||
aria-label={`On step ${currentStep} of ${p.total}`}
|
||||
style={{
|
||||
fontWeight: 800,
|
||||
fontSize: "0.85rem",
|
||||
color: "var(--primary-dark)",
|
||||
background: "#eaf0ff",
|
||||
padding: "4px 10px",
|
||||
borderRadius: 999,
|
||||
}}
|
||||
>
|
||||
Step {currentStep}/{p.total}
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
aria-label="Not started"
|
||||
style={{
|
||||
display: "inline-block",
|
||||
width: 26,
|
||||
height: 26,
|
||||
borderRadius: 8,
|
||||
background: "#f3eefb",
|
||||
border: "2px solid var(--line)",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Link key={subj.key} href={`/learn/${l.level}/${l.slug}`} className="card" style={subCard(done)}>
|
||||
<div className="row" style={{ justifyContent: "space-between", alignItems: "center" }}>
|
||||
<strong>{subj.emoji} {l.title}</strong>
|
||||
<span>{done ? <span className="pill active">✓ Done</span> : inProg ? <span className="pill pending">Step {Math.min(doneCount(l) + 1, stepCount(l))}/{stepCount(l)}</span> : <span className="muted" style={{ fontSize: "0.85rem" }}>{stepCount(l)} steps</span>}</span>
|
||||
</div>
|
||||
|
||||
<div style={{ fontSize: "1.8rem" }}>{lesson.emoji}</div>
|
||||
<strong style={{ display: "block", paddingRight: 44 }}>
|
||||
Level {lesson.sublevel}: {lesson.title}
|
||||
</strong>
|
||||
<p className="muted" style={{ margin: "4px 0 0", fontSize: "0.9rem" }}>
|
||||
{p.done
|
||||
? "Completed 🎉"
|
||||
: inProgress
|
||||
? `${p.completed} of ${p.total} steps done`
|
||||
: `${p.total} simple steps`}
|
||||
</p>
|
||||
|
||||
{/* Progress bar */}
|
||||
{(inProgress || p.done) && (
|
||||
<div
|
||||
aria-hidden
|
||||
style={{
|
||||
marginTop: 10,
|
||||
height: 7,
|
||||
borderRadius: 999,
|
||||
background: "#ece6f0",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: `${pct}%`,
|
||||
height: "100%",
|
||||
background: p.done ? "var(--leaf)" : "var(--primary)",
|
||||
borderRadius: 999,
|
||||
transition: "width .3s ease",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
}
|
||||
// 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 = (
|
||||
<div style={{ position: "relative" }}>
|
||||
<strong style={{ display: "block" }}>{PHASE_LABEL[l.phase ?? ""]}{optional ? " (optional)" : ""}</strong>
|
||||
<span style={{ fontSize: "0.82rem" }} className="muted">
|
||||
{locked ? "🔒 Locked" : done ? "✓ Done" : inProg ? `Step ${Math.min(doneCount(l) + 1, stepCount(l))}/${stepCount(l)}` : l.phase === "extra" ? "Add color" : `${stepCount(l)} steps`}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
return locked ? (
|
||||
<div key={l.slug} style={{ ...phaseBox, opacity: 0.55 }}>{inner}</div>
|
||||
) : (
|
||||
<Link key={l.slug} href={`/learn/${l.level}/${l.slug}`} style={{ ...phaseBox, textDecoration: "none", color: "inherit", borderColor: done ? "var(--leaf)" : "var(--line)" }}>{inner}</Link>
|
||||
);
|
||||
};
|
||||
return (
|
||||
<div key={subj.key} className="card" style={{ border: "2px solid var(--line)", boxShadow: "none" }}>
|
||||
<strong style={{ fontSize: "1.1rem" }}>{subj.emoji} {subj.name}</strong>
|
||||
<div style={{ display: "grid", gap: 10, gridTemplateColumns: "repeat(auto-fit, minmax(140px, 1fr))", marginTop: 10 }}>
|
||||
{phaseCard(outline, false)}
|
||||
{phaseCard(detail, !outlineDone)}
|
||||
{phaseCard(extra, !detailDone, true)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -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" };
|
||||
+51
-123
@@ -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 (
|
||||
<>
|
||||
<SiteNav />
|
||||
<main>
|
||||
{/* Hero */}
|
||||
<section className="section">
|
||||
<div className="container" style={{ textAlign: "center" }}>
|
||||
<div className="pill role" style={{ marginBottom: 14 }}>
|
||||
🆓 Free & open source · 🔒 No data collected, ever
|
||||
</div>
|
||||
<h1>Learn to draw, one shape at a time</h1>
|
||||
<p className="muted" style={{ fontSize: "1.2rem", maxWidth: 620, margin: "0 auto 24px" }}>
|
||||
DrawIt teaches kids to draw with simple, friendly steps — built for iPads and tablets.
|
||||
<strong> Level 1 is free to try right now</strong> — create a free account to unlock every
|
||||
lesson, save progress, and earn badges.
|
||||
<div className="pill role" style={{ marginBottom: 14 }}>🆓 Free & open source · 🔒 No data collected, ever</div>
|
||||
<h1>Learn to draw, one line at a time</h1>
|
||||
<p className="muted" style={{ fontSize: "1.2rem", maxWidth: 640, margin: "0 auto 24px" }}>
|
||||
DrawIt teaches kids to draw with simple, friendly steps — built for iPads and tablets. Each
|
||||
picture has an <strong>Outline</strong> lesson, a <strong>Details</strong> lesson, and a fun
|
||||
<strong> Color it!</strong> lesson. <strong>The first lesson is free</strong> — create an account to unlock the rest.
|
||||
</p>
|
||||
<div className="row" style={{ justifyContent: "center" }}>
|
||||
<Link href={`/learn/early-beginner/${FREE_SLUG}`} className="btn big">
|
||||
🐟 Try Level 1 free
|
||||
</Link>
|
||||
<Link href="/signup" className="btn secondary big">
|
||||
Create a free account
|
||||
</Link>
|
||||
<Link href={`/learn/early-beginner/${FREE_SLUG}`} className="btn big">🐟 Try the first lesson free</Link>
|
||||
<Link href="/signup" className="btn secondary big">Create a free account</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Slideshow of Early Beginner Level 1 */}
|
||||
<section className="section" style={{ background: "#fff", borderBlock: "1px solid var(--line)" }}>
|
||||
<div className="container">
|
||||
<h2 className="center">Peek at Level 1: Draw a Fish 🐟</h2>
|
||||
<h2 className="center">Watch it drawn, line by line 🐟</h2>
|
||||
<p className="center muted" style={{ maxWidth: 560, margin: "0 auto 26px" }}>
|
||||
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.
|
||||
</p>
|
||||
<FishSlideshow steps={slides} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Curriculum preview: Level 1 free, everything else locked */}
|
||||
<section className="section">
|
||||
<div className="container">
|
||||
<h2 className="center">Start free — unlock everything with an account</h2>
|
||||
<p className="center muted" style={{ maxWidth: 600, margin: "0 auto 26px" }}>
|
||||
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.
|
||||
</p>
|
||||
|
||||
<div className="stack">
|
||||
{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 (
|
||||
<div className="card" key={lvl.key}>
|
||||
<div className="row" style={{ justifyContent: "space-between", alignItems: "center" }}>
|
||||
<h3 style={{ margin: 0 }}>
|
||||
{lvl.emoji} {idx + 1}. {lvl.name}
|
||||
</h3>
|
||||
{isFirstLevel ? (
|
||||
<span className="pill active">Level 1 free</span>
|
||||
) : (
|
||||
<span className="pill pending">🔒 Account needed</span>
|
||||
)}
|
||||
<h3 style={{ margin: 0 }}>{lvl.emoji} {idx + 1}. {lvl.name}</h3>
|
||||
{!isFirst && <span className="pill pending">🔒 Account needed</span>}
|
||||
</div>
|
||||
<p className="muted" style={{ marginTop: 6 }}>{lvl.blurb}</p>
|
||||
|
||||
{isFirstLevel && lessons.length > 0 ? (
|
||||
{isFirst && subjects.length > 0 && (
|
||||
<div style={{ display: "grid", gap: 12, gridTemplateColumns: "repeat(auto-fit, minmax(200px, 1fr))", marginTop: 8 }}>
|
||||
{lessons.map((lesson) => {
|
||||
const free = lesson.slug === FREE_SLUG;
|
||||
return free ? (
|
||||
<Link key={lesson.slug} href={`/learn/${lesson.level}/${lesson.slug}`} style={lessonCard(false)}>
|
||||
<span style={cornerTag("free")}>Free</span>
|
||||
<div style={{ fontSize: "1.8rem" }}>{lesson.emoji}</div>
|
||||
<strong style={{ display: "block", paddingRight: 44 }}>
|
||||
Level {lesson.sublevel}: {lesson.title}
|
||||
</strong>
|
||||
<p className="muted" style={{ margin: "4px 0 0", fontSize: "0.9rem" }}>
|
||||
{lesson.steps.length} simple steps · try it now
|
||||
</p>
|
||||
</Link>
|
||||
) : (
|
||||
<Link key={lesson.slug} href="/signup" style={lessonCard(true)} title="Create a free account to unlock">
|
||||
<span style={cornerTag("lock")}>🔒</span>
|
||||
<div style={{ fontSize: "1.8rem" }}>{lesson.emoji}</div>
|
||||
<strong style={{ display: "block", paddingRight: 44 }}>
|
||||
Level {lesson.sublevel}: {lesson.title}
|
||||
</strong>
|
||||
<p className="muted" style={{ margin: "4px 0 0", fontSize: "0.9rem" }}>
|
||||
Create an account to unlock
|
||||
{subjects.map((subj) => {
|
||||
const first = subj.lessons[0];
|
||||
const free = first.slug === FREE_SLUG;
|
||||
return (
|
||||
<Link key={subj.key} href={free ? `/learn/${first.level}/${first.slug}` : "/signup"} style={card(!free)}>
|
||||
<span style={tag(free)}>{free ? "Free" : "🔒"}</span>
|
||||
<div style={{ fontSize: "1.8rem" }}>{subj.emoji}</div>
|
||||
<strong style={{ display: "block", paddingRight: 40 }}>{subj.name}</strong>
|
||||
<p className="muted" style={{ margin: "4px 0 0", fontSize: "0.85rem" }}>
|
||||
{free ? "Outline · free to try" : "Create an account to unlock"}
|
||||
</p>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<Link href="/signup" style={{ ...lessonCard(true), marginTop: 8, display: "block" }} title="Create a free account to unlock">
|
||||
<span style={cornerTag("lock")}>🔒</span>
|
||||
)}
|
||||
{!isFirst && (
|
||||
<Link href="/signup" style={{ ...card(true), marginTop: 8, display: "block" }}>
|
||||
<span style={tag(false)}>🔒</span>
|
||||
<strong>Unlock {lvl.name}</strong>
|
||||
<p className="muted" style={{ margin: "4px 0 0", fontSize: "0.9rem" }}>
|
||||
More lessons inside — create a free account to explore.
|
||||
</p>
|
||||
<p className="muted" style={{ margin: "4px 0 0", fontSize: "0.85rem" }}>Create a free account to explore.</p>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="center" style={{ marginTop: 26 }}>
|
||||
<Link href="/signup" className="btn big">Create a free account to unlock everything</Link>
|
||||
<Link href="/signup" className="btn big">Create a free account</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* How it works */}
|
||||
<section className="section" style={{ background: "#fff", borderTop: "1px solid var(--line)" }}>
|
||||
<div className="container">
|
||||
<h2 className="center">How DrawIt works</h2>
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gap: 16,
|
||||
gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))",
|
||||
marginTop: 18,
|
||||
}}
|
||||
>
|
||||
<div className="card">
|
||||
<h3>👣 Step by step</h3>
|
||||
<p className="muted">Each lesson breaks a picture into tiny, doable steps using only simple shapes.</p>
|
||||
</div>
|
||||
<div className="card">
|
||||
<h3>🏅 Badges & awards</h3>
|
||||
<p className="muted">Finish a lesson to earn a shareable badge and a printable award certificate (PDF).</p>
|
||||
</div>
|
||||
<div className="card">
|
||||
<h3>🔒 Private by design</h3>
|
||||
<p className="muted">No analytics, no trackers, no data sold or shared. Just drawing.</p>
|
||||
</div>
|
||||
<div style={{ display: "grid", gap: 16, gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))", marginTop: 18 }}>
|
||||
<div className="card"><h3>✏️ Outline → Details</h3><p className="muted">Trace the coloring-book outline, then add the details that bring it to life.</p></div>
|
||||
<div className="card"><h3>🎨 Color it in</h3><p className="muted">Finish with the coloring studio — color, shade, and make it your own.</p></div>
|
||||
<div className="card"><h3>🔒 Private by design</h3><p className="muted">No analytics, no trackers, no data sold or shared. Just drawing.</p></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer className="site">
|
||||
DrawIt · Free & open source · No data collected. Made for young artists. 🎨
|
||||
</footer>
|
||||
<footer className="site">DrawIt · Free & open source · No data collected. Made for young artists. 🎨</footer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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)" };
|
||||
}
|
||||
@@ -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<string | null>(null);
|
||||
const [uploadMsg, setUploadMsg] = useState("");
|
||||
const uploadRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const stageRef = useRef<HTMLDivElement | null>(null);
|
||||
const strokesRef = useRef<Stroke[]>([]);
|
||||
const drawingRef = useRef<Stroke | null>(null);
|
||||
const baseImgRef = useRef<HTMLImageElement | null>(null);
|
||||
const saveTimer = useRef<ReturnType<typeof setTimeout> | 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<HTMLInputElement>) {
|
||||
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 (
|
||||
<div className="form-narrow center">
|
||||
<div className="card">
|
||||
<h1>🎨 Beautiful!</h1>
|
||||
<p className="muted">You colored your {meta.title.replace(" · Color it!", "")}.</p>
|
||||
{finalImage && (
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<img src={finalImage} alt="Your colored drawing" style={{ width: "100%", borderRadius: 12, border: "1px solid var(--line)" }} />
|
||||
<a className="btn secondary" href={finalImage} download={`drawit-${meta.level}-${meta.sublevel}.png`} style={{ marginTop: 10 }}>⬇ Download my drawing</a>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<input ref={uploadRef} type="file" accept="image/*" onChange={onUpload} style={{ display: "none" }} />
|
||||
<button className="btn secondary" onClick={() => uploadRef.current?.click()}>📷 Upload a paper drawing</button>
|
||||
{uploadMsg && <p className="muted" style={{ marginTop: 8 }}>{uploadMsg}</p>}
|
||||
</div>
|
||||
<div className="row" style={{ justifyContent: "center", marginTop: 18 }}>
|
||||
{loggedIn && <Link className="btn ghost" href="/profile">🖼️ My drawings</Link>}
|
||||
<button className="btn ghost" onClick={() => { setDone(false); setFinalImage(null); setUploadMsg(""); }}>↺ Keep coloring</button>
|
||||
<Link className="btn ghost" href="/learn">Back to lessons</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="row" style={{ justifyContent: "space-between", alignItems: "flex-start" }}>
|
||||
<div>
|
||||
<div className="muted" style={{ fontWeight: 700 }}>{meta.levelName} · Color it!</div>
|
||||
<h1 style={{ margin: "2px 0" }}>{meta.emoji} {meta.title}</h1>
|
||||
</div>
|
||||
<Link className="btn ghost" href={`/report?level=${meta.level}&sublevel=${meta.sublevel}`} style={{ minHeight: 40 }}>Report a problem</Link>
|
||||
</div>
|
||||
<p className="muted">{meta.intro}</p>
|
||||
<div className="notice info" style={{ marginBottom: 12 }}>More tools (pencil, marker, fill bucket, layers, custom colors) are coming in the next update — for now, color away!</div>
|
||||
|
||||
<div style={{ display: "grid", gap: 18, gridTemplateColumns: "1fr", alignItems: "start" }}>
|
||||
<div>
|
||||
<div ref={stageRef} className="no-touch-scroll" style={{ position: "relative", width: "100%", maxWidth: 640, margin: "0 auto", aspectRatio: "4 / 3", background: "#fff", border: "2px solid var(--line)", borderRadius: 16, overflow: "hidden" }}>
|
||||
<canvas ref={canvasRef} onPointerDown={onDown} onPointerMove={onMove} onPointerUp={onUp} onPointerCancel={onUp}
|
||||
style={{ position: "absolute", inset: 0, width: "100%", height: "100%", touchAction: "none", cursor: "crosshair" }} />
|
||||
{/* coloring-book lines on top so colors fill underneath */}
|
||||
<svg viewBox="0 0 400 300" style={{ position: "absolute", inset: 0, width: "100%", height: "100%", pointerEvents: "none" }}
|
||||
dangerouslySetInnerHTML={{ __html: meta.baseSvg }} />
|
||||
</div>
|
||||
|
||||
<div className="row" style={{ justifyContent: "center", marginTop: 12, gap: 8, alignItems: "center", flexWrap: "wrap" }}>
|
||||
{COLORS.map((c) => (
|
||||
<button key={c} onClick={() => { setColor(c); setErasing(false); }} aria-label={`Color ${c}`}
|
||||
style={{ width: 30, height: 30, borderRadius: "50%", background: c, border: !erasing && color === c ? "3px solid var(--ink)" : "3px solid #fff", boxShadow: "0 0 0 1px var(--line)", cursor: "pointer" }} />
|
||||
))}
|
||||
<button onClick={() => setErasing(true)} title="Eraser" style={{ minHeight: 30, padding: "4px 12px", borderRadius: 8, background: erasing ? "var(--primary)" : "#fff", color: erasing ? "#fff" : "var(--ink)", border: "2px solid var(--line)", cursor: "pointer", fontWeight: 700, fontSize: "0.85rem" }}>🧽 Erase</button>
|
||||
</div>
|
||||
<div className="row" style={{ justifyContent: "center", marginTop: 10, gap: 10, alignItems: "center" }}>
|
||||
{SIZES.map((s) => (
|
||||
<button key={s} onClick={() => setSize(s)} aria-label={`Size ${s}`}
|
||||
style={{ width: 34, height: 34, borderRadius: "50%", background: size === s ? "var(--primary)" : "#fff", border: "2px solid var(--line)", cursor: "pointer", display: "grid", placeItems: "center" }}>
|
||||
<span style={{ width: s / 3, height: s / 3, borderRadius: "50%", background: size === s ? "#fff" : "var(--ink)" }} />
|
||||
</button>
|
||||
))}
|
||||
<span style={{ width: 1, height: 28, background: "var(--line)" }} />
|
||||
<label className="muted" style={{ fontSize: "0.82rem", fontWeight: 700 }}>Opacity
|
||||
<input type="range" min={0.1} max={1} step={0.1} value={opacity} onChange={(e) => setOpacity(Number(e.target.value))} style={{ verticalAlign: "middle", marginLeft: 6 }} />
|
||||
</label>
|
||||
<button className="btn secondary" style={toolBtn} onClick={undo}>↶ Undo</button>
|
||||
<button className="btn secondary" style={toolBtn} onClick={clearCanvas}>🗑 Clear</button>
|
||||
</div>
|
||||
{loggedIn && <p className="muted center" style={{ fontSize: "0.8rem", marginTop: 8 }}>Lower the opacity and go over an area a few times to shade and layer. Saved automatically.</p>}
|
||||
</div>
|
||||
|
||||
<div className="card center">
|
||||
<p className="muted" style={{ marginTop: 0 }}>Color your picture however you like — then save it to your gallery.</p>
|
||||
<button className="btn big" onClick={finish}>✓ I'm done — save it</button>
|
||||
{!loggedIn && <p className="muted" style={{ marginTop: 12, fontSize: "0.9rem" }}><Link href="/login">Log in</Link> to keep your drawing.</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const toolBtn: React.CSSProperties = { minHeight: 34, padding: "6px 12px", fontSize: "0.85rem", boxShadow: "none" };
|
||||
@@ -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<SVGGElement | null>(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<typeof setTimeout>[] = [];
|
||||
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 (
|
||||
<svg
|
||||
viewBox="0 0 400 300"
|
||||
style={{ position: "absolute", inset: 0, width: "100%", height: "100%", pointerEvents: "none" }}
|
||||
>
|
||||
<g ref={ref} dangerouslySetInnerHTML={{ __html: lines.join("\n") }} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -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<string | null>(null);
|
||||
const [uploadMsg, setUploadMsg] = useState("");
|
||||
const uploadRef = useRef<HTMLInputElement | null>(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<HTMLCanvasElement | null>(null);
|
||||
const stageRef = useRef<HTMLDivElement | null>(null);
|
||||
const strokesRef = useRef<Stroke[]>([]);
|
||||
const drawingRef = useRef<Stroke | null>(null);
|
||||
const baseImgRef = useRef<HTMLImageElement | null>(null);
|
||||
const saveTimer = useRef<ReturnType<typeof setTimeout> | 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<HTMLInputElement>) {
|
||||
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<Blob | null> {
|
||||
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 (
|
||||
<div className="form-narrow center">
|
||||
<div className="card">
|
||||
<h1>🎉 Great work!</h1>
|
||||
<p className="muted">You finished <strong>{meta.title}</strong>{earned ? <> and earned the <strong>{meta.badgeName}</strong> badge!</> : "."}</p>
|
||||
{earned && <div style={{ maxWidth: 220, margin: "10px auto" }}><svg viewBox="0 0 300 300" width="100%" dangerouslySetInnerHTML={{ __html: badgeSvgMarkup(meta.badgeName, meta.emoji) }} /></div>}
|
||||
{earned && (
|
||||
<div className="stack">
|
||||
<div className="row" style={{ justifyContent: "center" }}>
|
||||
<button className="btn" onClick={shareBadge}>📣 Share badge</button>
|
||||
<button className="btn secondary" onClick={downloadBadge}>⬇ Save badge</button>
|
||||
</div>
|
||||
<a className="btn accent" href={`/api/award/${meta.badgeKey}`}>🏆 Download award (PDF)</a>
|
||||
</div>
|
||||
)}
|
||||
{finalImage && (
|
||||
<div style={{ marginTop: 16, textAlign: "center" }}>
|
||||
<strong>Your drawing</strong>
|
||||
<img src={finalImage} alt="Your drawing" style={{ display: "block", width: "100%", borderRadius: 12, border: "1px solid var(--line)", margin: "8px 0" }} />
|
||||
<a className="btn secondary" href={finalImage} download={`drawit-${meta.level}-${meta.sublevel}.png`}>⬇ Download my drawing</a>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<strong>Drew it on paper?</strong>
|
||||
<p className="muted" style={{ fontSize: "0.9rem", margin: "4px 0 8px" }}>Take a photo and add it to your gallery.</p>
|
||||
<input ref={uploadRef} type="file" accept="image/*" onChange={onUpload} style={{ display: "none" }} />
|
||||
<button className="btn secondary" onClick={() => uploadRef.current?.click()}>📷 Upload my paper drawing</button>
|
||||
{uploadMsg && <p className="muted" style={{ marginTop: 8 }}>{uploadMsg}</p>}
|
||||
</div>
|
||||
<div className="row" style={{ justifyContent: "center", marginTop: 18 }}>
|
||||
{loggedIn && <Link className="btn ghost" href="/profile">🖼️ My drawings</Link>}
|
||||
<button className="btn ghost" onClick={() => { clearCanvas(); setFinalImage(null); setUploadMsg(""); setIdx(0); setFinished(false); }}>↺ Try again</button>
|
||||
<Link className="btn ghost" href="/learn">Back to lessons</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="row" style={{ justifyContent: "space-between", alignItems: "flex-start" }}>
|
||||
<div>
|
||||
<div className="muted" style={{ fontWeight: 700 }}>{meta.levelName} · {meta.phaseLabel}</div>
|
||||
<h1 style={{ margin: "2px 0" }}>{meta.emoji} {meta.title}</h1>
|
||||
</div>
|
||||
<Link className="btn ghost" href={`/report?level=${meta.level}&sublevel=${meta.sublevel}&step=${step.n}`} style={{ minHeight: 40 }}>Report a problem</Link>
|
||||
</div>
|
||||
{idx === 0 && <p className="muted">{meta.intro}</p>}
|
||||
|
||||
<div className="row" style={{ gap: 8, margin: "8px 0 14px" }}>
|
||||
{steps.map((s, i) => (
|
||||
<button key={s.n} onClick={() => { flushSave(); setIdx(i); }} aria-label={`Go to step ${s.n}`}
|
||||
style={{ width: 30, height: 30, borderRadius: "50%", border: "none", cursor: "pointer", fontWeight: 800, color: i <= idx ? "#fff" : "var(--ink-soft)", background: i < idx ? "var(--leaf)" : i === idx ? "var(--primary)" : "#e6e1ee" }}>
|
||||
{i < idx ? "✓" : s.n}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ display: "grid", gap: 18, gridTemplateColumns: "1fr", alignItems: "start" }}>
|
||||
<div>
|
||||
<div ref={stageRef} className="no-touch-scroll" style={{ position: "relative", width: "100%", maxWidth: 640, margin: "0 auto", aspectRatio: "4 / 3", background: "#fff", border: "2px solid var(--line)", borderRadius: 16, overflow: "hidden" }}>
|
||||
{/* faint trace target (outline base + lines so far) */}
|
||||
<svg viewBox="0 0 400 300" style={{ position: "absolute", inset: 0, width: "100%", height: "100%", opacity: 0.28, pointerEvents: "none" }}
|
||||
dangerouslySetInnerHTML={{ __html: meta.baseSvg + "\n" + guideUpTo }} />
|
||||
{/* the slowly-drawn video for the current step */}
|
||||
{playing && <LineDraw lines={step.lines} playKey={playKey} onDone={() => setPlaying(false)} />}
|
||||
<canvas ref={canvasRef} onPointerDown={onDown} onPointerMove={onMove} onPointerUp={onUp} onPointerCancel={onUp}
|
||||
style={{ position: "absolute", inset: 0, width: "100%", height: "100%", touchAction: "none", cursor: "crosshair" }} />
|
||||
</div>
|
||||
|
||||
<div className="row" style={{ justifyContent: "center", marginTop: 12, gap: 10 }}>
|
||||
<button className="btn" style={toolBtn} onClick={() => { setPlaying(true); setPlayKey((k) => k + 1); }}>▶ Watch this step again</button>
|
||||
</div>
|
||||
|
||||
<div className="row" style={{ justifyContent: "center", marginTop: 12, gap: 10 }}>
|
||||
{COLORS.map((c) => (
|
||||
<button key={c} onClick={() => setColor(c)} aria-label={`Color ${c}`}
|
||||
style={{ width: 32, height: 32, borderRadius: "50%", background: c, border: color === c ? "3px solid var(--ink)" : "3px solid #fff", boxShadow: "0 0 0 1px var(--line)", cursor: "pointer" }} />
|
||||
))}
|
||||
<span style={{ width: 1, height: 28, background: "var(--line)" }} />
|
||||
{SIZES.map((s) => (
|
||||
<button key={s} onClick={() => setSize(s)} aria-label={`Brush ${s}`}
|
||||
style={{ width: 34, height: 34, borderRadius: "50%", background: size === s ? "var(--primary)" : "#fff", border: "2px solid var(--line)", cursor: "pointer", display: "grid", placeItems: "center" }}>
|
||||
<span style={{ width: s, height: s, borderRadius: "50%", background: size === s ? "#fff" : "var(--ink)" }} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="row" style={{ justifyContent: "center", marginTop: 10 }}>
|
||||
<button className="btn secondary" style={toolBtn} onClick={undo}>↶ Undo</button>
|
||||
<button className="btn secondary" style={toolBtn} onClick={clearCanvas}>🗑 Clear</button>
|
||||
</div>
|
||||
{loggedIn && <p className="muted center" style={{ fontSize: "0.8rem", marginTop: 8 }}>Your drawing is saved automatically.</p>}
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<strong style={{ fontSize: "1.15rem" }}>Step {step.n}: {step.title}</strong>
|
||||
<p style={{ margin: "8px 0 0" }}>{step.instruction}</p>
|
||||
{step.tip && <p className="muted" style={{ margin: "8px 0 0" }}>💡 {step.tip}</p>}
|
||||
<div className="row" style={{ justifyContent: "space-between", marginTop: 16 }}>
|
||||
<button className="btn ghost" onClick={prev} disabled={idx === 0}>← Back</button>
|
||||
<button className="btn big" onClick={next}>{idx < lastIndex ? "Next step →" : meta.badgeKey ? "🎉 Finish & get my badge" : "✓ Finish outline"}</button>
|
||||
</div>
|
||||
{!loggedIn && <p className="muted center" style={{ marginTop: 12, fontSize: "0.9rem" }}><Link href="/login">Log in</Link> to save your progress.</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const toolBtn: React.CSSProperties = { minHeight: 40, padding: "8px 14px", fontSize: "0.9rem", boxShadow: "none" };
|
||||
function loadImage(src: string): Promise<HTMLImageElement> { 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); }
|
||||
+48
-21
@@ -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: `<path d="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" fill="#ffd45e" stroke="#2b2440" stroke-width="4"/>` },
|
||||
{ n: 2, kind: "outline", title: "The tail", instruction: "Add a fan-shaped tail on the right with a little notch.",
|
||||
svg: `<path d="M300 150 C 332 130, 360 112, 390 100 C 374 130, 374 170, 390 200 C 360 188, 332 170, 300 150 Z" fill="#f6b73f" stroke="#2b2440" stroke-width="4" stroke-linejoin="round"/>` },
|
||||
{ n: 3, kind: "outline", title: "The fins", instruction: "Add a small fin on top (the back) and one underneath the belly.",
|
||||
svg: `<path d="M150 92 C 168 62, 212 60, 240 84 C 212 86, 178 88, 150 92 Z" fill="#f6b73f" stroke="#2b2440" stroke-width="4" stroke-linejoin="round"/><path d="M150 178 C 168 208, 206 210, 228 196 C 206 190, 176 184, 150 178 Z" fill="#f6b73f" stroke="#2b2440" stroke-width="4" stroke-linejoin="round"/>` },
|
||||
{ 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: `<circle cx="132" cy="134" r="15" fill="#fff" stroke="#2b2440" stroke-width="3"/><circle cx="129" cy="136" r="7" fill="#2b2440"/><circle cx="125" cy="132" r="2.6" fill="#fff"/>` },
|
||||
{ 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: `<path d="M90 162 q15 13 32 4" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round"/><path d="M160 104 q-16 46 0 92" fill="none" stroke="#e0a92f" stroke-width="3.5" stroke-linecap="round"/>` },
|
||||
{ n: 6, kind: "detail", title: "Stripes", instruction: "Add a couple of curved stripes, then color your fish any way you like!",
|
||||
tip: "🎉",
|
||||
svg: `<path d="M205 96 q-20 54 4 110" fill="none" stroke="#f6b73f" stroke-width="7" stroke-linecap="round"/><path d="M240 100 q-16 50 4 100" fill="none" stroke="#f6b73f" stroke-width="7" stroke-linecap="round"/>` },
|
||||
// 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) => `<path d="${d}" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>`;
|
||||
|
||||
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"), `<circle cx="129" cy="136" r="6" fill="#2b2440"/>`] },
|
||||
{ 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<string, { key: string; name: string; emoji: string; order: number; lessons: Lesson[] }>();
|
||||
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<string, number> = { 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);
|
||||
}
|
||||
Reference in new issue
Block a user