Add lessons in each level: outline, detail, extra
This commit is contained in:
1 parent
f58b470396
commit
2dc54c1dff
7 files changed
+734
-322
No files matched your search
@@ -0,0 +1,180 @@
|
||||
"use client";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { loadProgressDrawing, saveProgressDrawing, saveFinalDrawing, exportCanvasToDataURL, fileToDataURL } from "./drawingPersistence";
|
||||
|
||||
interface Meta {
|
||||
level: string; levelName: string; sublevel: number; title: string; emoji: string; intro: string; baseSvg: string;
|
||||
}
|
||||
const COLORS = ["#2b2440", "#4f7cff", "#34c6c6", "#58c777", "#ffc843", "#ff7eb6", "#e85a5a", "#a06bff", "#ffffff", "#c4c8cc"];
|
||||
const SIZES = [10, 22, 40];
|
||||
interface Point { x: number; y: number }
|
||||
interface Stroke { color: string; size: number; alpha: number; erase: boolean; points: Point[] }
|
||||
|
||||
export default function ColoringRunner({ meta, loggedIn, username, alreadyEarned }: {
|
||||
meta: Meta; loggedIn: boolean; username: string; alreadyEarned: boolean;
|
||||
}) {
|
||||
const [done, setDone] = useState(false);
|
||||
const [finalImage, setFinalImage] = useState<string | null>(null);
|
||||
const [uploadMsg, setUploadMsg] = useState("");
|
||||
const uploadRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
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 [color, setColor] = useState(COLORS[1]);
|
||||
const [size, setSize] = useState(SIZES[1]);
|
||||
const [opacity, setOpacity] = useState(1);
|
||||
const [erasing, setErasing] = useState(false);
|
||||
const [, force] = useState(0);
|
||||
|
||||
const redraw = useCallback(() => {
|
||||
const c = canvasRef.current; if (!c) return;
|
||||
const ctx = c.getContext("2d"); if (!ctx) return;
|
||||
ctx.clearRect(0, 0, c.width, c.height);
|
||||
ctx.globalCompositeOperation = "source-over"; ctx.globalAlpha = 1;
|
||||
if (baseImgRef.current) ctx.drawImage(baseImgRef.current, 0, 0, c.width, c.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) 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 * c.width, s.points[0].y * c.height);
|
||||
if (s.points.length === 1) ctx.lineTo(s.points[0].x * c.width + 0.1, s.points[0].y * c.height + 0.1);
|
||||
else for (let i = 1; i < s.points.length; i++) ctx.lineTo(s.points[i].x * c.width, s.points[i].y * c.height);
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.globalCompositeOperation = "source-over"; ctx.globalAlpha = 1;
|
||||
}, []);
|
||||
const resize = useCallback(() => {
|
||||
const c = canvasRef.current, st = stageRef.current; if (!c || !st) return;
|
||||
const r = st.getBoundingClientRect(); const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
c.width = Math.round(r.width * dpr); c.height = Math.round(r.height * dpr); redraw();
|
||||
}, [redraw]);
|
||||
useEffect(() => { resize(); window.addEventListener("resize", resize); return () => window.removeEventListener("resize", resize); }, [resize]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loggedIn) 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, meta.level, meta.sublevel, redraw]);
|
||||
|
||||
function hasContent() { 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 pe(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) {
|
||||
(e.target as Element).setPointerCapture?.(e.pointerId);
|
||||
const pressure = e.pressure && e.pressure > 0 ? e.pressure : 1;
|
||||
const s: Stroke = { color, size, alpha: Math.min(1, opacity * (0.6 + pressure * 0.4)), erase: erasing, points: [pe(e)] };
|
||||
drawingRef.current = s; strokesRef.current.push(s);
|
||||
}
|
||||
function onMove(e: React.PointerEvent) { const s = drawingRef.current; if (!s) return; s.points.push(pe(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 finish() {
|
||||
const c = canvasRef.current;
|
||||
if (c && hasContent()) { const data = exportCanvasToDataURL(c); setFinalImage(data); if (loggedIn) await saveFinalDrawing(meta.level, meta.sublevel, "canvas", data); }
|
||||
setDone(true);
|
||||
}
|
||||
async function onUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0]; if (!file) return;
|
||||
if (!loggedIn) { setUploadMsg("Log in to save 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 = ""; }
|
||||
}
|
||||
|
||||
if (done) {
|
||||
return (
|
||||
<div className="form-narrow center">
|
||||
<div className="card">
|
||||
<h1>🎨 Beautiful!</h1>
|
||||
<p className="muted">You colored your {meta.title.replace(" · Color it!", "")}.</p>
|
||||
{finalImage && (
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<img src={finalImage} alt="Your colored drawing" style={{ width: "100%", borderRadius: 12, border: "1px solid var(--line)" }} />
|
||||
<a className="btn secondary" href={finalImage} download={`drawit-${meta.level}-${meta.sublevel}.png`} style={{ marginTop: 10 }}>⬇ Download my drawing</a>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<input ref={uploadRef} type="file" accept="image/*" onChange={onUpload} style={{ display: "none" }} />
|
||||
<button className="btn secondary" onClick={() => uploadRef.current?.click()}>📷 Upload a 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={() => { setDone(false); setFinalImage(null); setUploadMsg(""); }}>↺ Keep coloring</button>
|
||||
<Link className="btn ghost" href="/learn">Back to 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} · Color it!</div>
|
||||
<h1 style={{ margin: "2px 0" }}>{meta.emoji} {meta.title}</h1>
|
||||
</div>
|
||||
<Link className="btn ghost" href={`/report?level=${meta.level}&sublevel=${meta.sublevel}`} style={{ minHeight: 40 }}>Report a problem</Link>
|
||||
</div>
|
||||
<p className="muted">{meta.intro}</p>
|
||||
<div className="notice info" style={{ marginBottom: 12 }}>More tools (pencil, marker, fill bucket, layers, custom colors) are coming in the next update — for now, color away!</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" }}>
|
||||
<canvas ref={canvasRef} onPointerDown={onDown} onPointerMove={onMove} onPointerUp={onUp} onPointerCancel={onUp}
|
||||
style={{ position: "absolute", inset: 0, width: "100%", height: "100%", touchAction: "none", cursor: "crosshair" }} />
|
||||
{/* coloring-book lines on top so colors fill underneath */}
|
||||
<svg viewBox="0 0 400 300" style={{ position: "absolute", inset: 0, width: "100%", height: "100%", pointerEvents: "none" }}
|
||||
dangerouslySetInnerHTML={{ __html: meta.baseSvg }} />
|
||||
</div>
|
||||
|
||||
<div className="row" style={{ justifyContent: "center", marginTop: 12, gap: 8, alignItems: "center", flexWrap: "wrap" }}>
|
||||
{COLORS.map((c) => (
|
||||
<button key={c} onClick={() => { setColor(c); setErasing(false); }} aria-label={`Color ${c}`}
|
||||
style={{ width: 30, height: 30, borderRadius: "50%", background: c, border: !erasing && color === c ? "3px solid var(--ink)" : "3px solid #fff", boxShadow: "0 0 0 1px var(--line)", cursor: "pointer" }} />
|
||||
))}
|
||||
<button onClick={() => setErasing(true)} title="Eraser" 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: 10, alignItems: "center" }}>
|
||||
{SIZES.map((s) => (
|
||||
<button key={s} onClick={() => setSize(s)} aria-label={`Size ${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 / 3, height: s / 3, borderRadius: "50%", background: size === s ? "#fff" : "var(--ink)" }} />
|
||||
</button>
|
||||
))}
|
||||
<span style={{ width: 1, height: 28, background: "var(--line)" }} />
|
||||
<label className="muted" style={{ fontSize: "0.82rem", fontWeight: 700 }}>Opacity
|
||||
<input type="range" min={0.1} max={1} step={0.1} value={opacity} onChange={(e) => setOpacity(Number(e.target.value))} style={{ verticalAlign: "middle", marginLeft: 6 }} />
|
||||
</label>
|
||||
<button className="btn secondary" style={toolBtn} onClick={undo}>↶ Undo</button>
|
||||
<button className="btn secondary" style={toolBtn} onClick={clearCanvas}>🗑 Clear</button>
|
||||
</div>
|
||||
{loggedIn && <p className="muted center" style={{ fontSize: "0.8rem", marginTop: 8 }}>Lower the opacity and go over an area a few times to shade and layer. Saved automatically.</p>}
|
||||
</div>
|
||||
|
||||
<div className="card center">
|
||||
<p className="muted" style={{ marginTop: 0 }}>Color your picture however you like — then save it to your gallery.</p>
|
||||
<button className="btn big" onClick={finish}>✓ I'm done — save it</button>
|
||||
{!loggedIn && <p className="muted" style={{ marginTop: 12, fontSize: "0.9rem" }}><Link href="/login">Log in</Link> to keep your drawing.</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const toolBtn: React.CSSProperties = { minHeight: 34, padding: "6px 12px", fontSize: "0.85rem", boxShadow: "none" };
|
||||
@@ -0,0 +1,91 @@
|
||||
"use client";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
/**
|
||||
* Draws a set of SVG line elements one at a time (stroke reveal), slowly, for the "video".
|
||||
* Re-runs whenever `playKey` changes. Filled bits (e.g. a pupil dot) just fade in on their turn.
|
||||
*/
|
||||
export default function LineDraw({
|
||||
lines,
|
||||
playKey,
|
||||
onDone,
|
||||
}: {
|
||||
lines: string[];
|
||||
playKey: number;
|
||||
onDone?: () => void;
|
||||
}) {
|
||||
const ref = useRef<SVGGElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const g = ref.current;
|
||||
if (!g) return;
|
||||
const els = Array.from(
|
||||
g.querySelectorAll("path,line,polyline,polygon,circle,ellipse")
|
||||
) as SVGGeometryElement[];
|
||||
let cancelled = false;
|
||||
const timers: ReturnType<typeof setTimeout>[] = [];
|
||||
const len = (el: SVGGeometryElement) => {
|
||||
try {
|
||||
return el.getTotalLength();
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
const isFilled = (el: SVGGeometryElement) => {
|
||||
const f = el.getAttribute("fill");
|
||||
return !!f && f !== "none";
|
||||
};
|
||||
|
||||
// Reset: stroked lines hidden via dash offset; filled bits hidden via opacity.
|
||||
els.forEach((el) => {
|
||||
const s = el as unknown as { style: CSSStyleDeclaration };
|
||||
s.style.transition = "none";
|
||||
const L = len(el);
|
||||
if (!isFilled(el) && L) {
|
||||
s.style.strokeDasharray = String(L);
|
||||
s.style.strokeDashoffset = String(L);
|
||||
s.style.opacity = "1";
|
||||
} else {
|
||||
s.style.opacity = "0";
|
||||
}
|
||||
});
|
||||
|
||||
// Play sequentially
|
||||
let acc = 0;
|
||||
els.forEach((el) => {
|
||||
const s = el as unknown as { style: CSSStyleDeclaration };
|
||||
const filled = isFilled(el);
|
||||
const L = len(el);
|
||||
const dur = filled ? 280 : Math.max(900, Math.min(2600, Math.round(L * 7)));
|
||||
timers.push(
|
||||
setTimeout(() => {
|
||||
if (cancelled) return;
|
||||
if (!filled && L) {
|
||||
s.style.transition = `stroke-dashoffset ${dur}ms ease-in-out`;
|
||||
s.style.strokeDashoffset = "0";
|
||||
} else {
|
||||
s.style.transition = "opacity 280ms ease";
|
||||
s.style.opacity = "1";
|
||||
}
|
||||
}, acc)
|
||||
);
|
||||
acc += dur + 220;
|
||||
});
|
||||
timers.push(setTimeout(() => { if (!cancelled) onDone?.(); }, acc + 60));
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
timers.forEach(clearTimeout);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [playKey]);
|
||||
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 400 300"
|
||||
style={{ position: "absolute", inset: 0, width: "100%", height: "100%", pointerEvents: "none" }}
|
||||
>
|
||||
<g ref={ref} dangerouslySetInnerHTML={{ __html: lines.join("\n") }} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
"use client";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { badgeSvgMarkup, badgeFullSvg } from "./BadgeArt";
|
||||
import LineDraw from "./LineDraw";
|
||||
import { loadProgressDrawing, saveProgressDrawing, saveFinalDrawing, exportCanvasToDataURL, fileToDataURL } from "./drawingPersistence";
|
||||
|
||||
interface StepData { n: number; title: string; instruction: string; tip: string; lines: string[] }
|
||||
interface Meta {
|
||||
level: string; levelName: string; sublevel: number; title: string; emoji: string; intro: string;
|
||||
badgeKey: string; badgeName: string; baseSvg: string; phaseLabel: string;
|
||||
}
|
||||
|
||||
const COLORS = ["#2b2440", "#4f7cff", "#34c6c6", "#58c777", "#ffc843", "#ff7eb6", "#e85a5a", "#ffffff", "#c4c8cc"];
|
||||
const SIZES = [4, 8, 16];
|
||||
interface Point { x: number; y: number }
|
||||
interface Stroke { color: string; size: number; points: Point[] }
|
||||
|
||||
export default function TraceRunner({ 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 [playKey, setPlayKey] = useState(0);
|
||||
const [playing, setPlaying] = useState(true);
|
||||
const [finalImage, setFinalImage] = useState<string | null>(null);
|
||||
const [uploadMsg, setUploadMsg] = useState("");
|
||||
const uploadRef = useRef<HTMLInputElement | null>(null);
|
||||
const step = steps[idx];
|
||||
|
||||
// faint guide = base outline + every line up to & including the current step
|
||||
const guideUpTo = steps.slice(0, idx + 1).flatMap((s) => s.lines).join("\n");
|
||||
|
||||
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 [color, setColor] = useState(COLORS[0]);
|
||||
const [size, setSize] = useState(SIZES[1]);
|
||||
const [, force] = useState(0);
|
||||
|
||||
const redraw = useCallback(() => {
|
||||
const c = canvasRef.current; if (!c) return;
|
||||
const ctx = c.getContext("2d"); if (!ctx) return;
|
||||
ctx.clearRect(0, 0, c.width, c.height);
|
||||
if (baseImgRef.current) ctx.drawImage(baseImgRef.current, 0, 0, c.width, c.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) continue;
|
||||
ctx.strokeStyle = s.color; ctx.lineWidth = s.size * dpr;
|
||||
ctx.beginPath(); ctx.moveTo(s.points[0].x * c.width, s.points[0].y * c.height);
|
||||
if (s.points.length === 1) ctx.lineTo(s.points[0].x * c.width + 0.1, s.points[0].y * c.height + 0.1);
|
||||
else for (let i = 1; i < s.points.length; i++) ctx.lineTo(s.points[i].x * c.width, s.points[i].y * c.height);
|
||||
ctx.stroke();
|
||||
}
|
||||
}, []);
|
||||
const resize = useCallback(() => {
|
||||
const c = canvasRef.current, st = stageRef.current; if (!c || !st) return;
|
||||
const r = st.getBoundingClientRect(); const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
c.width = Math.round(r.width * dpr); c.height = Math.round(r.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]);
|
||||
|
||||
// Auto-play the line video whenever the step changes
|
||||
useEffect(() => { setPlaying(true); setPlayKey((k) => k + 1); }, [idx]);
|
||||
|
||||
function hasContent() { 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 pe(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 s: Stroke = { color, size, points: [pe(e)] }; drawingRef.current = s; strokesRef.current.push(s); }
|
||||
function onMove(e: React.PointerEvent) { const s = drawingRef.current; if (!s) return; s.points.push(pe(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 { /* */ }
|
||||
}
|
||||
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 || undefined);
|
||||
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 && !!meta.badgeKey);
|
||||
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 { /* */ } }
|
||||
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>🎉 Great work!</h1>
|
||||
<p className="muted">You finished <strong>{meta.title}</strong>{earned ? <> and earned the <strong>{meta.badgeName}</strong> badge!</> : "."}</p>
|
||||
{earned && <div style={{ maxWidth: 220, 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</button>
|
||||
</div>
|
||||
<a className="btn accent" href={`/api/award/${meta.badgeKey}`}>🏆 Download award (PDF)</a>
|
||||
</div>
|
||||
)}
|
||||
{finalImage && (
|
||||
<div style={{ marginTop: 16, textAlign: "center" }}>
|
||||
<strong>Your drawing</strong>
|
||||
<img src={finalImage} alt="Your 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); }}>↺ Try again</button>
|
||||
<Link className="btn ghost" href="/learn">Back to 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} · {meta.phaseLabel}</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={{ gap: 8, margin: "8px 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" }}>
|
||||
{/* faint trace target (outline base + lines so far) */}
|
||||
<svg viewBox="0 0 400 300" style={{ position: "absolute", inset: 0, width: "100%", height: "100%", opacity: 0.28, pointerEvents: "none" }}
|
||||
dangerouslySetInnerHTML={{ __html: meta.baseSvg + "\n" + guideUpTo }} />
|
||||
{/* the slowly-drawn video for the current step */}
|
||||
{playing && <LineDraw lines={step.lines} playKey={playKey} onDone={() => setPlaying(false)} />}
|
||||
<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: 10 }}>
|
||||
<button className="btn" style={toolBtn} onClick={() => { setPlaying(true); setPlayKey((k) => k + 1); }}>▶ Watch this step again</button>
|
||||
</div>
|
||||
|
||||
<div className="row" style={{ justifyContent: "center", marginTop: 12, gap: 10 }}>
|
||||
{COLORS.map((c) => (
|
||||
<button key={c} onClick={() => setColor(c)} aria-label={`Color ${c}`}
|
||||
style={{ width: 32, height: 32, borderRadius: "50%", background: c, border: color === c ? "3px solid var(--ink)" : "3px solid #fff", boxShadow: "0 0 0 1px var(--line)", cursor: "pointer" }} />
|
||||
))}
|
||||
<span style={{ width: 1, height: 28, background: "var(--line)" }} />
|
||||
{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, height: s, borderRadius: "50%", background: size === s ? "#fff" : "var(--ink)" }} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="row" style={{ justifyContent: "center", marginTop: 10 }}>
|
||||
<button className="btn secondary" style={toolBtn} onClick={undo}>↶ Undo</button>
|
||||
<button className="btn secondary" style={toolBtn} onClick={clearCanvas}>🗑 Clear</button>
|
||||
</div>
|
||||
{loggedIn && <p className="muted center" style={{ fontSize: "0.8rem", marginTop: 8 }}>Your drawing is saved automatically.</p>}
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<strong style={{ fontSize: "1.15rem" }}>Step {step.n}: {step.title}</strong>
|
||||
<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 →" : meta.badgeKey ? "🎉 Finish & get my badge" : "✓ Finish outline"}</button>
|
||||
</div>
|
||||
{!loggedIn && <p className="muted center" style={{ marginTop: 12, fontSize: "0.9rem" }}><Link href="/login">Log in</Link> to save your progress.</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 i = new Image(); i.onload = () => res(i); i.onerror = rej; i.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); }
|
||||
Reference in new issue
Block a user