Add shading to higher level and tutorial
This commit is contained in:
1 parent
338665c2f9
commit
81c63e5822
3 files changed
+552
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user