Store progress in a level including anything drawn on web canvas, be able to save and download drawings from profile page
This commit is contained in:
1 parent
9b9408f596
commit
f58b470396
8 files changed
+268
-87
No files matched your search
@@ -1,6 +1,6 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getCurrentUser } from "@/lib/session";
|
||||
import { markStepComplete, awardBadge } from "@/lib/progress";
|
||||
import { markStepComplete, awardBadge, recordCompletion } from "@/lib/progress";
|
||||
import { getLesson } from "@/lib/curriculum";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
@@ -28,6 +28,7 @@ export async function POST(req: Request) {
|
||||
// Only award the badge that actually belongs to this lesson.
|
||||
if (lesson && lesson.badgeKey === body.badgeKey) {
|
||||
awardBadge(user.id, body.badgeKey);
|
||||
recordCompletion(user.id, level, sublevel, body.badgeKey);
|
||||
awarded = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,17 @@ html, body {
|
||||
}
|
||||
.guide-reveal { animation: guideReveal 0.45s ease-out; }
|
||||
|
||||
/* Grip/pressure demo: the shading scribble "draws in" then fades and loops */
|
||||
@keyframes shadeDraw {
|
||||
0% { stroke-dashoffset: 440; opacity: 0; }
|
||||
12% { opacity: 1; }
|
||||
55% { stroke-dashoffset: 0; opacity: 1; }
|
||||
88% { stroke-dashoffset: 0; opacity: 1; }
|
||||
100% { stroke-dashoffset: 0; opacity: 0; }
|
||||
}
|
||||
.shade-scribble { stroke-dasharray: 440; animation: shadeDraw 3s ease-in-out infinite; }
|
||||
@media (prefers-reduced-motion: reduce) { .shade-scribble { animation: none; stroke-dashoffset: 0; } }
|
||||
|
||||
h1, h2, h3 { line-height: 1.15; margin: 0 0 0.4em; }
|
||||
h1 { font-size: clamp(1.9rem, 6vw, 3rem); }
|
||||
h2 { font-size: clamp(1.4rem, 4.5vw, 2rem); }
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import SiteNav from "@/components/SiteNav";
|
||||
import LessonRunner from "@/components/LessonRunner";
|
||||
import { getLessonBySlug, cumulativeSvg } from "@/lib/curriculum";
|
||||
import ShadingRunner from "@/components/ShadingRunner";
|
||||
import { getLessonBySlug, getLevel, cumulativeSvg } from "@/lib/curriculum";
|
||||
import { getCurrentUser } from "@/lib/session";
|
||||
import { getCompletedSteps, hasBadge } from "@/lib/progress";
|
||||
|
||||
@@ -30,6 +31,8 @@ export default async function LessonPage({
|
||||
|
||||
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 isShade = lesson.mode === "shade";
|
||||
|
||||
const steps = lesson.steps.map((s) => ({
|
||||
n: s.n,
|
||||
@@ -37,30 +40,44 @@ export default async function LessonPage({
|
||||
instruction: s.instruction,
|
||||
tip: s.tip ?? "",
|
||||
kind: s.kind ?? "",
|
||||
guideSvg: cumulativeSvg(lesson, s.n),
|
||||
// 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,
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<SiteNav />
|
||||
<main className="container page">
|
||||
<LessonRunner
|
||||
meta={{
|
||||
level: lesson.level,
|
||||
levelName: "Early Beginner",
|
||||
sublevel: lesson.sublevel,
|
||||
title: lesson.title,
|
||||
emoji: lesson.emoji,
|
||||
intro: lesson.intro,
|
||||
badgeKey: lesson.badgeKey,
|
||||
badgeName: lesson.badgeName,
|
||||
}}
|
||||
steps={steps}
|
||||
loggedIn={!!user}
|
||||
username={user?.username ?? ""}
|
||||
completedSteps={completed}
|
||||
alreadyEarned={alreadyEarned}
|
||||
/>
|
||||
{isShade ? (
|
||||
<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}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
|
||||
+138
-56
@@ -2,13 +2,20 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { badgeSvgMarkup, badgeFullSvg } from "./BadgeArt";
|
||||
import {
|
||||
loadProgressDrawing,
|
||||
saveProgressDrawing,
|
||||
saveFinalDrawing,
|
||||
exportCanvasToDataURL,
|
||||
fileToDataURL,
|
||||
} from "./drawingPersistence";
|
||||
|
||||
interface StepData {
|
||||
n: number;
|
||||
title: string;
|
||||
instruction: string;
|
||||
tip: string;
|
||||
kind: string; // optional teaching tag ("" = derive from position)
|
||||
kind: string;
|
||||
guideSvg: string;
|
||||
}
|
||||
interface Meta {
|
||||
@@ -22,7 +29,7 @@ interface Meta {
|
||||
badgeName: string;
|
||||
}
|
||||
|
||||
const COLORS = ["#2b2440", "#4f7cff", "#34c6c6", "#58c777", "#ffc843", "#ff7eb6", "#e85a5a"];
|
||||
const COLORS = ["#2b2440", "#4f7cff", "#34c6c6", "#58c777", "#ffc843", "#ff7eb6", "#e85a5a", "#ffffff", "#c4c8cc"];
|
||||
const SIZES = [4, 8, 16];
|
||||
|
||||
const KIND_META: Record<string, { label: string; emoji: string }> = {
|
||||
@@ -45,7 +52,7 @@ interface Point {
|
||||
interface Stroke {
|
||||
color: string;
|
||||
size: number;
|
||||
points: Point[]; // normalized 0..1
|
||||
points: Point[];
|
||||
}
|
||||
|
||||
export default function LessonRunner({
|
||||
@@ -70,19 +77,24 @@ export default function LessonRunner({
|
||||
const [finished, setFinished] = useState(alreadyEarned);
|
||||
const [earned, setEarned] = useState(alreadyEarned);
|
||||
|
||||
// Replay ("watch it drawn") + narration
|
||||
const [replaying, setReplaying] = useState(false);
|
||||
const [replayK, setReplayK] = useState(1);
|
||||
const [narrate, setNarrate] = useState(false);
|
||||
|
||||
// Completion drawing + paper upload
|
||||
const [finalImage, setFinalImage] = useState<string | null>(null);
|
||||
const [uploadMsg, setUploadMsg] = useState("");
|
||||
const uploadRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const step = steps[idx];
|
||||
const kindInfo = kindForStep(step.kind, idx, steps.length);
|
||||
|
||||
// ---------------- Canvas drawing ----------------
|
||||
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); // restored saved drawing
|
||||
const saveTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
const [color, setColor] = useState(COLORS[0]);
|
||||
const [size, setSize] = useState(SIZES[1]);
|
||||
const [, force] = useState(0);
|
||||
@@ -92,14 +104,11 @@ export default function LessonRunner({
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
const w = canvas.width;
|
||||
const h = canvas.height;
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
if (baseImgRef.current) ctx.drawImage(baseImgRef.current, 0, 0, canvas.width, canvas.height);
|
||||
ctx.lineCap = "round";
|
||||
ctx.lineJoin = "round";
|
||||
for (const s of strokesRef.current) {
|
||||
drawStroke(ctx, s, w, h);
|
||||
}
|
||||
for (const s of strokesRef.current) drawStroke(ctx, s, canvas.width, canvas.height);
|
||||
}, []);
|
||||
|
||||
const resize = useCallback(() => {
|
||||
@@ -119,6 +128,40 @@ export default function LessonRunner({
|
||||
return () => window.removeEventListener("resize", resize);
|
||||
}, [resize]);
|
||||
|
||||
// Restore the saved in-progress drawing
|
||||
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]);
|
||||
|
||||
function hasContent(): boolean {
|
||||
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 pointFromEvent(e: React.PointerEvent): Point {
|
||||
const canvas = canvasRef.current!;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
@@ -141,29 +184,31 @@ export default function LessonRunner({
|
||||
function onPointerUp() {
|
||||
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();
|
||||
}
|
||||
|
||||
// ---------------- "Watch it drawn" replay ----------------
|
||||
function watch() {
|
||||
if (replaying) return;
|
||||
setReplayK(1);
|
||||
setReplaying(true);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!replaying) return;
|
||||
const target = idx + 1; // reveal steps 1..current
|
||||
const target = idx + 1;
|
||||
if (replayK < target) {
|
||||
const t = setTimeout(() => setReplayK((k) => k + 1), 850);
|
||||
return () => clearTimeout(t);
|
||||
@@ -172,7 +217,6 @@ export default function LessonRunner({
|
||||
return () => clearTimeout(t);
|
||||
}, [replaying, replayK, idx]);
|
||||
|
||||
// ---------------- Narration (on-device, optional) ----------------
|
||||
const speak = useCallback((text: string) => {
|
||||
try {
|
||||
const synth = window.speechSynthesis;
|
||||
@@ -183,28 +227,24 @@ export default function LessonRunner({
|
||||
u.pitch = 1.05;
|
||||
synth.speak(u);
|
||||
} catch {
|
||||
/* speech is best-effort */
|
||||
/* best-effort */
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (narrate && !finished) {
|
||||
speak(`Step ${step.n}. ${step.title}. ${step.instruction}`);
|
||||
}
|
||||
if (narrate && !finished) speak(`Step ${step.n}. ${step.title}. ${step.instruction}`);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [idx, narrate, finished]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
useEffect(
|
||||
() => () => {
|
||||
try {
|
||||
window.speechSynthesis?.cancel();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// ---------------- Step navigation + progress ----------------
|
||||
async function saveStep(stepN: number, badgeKey?: string) {
|
||||
if (!loggedIn) return;
|
||||
try {
|
||||
@@ -214,19 +254,18 @@ export default function LessonRunner({
|
||||
body: JSON.stringify({ level: meta.level, sublevel: meta.sublevel, step: stepN, badgeKey }),
|
||||
});
|
||||
} catch {
|
||||
/* progress is best-effort */
|
||||
/* best-effort */
|
||||
}
|
||||
}
|
||||
|
||||
function next() {
|
||||
saveStep(step.n);
|
||||
if (idx < lastIndex) {
|
||||
setIdx(idx + 1);
|
||||
} else {
|
||||
finish();
|
||||
}
|
||||
flushSave();
|
||||
if (idx < lastIndex) setIdx(idx + 1);
|
||||
else finish();
|
||||
}
|
||||
function prev() {
|
||||
flushSave();
|
||||
if (idx > 0) setIdx(idx - 1);
|
||||
}
|
||||
|
||||
@@ -237,11 +276,35 @@ export default function LessonRunner({
|
||||
/* ignore */
|
||||
}
|
||||
await saveStep(step.n, meta.badgeKey);
|
||||
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);
|
||||
setFinished(true);
|
||||
}
|
||||
|
||||
// ---------------- Sharing ----------------
|
||||
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 svg = badgeFullSvg(meta.badgeName, meta.emoji);
|
||||
const blob = new Blob([svg], { type: "image/svg+xml" });
|
||||
@@ -258,7 +321,6 @@ export default function LessonRunner({
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
|
||||
async function shareBadge() {
|
||||
const blob = await rasterizeBadge();
|
||||
if (!blob) return;
|
||||
@@ -266,21 +328,19 @@ export default function LessonRunner({
|
||||
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! 🐟` });
|
||||
await nav.share({ files: [file], title: "My DrawIt badge!", text: `I earned the ${meta.badgeName} badge on DrawIt!` });
|
||||
return;
|
||||
} catch {
|
||||
/* fall through to download */
|
||||
/* fall through */
|
||||
}
|
||||
}
|
||||
downloadBlob(blob, "drawit-badge.png");
|
||||
}
|
||||
|
||||
async function downloadBadge() {
|
||||
const blob = await rasterizeBadge();
|
||||
if (blob) downloadBlob(blob, "drawit-badge.png");
|
||||
}
|
||||
|
||||
// ---------------- Render ----------------
|
||||
if (finished) {
|
||||
return (
|
||||
<div className="form-narrow center">
|
||||
@@ -289,7 +349,7 @@ export default function LessonRunner({
|
||||
<p className="muted">
|
||||
You finished <strong>{meta.title}</strong> and earned the <strong>{meta.badgeName}</strong> badge.
|
||||
</p>
|
||||
<div style={{ maxWidth: 280, margin: "10px auto" }}>
|
||||
<div style={{ maxWidth: 240, margin: "10px auto" }}>
|
||||
<svg viewBox="0 0 300 300" width="100%" dangerouslySetInnerHTML={{ __html: badgeSvgMarkup(meta.badgeName, meta.emoji) }} />
|
||||
</div>
|
||||
|
||||
@@ -304,15 +364,44 @@ export default function LessonRunner({
|
||||
) : (
|
||||
<div className="notice info">
|
||||
Nice drawing! <Link href="/signup">Create an account</Link> or <Link href="/login">log in</Link> to
|
||||
earn the badge and download your printable award.
|
||||
earn the badge and keep your drawings.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="row" style={{ justifyContent: "center", marginTop: 16 }}>
|
||||
{finalImage && (
|
||||
<div style={{ marginTop: 16, textAlign: "center" }}>
|
||||
<strong>Your drawing</strong>
|
||||
<img
|
||||
src={finalImage}
|
||||
alt="Your finished 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);
|
||||
}}
|
||||
@@ -352,12 +441,14 @@ export default function LessonRunner({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Progress dots */}
|
||||
<div className="row" style={{ gap: 8, margin: "8px 0 14px" }}>
|
||||
{steps.map((s, i) => (
|
||||
<button
|
||||
key={s.n}
|
||||
onClick={() => setIdx(i)}
|
||||
onClick={() => {
|
||||
flushSave();
|
||||
setIdx(i);
|
||||
}}
|
||||
aria-label={`Go to step ${s.n}`}
|
||||
style={{
|
||||
width: 30,
|
||||
@@ -376,7 +467,6 @@ export default function LessonRunner({
|
||||
</div>
|
||||
|
||||
<div style={{ display: "grid", gap: 18, gridTemplateColumns: "1fr", alignItems: "start" }}>
|
||||
{/* Stage */}
|
||||
<div>
|
||||
<div
|
||||
ref={stageRef}
|
||||
@@ -417,23 +507,15 @@ export default function LessonRunner({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Watch + narration */}
|
||||
<div className="row" style={{ justifyContent: "center", marginTop: 12, gap: 10 }}>
|
||||
<button className="btn" style={toolBtn} onClick={watch} disabled={replaying}>
|
||||
{replaying ? "▶ Playing…" : "▶ Watch it drawn"}
|
||||
</button>
|
||||
<button
|
||||
className="btn secondary"
|
||||
style={toolBtn}
|
||||
aria-pressed={narrate}
|
||||
onClick={() => setNarrate((v) => !v)}
|
||||
title="Read each step out loud (stays on your device)"
|
||||
>
|
||||
<button className="btn secondary" style={toolBtn} aria-pressed={narrate} onClick={() => setNarrate((v) => !v)} title="Read each step out loud (stays on your device)">
|
||||
{narrate ? "🔊 Read aloud: on" : "🔇 Read aloud: off"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tools */}
|
||||
<div className="row" style={{ justifyContent: "center", marginTop: 12, gap: 10 }}>
|
||||
{COLORS.map((c) => (
|
||||
<button
|
||||
@@ -441,8 +523,8 @@ export default function LessonRunner({
|
||||
onClick={() => setColor(c)}
|
||||
aria-label={`Color ${c}`}
|
||||
style={{
|
||||
width: 34,
|
||||
height: 34,
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: "50%",
|
||||
background: c,
|
||||
border: color === c ? "3px solid var(--ink)" : "3px solid #fff",
|
||||
@@ -480,9 +562,9 @@ export default function LessonRunner({
|
||||
{showGuide ? "🙈 Hide guide" : "👁 Show guide"}
|
||||
</button>
|
||||
</div>
|
||||
{loggedIn && <p className="muted center" style={{ fontSize: "0.8rem", marginTop: 8 }}>Your drawing is saved automatically.</p>}
|
||||
</div>
|
||||
|
||||
{/* Instruction */}
|
||||
<div className="card">
|
||||
<div className="row" style={{ justifyContent: "space-between", alignItems: "center" }}>
|
||||
<strong style={{ fontSize: "1.15rem" }}>
|
||||
|
||||
@@ -34,7 +34,9 @@ export default function NavLinks({
|
||||
<Link href="/report" className={cls("/report")} aria-current={current("/report")}>
|
||||
Report a problem
|
||||
</Link>
|
||||
<span className="pill role">{user.username}</span>
|
||||
<Link href="/profile" className={cls("/profile")} aria-current={current("/profile")} title="My drawings & badges">
|
||||
🖼️ {user.username}
|
||||
</Link>
|
||||
<LogoutButton />
|
||||
</>
|
||||
) : (
|
||||
|
||||
+29
-10
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* The DrawIt curriculum. Levels describe the learner; each level has numbered sub-levels (lessons).
|
||||
* Each lesson is ordered steps built from simple shapes. SVG fragments use a 400x300 viewBox and
|
||||
* STACK: rendering steps 1..n draws the whole picture (powers the slideshow and the lesson guide).
|
||||
* Draw lessons: SVG fragments STACK (steps 1..n = full picture). Shade lessons (mode "shade"):
|
||||
* each step's svg is a full guide (light source + zone hints), not cumulative.
|
||||
*/
|
||||
|
||||
export interface LevelMeta { key: string; name: string; emoji: string; blurb: string; }
|
||||
@@ -20,7 +20,7 @@ export type StepKind = "construct" | "outline" | "color" | "shade" | "detail";
|
||||
|
||||
export interface LessonStep { n: number; title: string; instruction: string; tip?: string; kind?: StepKind; svg: string; }
|
||||
|
||||
export interface Lesson { level: string; sublevel: number; slug: string; title: string; subject: string; emoji: string; intro: string; badgeKey: string; badgeName: string; 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"; steps: LessonStep[]; }
|
||||
|
||||
|
||||
const FISH_STEPS: LessonStep[] = [
|
||||
@@ -41,7 +41,7 @@ const FISH_STEPS: LessonStep[] = [
|
||||
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"/>` },
|
||||
];
|
||||
|
||||
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", steps: FISH_STEPS };
|
||||
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 PANDA_STEPS: LessonStep[] = [
|
||||
{ n: 1, kind: "construct", title: "The head", instruction: "Draw one big round face.",
|
||||
@@ -61,7 +61,7 @@ const PANDA_STEPS: LessonStep[] = [
|
||||
svg: `<ellipse cx="150" cy="186" rx="15" ry="9" fill="#ffb6cf"/><ellipse cx="250" cy="186" rx="15" ry="9" fill="#ffb6cf"/>` },
|
||||
];
|
||||
|
||||
export const PANDA_LESSON: Lesson = { level: "early-beginner", sublevel: 2, slug: "panda", title: "Draw a Panda", subject: "a cuddly panda", emoji: "🐼", intro: "A panda is just circles and ovals. Let's make a cuddly friend!", badgeKey: "early-beginner-2-panda", badgeName: "Panda Pal", steps: PANDA_STEPS };
|
||||
export const PANDA_LESSON: Lesson = { level: "early-beginner", sublevel: 2, slug: "panda", title: "Draw a Panda", subject: "a cuddly panda", emoji: "🐼", intro: "A panda is just circles and ovals. Let's make a cuddly friend!", badgeKey: "early-beginner-2-panda", badgeName: "Panda Pal", mode: "draw", steps: PANDA_STEPS };
|
||||
|
||||
const FLOWER_STEPS: LessonStep[] = [
|
||||
{ n: 1, kind: "construct", title: "The middle", instruction: "Draw a circle in the center of the page.",
|
||||
@@ -83,7 +83,7 @@ const FLOWER_STEPS: LessonStep[] = [
|
||||
svg: `<path d="M168 210 q22 4 30 16 M232 238 q-20 4 -30 -8" fill="none" stroke="#3a9b57" stroke-width="2.5"/>` },
|
||||
];
|
||||
|
||||
export const FLOWER_LESSON: Lesson = { level: "early-beginner", sublevel: 3, slug: "flower", title: "Draw a Flower", subject: "a cheerful flower", emoji: "🌸", intro: "A circle in the middle and petals all around — then a stem and leaves.", badgeKey: "early-beginner-3-flower", badgeName: "Flower Power", steps: FLOWER_STEPS };
|
||||
export const FLOWER_LESSON: Lesson = { level: "early-beginner", sublevel: 3, slug: "flower", title: "Draw a Flower", subject: "a cheerful flower", emoji: "🌸", intro: "A circle in the middle and petals all around — then a stem and leaves.", badgeKey: "early-beginner-3-flower", badgeName: "Flower Power", mode: "draw", steps: FLOWER_STEPS };
|
||||
|
||||
const UNICORN_STEPS: LessonStep[] = [
|
||||
{ n: 1, kind: "construct", title: "The head", instruction: "Draw a rounded head that's a little narrower at the chin.",
|
||||
@@ -103,7 +103,7 @@ const UNICORN_STEPS: LessonStep[] = [
|
||||
svg: `<path d="M176 92 C 160 110, 168 140, 190 132 C 176 120, 182 104, 196 96 Z" fill="#c79bff"/><path d="M200 88 C 196 120, 214 122, 214 96 Z" fill="#8ec9ff"/><g stroke="#ffd45e" stroke-width="3" fill="none" stroke-linecap="round"><path d="M300 95 l0 14 M293 102 l14 0"/><path d="M120 110 l0 10 M115 115 l10 0"/></g>` },
|
||||
];
|
||||
|
||||
export const UNICORN_LESSON: Lesson = { level: "early-beginner", sublevel: 4, slug: "unicorn", title: "Draw a Unicorn", subject: "a magical unicorn", emoji: "🦄", intro: "A rounded head, a sparkly horn, and a rainbow mane. Let's make magic!", badgeKey: "early-beginner-4-unicorn", badgeName: "Unicorn Magic", steps: UNICORN_STEPS };
|
||||
export const UNICORN_LESSON: Lesson = { level: "early-beginner", sublevel: 4, slug: "unicorn", title: "Draw a Unicorn", subject: "a magical unicorn", emoji: "🦄", intro: "A rounded head, a sparkly horn, and a rainbow mane. Let's make magic!", badgeKey: "early-beginner-4-unicorn", badgeName: "Unicorn Magic", mode: "draw", steps: UNICORN_STEPS };
|
||||
|
||||
const TREE_STEPS: LessonStep[] = [
|
||||
{ n: 1, kind: "construct", title: "The trunk", instruction: "Draw a trunk that's a little wider at the bottom.",
|
||||
@@ -125,7 +125,7 @@ const TREE_STEPS: LessonStep[] = [
|
||||
svg: `<path d="M194 198 q4 24 0 56 M206 198 q-3 24 0 54" fill="none" stroke="#8a5a2b" stroke-width="2.5"/>` },
|
||||
];
|
||||
|
||||
export const TREE_LESSON: Lesson = { level: "early-beginner", sublevel: 5, slug: "tree", title: "Draw a Tree", subject: "a leafy tree", emoji: "🌳", intro: "A trunk, some branches, and big clumps of leaves make a leafy tree.", badgeKey: "early-beginner-5-tree", badgeName: "Tree Top", steps: TREE_STEPS };
|
||||
export const TREE_LESSON: Lesson = { level: "early-beginner", sublevel: 5, slug: "tree", title: "Draw a Tree", subject: "a leafy tree", emoji: "🌳", intro: "A trunk, some branches, and big clumps of leaves make a leafy tree.", badgeKey: "early-beginner-5-tree", badgeName: "Tree Top", mode: "draw", steps: TREE_STEPS };
|
||||
|
||||
const DINO_STEPS: LessonStep[] = [
|
||||
{ n: 1, kind: "construct", title: "The body", instruction: "Draw a big rounded body.",
|
||||
@@ -149,9 +149,28 @@ const DINO_STEPS: LessonStep[] = [
|
||||
svg: `<path d="M108 56 q12 10 24 3" fill="none" stroke="#2b2440" stroke-width="2.5" stroke-linecap="round"/>` },
|
||||
];
|
||||
|
||||
export const DINO_LESSON: Lesson = { level: "early-beginner", sublevel: 6, slug: "dinosaur", title: "Draw a Dinosaur", subject: "a friendly dinosaur", emoji: "🦕", intro: "A round body, a long neck, a small head, and a swooshy tail!", badgeKey: "early-beginner-6-dinosaur", badgeName: "Dino Master", steps: DINO_STEPS };
|
||||
export const DINO_LESSON: Lesson = { level: "early-beginner", sublevel: 6, slug: "dinosaur", title: "Draw a Dinosaur", subject: "a friendly dinosaur", emoji: "🦕", intro: "A round body, a long neck, a small head, and a swooshy tail!", badgeKey: "early-beginner-6-dinosaur", badgeName: "Dino Master", mode: "draw", steps: DINO_STEPS };
|
||||
|
||||
export const LESSONS: Lesson[] = [FISH_LESSON, PANDA_LESSON, FLOWER_LESSON, UNICORN_LESSON, TREE_LESSON, DINO_LESSON];
|
||||
const SHADE_STEPS: LessonStep[] = [
|
||||
{ n: 1, kind: "shade", title: "How to hold your pencil", instruction: "Watch the demo above. Holding near the tip and pressing hard makes DARK shades; holding high up and pressing soft makes LIGHT shades.",
|
||||
tip: "Try it in the air first!",
|
||||
svg: `<g><circle cx="62" cy="46" r="20" fill="#ffd45e"/><g stroke="#ffd45e" stroke-width="4" stroke-linecap="round"><path d="M62 14v-8M62 86v8M30 46h-8M94 46h8M39 23l-6-6M85 23l6-6M39 69l-6 6M85 69l6 6"/></g></g><line x1="40" y1="234" x2="370" y2="234" stroke="#c9c4d6" stroke-width="3"/><circle cx="210" cy="150" r="84" fill="#f1f1f4" stroke="#2b2440" stroke-width="4"/>` },
|
||||
{ n: 2, kind: "shade", title: "Find the light", instruction: "The sun is up and to the left. The side of the ball facing the sun stays the brightest. That bright spot is the highlight.",
|
||||
svg: `<g><circle cx="62" cy="46" r="20" fill="#ffd45e"/><g stroke="#ffd45e" stroke-width="4" stroke-linecap="round"><path d="M62 14v-8M62 86v8M30 46h-8M94 46h8M39 23l-6-6M85 23l6-6M39 69l-6 6M85 69l6 6"/></g></g><line x1="40" y1="234" x2="370" y2="234" stroke="#c9c4d6" stroke-width="3"/><circle cx="210" cy="150" r="84" fill="#f1f1f4" stroke="#2b2440" stroke-width="4"/><circle cx="178" cy="118" r="22" fill="none" stroke="#4f7cff" stroke-width="3" stroke-dasharray="6 6"/>` },
|
||||
{ n: 3, kind: "shade", title: "Soft shade all over", instruction: "Pick a light tone and, with soft gentle passes, shade the whole ball — but leave the bright highlight spot white.",
|
||||
tip: "Hold high and go easy.",
|
||||
svg: `<g><circle cx="62" cy="46" r="20" fill="#ffd45e"/><g stroke="#ffd45e" stroke-width="4" stroke-linecap="round"><path d="M62 14v-8M62 86v8M30 46h-8M94 46h8M39 23l-6-6M85 23l6-6M39 69l-6 6M85 69l6 6"/></g></g><line x1="40" y1="234" x2="370" y2="234" stroke="#c9c4d6" stroke-width="3"/><circle cx="210" cy="150" r="84" fill="#f1f1f4" stroke="#2b2440" stroke-width="4"/><circle cx="178" cy="118" r="22" fill="none" stroke="#4f7cff" stroke-width="3" stroke-dasharray="6 6"/>` },
|
||||
{ n: 4, kind: "shade", title: "Build up the shadow", instruction: "Pick a darker tone and press a little harder on the side AWAY from the sun. Go over it a few times to build it up slowly.",
|
||||
tip: "More passes = darker.",
|
||||
svg: `<g><circle cx="62" cy="46" r="20" fill="#ffd45e"/><g stroke="#ffd45e" stroke-width="4" stroke-linecap="round"><path d="M62 14v-8M62 86v8M30 46h-8M94 46h8M39 23l-6-6M85 23l6-6M39 69l-6 6M85 69l6 6"/></g></g><line x1="40" y1="234" x2="370" y2="234" stroke="#c9c4d6" stroke-width="3"/><circle cx="210" cy="150" r="84" fill="#f1f1f4" stroke="#2b2440" stroke-width="4"/><path d="M250 96 A84 84 0 0 1 250 204 A60 70 0 0 0 250 96 Z" fill="#2b2440" opacity="0.18"/>` },
|
||||
{ n: 5, kind: "shade", title: "Cast shadow & finish", instruction: "Shade the ground just under the ball on the shadow side. Keep that highlight bright and clean. You did it!",
|
||||
tip: "🌗",
|
||||
svg: `<g><circle cx="62" cy="46" r="20" fill="#ffd45e"/><g stroke="#ffd45e" stroke-width="4" stroke-linecap="round"><path d="M62 14v-8M62 86v8M30 46h-8M94 46h8M39 23l-6-6M85 23l6-6M39 69l-6 6M85 69l6 6"/></g></g><line x1="40" y1="234" x2="370" y2="234" stroke="#c9c4d6" stroke-width="3"/><circle cx="210" cy="150" r="84" fill="#f1f1f4" stroke="#2b2440" stroke-width="4"/><path d="M250 96 A84 84 0 0 1 250 204 A60 70 0 0 0 250 96 Z" fill="#2b2440" opacity="0.18"/><ellipse cx="262" cy="236" rx="78" ry="15" fill="#2b2440" opacity="0.18"/>` },
|
||||
];
|
||||
|
||||
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 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); }
|
||||
|
||||
@@ -99,10 +99,34 @@ function migrate(db: Database.Database) {
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS drawings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
level TEXT NOT NULL,
|
||||
sublevel INTEGER NOT NULL,
|
||||
status TEXT NOT NULL, -- 'progress' | 'final'
|
||||
kind TEXT NOT NULL, -- 'canvas' | 'upload'
|
||||
image TEXT NOT NULL, -- data URL (PNG/JPEG)
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tokens_token ON tokens(token);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token);
|
||||
CREATE INDEX IF NOT EXISTS idx_progress_user ON progress(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_reports_status ON reports(status);
|
||||
CREATE TABLE IF NOT EXISTS completions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
level TEXT NOT NULL,
|
||||
sublevel INTEGER NOT NULL,
|
||||
badge_key TEXT NOT NULL,
|
||||
completed_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_drawings_user ON drawings(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_drawings_progress ON drawings(user_id, level, sublevel, status);
|
||||
CREATE INDEX IF NOT EXISTS idx_completions_user ON completions(user_id);
|
||||
`);
|
||||
}
|
||||
|
||||
|
||||
@@ -52,3 +52,28 @@ export function listBadges(userId: number): { badge_key: string; awarded_at: str
|
||||
.prepare("SELECT badge_key, awarded_at FROM badges WHERE user_id = ? ORDER BY awarded_at")
|
||||
.all(userId) as { badge_key: string; awarded_at: string }[];
|
||||
}
|
||||
|
||||
/** Records one row per lesson completion, so the profile can show every date completed. */
|
||||
export function recordCompletion(userId: number, level: string, sublevel: number, badgeKey: string): void {
|
||||
getDb()
|
||||
.prepare(
|
||||
`INSERT INTO completions (user_id, level, sublevel, badge_key) VALUES (?, ?, ?, ?)`
|
||||
)
|
||||
.run(userId, level, sublevel, badgeKey);
|
||||
}
|
||||
|
||||
export interface CompletionRow {
|
||||
badge_key: string;
|
||||
level: string;
|
||||
sublevel: number;
|
||||
completed_at: string;
|
||||
}
|
||||
|
||||
export function listCompletions(userId: number): CompletionRow[] {
|
||||
return getDb()
|
||||
.prepare(
|
||||
`SELECT badge_key, level, sublevel, completed_at FROM completions
|
||||
WHERE user_id = ? ORDER BY completed_at DESC`
|
||||
)
|
||||
.all(userId) as CompletionRow[];
|
||||
}
|
||||
Reference in new issue
Block a user