Add dark mode, theming

This commit is contained in:
iamdoubz committed 2026-06-20 20:45:05 -05:00
1 parent 5803d65a48
commit c02893371a
8 files changed
+190 -28

No files matched your search

+63 -18
View File
@@ -2,7 +2,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { loadProgressDrawing, saveProgressDrawing, saveFinalDrawing, fileToDataURL } from "./drawingPersistence";
import { loadProgressDrawing, saveProgressDrawing, saveFinalDrawing, fileToDataURL, loadDrawingAsLayer } from "./drawingPersistence";
interface Meta { level: string; levelName: string; sublevel: number; title: string; emoji: string; intro: string; baseSvg: string }
@@ -32,10 +32,14 @@ function newLayer(id: number, name: string): Layer {
return { id, name, visible: true, canvas: c };
}
export default function ColoringRunner({ meta, loggedIn, username, alreadyEarned }: {
meta: Meta; loggedIn: boolean; username: string; alreadyEarned: boolean;
export default function ColoringRunner({ meta, loggedIn, username, alreadyEarned, userSketch = "" }: {
meta: Meta; loggedIn: boolean; username: string; alreadyEarned: boolean; userSketch?: string;
}) {
const router = useRouter();
// 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<string>("");
const [done, setDone] = useState(false);
const [finalImage, setFinalImage] = useState<string | null>(null);
const [uploadMsg, setUploadMsg] = useState("");
@@ -81,21 +85,35 @@ export default function ColoringRunner({ meta, loggedIn, username, alreadyEarned
// Repaint when returning to the studio from the "done" screen
useEffect(() => { if (!done) composite(); }, [done, composite]);
// Rasterize the coloring-book template for the fill boundary + final export
// 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(() => {
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 300" width="${W}" height="${H}">${meta.baseSvg}</svg>`;
const blob = new Blob([svg], { type: "image/svg+xml" });
const url = URL.createObjectURL(blob);
const img = new Image();
img.onload = () => {
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);
URL.revokeObjectURL(url);
};
img.src = url;
}, [meta.baseSvg]);
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(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 300" width="${W}" height="${H}">${meta.baseSvg}</svg>`);
}
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
useEffect(() => {
@@ -239,6 +257,33 @@ export default function ColoringRunner({ meta, loggedIn, username, alreadyEarned
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 (
<div>
<div className="muted" style={{ fontWeight: 700 }}>{meta.levelName} · Color it!</div>
<h1 style={{ margin: "2px 0" }}>{meta.emoji} {meta.title}</h1>
<p className="muted">Which drawing would you like to color in?</p>
<div style={{ display: "grid", gap: 16, gridTemplateColumns: "repeat(auto-fit, minmax(240px, 1fr))", marginTop: 12 }}>
<button onClick={() => setSketch("yours")} className="card" style={{ cursor: "pointer", textAlign: "center", border: "2px solid var(--line)" }}>
<div style={{ background: "#fff", borderRadius: 12, border: "1px solid var(--line)", overflow: "hidden", aspectRatio: "4 / 3" }}>
<img src={userSketch} alt="Your sketch" style={{ width: "100%", height: "100%", objectFit: "contain" }} />
</div>
<strong style={{ display: "block", marginTop: 10 }}>✏️ Use my sketch</strong>
<span className="muted" style={{ fontSize: "0.85rem" }}>Color the drawing you made</span>
</button>
<button onClick={() => setSketch("stock")} className="card" style={{ cursor: "pointer", textAlign: "center", border: "2px solid var(--line)" }}>
<div style={{ background: "#fff", borderRadius: 12, border: "1px solid var(--line)", overflow: "hidden", aspectRatio: "4 / 3" }}>
<svg viewBox="0 0 400 300" style={{ width: "100%", height: "100%" }} dangerouslySetInnerHTML={{ __html: meta.baseSvg }} />
</div>
<strong style={{ display: "block", marginTop: 10 }}>🖼️ Use the stock picture</strong>
<span className="muted" style={{ fontSize: "0.85rem" }}>Color our ready-made outline</span>
</button>
</div>
</div>
);
}
if (done) {
return (
<div className="form-narrow center">
@@ -287,7 +332,7 @@ export default function ColoringRunner({ meta, loggedIn, username, alreadyEarned
<div style={{ position: "relative", width: "100%", maxWidth: 660, margin: "0 auto", aspectRatio: "4 / 3", background: "#fff", border: "2px solid var(--line)", borderRadius: 16, overflow: "hidden" }} className="no-touch-scroll">
<canvas ref={dispRef} width={W} height={H} onPointerDown={onDown} onPointerMove={onMove} onPointerUp={onUp} onPointerCancel={onUp}
style={{ position: "absolute", inset: 0, width: "100%", height: "100%", touchAction: "none", cursor: tool === "fill" ? "pointer" : "crosshair" }} />
<svg viewBox="0 0 400 300" style={{ position: "absolute", inset: 0, width: "100%", height: "100%", pointerEvents: "none" }} dangerouslySetInnerHTML={{ __html: meta.baseSvg }} />
{overlayUrl && <img src={overlayUrl} alt="" style={{ position: "absolute", inset: 0, width: "100%", height: "100%", objectFit: "fill", pointerEvents: "none" }} />}
</div>
{/* Tools */}
+2
View File
@@ -2,6 +2,7 @@
import Link from "next/link";
import { usePathname } from "next/navigation";
import LogoutButton from "./LogoutButton";
import ThemeToggle from "./ThemeToggle";
export default function NavLinks({
user,
@@ -54,6 +55,7 @@ export default function NavLinks({
</Link>
</>
)}
<ThemeToggle />
</div>
);
}
+67
View File
@@ -0,0 +1,67 @@
"use client";
import { useEffect, useState } from "react";
type Pref = "system" | "light" | "dark";
const ORDER: Pref[] = ["system", "light", "dark"];
const ICON: Record<Pref, string> = { system: "🌓", light: "☀️", dark: "🌙" };
export default function ThemeToggle() {
const [pref, setPref] = useState<Pref>("system");
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
try {
const p = localStorage.getItem("drawit-theme") as Pref | null;
if (p === "light" || p === "dark" || p === "system") setPref(p);
} catch {
/* ignore */
}
}, []);
// Apply the effective theme whenever the preference changes (and follow the system if "system").
useEffect(() => {
if (!mounted) return;
const mq = window.matchMedia("(prefers-color-scheme: dark)");
const apply = () => {
const dark = pref === "dark" || (pref === "system" && mq.matches);
document.documentElement.setAttribute("data-theme", dark ? "dark" : "light");
};
apply();
if (pref === "system") {
mq.addEventListener("change", apply);
return () => mq.removeEventListener("change", apply);
}
}, [pref, mounted]);
function cycle() {
const nextPref = ORDER[(ORDER.indexOf(pref) + 1) % ORDER.length];
setPref(nextPref);
try {
localStorage.setItem("drawit-theme", nextPref);
} catch {
/* ignore */
}
}
return (
<button
onClick={cycle}
title={`Theme: ${pref}${pref === "system" ? " (follows your device)" : ""} — tap to change`}
aria-label={`Theme: ${pref}`}
style={{
minHeight: 40,
padding: "8px 12px",
borderRadius: 999,
border: "2px solid var(--line)",
background: "transparent",
color: "var(--ink)",
cursor: "pointer",
fontWeight: 700,
}}
>
{/* avoid hydration mismatch: show a neutral icon until mounted */}
{mounted ? ICON[pref] : "🌓"}
</button>
);
}