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