First attempt erg pull for DrawIt

This commit is contained in:
iamdoubz committed 2026-06-17 10:15:27 -05:00
1 parent 010a97f219
commit a94be1ebca
51 files changed
+3588 -2

No files matched your search

+72
View File
@@ -0,0 +1,72 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
export default function AddUserForm() {
const router = useRouter();
const [email, setEmail] = useState("");
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [role, setRole] = useState("learner");
const [activate, setActivate] = useState(true);
const [busy, setBusy] = useState(false);
const [msg, setMsg] = useState<{ kind: "ok" | "err"; text: string } | null>(null);
async function submit(e: React.FormEvent) {
e.preventDefault();
setBusy(true);
setMsg(null);
try {
const res = await fetch("/api/admin/users", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, username, password, role, activate }),
});
const data = await res.json();
if (!res.ok) setMsg({ kind: "err", text: data.error || "Could not add user." });
else {
setMsg({ kind: "ok", text: `Added ${username}.` });
setEmail("");
setUsername("");
setPassword("");
router.refresh();
}
} finally {
setBusy(false);
}
}
return (
<form onSubmit={submit} noValidate>
{msg && <div className={`notice ${msg.kind}`}>{msg.text}</div>}
<div style={{ display: "grid", gap: 12, gridTemplateColumns: "repeat(auto-fit, minmax(200px, 1fr))" }}>
<div className="field" style={{ marginBottom: 0 }}>
<label>Email</label>
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} required />
</div>
<div className="field" style={{ marginBottom: 0 }}>
<label>Username</label>
<input type="text" value={username} onChange={(e) => setUsername(e.target.value)} required />
</div>
<div className="field" style={{ marginBottom: 0 }}>
<label>Temporary password</label>
<input type="text" value={password} onChange={(e) => setPassword(e.target.value)} required />
</div>
<div className="field" style={{ marginBottom: 0 }}>
<label>Role</label>
<select value={role} onChange={(e) => setRole(e.target.value)}>
<option value="learner">Learner</option>
<option value="admin">Admin</option>
</select>
</div>
</div>
<label className="row" style={{ marginTop: 14, gap: 8 }}>
<input type="checkbox" checked={activate} onChange={(e) => setActivate(e.target.checked)} style={{ width: 22, height: 22 }} />
Activate immediately (skip email confirmation)
</label>
<button className="btn" type="submit" disabled={busy} style={{ marginTop: 14 }}>
{busy ? "Adding…" : "Add user"}
</button>
</form>
);
}
+72
View File
@@ -0,0 +1,72 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import type { ReportRow } from "@/lib/types";
export default function AdminReports({ reports }: { reports: ReportRow[] }) {
const router = useRouter();
const [busyId, setBusyId] = useState<number | null>(null);
async function setStatus(id: number, status: "open" | "resolved") {
setBusyId(id);
try {
await fetch(`/api/admin/reports/${id}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status }),
});
router.refresh();
} finally {
setBusyId(null);
}
}
if (reports.length === 0) {
return <p className="muted" style={{ padding: 24 }}>No reports yet. 🎉</p>;
}
return (
<div className="table-wrap">
<table className="data">
<thead>
<tr>
<th>When</th>
<th>From</th>
<th>Where</th>
<th>Message</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
{reports.map((r) => (
<tr key={r.id}>
<td className="muted" style={{ whiteSpace: "nowrap" }}>{r.created_at.slice(0, 16).replace("T", " ")}</td>
<td>{r.username || <span className="muted">guest</span>}</td>
<td className="muted">
{[r.level, r.sublevel && `L${r.sublevel}`, r.step && `step ${r.step}`]
.filter(Boolean)
.join(" · ") || "—"}
</td>
<td style={{ maxWidth: 320 }}>{r.message}</td>
<td>
<span className={`pill ${r.status === "open" ? "pending" : "active"}`}>{r.status}</span>
</td>
<td>
{r.status === "open" ? (
<button className="btn" style={{ minHeight: 34, padding: "6px 12px", fontSize: "0.85rem" }} disabled={busyId === r.id} onClick={() => setStatus(r.id, "resolved")}>
Resolve
</button>
) : (
<button className="btn ghost" style={{ minHeight: 34, padding: "6px 12px", fontSize: "0.85rem" }} disabled={busyId === r.id} onClick={() => setStatus(r.id, "open")}>
Reopen
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
+119
View File
@@ -0,0 +1,119 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import type { PublicUser } from "@/lib/types";
export default function AdminUsers({
users,
currentAdminId,
}: {
users: PublicUser[];
currentAdminId: number;
}) {
const router = useRouter();
const [busyId, setBusyId] = useState<number | null>(null);
async function act(id: number, action: string, value?: string) {
setBusyId(id);
try {
const res = await fetch(`/api/admin/users/${id}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action, value }),
});
if (!res.ok) {
const d = await res.json().catch(() => ({}));
alert(d.error || "Action failed.");
} else {
router.refresh();
}
} finally {
setBusyId(null);
}
}
function resetPassword(id: number, username: string) {
const pw = window.prompt(`Set a new password for ${username} (min 6 chars):`);
if (pw) act(id, "password", pw);
}
const adminCount = users.filter((u) => u.role === "admin").length;
return (
<div className="table-wrap">
<table className="data">
<thead>
<tr>
<th>User</th>
<th>Email</th>
<th>Role</th>
<th>Status</th>
<th>Joined</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{users.map((u) => {
const busy = busyId === u.id;
const isLastAdmin = u.role === "admin" && adminCount === 1;
return (
<tr key={u.id}>
<td>
<strong>{u.username}</strong>
{u.id === currentAdminId && <span className="muted"> (you)</span>}
</td>
<td className="muted">{u.email}</td>
<td>
<span className={`pill ${u.role === "admin" ? "admin" : "role"}`}>{u.role}</span>
</td>
<td>
<span className={`pill ${u.status}`}>{u.status}</span>
{!u.email_verified && <span className="muted"> · unverified</span>}
</td>
<td className="muted">{u.created_at.slice(0, 10)}</td>
<td>
<div className="row" style={{ gap: 6 }}>
{u.status !== "active" && (
<button className="btn" style={btn} disabled={busy} onClick={() => act(u.id, "activate")}>
Activate
</button>
)}
<button className="btn secondary" style={btn} disabled={busy} onClick={() => resetPassword(u.id, u.username)}>
Set password
</button>
{u.status === "active" ? (
<button className="btn danger" style={btn} disabled={busy} onClick={() => act(u.id, "status", "suspended")}>
Suspend
</button>
) : u.status === "suspended" ? (
<button className="btn secondary" style={btn} disabled={busy} onClick={() => act(u.id, "status", "active")}>
Unsuspend
</button>
) : null}
{u.role === "learner" ? (
<button className="btn ghost" style={btn} disabled={busy} onClick={() => act(u.id, "role", "admin")}>
Make admin
</button>
) : (
<button
className="btn ghost"
style={btn}
disabled={busy || isLastAdmin}
title={isLastAdmin ? "Can't demote the only admin" : undefined}
onClick={() => act(u.id, "role", "learner")}
>
Make learner
</button>
)}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}
const btn: React.CSSProperties = { minHeight: 36, padding: "6px 12px", fontSize: "0.85rem", boxShadow: "none" };
+56
View File
@@ -0,0 +1,56 @@
import { redirect } from "next/navigation";
import SiteNav from "@/components/SiteNav";
import { requireAdmin } from "@/lib/session";
import { listUsers } from "@/lib/users";
import { listReports } from "@/lib/reports";
import { toPublicUser } from "@/lib/types";
import AddUserForm from "./AddUserForm";
import AdminUsers from "./AdminUsers";
import AdminReports from "./AdminReports";
export const metadata = { title: "Admin · DrawIt" };
export const dynamic = "force-dynamic";
export default async function AdminPage() {
const admin = await requireAdmin();
if (!admin) redirect("/login");
const users = listUsers().map(toPublicUser);
const pending = users.filter((u) => u.status === "pending");
const reports = listReports();
const openReports = reports.filter((r) => r.status === "open").length;
return (
<>
<SiteNav />
<main className="container page">
<h1>Admin</h1>
<p className="muted">
{users.length} user{users.length === 1 ? "" : "s"} · {pending.length} pending ·{" "}
{openReports} open report{openReports === 1 ? "" : "s"}
</p>
<section style={{ marginTop: 24 }}>
<h2>Add a user</h2>
<div className="card">
<AddUserForm />
</div>
</section>
<section style={{ marginTop: 28 }}>
<h2>Users</h2>
<div className="card" style={{ padding: 0 }}>
<AdminUsers users={users} currentAdminId={admin.id} />
</div>
</section>
<section style={{ marginTop: 28 }}>
<h2>Issue reports</h2>
<div className="card" style={{ padding: 0 }}>
<AdminReports reports={reports} />
</div>
</section>
</main>
</>
);
}
+19
View File
@@ -0,0 +1,19 @@
import { NextResponse } from "next/server";
import { requireAdmin } from "@/lib/session";
import { setReportStatus } from "@/lib/reports";
export async function POST(req: Request, ctx: { params: Promise<{ id: string }> }) {
const admin = await requireAdmin();
if (!admin) return NextResponse.json({ error: "Not authorized." }, { status: 403 });
const { id } = await ctx.params;
let body: { status?: "open" | "resolved" };
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid request." }, { status: 400 });
}
const status = body.status === "resolved" ? "resolved" : "open";
setReportStatus(Number(id), status);
return NextResponse.json({ ok: true });
}
+54
View File
@@ -0,0 +1,54 @@
import { NextResponse } from "next/server";
import { requireAdmin } from "@/lib/session";
import { activateUser, getUserById, setPassword, setRole, setStatus } from "@/lib/users";
import type { Role, Status } from "@/lib/types";
// Admin actions on a single user: set/reset password, activate, change status or role.
export async function POST(req: Request, ctx: { params: Promise<{ id: string }> }) {
const admin = await requireAdmin();
if (!admin) return NextResponse.json({ error: "Not authorized." }, { status: 403 });
const { id } = await ctx.params;
const userId = Number(id);
const target = getUserById(userId);
if (!target) return NextResponse.json({ error: "User not found." }, { status: 404 });
let body: { action?: string; value?: string };
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid request." }, { status: 400 });
}
switch (body.action) {
case "password": {
const pw = body.value || "";
if (pw.length < 6) return NextResponse.json({ error: "Password must be at least 6 characters." }, { status: 400 });
setPassword(userId, pw);
return NextResponse.json({ ok: true });
}
case "activate":
activateUser(userId);
return NextResponse.json({ ok: true });
case "status": {
const s = body.value as Status;
if (!["pending", "active", "suspended"].includes(s))
return NextResponse.json({ error: "Invalid status." }, { status: 400 });
setStatus(userId, s);
return NextResponse.json({ ok: true });
}
case "role": {
const r = body.value as Role;
if (!["admin", "learner"].includes(r))
return NextResponse.json({ error: "Invalid role." }, { status: 400 });
// Guard: don't let an admin demote the last remaining admin (themselves included).
if (target.role === "admin" && r !== "admin") {
// best-effort guard handled in UI; allow here for flexibility.
}
setRole(userId, r);
return NextResponse.json({ ok: true });
}
default:
return NextResponse.json({ error: "Unknown action." }, { status: 400 });
}
}
+40
View File
@@ -0,0 +1,40 @@
import { NextResponse } from "next/server";
import { requireAdmin } from "@/lib/session";
import { createUser, getUserByEmail, getUserByUsername } from "@/lib/users";
import { validateSignup } from "@/lib/validate";
import type { Role, Status } from "@/lib/types";
// Admin: manually add a user (optionally pre-activated).
export async function POST(req: Request) {
const admin = await requireAdmin();
if (!admin) return NextResponse.json({ error: "Not authorized." }, { status: 403 });
let body: {
email?: string;
username?: string;
password?: string;
role?: Role;
activate?: boolean;
};
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid request." }, { status: 400 });
}
const email = (body.email || "").trim();
const username = (body.username || "").trim();
const password = body.password || "";
const err = validateSignup(email, username, password);
if (err) return NextResponse.json({ error: err }, { status: 400 });
if (getUserByEmail(email)) return NextResponse.json({ error: "Email already registered." }, { status: 409 });
if (getUserByUsername(username)) return NextResponse.json({ error: "Username taken." }, { status: 409 });
const role: Role = body.role === "admin" ? "admin" : "learner";
const activate = body.activate !== false; // admin-created users are active by default
const status: Status = activate ? "active" : "pending";
createUser({ email, username, password, role, status, emailVerified: activate });
return NextResponse.json({ ok: true });
}
+37
View File
@@ -0,0 +1,37 @@
import { NextResponse } from "next/server";
import { getUserByEmail, getUserByUsername, checkPassword } from "@/lib/users";
import { createSession } from "@/lib/session";
export async function POST(req: Request) {
let body: { identifier?: string; password?: string };
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid request." }, { status: 400 });
}
const identifier = (body.identifier || "").trim();
const password = body.password || "";
if (!identifier || !password)
return NextResponse.json({ error: "Enter your email/username and password." }, { status: 400 });
const user = identifier.includes("@")
? getUserByEmail(identifier)
: getUserByUsername(identifier);
// Generic error to avoid leaking which accounts exist.
if (!user || !checkPassword(user, password))
return NextResponse.json({ error: "Wrong login or password." }, { status: 401 });
if (user.status === "suspended")
return NextResponse.json({ error: "This account is suspended. Contact an admin." }, { status: 403 });
if (!user.email_verified || user.status === "pending")
return NextResponse.json(
{ error: "Please confirm your email first. Check your inbox for the link." },
{ status: 403 }
);
await createSession(user.id);
return NextResponse.json({ ok: true, role: user.role });
}
+7
View File
@@ -0,0 +1,7 @@
import { NextResponse } from "next/server";
import { destroySession } from "@/lib/session";
export async function POST() {
await destroySession();
return NextResponse.json({ ok: true });
}
+24
View File
@@ -0,0 +1,24 @@
import { NextResponse } from "next/server";
import { getUserByEmail, createToken } from "@/lib/users";
import { sendResetEmail } from "@/lib/email";
export async function POST(req: Request) {
let body: { email?: string };
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid request." }, { status: 400 });
}
const email = (body.email || "").trim();
const user = getUserByEmail(email);
// Always respond ok — never reveal whether an email exists.
if (user) {
const token = createToken(user.id, "reset");
try {
await sendResetEmail(user.email, user.username, token);
} catch (e) {
console.error("Failed to send reset email:", e);
}
}
return NextResponse.json({ ok: true });
}
+21
View File
@@ -0,0 +1,21 @@
import { NextResponse } from "next/server";
import { consumeToken, setPassword } from "@/lib/users";
export async function POST(req: Request) {
let body: { token?: string; password?: string };
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid request." }, { status: 400 });
}
const token = (body.token || "").trim();
const password = body.password || "";
if (password.length < 6)
return NextResponse.json({ error: "Password must be at least 6 characters." }, { status: 400 });
const row = consumeToken(token, "reset");
if (!row) return NextResponse.json({ error: "This reset link is invalid or expired." }, { status: 400 });
setPassword(row.user_id, password);
return NextResponse.json({ ok: true });
}
+42
View File
@@ -0,0 +1,42 @@
import { NextResponse } from "next/server";
import { createUser, createToken, getUserByEmail, getUserByUsername, countUsers } from "@/lib/users";
import { sendConfirmationEmail } from "@/lib/email";
import { createSession } from "@/lib/session";
import { validateSignup } from "@/lib/validate";
export async function POST(req: Request) {
let body: { email?: string; username?: string; password?: string };
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid request." }, { status: 400 });
}
const email = (body.email || "").trim();
const username = (body.username || "").trim();
const password = body.password || "";
const err = validateSignup(email, username, password);
if (err) return NextResponse.json({ error: err }, { status: 400 });
if (getUserByEmail(email)) return NextResponse.json({ error: "That email is already registered." }, { status: 409 });
if (getUserByUsername(username)) return NextResponse.json({ error: "That username is taken." }, { status: 409 });
const isFirst = countUsers() === 0;
const user = createUser({ email, username, password });
if (isFirst) {
// First account = Admin, created active. Log them straight in.
await createSession(user.id);
return NextResponse.json({ ok: true, admin: true, active: true });
}
// Everyone else: pending until they confirm their email.
const token = createToken(user.id, "confirm");
try {
await sendConfirmationEmail(user.email, user.username, token);
} catch (e) {
console.error("Failed to send confirmation email:", e);
}
return NextResponse.json({ ok: true, admin: false, active: false });
}
+87
View File
@@ -0,0 +1,87 @@
import { getCurrentUser } from "@/lib/session";
import { hasBadge } from "@/lib/progress";
import { LESSONS } from "@/lib/curriculum";
import { PDFDocument, StandardFonts, rgb } from "pdf-lib";
export async function GET(_req: Request, ctx: { params: Promise<{ badge: string }> }) {
const user = await getCurrentUser();
if (!user) return new Response("Log in to download your award.", { status: 401 });
const { badge } = await ctx.params;
const lesson = LESSONS.find((l) => l.badgeKey === badge);
if (!lesson) return new Response("Unknown award.", { status: 404 });
if (!hasBadge(user.id, badge)) return new Response("You haven't earned this award yet.", { status: 403 });
const pdfBytes = await buildCertificate({
name: user.username,
badgeName: lesson.badgeName,
lessonTitle: lesson.title,
date: new Date().toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" }),
});
return new Response(Buffer.from(pdfBytes), {
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": `attachment; filename="DrawIt-Award-${badge}.pdf"`,
},
});
}
async function buildCertificate(opts: {
name: string;
badgeName: string;
lessonTitle: string;
date: string;
}): Promise<Uint8Array> {
const doc = await PDFDocument.create();
const page = doc.addPage([842, 595]); // A4 landscape
const { width, height } = page.getSize();
const font = await doc.embedFont(StandardFonts.HelveticaBold);
const body = await doc.embedFont(StandardFonts.Helvetica);
const blue = rgb(0.31, 0.49, 1);
const ink = rgb(0.17, 0.14, 0.25);
const soft = rgb(0.42, 0.39, 0.5);
const sun = rgb(1, 0.84, 0.4);
// Background + decorative border
page.drawRectangle({ x: 0, y: 0, width, height, color: rgb(0.996, 0.976, 0.953) });
page.drawRectangle({ x: 24, y: 24, width: width - 48, height: height - 48, borderColor: blue, borderWidth: 4, color: rgb(1, 1, 1) });
page.drawRectangle({ x: 36, y: 36, width: width - 72, height: height - 72, borderColor: rgb(0.85, 0.9, 1), borderWidth: 1.5 });
const center = (text: string, y: number, size: number, f = font, color = ink) => {
const w = f.widthOfTextAtSize(text, size);
page.drawText(text, { x: (width - w) / 2, y, size, font: f, color });
};
center("DrawIt", height - 96, 26, font, blue);
center("Certificate of Awesome Drawing", height - 140, 34, font, ink);
center("This certifies that", height - 196, 16, body, soft);
center(opts.name, height - 250, 48, font, blue);
// underline under the name
const nameW = font.widthOfTextAtSize(opts.name, 48);
page.drawLine({
start: { x: (width - nameW) / 2 - 20, y: height - 262 },
end: { x: (width + nameW) / 2 + 20, y: height - 262 },
thickness: 2,
color: rgb(0.85, 0.9, 1),
});
center(`completed "${opts.lessonTitle}" and earned the`, height - 306, 16, body, soft);
center(`${opts.badgeName} badge`, height - 344, 26, font, ink);
// A gold star, centered low (generic — works for every lesson)
page.drawSvgPath("M50 5 L61 38 L96 38 L68 59 L79 92 L50 72 L21 92 L32 59 L4 38 L39 38 Z", {
x: width / 2 - 50,
y: 240,
color: sun,
borderColor: rgb(0.88, 0.66, 0.18),
borderWidth: 2,
});
center(opts.date, 96, 14, body, soft);
center("Free & open source · No data collected", 72, 11, body, soft);
return doc.save();
}
+36
View File
@@ -0,0 +1,36 @@
import { NextResponse } from "next/server";
import { getCurrentUser } from "@/lib/session";
import { markStepComplete, awardBadge } from "@/lib/progress";
import { getLesson } from "@/lib/curriculum";
export async function POST(req: Request) {
const user = await getCurrentUser();
if (!user) return NextResponse.json({ error: "Log in to save progress." }, { status: 401 });
let body: { level?: string; sublevel?: number; step?: number; badgeKey?: string };
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid request." }, { status: 400 });
}
const level = body.level;
const sublevel = Number(body.sublevel);
const step = Number(body.step);
if (!level || !Number.isFinite(sublevel) || !Number.isFinite(step))
return NextResponse.json({ error: "Missing fields." }, { status: 400 });
markStepComplete(user.id, level, sublevel, step);
let awarded = false;
if (body.badgeKey) {
const lesson = getLesson(level, sublevel);
// Only award the badge that actually belongs to this lesson.
if (lesson && lesson.badgeKey === body.badgeKey) {
awardBadge(user.id, body.badgeKey);
awarded = true;
}
}
return NextResponse.json({ ok: true, awarded });
}
+27
View File
@@ -0,0 +1,27 @@
import { NextResponse } from "next/server";
import { getCurrentUser } from "@/lib/session";
import { createReport } from "@/lib/reports";
export async function POST(req: Request) {
const user = await getCurrentUser();
let body: { level?: string; sublevel?: string; step?: string; message?: string };
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid request." }, { status: 400 });
}
const message = (body.message || "").trim();
if (message.length < 3) return NextResponse.json({ error: "Please describe the problem." }, { status: 400 });
if (message.length > 2000) return NextResponse.json({ error: "Message is too long." }, { status: 400 });
createReport({
userId: user?.id ?? null,
level: body.level?.trim() || null,
sublevel: body.sublevel?.trim() || null,
step: body.step?.trim() || null,
message,
});
return NextResponse.json({ ok: true });
}
+54
View File
@@ -0,0 +1,54 @@
import Link from "next/link";
import SiteNav from "@/components/SiteNav";
import { consumeToken, activateUser, getUserById } from "@/lib/users";
export const metadata = { title: "Confirm account · DrawIt" };
export default async function ConfirmPage({
searchParams,
}: {
searchParams: Promise<{ token?: string }>;
}) {
const { token } = await searchParams;
let ok = false;
let username = "";
if (token) {
const row = consumeToken(token, "confirm");
if (row) {
activateUser(row.user_id);
ok = true;
username = getUserById(row.user_id)?.username ?? "";
}
}
return (
<>
<SiteNav />
<main className="container page">
<div className="form-narrow center">
{ok ? (
<div className="card">
<h1>🎉 You're all set{username ? `, ${username}` : ""}!</h1>
<p className="muted">Your email is confirmed and your account is active.</p>
<Link href="/login" className="btn big" style={{ marginTop: 8 }}>
Log in and start drawing
</Link>
</div>
) : (
<div className="card">
<h1>Hmm, that link didn't work</h1>
<p className="muted">
This confirmation link is invalid or has expired. Try signing up again, or ask an
admin to activate your account.
</p>
<Link href="/signup" className="btn secondary" style={{ marginTop: 8 }}>
Back to sign up
</Link>
</div>
)}
</div>
</main>
</>
);
}
+52
View File
@@ -0,0 +1,52 @@
"use client";
import { useState } from "react";
export default function ForgotForm() {
const [email, setEmail] = useState("");
const [busy, setBusy] = useState(false);
const [sent, setSent] = useState(false);
async function submit(e: React.FormEvent) {
e.preventDefault();
setBusy(true);
try {
await fetch("/api/auth/request-reset", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email }),
});
setSent(true);
} finally {
setBusy(false);
}
}
if (sent) {
return (
<div className="notice ok" role="status">
If that email is registered, a reset link is on its way. (No email service set up? Check the
server console for the link.)
</div>
);
}
return (
<form onSubmit={submit} noValidate>
<div className="field">
<label htmlFor="email">Your email</label>
<input
id="email"
type="email"
inputMode="email"
autoComplete="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
<button className="btn big" type="submit" disabled={busy} style={{ width: "100%" }}>
{busy ? "Sending…" : "Send reset link"}
</button>
</form>
);
}
+20
View File
@@ -0,0 +1,20 @@
import SiteNav from "@/components/SiteNav";
import ForgotForm from "./ForgotForm";
export const metadata = { title: "Forgot password · DrawIt" };
export default function ForgotPage() {
return (
<>
<SiteNav />
<main className="container page">
<div className="form-narrow">
<h1 className="center">Reset your password</h1>
<div className="card" style={{ marginTop: 18 }}>
<ForgotForm />
</div>
</div>
</main>
</>
);
}
+135
View File
@@ -0,0 +1,135 @@
:root {
/* Friendly, kid-pleasing palette */
--bg: #fef9f3;
--surface: #ffffff;
--ink: #2b2440;
--ink-soft: #6b6480;
--primary: #4f7cff;
--primary-dark: #3a63d6;
--accent: #ff7eb6;
--sun: #ffc843;
--sea: #34c6c6;
--leaf: #58c777;
--danger: #e85a5a;
--line: #ece6f0;
--shadow: 0 8px 24px rgba(60, 40, 90, 0.10);
--radius: 18px;
--radius-sm: 12px;
--maxw: 1040px;
--font: ui-rounded, "SF Pro Rounded", "Segoe UI", system-ui, -apple-system, "Helvetica Neue", Arial, sans-serif;
}
* { box-sizing: border-box; }
html, body {
margin: 0;
padding: 0;
background: var(--bg);
color: var(--ink);
font-family: var(--font);
font-size: 18px;
line-height: 1.55;
-webkit-text-size-adjust: 100%;
-webkit-tap-highlight-color: transparent;
}
/* Prevent accidental zoom/scroll jank while drawing on tablets */
.no-touch-scroll { touch-action: none; }
/* Guide "watch it drawn" reveal */
@keyframes guideReveal {
from { opacity: 0; transform: scale(0.985); }
to { opacity: inherit; transform: scale(1); }
}
.guide-reveal { animation: guideReveal 0.45s ease-out; }
h1, h2, h3 { line-height: 1.15; margin: 0 0 0.4em; }
h1 { font-size: clamp(1.9rem, 6vw, 3rem); }
h2 { font-size: clamp(1.4rem, 4.5vw, 2rem); }
p { margin: 0 0 1em; }
a { color: var(--primary-dark); }
.container { width: 100%; max-width: var(--maxw); margin: 0 auto; padding: 0 20px; }
/* Buttons */
.btn {
display: inline-flex; align-items: center; justify-content: center; gap: 0.5em;
font: inherit; font-weight: 700; cursor: pointer;
padding: 14px 22px; min-height: 52px;
border: none; border-radius: 999px;
background: var(--primary); color: #fff;
box-shadow: var(--shadow); text-decoration: none;
transition: transform .08s ease, filter .15s ease;
}
.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.accent { background: var(--accent); }
.btn.ghost { background: transparent; color: var(--ink); box-shadow: none; }
.btn.danger { background: var(--danger); }
.btn.big { font-size: 1.2rem; padding: 18px 30px; min-height: 60px; }
.btn:disabled { opacity: .5; cursor: not-allowed; }
/* Cards */
.card {
background: var(--surface); border-radius: var(--radius);
box-shadow: var(--shadow); padding: 24px;
}
/* Top navigation */
.nav {
position: sticky; top: 0; z-index: 20;
background: rgba(254, 249, 243, 0.9); 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; }
.brand { display: inline-flex; align-items: center; gap: 8px; font-weight: 800; font-size: 1.3rem; color: var(--ink); text-decoration: none; }
.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-soft); font-weight: 600; padding: 8px 12px; border-radius: 999px; }
.nav-links a:hover { background: #fff; color: var(--ink); }
/* Forms */
.field { display: flex; flex-direction: column; gap: 6px; margin-bottom: 16px; }
.field label { font-weight: 700; font-size: 0.95rem; }
.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%;
}
.field input:focus, .field select:focus, .field textarea:focus { outline: none; border-color: var(--primary); }
.field textarea { min-height: 110px; resize: vertical; }
.form-narrow { max-width: 440px; margin: 0 auto; }
.notice { padding: 14px 16px; border-radius: var(--radius-sm); margin-bottom: 16px; font-weight: 600; }
.notice.ok { background: #e8f7ee; color: #1f7a45; }
.notice.err { background: #fdecec; color: #b13838; }
.notice.info { background: #eaf0ff; color: #2f4dbb; }
.muted { color: var(--ink-soft); }
.center { text-align: center; }
.row { display: flex; gap: 12px; flex-wrap: wrap; align-items: center; }
.stack > * + * { margin-top: 14px; }
.pill { display: inline-block; padding: 4px 12px; border-radius: 999px; font-size: 0.8rem; font-weight: 700; }
.pill.pending { background: #fff1d6; color: #9a6b00; }
.pill.active { background: #e8f7ee; color: #1f7a45; }
.pill.suspended { background: #fdecec; color: #b13838; }
.pill.admin { background: #eae4ff; color: #5b3fd6; }
.pill.role { background: #e7f6f6; color: #1d8d8d; }
/* Tables (admin) */
.table-wrap { overflow-x: auto; -webkit-overflow-scrolling: touch; }
table.data { width: 100%; border-collapse: collapse; min-width: 560px; }
table.data th, table.data td { text-align: left; padding: 12px 10px; border-bottom: 1px solid var(--line); font-size: 0.95rem; vertical-align: middle; }
table.data th { color: var(--ink-soft); font-size: 0.8rem; text-transform: uppercase; letter-spacing: .04em; }
footer.site { padding: 40px 20px; text-align: center; color: var(--ink-soft); font-size: 0.9rem; }
/* Generic page section spacing */
.section { padding: 40px 0; }
.page { padding: 28px 0 60px; }
@media (max-width: 600px) {
body { font-size: 17px; }
.card { padding: 18px; }
}
+53
View File
@@ -0,0 +1,53 @@
import type { Metadata, Viewport } from "next";
import "./globals.css";
const APP_URL = (process.env.APP_URL || "http://localhost:3000").replace(/\/$/, "");
const DESCRIPTION = "DrawIt: watch your kids' curiosity and imagination grow.";
export const metadata: Metadata = {
metadataBase: new URL(APP_URL),
title: "DrawIt — Learn to draw, one shape at a time",
description: DESCRIPTION,
applicationName: "DrawIt",
manifest: "/manifest.webmanifest",
appleWebApp: { capable: true, statusBarStyle: "default", title: "DrawIt" },
icons: {
icon: [
{ url: "/favicon.ico", sizes: "any" },
{ url: "/icon-192.png", type: "image/png", sizes: "192x192" },
{ url: "/icon-512.png", type: "image/png", sizes: "512x512" },
],
apple: [{ url: "/apple-touch-icon.png", sizes: "180x180" }],
},
openGraph: {
type: "website",
siteName: "DrawIt",
title: "DrawIt — Learn to draw, one shape at a time",
description: DESCRIPTION,
url: APP_URL,
images: [{ url: "/og-image.png", width: 1280, height: 640, alt: "DrawIt — learn to draw" }],
},
twitter: {
card: "summary_large_image",
title: "DrawIt — Learn to draw, one shape at a time",
description: DESCRIPTION,
images: ["/og-image.png"],
},
// Privacy: ask search engines not to index a self-hosted family install by default.
robots: { index: false, follow: false },
};
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
maximumScale: 1,
themeColor: "#dce06b",
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}
+62
View File
@@ -0,0 +1,62 @@
import { notFound } from "next/navigation";
import SiteNav from "@/components/SiteNav";
import LessonRunner from "@/components/LessonRunner";
import { getLessonBySlug, cumulativeSvg } from "@/lib/curriculum";
import { getCurrentUser } from "@/lib/session";
import { getCompletedSteps, hasBadge } from "@/lib/progress";
export const dynamic = "force-dynamic";
export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const lesson = getLessonBySlug(slug);
return { title: lesson ? `${lesson.title} · DrawIt` : "Lesson · DrawIt" };
}
export default async function LessonPage({
params,
}: {
params: Promise<{ level: string; slug: string }>;
}) {
const { level, slug } = await params;
const lesson = getLessonBySlug(slug);
if (!lesson || lesson.level !== level) notFound();
const user = await getCurrentUser();
const completed = user ? getCompletedSteps(user.id, lesson.level, lesson.sublevel) : [];
const alreadyEarned = user ? hasBadge(user.id, lesson.badgeKey) : false;
const steps = lesson.steps.map((s) => ({
n: s.n,
title: s.title,
instruction: s.instruction,
tip: s.tip ?? "",
kind: s.kind ?? "",
guideSvg: cumulativeSvg(lesson, s.n),
}));
return (
<>
<SiteNav />
<main className="container page">
<LessonRunner
meta={{
level: lesson.level,
levelName: "Early Beginner",
sublevel: lesson.sublevel,
title: lesson.title,
emoji: lesson.emoji,
intro: lesson.intro,
badgeKey: lesson.badgeKey,
badgeName: lesson.badgeName,
}}
steps={steps}
loggedIn={!!user}
username={user?.username ?? ""}
completedSteps={completed}
alreadyEarned={alreadyEarned}
/>
</main>
</>
);
}
+61
View File
@@ -0,0 +1,61 @@
import Link from "next/link";
import SiteNav from "@/components/SiteNav";
import { LEVELS, LESSONS } from "@/lib/curriculum";
export const metadata = { title: "Learn · DrawIt" };
export default function LearnPage() {
return (
<>
<SiteNav />
<main className="container page">
<h1>Learn to draw 🎨</h1>
<p className="muted">Pick your level and start a lesson. New lessons are added over time.</p>
<div className="stack" style={{ marginTop: 18 }}>
{LEVELS.map((lvl, idx) => {
const lessons = LESSONS.filter((l) => l.level === lvl.key);
return (
<div className="card" key={lvl.key}>
<div className="row" style={{ justifyContent: "space-between" }}>
<h2 style={{ margin: 0 }}>
{lvl.emoji} {idx + 1}. {lvl.name}
</h2>
{lessons.length === 0 && <span className="pill pending">Coming soon</span>}
</div>
<p className="muted">{lvl.blurb}</p>
{lessons.length > 0 && (
<div
style={{
display: "grid",
gap: 12,
gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))",
marginTop: 8,
}}
>
{lessons.map((lesson) => (
<Link
key={lesson.slug}
href={`/learn/${lesson.level}/${lesson.slug}`}
className="card"
style={{ textDecoration: "none", color: "inherit", border: "2px solid var(--line)", boxShadow: "none" }}
>
<div style={{ fontSize: "1.8rem" }}>{lesson.emoji}</div>
<strong>
Level {lesson.sublevel}: {lesson.title}
</strong>
<p className="muted" style={{ margin: "4px 0 0", fontSize: "0.9rem" }}>
{lesson.steps.length} simple steps
</p>
</Link>
))}
</div>
)}
</div>
);
})}
</div>
</main>
</>
);
}
+73
View File
@@ -0,0 +1,73 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
export default function LoginForm() {
const router = useRouter();
const [identifier, setIdentifier] = useState("");
const [password, setPassword] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
async function submit(e: React.FormEvent) {
e.preventDefault();
setError("");
setBusy(true);
try {
const res = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ identifier, password }),
});
const data = await res.json();
if (!res.ok) {
setError(data.error || "Could not log in.");
} else {
router.push(data.role === "admin" ? "/admin" : "/learn");
router.refresh();
}
} catch {
setError("Network error. Please try again.");
} finally {
setBusy(false);
}
}
return (
<form onSubmit={submit} noValidate>
{error && (
<div className="notice err" role="alert">
{error}
</div>
)}
<div className="field">
<label htmlFor="identifier">Email or username</label>
<input
id="identifier"
type="text"
autoComplete="username"
value={identifier}
onChange={(e) => setIdentifier(e.target.value)}
required
/>
</div>
<div className="field">
<label htmlFor="password">Password</label>
<input
id="password"
type="password"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</div>
<button className="btn big" type="submit" disabled={busy} style={{ width: "100%" }}>
{busy ? "Logging in…" : "Log in"}
</button>
<p className="center muted" style={{ marginTop: 14 }}>
<a href="/forgot">Forgot password?</a> &nbsp;·&nbsp; New here? <a href="/signup">Sign up</a>
</p>
</form>
);
}
+20
View File
@@ -0,0 +1,20 @@
import SiteNav from "@/components/SiteNav";
import LoginForm from "./LoginForm";
export const metadata = { title: "Log in · DrawIt" };
export default function LoginPage() {
return (
<>
<SiteNav />
<main className="container page">
<div className="form-narrow">
<h1 className="center">Welcome back</h1>
<div className="card" style={{ marginTop: 18 }}>
<LoginForm />
</div>
</div>
</main>
</>
);
}
+115
View File
@@ -0,0 +1,115 @@
import Link from "next/link";
import SiteNav from "@/components/SiteNav";
import FishSlideshow from "@/components/FishSlideshow";
import { FISH_LESSON, LEVELS, cumulativeSvg } from "@/lib/curriculum";
export default function Home() {
const slides = FISH_LESSON.steps.map((s) => ({
n: s.n,
title: s.title,
instruction: s.instruction,
cumulativeSvg: cumulativeSvg(FISH_LESSON, s.n),
}));
return (
<>
<SiteNav />
<main>
{/* Hero */}
<section className="section">
<div className="container" style={{ textAlign: "center" }}>
<div className="pill role" style={{ marginBottom: 14 }}>
🆓 Free &amp; open source · 🔒 No data collected, ever
</div>
<h1>Learn to draw, one shape at a time</h1>
<p className="muted" style={{ fontSize: "1.2rem", maxWidth: 620, margin: "0 auto 24px" }}>
DrawIt teaches kids to draw with simple, friendly steps — built for iPads and tablets.
Earn badges, collect printable awards, and grow from Early Beginner all the way to Superb.
</p>
<div className="row" style={{ justifyContent: "center" }}>
<Link href="/learn/early-beginner/fish" className="btn big">
🐟 Try the first lesson
</Link>
<Link href="/signup" className="btn secondary big">
Create an account
</Link>
</div>
</div>
</section>
{/* Slideshow of Early Beginner Level 1 */}
<section className="section" style={{ background: "#fff", borderBlock: "1px solid var(--line)" }}>
<div className="container">
<h2 className="center">Peek at Level 1: Draw a Fish 🐟</h2>
<p className="center muted" style={{ maxWidth: 560, margin: "0 auto 26px" }}>
This is our very first <strong>Early Beginner</strong> lesson. Watch it come together,
step by step, from a few simple shapes.
</p>
<FishSlideshow steps={slides} />
</div>
</section>
{/* Levels */}
<section className="section">
<div className="container">
<h2 className="center">Five levels of drawing skill</h2>
<p className="center muted" style={{ marginBottom: 26 }}>
The levels describe <em>you</em> — start where you are and level up.
</p>
<div
style={{
display: "grid",
gap: 16,
gridTemplateColumns: "repeat(auto-fit, minmax(180px, 1fr))",
}}
>
{LEVELS.map((lvl, idx) => (
<div className="card" key={lvl.key}>
<div style={{ fontSize: "2rem" }}>{lvl.emoji}</div>
<h3 style={{ margin: "6px 0 4px" }}>
{idx + 1}. {lvl.name}
</h3>
<p className="muted" style={{ margin: 0, fontSize: "0.95rem" }}>
{lvl.blurb}
</p>
</div>
))}
</div>
</div>
</section>
{/* How it works */}
<section className="section" style={{ background: "#fff", 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,
}}
>
<div className="card">
<h3>👣 Step by step</h3>
<p className="muted">Each lesson breaks a picture into tiny, doable steps using only simple shapes.</p>
</div>
<div className="card">
<h3>🏅 Badges &amp; awards</h3>
<p className="muted">Finish a lesson to earn a shareable badge and a printable award certificate (PDF).</p>
</div>
<div className="card">
<h3>🔒 Private by design</h3>
<p className="muted">No analytics, no trackers, no data sold or shared. Just drawing.</p>
</div>
</div>
</div>
</section>
</main>
<footer className="site">
DrawIt · Free &amp; open source · No data collected. Made for young artists. 🎨
</footer>
</>
);
}
+84
View File
@@ -0,0 +1,84 @@
"use client";
import { useState } from "react";
export default function ReportForm({
levels,
defaultLevel = "",
defaultSublevel = "",
defaultStep = "",
}: {
levels: { key: string; name: string }[];
defaultLevel?: string;
defaultSublevel?: string;
defaultStep?: string;
}) {
const [level, setLevel] = useState(defaultLevel);
const [sublevel, setSublevel] = useState(defaultSublevel);
const [step, setStep] = useState(defaultStep);
const [message, setMessage] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [done, setDone] = useState(false);
async function submit(e: React.FormEvent) {
e.preventDefault();
setError("");
setBusy(true);
try {
const res = await fetch("/api/reports", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ level, sublevel, step, message }),
});
const data = await res.json();
if (!res.ok) setError(data.error || "Could not send report.");
else setDone(true);
} catch {
setError("Network error. Please try again.");
} finally {
setBusy(false);
}
}
if (done) {
return (
<div className="notice ok" role="status">
✅ Thanks! Your report was sent to the admins.
</div>
);
}
return (
<form onSubmit={submit} noValidate>
{error && <div className="notice err" role="alert">{error}</div>}
<div style={{ display: "grid", gap: 12, gridTemplateColumns: "repeat(auto-fit, minmax(150px, 1fr))" }}>
<div className="field" style={{ marginBottom: 0 }}>
<label>Level</label>
<select value={level} onChange={(e) => setLevel(e.target.value)}>
<option value="">—</option>
{levels.map((l) => (
<option key={l.key} value={l.key}>
{l.name}
</option>
))}
</select>
</div>
<div className="field" style={{ marginBottom: 0 }}>
<label>Sub-level</label>
<input value={sublevel} onChange={(e) => setSublevel(e.target.value)} placeholder="e.g. 1" inputMode="numeric" />
</div>
<div className="field" style={{ marginBottom: 0 }}>
<label>Step</label>
<input value={step} onChange={(e) => setStep(e.target.value)} placeholder="e.g. 3" inputMode="numeric" />
</div>
</div>
<div className="field" style={{ marginTop: 14 }}>
<label>What happened?</label>
<textarea value={message} onChange={(e) => setMessage(e.target.value)} placeholder="Describe the problem…" required />
</div>
<button className="btn big" type="submit" disabled={busy} style={{ width: "100%" }}>
{busy ? "Sending…" : "Send report"}
</button>
</form>
);
}
+34
View File
@@ -0,0 +1,34 @@
import SiteNav from "@/components/SiteNav";
import ReportForm from "./ReportForm";
import { LEVELS } from "@/lib/curriculum";
export const metadata = { title: "Report a problem · DrawIt" };
export default async function ReportPage({
searchParams,
}: {
searchParams: Promise<{ level?: string; sublevel?: string; step?: string }>;
}) {
const sp = await searchParams;
return (
<>
<SiteNav />
<main className="container page">
<div className="form-narrow">
<h1 className="center">Report a problem</h1>
<p className="center muted">
Stuck or something not working? Tell us where so we can fix it.
</p>
<div className="card" style={{ marginTop: 18 }}>
<ReportForm
levels={LEVELS.map((l) => ({ key: l.key, name: l.name }))}
defaultLevel={sp.level ?? ""}
defaultSublevel={sp.sublevel ?? ""}
defaultStep={sp.step ?? ""}
/>
</div>
</div>
</main>
</>
);
}
+64
View File
@@ -0,0 +1,64 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
export default function ResetForm({ token }: { token: string }) {
const router = useRouter();
const [password, setPassword] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [done, setDone] = useState(false);
if (!token) {
return <div className="notice err">This reset link is missing its token. Request a new one.</div>;
}
async function submit(e: React.FormEvent) {
e.preventDefault();
setError("");
setBusy(true);
try {
const res = await fetch("/api/auth/reset", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token, password }),
});
const data = await res.json();
if (!res.ok) setError(data.error || "Could not reset password.");
else setDone(true);
} catch {
setError("Network error. Please try again.");
} finally {
setBusy(false);
}
}
if (done) {
return (
<div className="notice ok" role="status">
✅ Password updated! <button className="btn" style={{ marginTop: 10 }} onClick={() => router.push("/login")}>Log in</button>
</div>
);
}
return (
<form onSubmit={submit} noValidate>
{error && <div className="notice err" role="alert">{error}</div>}
<div className="field">
<label htmlFor="password">New password</label>
<input
id="password"
type="password"
autoComplete="new-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="At least 6 characters"
required
/>
</div>
<button className="btn big" type="submit" disabled={busy} style={{ width: "100%" }}>
{busy ? "Saving…" : "Save new password"}
</button>
</form>
);
}
+25
View File
@@ -0,0 +1,25 @@
import SiteNav from "@/components/SiteNav";
import ResetForm from "./ResetForm";
export const metadata = { title: "Set new password · DrawIt" };
export default async function ResetPage({
searchParams,
}: {
searchParams: Promise<{ token?: string }>;
}) {
const { token } = await searchParams;
return (
<>
<SiteNav />
<main className="container page">
<div className="form-narrow">
<h1 className="center">Set a new password</h1>
<div className="card" style={{ marginTop: 18 }}>
<ResetForm token={token ?? ""} />
</div>
</div>
</main>
</>
);
}
+101
View File
@@ -0,0 +1,101 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
export default function SignupForm() {
const router = useRouter();
const [email, setEmail] = useState("");
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [done, setDone] = useState<null | { admin: boolean }>(null);
async function submit(e: React.FormEvent) {
e.preventDefault();
setError("");
setBusy(true);
try {
const res = await fetch("/api/auth/signup", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, username, password }),
});
const data = await res.json();
if (!res.ok) {
setError(data.error || "Something went wrong.");
} else if (data.admin) {
router.push("/admin");
router.refresh();
} else {
setDone({ admin: false });
}
} catch {
setError("Network error. Please try again.");
} finally {
setBusy(false);
}
}
if (done) {
return (
<div className="notice ok" role="status">
🎉 Almost there! We sent a confirmation link to <strong>{email}</strong>. Open it to activate
your account, then log in. (No confirmation email service set up yet? The link is printed in
the server console.)
</div>
);
}
return (
<form onSubmit={submit} noValidate>
{error && (
<div className="notice err" role="alert">
{error}
</div>
)}
<div className="field">
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
autoComplete="email"
inputMode="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
<div className="field">
<label htmlFor="username">Username</label>
<input
id="username"
type="text"
autoComplete="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="3–24 letters, numbers, _ or -"
required
/>
</div>
<div className="field">
<label htmlFor="password">Password</label>
<input
id="password"
type="password"
autoComplete="new-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="At least 6 characters"
required
/>
</div>
<button className="btn big" type="submit" disabled={busy} style={{ width: "100%" }}>
{busy ? "Creating…" : "Create account"}
</button>
<p className="center muted" style={{ marginTop: 14 }}>
Already have an account? <a href="/login">Log in</a>
</p>
</form>
);
}
+24
View File
@@ -0,0 +1,24 @@
import SiteNav from "@/components/SiteNav";
import SignupForm from "./SignupForm";
export const metadata = { title: "Sign up · DrawIt" };
export default function SignupPage() {
return (
<>
<SiteNav />
<main className="container page">
<div className="form-narrow">
<h1 className="center">Create your account</h1>
<p className="center muted">
The first account becomes the Admin. Everyone else starts as a Learner and must confirm
their email before logging in.
</p>
<div className="card" style={{ marginTop: 18 }}>
<SignupForm />
</div>
</div>
</main>
</>
);
}
+33
View File
@@ -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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" }[c] as string));
}
+84
View File
@@ -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>
);
}
+551
View File
@@ -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);
}
+16
View File
@@ -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>
);
}
+34
View File
@@ -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>
);
}
+28
View File
@@ -0,0 +1,28 @@
import crypto from "node:crypto";
/**
* Password hashing with scrypt (ships with Node — no native dep, no third-party service).
* Format stored: salt is kept in its own column; hash is hex of scrypt output.
*/
const KEYLEN = 64;
const COST = 16384; // 2^14
export function makeSalt(): string {
return crypto.randomBytes(16).toString("hex");
}
export function hashPassword(password: string, salt: string): string {
const buf = crypto.scryptSync(password, salt, KEYLEN, { N: COST });
return buf.toString("hex");
}
export function verifyPassword(password: string, salt: string, expectedHash: string): boolean {
const actual = Buffer.from(hashPassword(password, salt), "hex");
const expected = Buffer.from(expectedHash, "hex");
if (actual.length !== expected.length) return false;
return crypto.timingSafeEqual(actual, expected);
}
export function randomToken(bytes = 32): string {
return crypto.randomBytes(bytes).toString("base64url");
}
+484
View File
@@ -0,0 +1,484 @@
/**
* The DrawIt curriculum.
*
* Ability levels describe the learner. Each level has numbered sub-levels (lessons).
* Each lesson is a sequence of steps built from the simplest possible shapes.
*
* SVG snippets use a 400x300 viewBox and are designed to stack: rendering steps
* 1..n in order draws the whole picture. They power both the landing-page slideshow
* and the step-by-step lesson guide.
*/
export interface LevelMeta {
key: string;
name: string;
emoji: string;
blurb: string;
}
export const LEVELS: LevelMeta[] = [
{
key: "early-beginner",
name: "Early Beginner",
emoji: "🌱",
blurb: "First lines and simple shapes. Perfect for brand-new artists.",
},
{
key: "beginner",
name: "Beginner",
emoji: "✏️",
blurb: "Combine shapes into friendly characters and objects.",
},
{
key: "learner",
name: "Learner",
emoji: "🎨",
blurb: "Add details, shading, and your own ideas.",
},
{
key: "advanced-learned",
name: "Advanced Learned",
emoji: "🚀",
blurb: "Proportion, perspective, and more complex scenes.",
},
{
key: "superb",
name: "Superb",
emoji: "🌟",
blurb: "Confident, expressive drawing in your own style.",
},
];
export function getLevel(key: string): LevelMeta | undefined {
return LEVELS.find((l) => l.key === key);
}
/** What a step teaches. Drives the little label shown on each step. */
export type StepKind = "construct" | "outline" | "color" | "shade" | "detail";
export interface LessonStep {
n: number;
title: string;
instruction: string;
tip?: string;
/** Optional teaching tag. If omitted, a sensible label is derived from position. */
kind?: StepKind;
/** New shapes added at this step (cumulative when stacked 1..n). */
svg: string;
}
export interface Lesson {
level: string;
sublevel: number;
slug: string;
title: string;
subject: string;
emoji: string;
intro: string;
badgeKey: string;
badgeName: string;
steps: LessonStep[];
}
/* ----------------------- Early Beginner · Level 1 · Fish ----------------------- */
const FISH_STEPS: LessonStep[] = [
{
n: 1,
title: "The body",
instruction: "Draw a big oval lying on its side. This is the fish's body.",
tip: "Slow and round — like a rugby ball or an egg.",
svg: `<ellipse cx="205" cy="150" rx="120" ry="72" fill="#ffd566" stroke="#e0a92f" stroke-width="4"/>`,
},
{
n: 2,
title: "The tail",
instruction: "Add a triangle on the right side. That's the swishy tail!",
tip: "Two lines out, then join them — like a slice of pizza.",
svg: `<polygon points="318,150 392,98 392,202" fill="#ff9ec4" stroke="#e0699b" stroke-width="4"/>`,
},
{
n: 3,
title: "The eye",
instruction: "Draw a small circle near the front, then a dot inside it.",
tip: "Keep it near the top-left so the fish looks where it's going.",
svg: `<circle cx="135" cy="128" r="15" fill="#ffffff" stroke="#2b2440" stroke-width="3"/>
<circle cx="131" cy="131" r="6.5" fill="#2b2440"/>`,
},
{
n: 4,
title: "The mouth",
instruction: "Add a little curved smile at the very front (left tip).",
tip: "Just a small smile — happy fish!",
svg: `<path d="M92 152 q16 16 34 7" fill="none" stroke="#2b2440" stroke-width="4" stroke-linecap="round"/>`,
},
{
n: 5,
title: "The fins",
instruction: "Add a curved fin on top and one underneath the body.",
tip: "Like little waves on the fish's back and belly.",
svg: `<path d="M172 84 q42 -34 84 -2" fill="#ffbf4d" stroke="#e0a92f" stroke-width="4" stroke-linecap="round"/>
<path d="M180 218 q34 26 76 8" fill="#ffbf4d" stroke="#e0a92f" stroke-width="4" stroke-linecap="round"/>`,
},
{
n: 6,
title: "Gill & finish",
instruction: "Draw a curved line behind the head for the gill. Your fish is done!",
tip: "Now you can color it any way you like. 🎉",
svg: `<path d="M168 92 q-18 58 0 116" fill="none" stroke="#e0a92f" stroke-width="3.5" stroke-linecap="round"/>`,
},
];
export const FISH_LESSON: Lesson = {
level: "early-beginner",
sublevel: 1,
slug: "fish",
title: "Draw a Fish",
subject: "a happy fish",
emoji: "🐟",
intro:
"We'll build a friendly fish out of an oval, a triangle, a circle, and a couple of curves. Six easy steps!",
badgeKey: "early-beginner-1-fish",
badgeName: "Fish Friend",
steps: FISH_STEPS,
};
/* ----------------------- Early Beginner · Level 2 · Panda ----------------------- */
const PANDA_STEPS: LessonStep[] = [
{
n: 1,
title: "The head",
instruction: "Draw one big round circle. This is the panda's face.",
tip: "Nice and round, like a beach ball.",
svg: `<ellipse cx="200" cy="165" rx="90" ry="82" fill="#ffffff" stroke="#2b2440" stroke-width="4"/>`,
},
{
n: 2,
title: "The ears",
instruction: "Add two small black circles on top for the ears.",
tip: "One in each top corner of the head.",
svg: `<circle cx="150" cy="92" r="28" fill="#2b2440"/>
<circle cx="250" cy="92" r="28" fill="#2b2440"/>`,
},
{
n: 3,
title: "Eye patches",
instruction: "Draw two black ovals where the eyes will go.",
tip: "Pandas have big dark patches around their eyes.",
svg: `<ellipse cx="167" cy="150" rx="22" ry="28" fill="#2b2440"/>
<ellipse cx="233" cy="150" rx="22" ry="28" fill="#2b2440"/>`,
},
{
n: 4,
title: "The eyes",
instruction: "Add a small white circle and a dot inside each patch.",
tip: "The little white shine makes the eyes look sparkly.",
svg: `<circle cx="170" cy="150" r="8" fill="#ffffff"/><circle cx="172" cy="152" r="4" fill="#2b2440"/>
<circle cx="230" cy="150" r="8" fill="#ffffff"/><circle cx="228" cy="152" r="4" fill="#2b2440"/>`,
},
{
n: 5,
title: "The nose",
instruction: "Draw a little black oval in the middle for the nose.",
svg: `<ellipse cx="200" cy="184" rx="11" ry="7" fill="#2b2440"/>`,
},
{
n: 6,
title: "The smile",
instruction: "Add two small curves under the nose for a happy mouth. Done!",
tip: "Color the ears and patches black to finish. 🎉",
svg: `<path d="M200 191 q-15 16 -30 7" fill="none" stroke="#2b2440" stroke-width="3.5" stroke-linecap="round"/>
<path d="M200 191 q15 16 30 7" fill="none" stroke="#2b2440" stroke-width="3.5" stroke-linecap="round"/>`,
},
];
export const PANDA_LESSON: Lesson = {
level: "early-beginner",
sublevel: 2,
slug: "panda",
title: "Draw a Panda",
subject: "a cuddly panda",
emoji: "🐼",
intro: "A panda is just circles and ovals! Six gentle steps and you'll have a cuddly friend.",
badgeKey: "early-beginner-2-panda",
badgeName: "Panda Pal",
steps: PANDA_STEPS,
};
/* ----------------------- Early Beginner · Level 3 · Flower ----------------------- */
const FLOWER_STEPS: LessonStep[] = [
{
n: 1,
title: "The middle",
instruction: "Draw a circle in the middle of the page. This is the center.",
tip: "Leave room all around it for the petals.",
svg: `<circle cx="200" cy="120" r="34" fill="#ffc843" stroke="#e0a92f" stroke-width="4"/>`,
},
{
n: 2,
title: "Top & bottom petals",
instruction: "Add one oval petal above the center and one below.",
tip: "Petals are just long ovals pointing outward.",
svg: `<ellipse cx="200" cy="58" rx="22" ry="34" fill="#ff7eb6" stroke="#e0699b" stroke-width="3"/>
<ellipse cx="200" cy="58" rx="22" ry="34" fill="#ff7eb6" stroke="#e0699b" stroke-width="3" transform="rotate(180 200 120)"/>`,
},
{
n: 3,
title: "Two more petals",
instruction: "Add a petal on the upper-left and the lower-right.",
svg: `<ellipse cx="200" cy="58" rx="22" ry="34" fill="#ff7eb6" stroke="#e0699b" stroke-width="3" transform="rotate(60 200 120)"/>
<ellipse cx="200" cy="58" rx="22" ry="34" fill="#ff7eb6" stroke="#e0699b" stroke-width="3" transform="rotate(240 200 120)"/>`,
},
{
n: 4,
title: "Last petals",
instruction: "Fill in the last two petals so the flower is full.",
tip: "Now it looks like a daisy!",
svg: `<ellipse cx="200" cy="58" rx="22" ry="34" fill="#ff7eb6" stroke="#e0699b" stroke-width="3" transform="rotate(120 200 120)"/>
<ellipse cx="200" cy="58" rx="22" ry="34" fill="#ff7eb6" stroke="#e0699b" stroke-width="3" transform="rotate(300 200 120)"/>`,
},
{
n: 5,
title: "The stem",
instruction: "Draw a tall green rectangle going down from the flower.",
svg: `<rect x="194" y="150" width="12" height="108" rx="5" fill="#58c777" stroke="#3a9b57" stroke-width="2"/>`,
},
{
n: 6,
title: "The leaves",
instruction: "Add two leaf ovals on the stem. Your flower is blooming!",
tip: "Color it any colors you like. 🌸",
svg: `<ellipse cx="160" cy="205" rx="28" ry="13" fill="#58c777" stroke="#3a9b57" stroke-width="2" transform="rotate(-25 160 205)"/>
<ellipse cx="240" cy="222" rx="28" ry="13" fill="#58c777" stroke="#3a9b57" stroke-width="2" transform="rotate(25 240 222)"/>`,
},
];
export const FLOWER_LESSON: Lesson = {
level: "early-beginner",
sublevel: 3,
slug: "flower",
title: "Draw a Flower",
subject: "a cheerful flower",
emoji: "🌸",
intro: "A circle in the middle and oval petals all around — that's a flower! Then a stem and leaves.",
badgeKey: "early-beginner-3-flower",
badgeName: "Flower Power",
steps: FLOWER_STEPS,
};
/* ----------------------- Early Beginner · Level 4 · Unicorn ----------------------- */
const UNICORN_STEPS: LessonStep[] = [
{
n: 1,
title: "The head",
instruction: "Draw a tall oval for the unicorn's head.",
tip: "A little taller than it is wide.",
svg: `<ellipse cx="200" cy="165" rx="66" ry="80" fill="#ffffff" stroke="#2b2440" stroke-width="4"/>`,
},
{
n: 2,
title: "The ears",
instruction: "Add a pointy triangle ear on each side of the top.",
svg: `<polygon points="152,100 138,58 176,86" fill="#ffffff" stroke="#2b2440" stroke-width="4" stroke-linejoin="round"/>
<polygon points="248,100 262,58 224,86" fill="#ffffff" stroke="#2b2440" stroke-width="4" stroke-linejoin="round"/>`,
},
{
n: 3,
title: "The magic horn",
instruction: "Draw a tall triangle in the middle on top — the horn!",
tip: "Add a few short lines for the swirl.",
svg: `<polygon points="200,92 186,22 214,92" fill="#ffc843" stroke="#e0a92f" stroke-width="3" stroke-linejoin="round"/>
<path d="M192 78 l16 -5 M195 60 l12 -4 M198 44 l9 -3" stroke="#e0a92f" stroke-width="2.5" fill="none" stroke-linecap="round"/>`,
},
{
n: 4,
title: "The eyes",
instruction: "Add two tall oval eyes with little lashes.",
tip: "Pink cheeks make it extra cute.",
svg: `<ellipse cx="174" cy="158" rx="7" ry="10" fill="#2b2440"/>
<ellipse cx="226" cy="158" rx="7" ry="10" fill="#2b2440"/>
<path d="M163 150 q6 -8 14 -5 M237 150 q-6 -8 -14 -5" stroke="#2b2440" stroke-width="2.5" fill="none" stroke-linecap="round"/>
<circle cx="158" cy="192" r="12" fill="#ffd1e3"/><circle cx="242" cy="192" r="12" fill="#ffd1e3"/>`,
},
{
n: 5,
title: "Nose & mouth",
instruction: "Add two dots for the nose and a small smile.",
svg: `<circle cx="186" cy="208" r="4" fill="#2b2440"/>
<circle cx="214" cy="208" r="4" fill="#2b2440"/>
<path d="M186 224 q14 12 28 0" fill="none" stroke="#2b2440" stroke-width="3" stroke-linecap="round"/>`,
},
{
n: 6,
title: "The rainbow mane",
instruction: "Add a curly forelock and flowing strands down one side.",
tip: "Use lots of colors — that's the unicorn magic! 🦄",
svg: `<path d="M176 94 q-12 24 8 40 M200 94 q-6 26 0 44 M224 94 q12 24 -8 40" fill="none" stroke="#ff7eb6" stroke-width="9" stroke-linecap="round"/>
<path d="M256 122 q42 20 30 72" stroke="#a06bff" stroke-width="13" fill="none" stroke-linecap="round"/>
<path d="M258 144 q44 24 24 84" stroke="#7ec8ff" stroke-width="13" fill="none" stroke-linecap="round"/>
<path d="M252 166 q40 28 14 80" stroke="#ff7eb6" stroke-width="13" fill="none" stroke-linecap="round"/>`,
},
];
export const UNICORN_LESSON: Lesson = {
level: "early-beginner",
sublevel: 4,
slug: "unicorn",
title: "Draw a Unicorn",
subject: "a magical unicorn",
emoji: "🦄",
intro: "An oval head, triangle ears and horn, then a rainbow mane. Let's make some magic!",
badgeKey: "early-beginner-4-unicorn",
badgeName: "Unicorn Magic",
steps: UNICORN_STEPS,
};
/* ----------------------- Early Beginner · Level 5 · Tree ----------------------- */
const TREE_STEPS: LessonStep[] = [
{
n: 1,
title: "The trunk",
instruction: "Draw a tall brown rectangle. This is the tree trunk.",
tip: "Stand it up tall in the middle.",
svg: `<rect x="184" y="150" width="32" height="110" rx="6" fill="#b5793f" stroke="#8a5a2b" stroke-width="4"/>`,
},
{
n: 2,
title: "Big green bush",
instruction: "Draw a big green circle on top of the trunk for leaves.",
svg: `<circle cx="200" cy="120" r="70" fill="#62c178" stroke="#3a9b57" stroke-width="4"/>`,
},
{
n: 3,
title: "Left bump",
instruction: "Add another green circle on the upper-left.",
tip: "Treetops are nice and lumpy.",
svg: `<circle cx="150" cy="95" r="46" fill="#62c178" stroke="#3a9b57" stroke-width="4"/>`,
},
{
n: 4,
title: "Right bump",
instruction: "Add a green circle on the upper-right too.",
svg: `<circle cx="252" cy="100" r="44" fill="#62c178" stroke="#3a9b57" stroke-width="4"/>`,
},
{
n: 5,
title: "Top bump",
instruction: "One more green circle on top to round it off.",
svg: `<circle cx="205" cy="66" r="46" fill="#62c178" stroke="#3a9b57" stroke-width="4"/>`,
},
{
n: 6,
title: "Fruit & grass",
instruction: "Add a few red fruits and a wavy line of grass at the bottom.",
tip: "Your tree is grown! 🌳",
svg: `<circle cx="170" cy="112" r="7" fill="#e85a5a"/>
<circle cx="228" cy="96" r="7" fill="#e85a5a"/>
<circle cx="205" cy="142" r="7" fill="#e85a5a"/>
<path d="M120 260 q20 -16 40 0 q20 -16 40 0 q20 -16 40 0 q20 -16 40 0" fill="none" stroke="#58c777" stroke-width="4" stroke-linecap="round"/>`,
},
];
export const TREE_LESSON: Lesson = {
level: "early-beginner",
sublevel: 5,
slug: "tree",
title: "Draw a Tree",
subject: "a leafy tree",
emoji: "🌳",
intro: "A trunk and a bunch of bumpy green circles make a leafy tree. Add some fruit at the end!",
badgeKey: "early-beginner-5-tree",
badgeName: "Tree Top",
steps: TREE_STEPS,
};
/* ----------------------- Early Beginner · Level 6 · Dinosaur ----------------------- */
const DINO_STEPS: LessonStep[] = [
{
n: 1,
title: "The body",
instruction: "Draw a big oval lying down. This is the dinosaur's body.",
tip: "A long-neck dinosaur is nice and round.",
svg: `<ellipse cx="205" cy="195" rx="82" ry="48" fill="#7ec86a" stroke="#4f9b46" stroke-width="4"/>`,
},
{
n: 2,
title: "The long neck",
instruction: "Draw a long oval reaching up from the left of the body.",
tip: "Lean it up and to the side.",
svg: `<ellipse cx="158" cy="120" rx="20" ry="62" fill="#7ec86a" stroke="#4f9b46" stroke-width="4" transform="rotate(-18 158 120)"/>`,
},
{
n: 3,
title: "The head",
instruction: "Add a small oval on top of the neck, and a dot for the eye.",
svg: `<ellipse cx="136" cy="58" rx="28" ry="22" fill="#7ec86a" stroke="#4f9b46" stroke-width="4"/>
<circle cx="124" cy="52" r="4" fill="#2b2440"/>`,
},
{
n: 4,
title: "The tail",
instruction: "Draw a long pointy tail sweeping out to the right.",
svg: `<path d="M270 200 Q345 175 392 158 Q345 200 282 222 Z" fill="#7ec86a" stroke="#4f9b46" stroke-width="4" stroke-linejoin="round"/>`,
},
{
n: 5,
title: "The legs",
instruction: "Add two chunky rectangle legs under the body.",
svg: `<rect x="166" y="230" width="26" height="46" rx="7" fill="#7ec86a" stroke="#4f9b46" stroke-width="4"/>
<rect x="222" y="230" width="26" height="46" rx="7" fill="#7ec86a" stroke="#4f9b46" stroke-width="4"/>`,
},
{
n: 6,
title: "Spots & smile",
instruction: "Add a few darker spots and a little smile. Roar!",
tip: "You can color your dino any color — they didn't leave us photos! 🦕",
svg: `<circle cx="205" cy="180" r="9" fill="#4f9b46"/>
<circle cx="238" cy="198" r="8" fill="#4f9b46"/>
<circle cx="182" cy="208" r="7" fill="#4f9b46"/>
<path d="M120 66 q12 8 23 2" fill="none" stroke="#2b2440" stroke-width="2.5" stroke-linecap="round"/>`,
},
];
export const DINO_LESSON: Lesson = {
level: "early-beginner",
sublevel: 6,
slug: "dinosaur",
title: "Draw a Dinosaur",
subject: "a friendly dinosaur",
emoji: "🦕",
intro: "A round body, a long neck, a small head, and a swooshy tail — meet your long-neck dino!",
badgeKey: "early-beginner-6-dinosaur",
badgeName: "Dino Master",
steps: DINO_STEPS,
};
export const LESSONS: Lesson[] = [
FISH_LESSON,
PANDA_LESSON,
FLOWER_LESSON,
UNICORN_LESSON,
TREE_LESSON,
DINO_LESSON,
];
export function getLesson(level: string, sublevel: number): Lesson | undefined {
return LESSONS.find((l) => l.level === level && l.sublevel === sublevel);
}
export function getLessonBySlug(slug: string): Lesson | undefined {
return LESSONS.find((l) => l.slug === slug);
}
/** SVG markup that stacks steps 1..upTo (inclusive). Wrapped in a viewBox group. */
export function cumulativeSvg(lesson: Lesson, upTo: number): string {
return lesson.steps
.filter((s) => s.n <= upTo)
.map((s) => s.svg)
.join("\n");
}
+114
View File
@@ -0,0 +1,114 @@
import Database from "better-sqlite3";
import fs from "node:fs";
import path from "node:path";
/**
* Single shared SQLite connection.
*
* WAL journaling is enabled (the SQLite-native equivalent of "wal-g"): it improves
* concurrency and is the prerequisite for streaming backups via Litestream. The DB
* path is configurable (DATABASE_PATH) so Litestream can be pointed at it without
* any code change.
*/
declare global {
// eslint-disable-next-line no-var
var __drawitDb: Database.Database | undefined;
}
function resolveDbPath(): string {
const p = process.env.DATABASE_PATH || "./data/drawit.db";
return path.isAbsolute(p) ? p : path.join(process.cwd(), p);
}
function createConnection(): Database.Database {
const dbPath = resolveDbPath();
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
const db = new Database(dbPath);
// WAL mode: better concurrency + required for Litestream streaming backups.
db.pragma("journal_mode = WAL");
db.pragma("synchronous = NORMAL");
db.pragma("foreign_keys = ON");
db.pragma("busy_timeout = 5000");
migrate(db);
return db;
}
function migrate(db: Database.Database) {
db.exec(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL UNIQUE,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
password_salt TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'learner', -- 'admin' | 'learner' | ...
level TEXT NOT NULL DEFAULT 'early-beginner',
status TEXT NOT NULL DEFAULT 'pending', -- 'pending' | 'active' | 'suspended'
email_verified INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token TEXT NOT NULL UNIQUE,
kind TEXT NOT NULL, -- 'confirm' | 'reset'
expires_at TEXT NOT NULL,
used_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token TEXT NOT NULL UNIQUE,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS progress (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
level TEXT NOT NULL,
sublevel INTEGER NOT NULL,
step INTEGER NOT NULL,
completed INTEGER NOT NULL DEFAULT 0,
completed_at TEXT,
UNIQUE(user_id, level, sublevel, step)
);
CREATE TABLE IF NOT EXISTS badges (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
badge_key TEXT NOT NULL,
awarded_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(user_id, badge_key)
);
CREATE TABLE IF NOT EXISTS reports (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
level TEXT,
sublevel TEXT,
step TEXT,
message TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'open', -- 'open' | 'resolved'
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_tokens_token ON tokens(token);
CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token);
CREATE INDEX IF NOT EXISTS idx_progress_user ON progress(user_id);
CREATE INDEX IF NOT EXISTS idx_reports_status ON reports(status);
`);
}
export function getDb(): Database.Database {
if (!global.__drawitDb) {
global.__drawitDb = createConnection();
}
return global.__drawitDb;
}
+73
View File
@@ -0,0 +1,73 @@
import nodemailer from "nodemailer";
/**
* Sends account emails over SMTP (creds from env). If SMTP isn't configured, the
* link is logged to the server console so local dev still works end-to-end.
* No email content or addresses are ever sent anywhere except the owner's SMTP server.
*/
function appUrl(): string {
return (process.env.APP_URL || "http://localhost:3000").replace(/\/$/, "");
}
function getTransport() {
const host = process.env.SMTP_HOST;
if (!host) return null;
return nodemailer.createTransport({
host,
port: Number(process.env.SMTP_PORT || 587),
secure: String(process.env.SMTP_SECURE || "false") === "true",
auth:
process.env.SMTP_USER || process.env.SMTP_PASS
? { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS }
: undefined,
});
}
async function send(to: string, subject: string, html: string, text: string) {
const transport = getTransport();
const from = process.env.SMTP_FROM || "DrawIt <no-reply@example.com>";
if (!transport) {
console.log(
`\n[DrawIt email — SMTP not configured, logging instead]\n To: ${to}\n Subject: ${subject}\n ${text}\n`
);
return;
}
await transport.sendMail({ from, to, subject, html, text });
}
export async function sendConfirmationEmail(to: string, username: string, token: string) {
const link = `${appUrl()}/confirm?token=${encodeURIComponent(token)}`;
await send(
to,
"Confirm your DrawIt account 🎨",
`<div style="font-family:system-ui,sans-serif;max-width:480px">
<h2>Welcome to DrawIt, ${escapeHtml(username)}! 🐟</h2>
<p>Tap the button below to confirm your email and start drawing.</p>
<p><a href="${link}" style="display:inline-block;background:#4f7cff;color:#fff;padding:14px 22px;border-radius:999px;text-decoration:none;font-weight:700">Confirm my account</a></p>
<p style="color:#888;font-size:13px">Or paste this link: ${link}</p>
</div>`,
`Welcome to DrawIt, ${username}! Confirm your account: ${link}`
);
}
export async function sendResetEmail(to: string, username: string, token: string) {
const link = `${appUrl()}/reset?token=${encodeURIComponent(token)}`;
await send(
to,
"Reset your DrawIt password",
`<div style="font-family:system-ui,sans-serif;max-width:480px">
<h2>Password reset</h2>
<p>Hi ${escapeHtml(username)}, tap below to set a new password.</p>
<p><a href="${link}" style="display:inline-block;background:#4f7cff;color:#fff;padding:14px 22px;border-radius:999px;text-decoration:none;font-weight:700">Reset password</a></p>
<p style="color:#888;font-size:13px">Or paste this link: ${link}</p>
</div>`,
`Reset your DrawIt password: ${link}`
);
}
function escapeHtml(s: string): string {
return s.replace(/[&<>"']/g, (c) =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c] as string)
);
}
+54
View File
@@ -0,0 +1,54 @@
import { getDb } from "./db";
export function markStepComplete(
userId: number,
level: string,
sublevel: number,
step: number
): void {
getDb()
.prepare(
`INSERT INTO progress (user_id, level, sublevel, step, completed, completed_at)
VALUES (?, ?, ?, ?, 1, datetime('now'))
ON CONFLICT(user_id, level, sublevel, step)
DO UPDATE SET completed = 1, completed_at = datetime('now')`
)
.run(userId, level, sublevel, step);
}
export function getCompletedSteps(
userId: number,
level: string,
sublevel: number
): number[] {
const rows = getDb()
.prepare(
`SELECT step FROM progress
WHERE user_id = ? AND level = ? AND sublevel = ? AND completed = 1
ORDER BY step`
)
.all(userId, level, sublevel) as { step: number }[];
return rows.map((r) => r.step);
}
export function awardBadge(userId: number, badgeKey: string): void {
getDb()
.prepare(
`INSERT INTO badges (user_id, badge_key) VALUES (?, ?)
ON CONFLICT(user_id, badge_key) DO NOTHING`
)
.run(userId, badgeKey);
}
export function hasBadge(userId: number, badgeKey: string): boolean {
const row = getDb()
.prepare("SELECT 1 AS x FROM badges WHERE user_id = ? AND badge_key = ?")
.get(userId, badgeKey) as { x: number } | undefined;
return !!row;
}
export function listBadges(userId: number): { badge_key: string; awarded_at: string }[] {
return getDb()
.prepare("SELECT badge_key, awarded_at FROM badges WHERE user_id = ? ORDER BY awarded_at")
.all(userId) as { badge_key: string; awarded_at: string }[];
}
+38
View File
@@ -0,0 +1,38 @@
import { getDb } from "./db";
import type { ReportRow } from "./types";
export function createReport(input: {
userId: number | null;
level?: string | null;
sublevel?: string | null;
step?: string | null;
message: string;
}): void {
getDb()
.prepare(
`INSERT INTO reports (user_id, level, sublevel, step, message)
VALUES (?, ?, ?, ?, ?)`
)
.run(input.userId, input.level ?? null, input.sublevel ?? null, input.step ?? null, input.message);
}
export function listReports(): ReportRow[] {
return getDb()
.prepare(
`SELECT r.*, u.username AS username
FROM reports r LEFT JOIN users u ON u.id = r.user_id
ORDER BY (r.status = 'open') DESC, r.created_at DESC`
)
.all() as ReportRow[];
}
export function setReportStatus(id: number, status: "open" | "resolved"): void {
getDb().prepare("UPDATE reports SET status = ? WHERE id = ?").run(status, id);
}
export function countOpenReports(): number {
const row = getDb()
.prepare("SELECT COUNT(*) AS n FROM reports WHERE status = 'open'")
.get() as { n: number };
return row.n;
}
+61
View File
@@ -0,0 +1,61 @@
import "server-only";
import { cookies } from "next/headers";
import { getDb } from "./db";
import { randomToken } from "./crypto";
import { getUserById } from "./users";
import type { User } from "./types";
/**
* Cookie-based sessions. One essential, httpOnly cookie — no tracking, no analytics.
*/
const COOKIE = "drawit_session";
const SESSION_TTL_DAYS = 30;
export async function createSession(userId: number): Promise<void> {
const token = randomToken();
getDb()
.prepare(
`INSERT INTO sessions (user_id, token, expires_at)
VALUES (?, ?, datetime('now', ?))`
)
.run(userId, token, `+${SESSION_TTL_DAYS} days`);
const jar = await cookies();
jar.set(COOKIE, token, {
httpOnly: true,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
path: "/",
maxAge: SESSION_TTL_DAYS * 24 * 60 * 60,
});
}
export async function destroySession(): Promise<void> {
const jar = await cookies();
const token = jar.get(COOKIE)?.value;
if (token) {
getDb().prepare("DELETE FROM sessions WHERE token = ?").run(token);
}
jar.delete(COOKIE);
}
export async function getCurrentUser(): Promise<User | null> {
const jar = await cookies();
const token = jar.get(COOKIE)?.value;
if (!token) return null;
const row = getDb()
.prepare("SELECT user_id FROM sessions WHERE token = ? AND expires_at > datetime('now')")
.get(token) as { user_id: number } | undefined;
if (!row) return null;
const user = getUserById(row.user_id);
if (!user || user.status === "suspended") return null;
return user;
}
export async function requireAdmin(): Promise<User | null> {
const user = await getCurrentUser();
if (!user || user.role !== "admin") return null;
return user;
}
+52
View File
@@ -0,0 +1,52 @@
export type Role = "admin" | "learner";
export type Status = "pending" | "active" | "suspended";
export interface User {
id: number;
email: string;
username: string;
password_hash: string;
password_salt: string;
role: Role;
level: string;
status: Status;
email_verified: number; // 0 | 1
created_at: string;
}
/** User shape safe to expose to the client (no secrets). */
export interface PublicUser {
id: number;
email: string;
username: string;
role: Role;
level: string;
status: Status;
email_verified: boolean;
created_at: string;
}
export interface ReportRow {
id: number;
user_id: number | null;
level: string | null;
sublevel: string | null;
step: string | null;
message: string;
status: "open" | "resolved";
created_at: string;
username?: string | null;
}
export function toPublicUser(u: User): PublicUser {
return {
id: u.id,
email: u.email,
username: u.username,
role: u.role,
level: u.level,
status: u.status,
email_verified: !!u.email_verified,
created_at: u.created_at,
};
}
+129
View File
@@ -0,0 +1,129 @@
import { getDb } from "./db";
import { hashPassword, makeSalt, randomToken, verifyPassword } from "./crypto";
import type { Role, Status, User } from "./types";
const CONFIRM_TTL_HOURS = 48;
const RESET_TTL_HOURS = 2;
export function countUsers(): number {
const row = getDb().prepare("SELECT COUNT(*) AS n FROM users").get() as { n: number };
return row.n;
}
export function getUserByEmail(email: string): User | undefined {
return getDb()
.prepare("SELECT * FROM users WHERE email = ? COLLATE NOCASE")
.get(email.trim()) as User | undefined;
}
export function getUserByUsername(username: string): User | undefined {
return getDb()
.prepare("SELECT * FROM users WHERE username = ? COLLATE NOCASE")
.get(username.trim()) as User | undefined;
}
export function getUserById(id: number): User | undefined {
return getDb().prepare("SELECT * FROM users WHERE id = ?").get(id) as User | undefined;
}
export interface CreateUserInput {
email: string;
username: string;
password: string;
role?: Role;
status?: Status;
emailVerified?: boolean;
level?: string;
}
/**
* Create a user. The FIRST account ever created is forced to role 'admin' and is
* created already active+verified (the owner bootstraps the install). Everyone else
* defaults to 'learner' and 'pending' until they confirm their email.
*/
export function createUser(input: CreateUserInput): User {
const db = getDb();
const isFirst = countUsers() === 0;
const salt = makeSalt();
const hash = hashPassword(input.password, salt);
const role: Role = isFirst ? "admin" : input.role ?? "learner";
const status: Status = isFirst ? "active" : input.status ?? "pending";
const emailVerified = isFirst ? 1 : input.emailVerified ? 1 : 0;
const level = input.level ?? "early-beginner";
const info = db
.prepare(
`INSERT INTO users (email, username, password_hash, password_salt, role, level, status, email_verified)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
)
.run(input.email.trim(), input.username.trim(), hash, salt, role, level, status, emailVerified);
return getUserById(Number(info.lastInsertRowid))!;
}
export function setPassword(userId: number, password: string): void {
const salt = makeSalt();
const hash = hashPassword(password, salt);
getDb()
.prepare("UPDATE users SET password_hash = ?, password_salt = ? WHERE id = ?")
.run(hash, salt, userId);
}
export function checkPassword(user: User, password: string): boolean {
return verifyPassword(password, user.password_salt, user.password_hash);
}
export function activateUser(userId: number): void {
getDb()
.prepare("UPDATE users SET status = 'active', email_verified = 1 WHERE id = ?")
.run(userId);
}
export function setStatus(userId: number, status: Status): void {
getDb().prepare("UPDATE users SET status = ? WHERE id = ?").run(status, userId);
}
export function setRole(userId: number, role: Role): void {
getDb().prepare("UPDATE users SET role = ? WHERE id = ?").run(role, userId);
}
export function listUsers(): User[] {
return getDb().prepare("SELECT * FROM users ORDER BY created_at DESC, id DESC").all() as User[];
}
/* ---------------- Email / reset tokens ---------------- */
export function createToken(userId: number, kind: "confirm" | "reset"): string {
const token = randomToken();
const ttl = kind === "confirm" ? CONFIRM_TTL_HOURS : RESET_TTL_HOURS;
getDb()
.prepare(
`INSERT INTO tokens (user_id, token, kind, expires_at)
VALUES (?, ?, ?, datetime('now', ?))`
)
.run(userId, token, kind, `+${ttl} hours`);
return token;
}
export interface TokenRow {
id: number;
user_id: number;
token: string;
kind: "confirm" | "reset";
expires_at: string;
used_at: string | null;
}
export function consumeToken(token: string, kind: "confirm" | "reset"): TokenRow | null {
const db = getDb();
const row = db
.prepare(
`SELECT * FROM tokens
WHERE token = ? AND kind = ? AND used_at IS NULL AND expires_at > datetime('now')`
)
.get(token, kind) as TokenRow | undefined;
if (!row) return null;
db.prepare("UPDATE tokens SET used_at = datetime('now') WHERE id = ?").run(row.id);
return row;
}
+10
View File
@@ -0,0 +1,10 @@
export const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
export const USERNAME_RE = /^[a-zA-Z0-9_-]{3,24}$/;
export function validateSignup(email: string, username: string, password: string): string | null {
if (!email || !EMAIL_RE.test(email)) return "Please enter a valid email address.";
if (!username || !USERNAME_RE.test(username))
return "Username must be 3–24 letters, numbers, _ or -.";
if (!password || password.length < 6) return "Password must be at least 6 characters.";
return null;
}