Merge pull request '[Feature] Add dark mode and theming' (#9) from feature_settings_avatar into main
Reviewed-on: #9
This commit was merged in pull request #9.
This commit is contained in:
+39
-4
@@ -19,6 +19,41 @@
|
||||
--font: ui-rounded, "SF Pro Rounded", "Segoe UI", system-ui, -apple-system, "Helvetica Neue", Arial, sans-serif;
|
||||
}
|
||||
|
||||
/* Dark theme — applied when data-theme="dark" is set on <html> (by the no-flash script / toggle). */
|
||||
:root[data-theme="dark"] {
|
||||
--bg: #17141f;
|
||||
--surface: #221d31;
|
||||
--ink: #f2eefb;
|
||||
--ink-soft: #aaa2c0;
|
||||
--primary: #7b9bff;
|
||||
--primary-dark: #9db4ff;
|
||||
--accent: #ff8ec0;
|
||||
--sun: #ffce5b;
|
||||
--sea: #43d3d3;
|
||||
--leaf: #5fd083;
|
||||
--danger: #ff7a7a;
|
||||
--line: #36304a;
|
||||
--shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
/* No-JS / pre-hydration fallback to the system preference */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme]) {
|
||||
--bg: #17141f;
|
||||
--surface: #221d31;
|
||||
--ink: #f2eefb;
|
||||
--ink-soft: #aaa2c0;
|
||||
--primary: #7b9bff;
|
||||
--primary-dark: #9db4ff;
|
||||
--accent: #ff8ec0;
|
||||
--sun: #ffce5b;
|
||||
--sea: #43d3d3;
|
||||
--leaf: #5fd083;
|
||||
--danger: #ff7a7a;
|
||||
--line: #36304a;
|
||||
--shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html, body {
|
||||
@@ -74,7 +109,7 @@ a { color: var(--primary-dark); }
|
||||
}
|
||||
.btn:active { transform: translateY(1px) scale(0.99); }
|
||||
.btn:hover { filter: brightness(1.04); }
|
||||
.btn.secondary { background: #fff; color: var(--primary-dark); border: 2px solid var(--line); box-shadow: none; }
|
||||
.btn.secondary { background: var(--surface); color: var(--primary-dark); border: 2px solid var(--line); box-shadow: none; }
|
||||
.btn.accent { background: var(--accent); }
|
||||
.btn.ghost { background: transparent; color: var(--ink); box-shadow: none; }
|
||||
.btn.danger { background: var(--danger); }
|
||||
@@ -90,7 +125,7 @@ a { color: var(--primary-dark); }
|
||||
/* Top navigation */
|
||||
.nav {
|
||||
position: sticky; top: 0; z-index: 20;
|
||||
background: rgba(254, 249, 243, 0.9); backdrop-filter: blur(8px);
|
||||
background: color-mix(in srgb, var(--bg) 88%, transparent); backdrop-filter: blur(8px);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.nav-inner { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 12px 20px; max-width: var(--maxw); margin: 0 auto; }
|
||||
@@ -98,7 +133,7 @@ a { color: var(--primary-dark); }
|
||||
.brand .logo { font-size: 1.5rem; }
|
||||
.nav-links { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.nav-links a { text-decoration: none; color: var(--ink); font-weight: 700; padding: 8px 14px; border-radius: 999px; border: 2px solid transparent; }
|
||||
.nav-links a:hover { background: #fff; color: var(--ink); }
|
||||
.nav-links a:hover { background: var(--surface); color: var(--ink); }
|
||||
/* Current page: lighter fill, darker bold text for clear, readable contrast */
|
||||
.nav-links a.active { background: #eaf0ff; color: var(--ink); font-weight: 800; }
|
||||
/* Sign-up call-to-action: outlined so it doesn't read as the active page */
|
||||
@@ -112,7 +147,7 @@ a { color: var(--primary-dark); }
|
||||
.field input, .field select, .field textarea {
|
||||
font: inherit; padding: 14px 16px; min-height: 52px;
|
||||
border: 2px solid var(--line); border-radius: var(--radius-sm);
|
||||
background: #fff; color: var(--ink); width: 100%;
|
||||
background: var(--surface); color: var(--ink); width: 100%;
|
||||
}
|
||||
.field input:focus, .field select:focus, .field textarea:focus { outline: none; border-color: var(--primary); }
|
||||
.field textarea { min-height: 110px; resize: vertical; }
|
||||
|
||||
+7
-1
@@ -44,9 +44,15 @@ export const viewport: Viewport = {
|
||||
themeColor: "#dce06b",
|
||||
};
|
||||
|
||||
// Runs before paint to set the theme (avoids a flash of the wrong colors).
|
||||
const themeScript = `(function(){try{var p=localStorage.getItem('drawit-theme')||'system';var d=p==='dark'||(p==='system'&&window.matchMedia('(prefers-color-scheme: dark)').matches);document.documentElement.setAttribute('data-theme',d?'dark':'light');}catch(e){}})();`;
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<head>
|
||||
<script dangerouslySetInnerHTML={{ __html: themeScript }} />
|
||||
</head>
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -78,11 +78,18 @@ export default async function LessonPage({ params }: { params: Promise<{ level:
|
||||
// Coloring studio (extra)
|
||||
if (lesson.phase === "extra") {
|
||||
const meta = { ...common, baseSvg: lesson.baseSvg ?? "" };
|
||||
// The user's own drawn sketch (from the Details lesson) — offered as an alternative to the stock line art.
|
||||
let userSketch = "";
|
||||
if (user) {
|
||||
const subj = getSubjects(level).find((s) => s.key === lesson.subjectKey);
|
||||
const d = subj?.lessons.find((l) => l.phase === "detail");
|
||||
if (d) userSketch = getLatestDrawing(user.id, level, d.sublevel) ?? "";
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<SiteNav />
|
||||
<main className="container page">
|
||||
<ColoringRunner key={lesson.slug} meta={meta} loggedIn={!!user} username={user?.username ?? ""} alreadyEarned={alreadyEarned} />
|
||||
<ColoringRunner key={lesson.slug} meta={meta} loggedIn={!!user} username={user?.username ?? ""} alreadyEarned={alreadyEarned} userSketch={userSketch} />
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -94,7 +94,7 @@ export default async function LearnPage() {
|
||||
<details
|
||||
key={subj.key}
|
||||
open={subj.key === firstIncomplete}
|
||||
style={{ border: subjComplete ? "2px solid var(--leaf)" : "2px solid var(--line)", borderRadius: 14, padding: "10px 14px", background: "#fff" }}
|
||||
style={{ border: subjComplete ? "2px solid var(--leaf)" : "2px solid var(--line)", borderRadius: 14, padding: "10px 14px", background: "var(--surface)" }}
|
||||
>
|
||||
<summary style={{ cursor: "pointer", fontWeight: 800, fontSize: "1.05rem" }}>
|
||||
{subj.emoji} {subj.name}
|
||||
@@ -123,4 +123,4 @@ export default async function LearnPage() {
|
||||
}
|
||||
|
||||
const subCard = (done: boolean): React.CSSProperties => ({ textDecoration: "none", color: "inherit", border: done ? "2px solid var(--leaf)" : "2px solid var(--line)", boxShadow: "none", padding: 14 });
|
||||
const phaseBox: React.CSSProperties = { display: "block", border: "2px solid var(--line)", borderRadius: 12, padding: "12px 14px", background: "#fff" };
|
||||
const phaseBox: React.CSSProperties = { display: "block", border: "2px solid var(--line)", borderRadius: 12, padding: "12px 14px", background: "var(--surface)" };
|
||||
|
||||
+2
-2
@@ -34,7 +34,7 @@ export default function Home() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="section" style={{ background: "#fff", borderBlock: "1px solid var(--line)" }}>
|
||||
<section className="section" style={{ background: "var(--surface)", borderBlock: "1px solid var(--line)" }}>
|
||||
<div className="container">
|
||||
<h2 className="center">Watch it drawn, line by line 🐟</h2>
|
||||
<p className="center muted" style={{ maxWidth: 560, margin: "0 auto 26px" }}>
|
||||
@@ -97,7 +97,7 @@ export default function Home() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="section" style={{ background: "#fff", borderTop: "1px solid var(--line)" }}>
|
||||
<section className="section" style={{ background: "var(--surface)", borderTop: "1px solid var(--line)" }}>
|
||||
<div className="container">
|
||||
<h2 className="center">How DrawIt works</h2>
|
||||
<div style={{ display: "grid", gap: 16, gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))", marginTop: 18 }}>
|
||||
|
||||
@@ -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