From 81c63e5822294282d7108292ee83ca2c1a81bc80 Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Wed, 17 Jun 2026 14:49:53 -0500 Subject: [PATCH] Add shading to higher level and tutorial --- src/components/GripPressureDemo.tsx | 101 ++++++++ src/components/ShadingRunner.tsx | 367 +++++++++++++++++++++++++++ src/components/drawingPersistence.ts | 84 ++++++ 3 files changed, 552 insertions(+) create mode 100644 src/components/GripPressureDemo.tsx create mode 100644 src/components/ShadingRunner.tsx create mode 100644 src/components/drawingPersistence.ts diff --git a/src/components/GripPressureDemo.tsx b/src/components/GripPressureDemo.tsx new file mode 100644 index 0000000..851d8ca --- /dev/null +++ b/src/components/GripPressureDemo.tsx @@ -0,0 +1,101 @@ +"use client"; + +/** + * Animated "how to hold your pencil" demo (no video file — pure SVG/CSS, stays on device). + * Left: grip near the tip + firm pressure → dark. Right: grip high + soft pressure → light. + */ + +function Pencil({ gripY }: { gripY: number }) { + // Tip at local (0,0) pointing down; body extends up (negative y). + return ( + + + + + + + + + + ); +} + +const ZIG = "M52 150 l24 -14 M44 162 l40 -22 M44 176 l54 -30 M50 190 l60 -34 M70 198 l52 -30"; + +function Panel({ + gripY, + color, + delay, + title, + sub, +}: { + gripY: number; + color: string; + delay: string; + title: string; + sub: string; +}) { + return ( +
+ + + + + + {title} + {sub} +
+ ); +} + +export default function GripPressureDemo() { + return ( +
+ ✏️ How to hold your pencil +

+ Where you hold the pencil and how hard you press changes how dark your shading is. +

+
+ + +
+
+
+
+ light + build up slowly for darker + dark +
+
+
+ ); +} diff --git a/src/components/ShadingRunner.tsx b/src/components/ShadingRunner.tsx new file mode 100644 index 0000000..db40b26 --- /dev/null +++ b/src/components/ShadingRunner.tsx @@ -0,0 +1,367 @@ +"use client"; +import { useCallback, useEffect, useRef, useState } from "react"; +import Link from "next/link"; +import { badgeSvgMarkup, badgeFullSvg } from "./BadgeArt"; +import GripPressureDemo from "./GripPressureDemo"; +import { + loadProgressDrawing, + saveProgressDrawing, + saveFinalDrawing, + exportCanvasToDataURL, + fileToDataURL, +} from "./drawingPersistence"; + +interface StepData { n: number; title: string; instruction: string; tip: string; guideSvg: string; } +interface Meta { + level: string; levelName: string; sublevel: number; title: string; emoji: string; + intro: string; badgeKey: string; badgeName: string; +} + +const TONES = [ + { label: "Light", color: "#cfcad8" }, + { label: "Medium", color: "#928ca6" }, + { label: "Dark", color: "#56506a" }, + { label: "Darkest", color: "#2b2440" }, +]; +const SIZES = [16, 30, 48]; + +interface Point { x: number; y: number; } +interface Stroke { color: string; size: number; erase: boolean; alpha: number; points: Point[]; } + +export default function ShadingRunner({ + 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 [showDemo, setShowDemo] = useState(true); + const [finalImage, setFinalImage] = useState(null); + const [uploadMsg, setUploadMsg] = useState(""); + const uploadRef = useRef(null); + const step = steps[idx]; + + const canvasRef = useRef(null); + const stageRef = useRef(null); + const strokesRef = useRef([]); + const drawingRef = useRef(null); + const baseImgRef = useRef(null); + const saveTimer = useRef | undefined>(undefined); + const [toneIdx, setToneIdx] = useState(0); + const [size, setSize] = useState(SIZES[1]); + const [erasing, setErasing] = useState(false); + const [, force] = useState(0); + + const redraw = useCallback(() => { + const canvas = canvasRef.current; + if (!canvas) return; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.globalCompositeOperation = "source-over"; + ctx.globalAlpha = 1; + if (baseImgRef.current) ctx.drawImage(baseImgRef.current, 0, 0, canvas.width, canvas.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 === 0) 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 * canvas.width, s.points[0].y * canvas.height); + if (s.points.length === 1) { + ctx.lineTo(s.points[0].x * canvas.width + 0.1, s.points[0].y * canvas.height + 0.1); + } else { + for (let i = 1; i < s.points.length; i++) ctx.lineTo(s.points[i].x * canvas.width, s.points[i].y * canvas.height); + } + ctx.stroke(); + } + ctx.globalCompositeOperation = "source-over"; + ctx.globalAlpha = 1; + }, []); + + const resize = useCallback(() => { + const canvas = canvasRef.current; + const stage = stageRef.current; + if (!canvas || !stage) return; + const rect = stage.getBoundingClientRect(); + const dpr = Math.min(window.devicePixelRatio || 1, 2); + canvas.width = Math.round(rect.width * dpr); + canvas.height = Math.round(rect.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]); + + 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 pt(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 pressure = e.pressure && e.pressure > 0 ? e.pressure : 0.5; + const alpha = Math.min(0.45, 0.1 + pressure * 0.32); + drawingRef.current = { color: TONES[toneIdx].color, size, erase: erasing, alpha, points: [pt(e)] }; + strokesRef.current.push(drawingRef.current); + } + function onMove(e: React.PointerEvent) { + const s = drawingRef.current; + if (!s) return; + s.points.push(pt(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 { /* best-effort */ } + } + 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); + 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); + } + + async function onUpload(e: React.ChangeEvent) { + const file = e.target.files?.[0]; + if (!file) return; + if (!loggedIn) { setUploadMsg("Log in to save your drawing to your gallery."); return; } + setUploadMsg("Uploading…"); + try { + const data = await fileToDataURL(file); + const ok = await saveFinalDrawing(meta.level, meta.sublevel, "upload", data); + setUploadMsg(ok ? "Saved to your gallery! 🖼️" : "Couldn't save that file."); + } catch { + setUploadMsg("Couldn't read that image."); + } finally { + if (uploadRef.current) uploadRef.current.value = ""; + } + } + + async function rasterizeBadge(): Promise { + const blob = new Blob([badgeFullSvg(meta.badgeName, meta.emoji)], { type: "image/svg+xml" }); + const url = URL.createObjectURL(blob); + try { + const img = await loadImage(url); + const c = document.createElement("canvas"); + c.width = 600; c.height = 600; + c.getContext("2d")!.drawImage(img, 0, 0, 600, 600); + return await new Promise((res) => c.toBlob((b) => res(b), "image/png")); + } finally { URL.revokeObjectURL(url); } + } + async function shareBadge() { + const blob = await rasterizeBadge(); + if (!blob) return; + const file = new File([blob], "drawit-badge.png", { type: "image/png" }); + const nav = navigator as Navigator & { canShare?: (d: ShareData) => boolean }; + if (nav.share && nav.canShare && nav.canShare({ files: [file] })) { + try { await nav.share({ files: [file], title: "My DrawIt badge!", text: `I earned the ${meta.badgeName} badge on DrawIt!` }); return; } catch { /* fall through */ } + } + downloadBlob(blob, "drawit-badge.png"); + } + async function downloadBadge() { const b = await rasterizeBadge(); if (b) downloadBlob(b, "drawit-badge.png"); } + + if (finished) { + return ( +
+
+

🎉 Beautiful shading!

+

You finished {meta.title} and earned the {meta.badgeName} badge.

+
+ +
+ {earned ? ( +
+
+ + +
+ 🏆 Download award (PDF) +
+ ) : ( +
Lovely work! Create an account or log in to earn the badge and keep your drawings.
+ )} + + {finalImage && ( +
+ Your drawing + Your finished drawing + ⬇ Download my drawing +
+ )} + +
+ Shaded it on paper? +

Take a photo and add it to your gallery.

+ + + {uploadMsg &&

{uploadMsg}

} +
+ +
+ {loggedIn && 🖼️ My drawings} + + More lessons +
+
+
+ ); + } + + return ( +
+
+
+
{meta.levelName} · Level {meta.sublevel}
+

{meta.emoji} {meta.title}

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

{meta.intro}

} + +
+ +
+ {showDemo &&
} + +
+ {steps.map((s, i) => ( + + ))} +
+ +
+
+
+ + +
+ +
+ Tone: + {TONES.map((t, i) => ( + +
+ +
+ {SIZES.map((s) => ( + + ))} + + + +
+

+ Tip: shade with soft, repeated passes — go over the dark side a few times to build it up. On a stylus, press harder for darker. + {loggedIn && " Your work is saved automatically."} +

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

{step.instruction}

+ {step.tip &&

💡 {step.tip}

} +
+ + +
+ {!loggedIn && ( +

Log in to save your progress and earn a badge.

+ )} +
+
+
+ ); +} + +const toolBtn: React.CSSProperties = { minHeight: 40, padding: "8px 14px", fontSize: "0.9rem", boxShadow: "none" }; + +function loadImage(src: string): Promise { + return new Promise((res, rej) => { const img = new Image(); img.onload = () => res(img); img.onerror = rej; img.src = src; }); +} +function downloadBlob(blob: Blob, name: string) { + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; a.download = name; a.click(); + setTimeout(() => URL.revokeObjectURL(url), 1000); +} diff --git a/src/components/drawingPersistence.ts b/src/components/drawingPersistence.ts new file mode 100644 index 0000000..3d2fb4e --- /dev/null +++ b/src/components/drawingPersistence.ts @@ -0,0 +1,84 @@ +// Client helpers for saving/loading a user's canvas drawing (shared by both lesson runners). + +export async function loadProgressDrawing(level: string, sublevel: number): Promise { + try { + const res = await fetch(`/api/drawings/progress?level=${encodeURIComponent(level)}&sublevel=${sublevel}`); + if (!res.ok) return null; + const data = await res.json(); + return data.image ?? null; + } catch { + return null; + } +} + +export async function saveProgressDrawing(level: string, sublevel: number, image: string): Promise { + try { + await fetch("/api/drawings/progress", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ level, sublevel, image }), + }); + } catch { + /* best-effort */ + } +} + +export async function saveFinalDrawing( + level: string, + sublevel: number, + kind: "canvas" | "upload", + image: string +): Promise { + try { + const res = await fetch("/api/drawings/final", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ level, sublevel, kind, image }), + }); + return res.ok; + } catch { + return false; + } +} + +/** Composite the (transparent) canvas onto white and downscale, returning a PNG data URL. */ +export function exportCanvasToDataURL(canvas: HTMLCanvasElement, maxW = 800): string { + const scale = Math.min(1, maxW / canvas.width); + const w = Math.round(canvas.width * scale); + const h = Math.round(canvas.height * scale); + const out = document.createElement("canvas"); + out.width = w; + out.height = h; + const ctx = out.getContext("2d")!; + ctx.fillStyle = "#ffffff"; + ctx.fillRect(0, 0, w, h); + ctx.drawImage(canvas, 0, 0, w, h); + return out.toDataURL("image/png"); +} + +/** Read an uploaded image File into a downscaled JPEG data URL (keeps photos small). */ +export function fileToDataURL(file: File, maxW = 1000): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onerror = reject; + reader.onload = () => { + const img = new Image(); + img.onerror = reject; + img.onload = () => { + const scale = Math.min(1, maxW / img.width); + const w = Math.round(img.width * scale); + const h = Math.round(img.height * scale); + const c = document.createElement("canvas"); + c.width = w; + c.height = h; + const ctx = c.getContext("2d")!; + ctx.fillStyle = "#ffffff"; + ctx.fillRect(0, 0, w, h); + ctx.drawImage(img, 0, 0, w, h); + resolve(c.toDataURL("image/jpeg", 0.85)); + }; + img.src = reader.result as string; + }; + reader.readAsDataURL(file); + }); +}