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:
iamdoubz committed 2026-06-17 16:46:38 -05:00
1 parent 9b9408f596
commit f58b470396
8 files changed
+268 -87

No files matched your search

+138 -56
View File
@@ -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" }}>
+3 -1
View File
@@ -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 &amp; badges">
🖼️ {user.username}
</Link>
<LogoutButton />
</>
) : (