Add dark mode, theming
This commit is contained in:
1 parent
5803d65a48
commit
c02893371a
8 files changed
+190
-28
No files matched your search
@@ -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,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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user