279 lines
18 KiB
TypeScript
279 lines
18 KiB
TypeScript
"use client";
|
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import Link from "next/link";
|
|
import { useRouter } from "next/navigation";
|
|
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, carryImage = "", nextHref = "", nextLabel = "" }: {
|
|
meta: Meta; steps: StepData[]; loggedIn: boolean; username: string; completedSteps: number[]; alreadyEarned: boolean;
|
|
carryImage?: string; nextHref?: string; nextLabel?: string;
|
|
}) {
|
|
const router = useRouter();
|
|
const lastIndex = steps.length - 1;
|
|
const [idx, setIdx] = useState(Math.min(completedSteps.length, lastIndex));
|
|
// Always open the lesson (so you can redraw/continue); show the finished screen only after finishing here.
|
|
const [finished, setFinished] = useState(false);
|
|
const [earned, setEarned] = useState(false);
|
|
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 (!finished) resize(); }, [finished, resize]);
|
|
|
|
useEffect(() => {
|
|
if (!loggedIn) return; let alive = true;
|
|
loadProgressDrawing(meta.level, meta.sublevel).then((img) => {
|
|
// Use this lesson's own saved canvas if any; otherwise carry over the previous lesson's drawing
|
|
const src = img || carryImage;
|
|
if (!src || !alive) return;
|
|
const im = new Image(); im.onload = () => { baseImgRef.current = im; redraw(); }; im.src = src;
|
|
});
|
|
return () => { alive = false; };
|
|
}, [loggedIn, alreadyEarned, meta.level, meta.sublevel, carryImage, 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, 800, false)); }
|
|
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); const c = canvasRef.current; if (loggedIn && c) saveProgressDrawing(meta.level, meta.sublevel, exportCanvasToDataURL(c, 800, false)); }
|
|
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);
|
|
// Only completing the level (the badge lesson) saves to the profile gallery.
|
|
// Earlier lessons (e.g. Outline) keep their working canvas so it carries forward, but aren't saved as finals.
|
|
if (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); }
|
|
} else {
|
|
flushSave(); // persist the outline canvas so the Details lesson can carry it over
|
|
}
|
|
setEarned(loggedIn && !!meta.badgeKey);
|
|
setFinished(true);
|
|
router.refresh(); // refresh cached pages (Learn, gating) with the new progress
|
|
}
|
|
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 && !meta.badgeKey) {
|
|
// Outline (or any non-badge step): completed, but the level isn't finished — no profile saving here.
|
|
return (
|
|
<div className="form-narrow center">
|
|
<div className="card">
|
|
<h1>✏️ Outline complete!</h1>
|
|
<p className="muted">Awesome lines! Your outline carries over to the next lesson, so you can add the details right on top.</p>
|
|
{nextHref && <Link className="btn big" href={nextHref}>{nextLabel || "Next"} →</Link>}
|
|
<div className="row" style={{ justifyContent: "center", marginTop: 16 }}>
|
|
<button className="btn ghost" onClick={() => { clearCanvas(); setIdx(0); setFinished(false); }}>↺ Draw it again</button>
|
|
<Link className="btn ghost" href="/learn">Back to lessons</Link>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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>
|
|
{nextHref && <div className="row" style={{ justifyContent: "center", marginTop: 14 }}><Link className="btn accent big" href={nextHref}>{nextLabel || "Next"} →</Link></div>}
|
|
<div className="row" style={{ justifyContent: "center", marginTop: 14 }}>
|
|
{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" }}>
|
|
{/* the already-drawn outline shown clearly so the child knows what they're adding to */}
|
|
{meta.baseSvg && (
|
|
<svg viewBox="0 0 400 300" style={{ position: "absolute", inset: 0, width: "100%", height: "100%", opacity: 0.5, pointerEvents: "none" }}
|
|
dangerouslySetInnerHTML={{ __html: meta.baseSvg }} />
|
|
)}
|
|
{/* faint trace target for this lesson's lines so far */}
|
|
<svg viewBox="0 0 400 300" style={{ position: "absolute", inset: 0, width: "100%", height: "100%", opacity: 0.3, pointerEvents: "none" }}
|
|
dangerouslySetInnerHTML={{ __html: guideUpTo }} />
|
|
{/* the child's drawing (transparent base so the guide + video show through) */}
|
|
<canvas ref={canvasRef} onPointerDown={onDown} onPointerMove={onMove} onPointerUp={onUp} onPointerCancel={onUp}
|
|
style={{ position: "absolute", inset: 0, width: "100%", height: "100%", touchAction: "none", cursor: "crosshair" }} />
|
|
{/* the slowly-drawn video for the current step — on top so it always shows, even over the imported outline */}
|
|
{playing && <LineDraw lines={step.lines} playKey={playKey} onDone={() => setPlaying(false)} />}
|
|
</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); }
|