"use client"; import { useCallback, useEffect, useRef, useState } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { loadProgressDrawing, saveProgressDrawing, saveFinalDrawing, fileToDataURL, loadDrawingAsLayer } from "./drawingPersistence"; interface Meta { level: string; levelName: string; sublevel: number; title: string; emoji: string; intro: string; baseSvg: string; phaseLabel?: string; hintSvg?: string } const W = 880; const H = 660; const COLORS = ["#2b2440", "#4f7cff", "#34c6c6", "#58c777", "#ffc843", "#ff8a3d", "#ff7eb6", "#a06bff", "#8a5a2b", "#ffffff"]; const SIZES = [8, 18, 36]; type Tool = "pen" | "pencil" | "marker" | "eraser" | "fill"; const TOOLS: { id: Tool; label: string; icon: string }[] = [ { id: "pen", label: "Pen", icon: "🖊️" }, { id: "pencil", label: "Pencil", icon: "✏️" }, { id: "marker", label: "Marker", icon: "🖍️" }, { id: "eraser", label: "Eraser", icon: "🧽" }, { id: "fill", label: "Fill", icon: "🪣" }, ]; interface Layer { id: number; name: string; visible: boolean; canvas: HTMLCanvasElement } function hexToRgb(hex: string): [number, number, number] { const m = hex.replace("#", ""); const n = m.length === 3 ? m.split("").map((c) => c + c).join("") : m; return [parseInt(n.slice(0, 2), 16), parseInt(n.slice(2, 4), 16), parseInt(n.slice(4, 6), 16)]; } function newLayer(id: number, name: string): Layer { const c = document.createElement("canvas"); c.width = W; c.height = H; return { id, name, visible: true, canvas: c }; } export default function ColoringRunner({ meta, loggedIn, username, alreadyEarned, userSketch = "", carryImage = "" }: { meta: Meta; loggedIn: boolean; username: string; alreadyEarned: boolean; userSketch?: string; carryImage?: string; }) { const router = useRouter(); const phaseLabel = meta.phaseLabel || "Color it!"; // Which line art to color: the stock template or the child's own sketch. // If they have no sketch of their own, skip the choice and use the stock art. const [sketch, setSketch] = useState<"stock" | "yours" | null>(userSketch ? null : "stock"); const [overlayUrl, setOverlayUrl] = useState(""); const [done, setDone] = useState(false); const [finalImage, setFinalImage] = useState(null); const [uploadMsg, setUploadMsg] = useState(""); const uploadRef = useRef(null); const dispRef = useRef(null); const boundaryRef = useRef(null); const templImgRef = useRef(null); const layersRef = useRef([]); const layerSeq = useRef(2); const committedRef = useRef(null); // active layer before current stroke const undoRef = useRef<{ layerId: number; data: ImageData }[]>([]); const drawingRef = useRef<{ x: number; y: number }[] | null>(null); const pressureRef = useRef(0.7); const saveTimer = useRef | undefined>(undefined); const [, force] = useState(0); const [activeId, setActiveId] = useState(0); const [tool, setTool] = useState("pen"); const [color, setColor] = useState(COLORS[1]); const [size, setSize] = useState(SIZES[1]); const [opacity, setOpacity] = useState(1); const [custom, setCustom] = useState("#7ec86a"); const layers = layersRef.current; const active = layers.find((l) => l.id === activeId) || layers[0]; const composite = useCallback(() => { const disp = dispRef.current; if (!disp) return; const ctx = disp.getContext("2d")!; ctx.clearRect(0, 0, W, H); ctx.fillStyle = "#ffffff"; ctx.fillRect(0, 0, W, H); for (const l of layersRef.current) if (l.visible) ctx.drawImage(l.canvas, 0, 0); }, []); // Create the initial layers on the client (canvas needs the DOM, not the server). useEffect(() => { if (layersRef.current.length === 0) layersRef.current = [newLayer(0, "Color"), newLayer(1, "Top")]; composite(); force((n) => n + 1); }, [composite]); // Repaint when returning to the studio from the "done" screen useEffect(() => { if (!done) composite(); }, [done, composite]); // Build the line-art overlay (+ fill boundary + final export template) from the chosen source. // "stock" = the curriculum's template SVG; "yours" = the child's own sketch (white knocked out to transparent so coloring shows under the lines). useEffect(() => { if (!sketch) return; let alive = true; (async () => { let url: string; if (sketch === "yours" && userSketch) { const layer = await loadDrawingAsLayer(userSketch); // dark lines on transparent const c = document.createElement("canvas"); c.width = W; c.height = H; c.getContext("2d")!.drawImage(layer, 0, 0, W, H); url = c.toDataURL("image/png"); } else { url = "data:image/svg+xml;utf8," + encodeURIComponent(`${meta.baseSvg}`); } if (!alive) return; const img = new Image(); img.onload = () => { if (!alive) return; templImgRef.current = img; const b = document.createElement("canvas"); b.width = W; b.height = H; const bctx = b.getContext("2d")!; bctx.drawImage(img, 0, 0, W, H); boundaryRef.current = bctx.getImageData(0, 0, W, H); setOverlayUrl(url); }; img.src = url; })(); return () => { alive = false; }; }, [sketch, meta.baseSvg, userSketch]); // Restore saved coloring into the bottom layer; if none, seed with the carry image // (e.g. the Light phase starts from the colored drawing). useEffect(() => { let alive = true; const seed = (data: string) => { const im = new Image(); im.onload = () => { if (alive) { layersRef.current[0]?.canvas.getContext("2d")!.drawImage(im, 0, 0, W, H); composite(); } }; im.src = data; }; if (!loggedIn) { if (carryImage) seed(carryImage); else composite(); return; } loadProgressDrawing(meta.level, meta.sublevel).then((data) => { if (!alive) return; if (data) seed(data); else if (carryImage) seed(carryImage); else composite(); }); return () => { alive = false; }; }, [loggedIn, meta.level, meta.sublevel, composite, carryImage]); function flatten(withTemplate: boolean): string { 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); for (const l of layersRef.current) if (l.visible) ctx.drawImage(l.canvas, 0, 0); if (withTemplate && templImgRef.current) ctx.drawImage(templImgRef.current, 0, 0, W, H); return c.toDataURL("image/png"); } function hasContent() { return undoRef.current.length > 0; } function flushSave() { if (!loggedIn || !hasContent()) return; saveProgressDrawing(meta.level, meta.sublevel, flatten(false)); } function scheduleSave() { if (!loggedIn) return; clearTimeout(saveTimer.current); saveTimer.current = setTimeout(flushSave, 1200); } function pushUndo() { const ctx = active.canvas.getContext("2d")!; undoRef.current.push({ layerId: active.id, data: ctx.getImageData(0, 0, W, H) }); if (undoRef.current.length > 8) undoRef.current.shift(); } function undo() { const u = undoRef.current.pop(); if (!u) return; const l = layersRef.current.find((x) => x.id === u.layerId); if (!l) return; l.canvas.getContext("2d")!.putImageData(u.data, 0, 0); composite(); force((n) => n + 1); scheduleSave(); } function clearActive() { pushUndo(); active.canvas.getContext("2d")!.clearRect(0, 0, W, H); composite(); force((n) => n + 1); scheduleSave(); } function pt(e: React.PointerEvent) { const r = dispRef.current!.getBoundingClientRect(); return { x: ((e.clientX - r.left) / r.width) * W, y: ((e.clientY - r.top) / r.height) * H }; } function strokeStyleFor(pressure: number) { const ctx = active.canvas.getContext("2d")!; if (tool === "eraser") { ctx.globalCompositeOperation = "destination-out"; ctx.globalAlpha = 1; ctx.strokeStyle = "#000"; ctx.lineWidth = size * 1.4; return; } ctx.globalCompositeOperation = "source-over"; ctx.strokeStyle = color; if (tool === "pen") { ctx.globalAlpha = opacity; ctx.lineWidth = size; } else if (tool === "pencil") { ctx.globalAlpha = Math.min(1, opacity * 0.5 * (0.6 + pressure * 0.8)); ctx.lineWidth = size * 0.8; } else { ctx.globalAlpha = Math.min(1, opacity * 0.55 * (0.6 + pressure * 0.6)); ctx.lineWidth = size * 1.7; } // marker } function renderStroke() { const ctx = active.canvas.getContext("2d")!; if (committedRef.current) ctx.putImageData(committedRef.current, 0, 0); const pts = drawingRef.current!; if (!pts.length) return; ctx.save(); // isolate this stroke's tool settings so they can't leak to the next stroke strokeStyleFor(pressureRef.current); ctx.lineCap = "round"; ctx.lineJoin = "round"; ctx.beginPath(); ctx.moveTo(pts[0].x, pts[0].y); if (pts.length === 1) ctx.lineTo(pts[0].x + 0.1, pts[0].y + 0.1); else for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i].x, pts[i].y); ctx.stroke(); ctx.restore(); composite(); } function floodFill(x: number, y: number) { const bd = boundaryRef.current; if (!bd) return; pushUndo(); const ctx = active.canvas.getContext("2d")!; const img = ctx.getImageData(0, 0, W, H); const d = img.data; const [fr, fg, fb] = hexToRgb(color); const isLine = (p: number) => bd.data[p * 4 + 3] > 60 && bd.data[p * 4] + bd.data[p * 4 + 1] + bd.data[p * 4 + 2] < 420; const sx = Math.round(x), sy = Math.round(y); if (sx < 0 || sy < 0 || sx >= W || sy >= H) return; const start = sy * W + sx; if (isLine(start)) return; const visited = new Uint8Array(W * H); const stack = [start]; while (stack.length) { const p = stack.pop()!; if (visited[p]) continue; visited[p] = 1; if (isLine(p)) continue; const o = p * 4; d[o] = fr; d[o + 1] = fg; d[o + 2] = fb; d[o + 3] = 255; const px = p % W, py = (p / W) | 0; if (px > 0) stack.push(p - 1); if (px < W - 1) stack.push(p + 1); if (py > 0) stack.push(p - W); if (py < H - 1) stack.push(p + W); } ctx.putImageData(img, 0, 0); composite(); force((n) => n + 1); scheduleSave(); } function onDown(e: React.PointerEvent) { (e.target as Element).setPointerCapture?.(e.pointerId); const p = pt(e); if (tool === "fill") { floodFill(p.x, p.y); return; } pushUndo(); committedRef.current = active.canvas.getContext("2d")!.getImageData(0, 0, W, H); drawingRef.current = [p]; pressureRef.current = e.pressure && e.pressure > 0 ? e.pressure : 0.7; renderStroke(); } function onMove(e: React.PointerEvent) { if (!drawingRef.current) return; drawingRef.current.push(pt(e)); pressureRef.current = e.pressure && e.pressure > 0 ? e.pressure : 0.7; renderStroke(); } function onUp() { if (!drawingRef.current) return; drawingRef.current = null; committedRef.current = null; force((n) => n + 1); scheduleSave(); } // Layers panel actions function addLayer() { if (layers.length >= 4) return; const id = layerSeq.current++; layersRef.current.push(newLayer(id, `Layer ${layers.length + 1}`)); setActiveId(id); force((n) => n + 1); } function deleteLayer(id: number) { if (layersRef.current.length <= 1) return; layersRef.current = layersRef.current.filter((l) => l.id !== id); undoRef.current = undoRef.current.filter((u) => u.layerId !== id); if (activeId === id) setActiveId(layersRef.current[layersRef.current.length - 1].id); composite(); force((n) => n + 1); scheduleSave(); } function toggleVisible(id: number) { const l = layersRef.current.find((x) => x.id === id); if (l) { l.visible = !l.visible; composite(); force((n) => n + 1); scheduleSave(); } } async function finish() { // Record completion of the Color it! lesson so the Learn page can mark it Done. if (loggedIn) { try { await fetch("/api/progress", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ level: meta.level, sublevel: meta.sublevel, step: 1 }) }); } catch { /* */ } } if (hasContent()) { const data = flatten(true); setFinalImage(data); if (loggedIn) await saveFinalDrawing(meta.level, meta.sublevel, "canvas", data); } setDone(true); router.refresh(); // refresh cached Learn page with the new completion } async function onUpload(e: React.ChangeEvent) { 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 = ""; } } // Ask which line art to color before opening the studio (only when the child has a sketch of their own). if (sketch === null) { return (
{meta.levelName} · {phaseLabel}

{meta.emoji} {meta.title}

Which drawing would you like to color in?

); } if (done) { return (

🎨 Beautiful!

You colored your {meta.title.replace(" · Color it!", "")}.

{finalImage && ( )}
{uploadMsg &&

{uploadMsg}

}
{loggedIn && 🖼️ My drawings} Back to lessons
); } const swatch = (c: string, key: string) => ( ))} {/* Sizes + opacity */}
{SIZES.map((s) => ( ))}
{/* Colors + custom */}
{COLORS.map((c, i) => swatch(c, "c" + i))}
{/* Layers + finish */}
Layers
{[...layers].reverse().map((l) => (
))}

Color on the bottom layer, shade on a layer above. Lower the opacity and use the pencil or marker to layer and shade. {loggedIn && " Saved automatically."}

{!loggedIn &&

Log in to keep your drawing.

}
); } const miniBtn: React.CSSProperties = { minHeight: 34, padding: "6px 12px", fontSize: "0.85rem", boxShadow: "none" }; const iconBtn: React.CSSProperties = { background: "none", border: "none", cursor: "pointer", fontSize: "1rem", padding: 2 };