Add shading to higher level and tutorial

This commit is contained in:
iamdoubz committed 2026-06-17 14:49:53 -05:00
1 parent 338665c2f9
commit 81c63e5822
3 files changed
+552

No files matched your search

+101
View File
@@ -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 (
<g transform="translate(120,150) rotate(-18)">
<polygon points="0,0 -7,-20 7,-20" fill="#3a3a3a" />
<polygon points="-7,-20 7,-20 12,-42 -12,-42" fill="#e7b24e" />
<rect x="-12" y="-192" width="24" height="150" fill="#ffd45e" stroke="#e0a92f" strokeWidth="2" />
<rect x="-12" y="-206" width="24" height="14" fill="#cfd3d6" />
<rect x="-12" y="-228" width="24" height="22" rx="8" fill="#ff9ec4" />
<ellipse cx="0" cy={gripY} rx="20" ry="14" fill="#4f7cff" opacity="0.28" />
<ellipse cx="0" cy={gripY} rx="20" ry="14" fill="none" stroke="#4f7cff" strokeWidth="2.5" />
</g>
);
}
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 (
<div style={{ textAlign: "center" }}>
<svg viewBox="0 -70 220 330" width="100%" style={{ maxHeight: 220 }} role="img" aria-label={title}>
<rect x="26" y="120" width="160" height="86" rx="12" fill="#fff" stroke="#e3dff0" strokeWidth="2" />
<path
className="shade-scribble"
style={{ animationDelay: delay }}
d={ZIG}
fill="none"
stroke={color}
strokeWidth="9"
strokeLinecap="round"
strokeLinejoin="round"
/>
<Pencil gripY={gripY} />
</svg>
<strong style={{ display: "block", marginTop: 4 }}>{title}</strong>
<span className="muted" style={{ fontSize: "0.9rem" }}>{sub}</span>
</div>
);
}
export default function GripPressureDemo() {
return (
<div className="card" style={{ background: "#fdf9f3" }}>
<strong style={{ fontSize: "1.05rem" }}>✏️ How to hold your pencil</strong>
<p className="muted" style={{ margin: "4px 0 10px", fontSize: "0.92rem" }}>
Where you hold the pencil and how hard you press changes how dark your shading is.
</p>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12 }}>
<Panel
gripY={-55}
color="#332f47"
delay="0s"
title="Near the tip + firm"
sub="= dark, strong shades"
/>
<Panel
gripY={-150}
color="#cdc8da"
delay="1.4s"
title="High up + soft"
sub="= light, gentle shades"
/>
</div>
<div style={{ marginTop: 12 }}>
<div
aria-hidden
style={{
height: 16,
borderRadius: 999,
background: "linear-gradient(90deg, #ffffff, #2b2440)",
border: "1px solid #e3dff0",
}}
/>
<div className="row" style={{ justifyContent: "space-between", fontSize: "0.8rem", marginTop: 2 }}>
<span className="muted">light</span>
<span className="muted">build up slowly for darker</span>
<span className="muted">dark</span>
</div>
</div>
</div>
);
}
+367
View File
@@ -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<string | null>(null);
const [uploadMsg, setUploadMsg] = useState("");
const uploadRef = useRef<HTMLInputElement | null>(null);
const step = steps[idx];
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 [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<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 { /* fall through */ }
}
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>🎉 Beautiful shading!</h1>
<p className="muted">You finished <strong>{meta.title}</strong> and earned the <strong>{meta.badgeName}</strong> badge.</p>
<div style={{ maxWidth: 240, 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 (PNG)</button>
</div>
<a className="btn accent" href={`/api/award/${meta.badgeKey}`}>🏆 Download award (PDF)</a>
</div>
) : (
<div className="notice info">Lovely work! <Link href="/signup">Create an account</Link> or <Link href="/login">log in</Link> to earn the badge and keep your drawings.</div>
)}
{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>Shaded 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">More 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} · Level {meta.sublevel}</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={{ marginBottom: 12 }}>
<button className="btn secondary" style={toolBtn} onClick={() => setShowDemo((v) => !v)}>
{showDemo ? "🙈 Hide pencil tips" : "✏️ How to hold your pencil"}
</button>
</div>
{showDemo && <div style={{ marginBottom: 16 }}><GripPressureDemo /></div>}
<div className="row" style={{ gap: 8, margin: "4px 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" }}>
<svg viewBox="0 0 400 300" style={{ position: "absolute", inset: 0, width: "100%", height: "100%", opacity: 0.7, pointerEvents: "none" }}
dangerouslySetInnerHTML={{ __html: step.guideSvg }} />
<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: 8, alignItems: "center" }}>
<span className="muted" style={{ fontSize: "0.85rem", fontWeight: 700 }}>Tone:</span>
{TONES.map((t, i) => (
<button key={t.color} onClick={() => { setToneIdx(i); setErasing(false); }} aria-label={t.label} title={t.label}
style={{ width: 38, height: 30, borderRadius: 8, background: t.color, cursor: "pointer", border: !erasing && toneIdx === i ? "3px solid var(--ink)" : "3px solid #fff", boxShadow: "0 0 0 1px var(--line)" }} />
))}
<button onClick={() => setErasing(true)} aria-label="Eraser" title="Eraser (lift the highlight)"
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: 8 }}>
{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 / 2.5, height: s / 2.5, borderRadius: "50%", background: size === s ? "#fff" : "var(--ink)" }} />
</button>
))}
<span style={{ width: 1, height: 28, background: "var(--line)" }} />
<button className="btn secondary" style={toolBtn} onClick={undo}>↶ Undo</button>
<button className="btn secondary" style={toolBtn} onClick={clearCanvas}>🗑 Clear</button>
</div>
<p className="muted center" style={{ fontSize: "0.82rem", marginTop: 8 }}>
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."}
</p>
</div>
<div className="card">
<div className="row" style={{ justifyContent: "space-between", alignItems: "center" }}>
<strong style={{ fontSize: "1.15rem" }}>Step {step.n}: {step.title}</strong>
<span className="pill role" title="What this step teaches">🌑 Shading</span>
</div>
<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 →" : "🎉 Finish & get my badge"}</button>
</div>
{!loggedIn && (
<p className="muted center" style={{ marginTop: 12, fontSize: "0.9rem" }}><Link href="/login">Log in</Link> to save your progress and earn a badge.</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 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);
}
+84
View File
@@ -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<string | null> {
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<void> {
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<boolean> {
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<string> {
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);
});
}