First attempt erg pull for DrawIt
This commit is contained in:
1 parent
010a97f219
commit
a94be1ebca
51 files changed
+3588
-2
No files matched your search
@@ -0,0 +1,33 @@
|
||||
// Shared badge artwork. `badgeSvgMarkup` is the INNER markup (no <svg> wrapper) for
|
||||
// inline rendering inside an existing <svg viewBox="0 0 300 300">. `badgeFullSvg`
|
||||
// wraps it in a standalone <svg> so it can be rasterized to PNG for sharing.
|
||||
// The medal center shows the lesson's emoji, so it works for every lesson.
|
||||
export function badgeSvgMarkup(badgeName: string, emoji: string): string {
|
||||
return `
|
||||
<defs>
|
||||
<linearGradient id="badgeGrad" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#ffd566"/>
|
||||
<stop offset="1" stop-color="#ffb02e"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="300" height="300" fill="#ffffff"/>
|
||||
<polygon points="120,250 150,210 180,250 165,292 150,266 135,292" fill="#4f7cff"/>
|
||||
<polygon points="150,210 182,250 212,250 176,204" fill="#3a63d6"/>
|
||||
<circle cx="150" cy="138" r="92" fill="url(#badgeGrad)" stroke="#e0a92f" stroke-width="6"/>
|
||||
<circle cx="150" cy="138" r="74" fill="#fff7e2" stroke="#e0a92f" stroke-width="2"/>
|
||||
<text x="150" y="170" text-anchor="middle" font-size="84">${escapeXml(emoji)}</text>
|
||||
<text x="150" y="250" text-anchor="middle" font-family="Segoe UI, system-ui, sans-serif" font-size="22" font-weight="800" fill="#2b2440">${escapeXml(
|
||||
badgeName
|
||||
)}</text>`;
|
||||
}
|
||||
|
||||
export function badgeFullSvg(badgeName: string, emoji: string): string {
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 300 300" width="300" height="300">${badgeSvgMarkup(
|
||||
badgeName,
|
||||
emoji
|
||||
)}</svg>`;
|
||||
}
|
||||
|
||||
function escapeXml(s: string): string {
|
||||
return s.replace(/[&<>]/g, (c) => ({ "&": "&", "<": "<", ">": ">" }[c] as string));
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
"use client";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export interface SlideStep {
|
||||
n: number;
|
||||
title: string;
|
||||
instruction: string;
|
||||
cumulativeSvg: string; // markup for steps 1..n
|
||||
}
|
||||
|
||||
export default function FishSlideshow({ steps }: { steps: SlideStep[] }) {
|
||||
const [i, setI] = useState(0);
|
||||
const [playing, setPlaying] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!playing) return;
|
||||
const t = setTimeout(() => setI((p) => (p + 1) % steps.length), 2200);
|
||||
return () => clearTimeout(t);
|
||||
}, [i, playing, steps.length]);
|
||||
|
||||
const step = steps[i];
|
||||
|
||||
return (
|
||||
<div className="card" style={{ maxWidth: 560, margin: "0 auto" }}>
|
||||
<div
|
||||
style={{
|
||||
background: "#f4fbff",
|
||||
borderRadius: 14,
|
||||
border: "2px dashed #bfe0ee",
|
||||
aspectRatio: "4 / 3",
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 400 300"
|
||||
width="100%"
|
||||
height="100%"
|
||||
role="img"
|
||||
aria-label={`Drawing a fish: ${step.title}`}
|
||||
dangerouslySetInnerHTML={{ __html: step.cumulativeSvg }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="row" style={{ justifyContent: "space-between", marginTop: 14 }}>
|
||||
<strong>
|
||||
Step {step.n}: {step.title}
|
||||
</strong>
|
||||
<button
|
||||
className="btn ghost"
|
||||
onClick={() => setPlaying((p) => !p)}
|
||||
style={{ minHeight: 40, padding: "6px 14px" }}
|
||||
aria-label={playing ? "Pause slideshow" : "Play slideshow"}
|
||||
>
|
||||
{playing ? "⏸ Pause" : "▶ Play"}
|
||||
</button>
|
||||
</div>
|
||||
<p className="muted" style={{ margin: "6px 0 12px" }}>
|
||||
{step.instruction}
|
||||
</p>
|
||||
|
||||
<div className="row" style={{ justifyContent: "center", gap: 8 }}>
|
||||
{steps.map((s, idx) => (
|
||||
<button
|
||||
key={s.n}
|
||||
onClick={() => {
|
||||
setI(idx);
|
||||
setPlaying(false);
|
||||
}}
|
||||
aria-label={`Go to step ${s.n}`}
|
||||
style={{
|
||||
width: 14,
|
||||
height: 14,
|
||||
borderRadius: "50%",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
background: idx === i ? "var(--primary)" : "#d7d2e0",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,551 @@
|
||||
"use client";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { badgeSvgMarkup, badgeFullSvg } from "./BadgeArt";
|
||||
|
||||
interface StepData {
|
||||
n: number;
|
||||
title: string;
|
||||
instruction: string;
|
||||
tip: string;
|
||||
kind: string; // optional teaching tag ("" = derive from position)
|
||||
guideSvg: string;
|
||||
}
|
||||
interface Meta {
|
||||
level: string;
|
||||
levelName: string;
|
||||
sublevel: number;
|
||||
title: string;
|
||||
emoji: string;
|
||||
intro: string;
|
||||
badgeKey: string;
|
||||
badgeName: string;
|
||||
}
|
||||
|
||||
const COLORS = ["#2b2440", "#4f7cff", "#34c6c6", "#58c777", "#ffc843", "#ff7eb6", "#e85a5a"];
|
||||
const SIZES = [4, 8, 16];
|
||||
|
||||
const KIND_META: Record<string, { label: string; emoji: string }> = {
|
||||
construct: { label: "Big shapes", emoji: "⬭" },
|
||||
outline: { label: "Build it up", emoji: "✏️" },
|
||||
color: { label: "Color", emoji: "🎨" },
|
||||
shade: { label: "Shading", emoji: "🌑" },
|
||||
detail: { label: "Finishing touches", emoji: "✨" },
|
||||
};
|
||||
|
||||
function kindForStep(kind: string, i: number, total: number): { label: string; emoji: string } {
|
||||
const key = kind || (i === 0 ? "construct" : i === total - 1 ? "detail" : "outline");
|
||||
return KIND_META[key] ?? KIND_META.outline;
|
||||
}
|
||||
|
||||
interface Point {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
interface Stroke {
|
||||
color: string;
|
||||
size: number;
|
||||
points: Point[]; // normalized 0..1
|
||||
}
|
||||
|
||||
export default function LessonRunner({
|
||||
meta,
|
||||
steps,
|
||||
loggedIn,
|
||||
username,
|
||||
completedSteps,
|
||||
alreadyEarned,
|
||||
}: {
|
||||
meta: Meta;
|
||||
steps: StepData[];
|
||||
loggedIn: boolean;
|
||||
username: string;
|
||||
completedSteps: number[];
|
||||
alreadyEarned: boolean;
|
||||
}) {
|
||||
const lastIndex = steps.length - 1;
|
||||
const startIndex = Math.min(completedSteps.length, lastIndex);
|
||||
const [idx, setIdx] = useState(startIndex);
|
||||
const [showGuide, setShowGuide] = useState(true);
|
||||
const [finished, setFinished] = useState(alreadyEarned);
|
||||
const [earned, setEarned] = useState(alreadyEarned);
|
||||
|
||||
// Replay ("watch it drawn") + narration
|
||||
const [replaying, setReplaying] = useState(false);
|
||||
const [replayK, setReplayK] = useState(1);
|
||||
const [narrate, setNarrate] = useState(false);
|
||||
|
||||
const step = steps[idx];
|
||||
const kindInfo = kindForStep(step.kind, idx, steps.length);
|
||||
|
||||
// ---------------- Canvas drawing ----------------
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const stageRef = useRef<HTMLDivElement | null>(null);
|
||||
const strokesRef = useRef<Stroke[]>([]);
|
||||
const drawingRef = useRef<Stroke | null>(null);
|
||||
const [color, setColor] = useState(COLORS[0]);
|
||||
const [size, setSize] = useState(SIZES[1]);
|
||||
const [, force] = useState(0);
|
||||
|
||||
const redraw = useCallback(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
const w = canvas.width;
|
||||
const h = canvas.height;
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
ctx.lineCap = "round";
|
||||
ctx.lineJoin = "round";
|
||||
for (const s of strokesRef.current) {
|
||||
drawStroke(ctx, s, w, h);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const resize = useCallback(() => {
|
||||
const canvas = canvasRef.current;
|
||||
const stage = stageRef.current;
|
||||
if (!canvas || !stage) return;
|
||||
const rect = stage.getBoundingClientRect();
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
canvas.width = Math.round(rect.width * dpr);
|
||||
canvas.height = Math.round(rect.height * dpr);
|
||||
redraw();
|
||||
}, [redraw]);
|
||||
|
||||
useEffect(() => {
|
||||
resize();
|
||||
window.addEventListener("resize", resize);
|
||||
return () => window.removeEventListener("resize", resize);
|
||||
}, [resize]);
|
||||
|
||||
function pointFromEvent(e: React.PointerEvent): Point {
|
||||
const canvas = canvasRef.current!;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
return { x: (e.clientX - rect.left) / rect.width, y: (e.clientY - rect.top) / rect.height };
|
||||
}
|
||||
|
||||
function onPointerDown(e: React.PointerEvent) {
|
||||
if (finished || replaying) return;
|
||||
(e.target as Element).setPointerCapture?.(e.pointerId);
|
||||
const stroke: Stroke = { color, size, points: [pointFromEvent(e)] };
|
||||
drawingRef.current = stroke;
|
||||
strokesRef.current.push(stroke);
|
||||
}
|
||||
function onPointerMove(e: React.PointerEvent) {
|
||||
const stroke = drawingRef.current;
|
||||
if (!stroke) return;
|
||||
stroke.points.push(pointFromEvent(e));
|
||||
redraw();
|
||||
}
|
||||
function onPointerUp() {
|
||||
drawingRef.current = null;
|
||||
force((n) => n + 1);
|
||||
}
|
||||
|
||||
function clearCanvas() {
|
||||
strokesRef.current = [];
|
||||
redraw();
|
||||
force((n) => n + 1);
|
||||
}
|
||||
function undo() {
|
||||
strokesRef.current.pop();
|
||||
redraw();
|
||||
force((n) => n + 1);
|
||||
}
|
||||
|
||||
// ---------------- "Watch it drawn" replay ----------------
|
||||
function watch() {
|
||||
if (replaying) return;
|
||||
setReplayK(1);
|
||||
setReplaying(true);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!replaying) return;
|
||||
const target = idx + 1; // reveal steps 1..current
|
||||
if (replayK < target) {
|
||||
const t = setTimeout(() => setReplayK((k) => k + 1), 850);
|
||||
return () => clearTimeout(t);
|
||||
}
|
||||
const t = setTimeout(() => setReplaying(false), 950);
|
||||
return () => clearTimeout(t);
|
||||
}, [replaying, replayK, idx]);
|
||||
|
||||
// ---------------- Narration (on-device, optional) ----------------
|
||||
const speak = useCallback((text: string) => {
|
||||
try {
|
||||
const synth = window.speechSynthesis;
|
||||
if (!synth) return;
|
||||
synth.cancel();
|
||||
const u = new SpeechSynthesisUtterance(text);
|
||||
u.rate = 0.95;
|
||||
u.pitch = 1.05;
|
||||
synth.speak(u);
|
||||
} catch {
|
||||
/* speech is best-effort */
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (narrate && !finished) {
|
||||
speak(`Step ${step.n}. ${step.title}. ${step.instruction}`);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [idx, narrate, finished]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
try {
|
||||
window.speechSynthesis?.cancel();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// ---------------- Step navigation + progress ----------------
|
||||
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 {
|
||||
/* progress is best-effort */
|
||||
}
|
||||
}
|
||||
|
||||
function next() {
|
||||
saveStep(step.n);
|
||||
if (idx < lastIndex) {
|
||||
setIdx(idx + 1);
|
||||
} else {
|
||||
finish();
|
||||
}
|
||||
}
|
||||
function prev() {
|
||||
if (idx > 0) setIdx(idx - 1);
|
||||
}
|
||||
|
||||
async function finish() {
|
||||
try {
|
||||
window.speechSynthesis?.cancel();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
await saveStep(step.n, meta.badgeKey);
|
||||
setEarned(loggedIn);
|
||||
setFinished(true);
|
||||
}
|
||||
|
||||
// ---------------- Sharing ----------------
|
||||
async function rasterizeBadge(): Promise<Blob | null> {
|
||||
const svg = badgeFullSvg(meta.badgeName, meta.emoji);
|
||||
const blob = new Blob([svg], { 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;
|
||||
const ctx = c.getContext("2d")!;
|
||||
ctx.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 {
|
||||
/* fall through to download */
|
||||
}
|
||||
}
|
||||
downloadBlob(blob, "drawit-badge.png");
|
||||
}
|
||||
|
||||
async function downloadBadge() {
|
||||
const blob = await rasterizeBadge();
|
||||
if (blob) downloadBlob(blob, "drawit-badge.png");
|
||||
}
|
||||
|
||||
// ---------------- Render ----------------
|
||||
if (finished) {
|
||||
return (
|
||||
<div className="form-narrow center">
|
||||
<div className="card">
|
||||
<h1>🎉 You did it!</h1>
|
||||
<p className="muted">
|
||||
You finished <strong>{meta.title}</strong> and earned the <strong>{meta.badgeName}</strong> badge.
|
||||
</p>
|
||||
<div style={{ maxWidth: 280, 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 (PNG)</button>
|
||||
</div>
|
||||
<a className="btn accent" href={`/api/award/${meta.badgeKey}`}>🏆 Download award (PDF)</a>
|
||||
</div>
|
||||
) : (
|
||||
<div className="notice info">
|
||||
Nice drawing! <Link href="/signup">Create an account</Link> or <Link href="/login">log in</Link> to
|
||||
earn the badge and download your printable award.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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">More lessons</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const guideMarkup = replaying ? steps[Math.min(replayK, idx + 1) - 1].guideSvg : step.guideSvg;
|
||||
const guideOpacity = replaying ? 0.65 : 0.32;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="row" style={{ justifyContent: "space-between", alignItems: "flex-start" }}>
|
||||
<div>
|
||||
<div className="muted" style={{ fontWeight: 700 }}>
|
||||
{meta.levelName} · Level {meta.sublevel}
|
||||
</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>}
|
||||
{idx === 0 && (
|
||||
<div className="notice info" style={{ marginTop: 4 }}>
|
||||
✏️ Artists always start with the <strong>big shapes</strong> first, then add the small details. Follow the faint guide!
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Progress dots */}
|
||||
<div className="row" style={{ gap: 8, margin: "8px 0 14px" }}>
|
||||
{steps.map((s, i) => (
|
||||
<button
|
||||
key={s.n}
|
||||
onClick={() => 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" }}>
|
||||
{/* Stage */}
|
||||
<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",
|
||||
}}
|
||||
>
|
||||
{(showGuide || replaying) && (
|
||||
<svg
|
||||
key={replaying ? `replay-${replayK}` : `guide-${idx}`}
|
||||
viewBox="0 0 400 300"
|
||||
className="guide-reveal"
|
||||
style={{ position: "absolute", inset: 0, width: "100%", height: "100%", opacity: guideOpacity, pointerEvents: "none" }}
|
||||
dangerouslySetInnerHTML={{ __html: guideMarkup }}
|
||||
/>
|
||||
)}
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onPointerCancel={onPointerUp}
|
||||
style={{ position: "absolute", inset: 0, width: "100%", height: "100%", touchAction: "none", cursor: "crosshair", pointerEvents: replaying ? "none" : "auto" }}
|
||||
/>
|
||||
{replaying && (
|
||||
<div style={{ position: "absolute", left: 0, right: 0, bottom: 0, textAlign: "center", padding: "6px 0", background: "rgba(79,124,255,0.92)", color: "#fff", fontWeight: 700, fontSize: "0.9rem" }}>
|
||||
▶ Watch it being drawn…
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Watch + narration */}
|
||||
<div className="row" style={{ justifyContent: "center", marginTop: 12, gap: 10 }}>
|
||||
<button className="btn" style={toolBtn} onClick={watch} disabled={replaying}>
|
||||
{replaying ? "▶ Playing…" : "▶ Watch it drawn"}
|
||||
</button>
|
||||
<button
|
||||
className="btn secondary"
|
||||
style={toolBtn}
|
||||
aria-pressed={narrate}
|
||||
onClick={() => setNarrate((v) => !v)}
|
||||
title="Read each step out loud (stays on your device)"
|
||||
>
|
||||
{narrate ? "🔊 Read aloud: on" : "🔇 Read aloud: off"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tools */}
|
||||
<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: 34,
|
||||
height: 34,
|
||||
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>
|
||||
<button className="btn secondary" style={toolBtn} onClick={() => setShowGuide((g) => !g)}>
|
||||
{showGuide ? "🙈 Hide guide" : "👁 Show guide"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Instruction */}
|
||||
<div className="card">
|
||||
<div className="row" style={{ justifyContent: "space-between", alignItems: "center" }}>
|
||||
<strong style={{ fontSize: "1.15rem" }}>
|
||||
Step {step.n}: {step.title}
|
||||
</strong>
|
||||
<span className="pill role" title="What this step teaches">
|
||||
{kindInfo.emoji} {kindInfo.label}
|
||||
</span>
|
||||
</div>
|
||||
<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 →" : "🎉 Finish & get my badge"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!loggedIn && (
|
||||
<p className="muted center" style={{ marginTop: 12, fontSize: "0.9rem" }}>
|
||||
<Link href="/login">Log in</Link> to save your progress and earn a badge.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const toolBtn: React.CSSProperties = { minHeight: 40, padding: "8px 14px", fontSize: "0.9rem", boxShadow: "none" };
|
||||
|
||||
function drawStroke(ctx: CanvasRenderingContext2D, s: Stroke, w: number, h: number) {
|
||||
if (s.points.length === 0) return;
|
||||
ctx.strokeStyle = s.color;
|
||||
ctx.lineWidth = s.size * (Math.min(window.devicePixelRatio || 1, 2));
|
||||
ctx.beginPath();
|
||||
const first = s.points[0];
|
||||
ctx.moveTo(first.x * w, first.y * h);
|
||||
if (s.points.length === 1) {
|
||||
ctx.lineTo(first.x * w + 0.1, first.y * h + 0.1);
|
||||
} else {
|
||||
for (let i = 1; i < s.points.length; i++) {
|
||||
ctx.lineTo(s.points[i].x * w, s.points[i].y * h);
|
||||
}
|
||||
}
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
function loadImage(src: string): Promise<HTMLImageElement> {
|
||||
return new Promise((res, rej) => {
|
||||
const img = new Image();
|
||||
img.onload = () => res(img);
|
||||
img.onerror = rej;
|
||||
img.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);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
"use client";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export default function LogoutButton() {
|
||||
const router = useRouter();
|
||||
async function logout() {
|
||||
await fetch("/api/auth/logout", { method: "POST" });
|
||||
router.push("/");
|
||||
router.refresh();
|
||||
}
|
||||
return (
|
||||
<button className="btn ghost" onClick={logout} style={{ minHeight: 40, padding: "8px 14px" }}>
|
||||
Log out
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import Link from "next/link";
|
||||
import { getCurrentUser } from "@/lib/session";
|
||||
import LogoutButton from "./LogoutButton";
|
||||
|
||||
export default async function SiteNav() {
|
||||
const user = await getCurrentUser();
|
||||
return (
|
||||
<nav className="nav">
|
||||
<div className="nav-inner">
|
||||
<Link href="/" className="brand">
|
||||
<span className="logo">🐟</span> DrawIt
|
||||
</Link>
|
||||
<div className="nav-links">
|
||||
<Link href="/learn">Learn</Link>
|
||||
{user ? (
|
||||
<>
|
||||
{user.role === "admin" && <Link href="/admin">Admin</Link>}
|
||||
<Link href="/report">Report a problem</Link>
|
||||
<span className="pill role">{user.username}</span>
|
||||
<LogoutButton />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Link href="/login">Log in</Link>
|
||||
<Link href="/signup" className="btn" style={{ minHeight: 40, padding: "8px 18px" }}>
|
||||
Sign up
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user