From a94be1ebca63432cade5771dafbdddc3265aaf5d Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Wed, 17 Jun 2026 10:15:27 -0500 Subject: [PATCH] First attempt erg pull for DrawIt --- README.md | 63 ++- litestream.yml | 13 + next.config.mjs | 10 + package.json | 30 ++ src/app/admin/AddUserForm.tsx | 72 ++++ src/app/admin/AdminReports.tsx | 72 ++++ src/app/admin/AdminUsers.tsx | 119 +++++ src/app/admin/page.tsx | 56 +++ src/app/api/admin/reports/[id]/route.ts | 19 + src/app/api/admin/users/[id]/route.ts | 54 +++ src/app/api/admin/users/route.ts | 40 ++ src/app/api/auth/login/route.ts | 37 ++ src/app/api/auth/logout/route.ts | 7 + src/app/api/auth/request-reset/route.ts | 24 ++ src/app/api/auth/reset/route.ts | 21 + src/app/api/auth/signup/route.ts | 42 ++ src/app/api/award/[badge]/route.ts | 87 ++++ src/app/api/progress/route.ts | 36 ++ src/app/api/reports/route.ts | 27 ++ src/app/confirm/page.tsx | 54 +++ src/app/forgot/ForgotForm.tsx | 52 +++ src/app/forgot/page.tsx | 20 + src/app/globals.css | 135 ++++++ src/app/layout.tsx | 53 +++ src/app/learn/[level]/[slug]/page.tsx | 62 +++ src/app/learn/page.tsx | 61 +++ src/app/login/LoginForm.tsx | 73 ++++ src/app/login/page.tsx | 20 + src/app/page.tsx | 115 +++++ src/app/report/ReportForm.tsx | 84 ++++ src/app/report/page.tsx | 34 ++ src/app/reset/ResetForm.tsx | 64 +++ src/app/reset/page.tsx | 25 ++ src/app/signup/SignupForm.tsx | 101 +++++ src/app/signup/page.tsx | 24 ++ src/components/BadgeArt.tsx | 33 ++ src/components/FishSlideshow.tsx | 84 ++++ src/components/LessonRunner.tsx | 551 ++++++++++++++++++++++++ src/components/LogoutButton.tsx | 16 + src/components/SiteNav.tsx | 34 ++ src/lib/crypto.ts | 28 ++ src/lib/curriculum.ts | 484 +++++++++++++++++++++ src/lib/db.ts | 114 +++++ src/lib/email.ts | 73 ++++ src/lib/progress.ts | 54 +++ src/lib/reports.ts | 38 ++ src/lib/session.ts | 61 +++ src/lib/types.ts | 52 +++ src/lib/users.ts | 129 ++++++ src/lib/validate.ts | 10 + tsconfig.json | 23 + 51 files changed, 3588 insertions(+), 2 deletions(-) create mode 100644 litestream.yml create mode 100644 next.config.mjs create mode 100644 package.json create mode 100644 src/app/admin/AddUserForm.tsx create mode 100644 src/app/admin/AdminReports.tsx create mode 100644 src/app/admin/AdminUsers.tsx create mode 100644 src/app/admin/page.tsx create mode 100644 src/app/api/admin/reports/[id]/route.ts create mode 100644 src/app/api/admin/users/[id]/route.ts create mode 100644 src/app/api/admin/users/route.ts create mode 100644 src/app/api/auth/login/route.ts create mode 100644 src/app/api/auth/logout/route.ts create mode 100644 src/app/api/auth/request-reset/route.ts create mode 100644 src/app/api/auth/reset/route.ts create mode 100644 src/app/api/auth/signup/route.ts create mode 100644 src/app/api/award/[badge]/route.ts create mode 100644 src/app/api/progress/route.ts create mode 100644 src/app/api/reports/route.ts create mode 100644 src/app/confirm/page.tsx create mode 100644 src/app/forgot/ForgotForm.tsx create mode 100644 src/app/forgot/page.tsx create mode 100644 src/app/globals.css create mode 100644 src/app/layout.tsx create mode 100644 src/app/learn/[level]/[slug]/page.tsx create mode 100644 src/app/learn/page.tsx create mode 100644 src/app/login/LoginForm.tsx create mode 100644 src/app/login/page.tsx create mode 100644 src/app/page.tsx create mode 100644 src/app/report/ReportForm.tsx create mode 100644 src/app/report/page.tsx create mode 100644 src/app/reset/ResetForm.tsx create mode 100644 src/app/reset/page.tsx create mode 100644 src/app/signup/SignupForm.tsx create mode 100644 src/app/signup/page.tsx create mode 100644 src/components/BadgeArt.tsx create mode 100644 src/components/FishSlideshow.tsx create mode 100644 src/components/LessonRunner.tsx create mode 100644 src/components/LogoutButton.tsx create mode 100644 src/components/SiteNav.tsx create mode 100644 src/lib/crypto.ts create mode 100644 src/lib/curriculum.ts create mode 100644 src/lib/db.ts create mode 100644 src/lib/email.ts create mode 100644 src/lib/progress.ts create mode 100644 src/lib/reports.ts create mode 100644 src/lib/session.ts create mode 100644 src/lib/types.ts create mode 100644 src/lib/users.ts create mode 100644 src/lib/validate.ts create mode 100644 tsconfig.json diff --git a/README.md b/README.md index 71b571e..ac33bc6 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,62 @@ -# drawit +# DrawIt 🐟🎨 -Kids web app to learn how to draw pictures \ No newline at end of file +A **free and open-source** web app that teaches kids how to draw β€” one simple shape at a time. + +Mobile-first (built for iPads and tablets), privacy-first (**no data collection, no trackers, no analytics**), +and self-hostable. Learners progress through levels, earn shareable **badges**, and unlock printable +**award certificates (PDF)**. + +> Ability levels: **Early Beginner β†’ Beginner β†’ Learner β†’ Advanced Learned β†’ Superb.** +> First lesson: **Early Beginner β†’ Level 1 β†’ Draw a Fish.** + +## Quick start + +```bash +npm install +cp .env.example .env.local # then edit SMTP settings + APP_URL +npm run dev # http://localhost:3000 +``` + +The **first account you register becomes the Admin.** Everyone else defaults to **Learner**. +Accounts stay **pending** until the email link is confirmed. + +### Email (account confirmation) + +Set real SMTP credentials in `.env.local`: + +``` +APP_URL=http://localhost:3000 +SMTP_HOST=smtp.example.com +SMTP_PORT=587 +SMTP_USER=your-user +SMTP_PASS=your-pass +SMTP_FROM="DrawIt " +SESSION_SECRET=change-me-to-a-long-random-string +``` + +If SMTP isn't configured, confirmation links are printed to the server console so you can still +test locally. + +## Scripts + +- `npm run dev` β€” development server +- `npm run build` β€” production build +- `npm start` β€” run the production build +- `npm run typecheck` β€” TypeScript check + +## Privacy + +DrawIt stores only what an account needs (email, username, hashed password, learning progress) and +keeps it in a local SQLite file on the server you run. There are **no** analytics, trackers, ad +scripts, fingerprinting, or third-party runtime requests. One essential httpOnly session cookie is +used for login β€” nothing else. + +## Data & backups + +Progress lives in a single SQLite database (`data/drawit.db`) with **WAL** journaling enabled. For +off-box backups, point [Litestream](https://litestream.io) at the DB file (see `litestream.yml`). +(`wal-g` is a Postgres/MySQL tool and does not apply to SQLite; Litestream is the SQLite equivalent.) + +## License + +See [LICENSE](./LICENSE). diff --git a/litestream.yml b/litestream.yml new file mode 100644 index 0000000..74c5306 --- /dev/null +++ b/litestream.yml @@ -0,0 +1,13 @@ +# Example Litestream config β€” the SQLite equivalent of wal-g. +# Litestream continuously streams the WAL to off-box storage (S3, etc.). +# Start with: litestream replicate -config litestream.yml +# +# WAL journaling must be enabled on the DB (DrawIt does this automatically). +dbs: + - path: ./data/drawit.db + replicas: + - type: s3 + bucket: your-bucket-name + path: drawit + endpoint: https://s3.your-region.example.com + # Credentials come from env: LITESTREAM_ACCESS_KEY_ID / LITESTREAM_SECRET_ACCESS_KEY diff --git a/next.config.mjs b/next.config.mjs new file mode 100644 index 0000000..9d7c4df --- /dev/null +++ b/next.config.mjs @@ -0,0 +1,10 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + reactStrictMode: true, + // better-sqlite3 is a native module; keep it external to the server bundle. + serverExternalPackages: ["better-sqlite3"], + // Privacy: no telemetry headers, no powered-by header. + poweredByHeader: false, +}; + +export default nextConfig; diff --git a/package.json b/package.json new file mode 100644 index 0000000..83e0b74 --- /dev/null +++ b/package.json @@ -0,0 +1,30 @@ +{ + "name": "drawit", + "version": "0.1.0", + "private": true, + "description": "Free, open-source, privacy-first web app that teaches kids how to draw.", + "license": "MIT", + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "typecheck": "tsc --noEmit", + "lint": "next lint" + }, + "dependencies": { + "better-sqlite3": "^11.8.1", + "next": "15.1.12", + "nodemailer": "^6.9.16", + "pdf-lib": "^1.17.1", + "react": "19.0.0", + "react-dom": "19.0.0" + }, + "devDependencies": { + "@types/better-sqlite3": "^7.6.12", + "@types/node": "^22.10.7", + "@types/nodemailer": "^6.4.17", + "@types/react": "^19.0.7", + "@types/react-dom": "^19.0.3", + "typescript": "^5.7.3" + } +} diff --git a/src/app/admin/AddUserForm.tsx b/src/app/admin/AddUserForm.tsx new file mode 100644 index 0000000..f4d90c6 --- /dev/null +++ b/src/app/admin/AddUserForm.tsx @@ -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 ( +
+ {msg &&
{msg.text}
} +
+
+ + setEmail(e.target.value)} required /> +
+
+ + setUsername(e.target.value)} required /> +
+
+ + setPassword(e.target.value)} required /> +
+
+ + +
+
+ + +
+ ); +} diff --git a/src/app/admin/AdminReports.tsx b/src/app/admin/AdminReports.tsx new file mode 100644 index 0000000..44019e1 --- /dev/null +++ b/src/app/admin/AdminReports.tsx @@ -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(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

No reports yet. πŸŽ‰

; + } + + return ( +
+ + + + + + + + + + + + + {reports.map((r) => ( + + + + + + + + + ))} + +
WhenFromWhereMessageStatus
{r.created_at.slice(0, 16).replace("T", " ")}{r.username || guest} + {[r.level, r.sublevel && `L${r.sublevel}`, r.step && `step ${r.step}`] + .filter(Boolean) + .join(" Β· ") || "β€”"} + {r.message} + {r.status} + + {r.status === "open" ? ( + + ) : ( + + )} +
+
+ ); +} diff --git a/src/app/admin/AdminUsers.tsx b/src/app/admin/AdminUsers.tsx new file mode 100644 index 0000000..7a39873 --- /dev/null +++ b/src/app/admin/AdminUsers.tsx @@ -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(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 ( +
+ + + + + + + + + + + + + {users.map((u) => { + const busy = busyId === u.id; + const isLastAdmin = u.role === "admin" && adminCount === 1; + return ( + + + + + + + + + ); + })} + +
UserEmailRoleStatusJoinedActions
+ {u.username} + {u.id === currentAdminId && (you)} + {u.email} + {u.role} + + {u.status} + {!u.email_verified && Β· unverified} + {u.created_at.slice(0, 10)} +
+ {u.status !== "active" && ( + + )} + + {u.status === "active" ? ( + + ) : u.status === "suspended" ? ( + + ) : null} + {u.role === "learner" ? ( + + ) : ( + + )} +
+
+
+ ); +} + +const btn: React.CSSProperties = { minHeight: 36, padding: "6px 12px", fontSize: "0.85rem", boxShadow: "none" }; diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx new file mode 100644 index 0000000..88c7b8c --- /dev/null +++ b/src/app/admin/page.tsx @@ -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 ( + <> + +
+

Admin

+

+ {users.length} user{users.length === 1 ? "" : "s"} Β· {pending.length} pending Β·{" "} + {openReports} open report{openReports === 1 ? "" : "s"} +

+ +
+

Add a user

+
+ +
+
+ +
+

Users

+
+ +
+
+ +
+

Issue reports

+
+ +
+
+
+ + ); +} diff --git a/src/app/api/admin/reports/[id]/route.ts b/src/app/api/admin/reports/[id]/route.ts new file mode 100644 index 0000000..75a86cf --- /dev/null +++ b/src/app/api/admin/reports/[id]/route.ts @@ -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 }); +} diff --git a/src/app/api/admin/users/[id]/route.ts b/src/app/api/admin/users/[id]/route.ts new file mode 100644 index 0000000..5c22bd3 --- /dev/null +++ b/src/app/api/admin/users/[id]/route.ts @@ -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 }); + } +} diff --git a/src/app/api/admin/users/route.ts b/src/app/api/admin/users/route.ts new file mode 100644 index 0000000..1fe4a3e --- /dev/null +++ b/src/app/api/admin/users/route.ts @@ -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 }); +} diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts new file mode 100644 index 0000000..4faa907 --- /dev/null +++ b/src/app/api/auth/login/route.ts @@ -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 }); +} diff --git a/src/app/api/auth/logout/route.ts b/src/app/api/auth/logout/route.ts new file mode 100644 index 0000000..98eac78 --- /dev/null +++ b/src/app/api/auth/logout/route.ts @@ -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 }); +} diff --git a/src/app/api/auth/request-reset/route.ts b/src/app/api/auth/request-reset/route.ts new file mode 100644 index 0000000..faeaf5b --- /dev/null +++ b/src/app/api/auth/request-reset/route.ts @@ -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 }); +} diff --git a/src/app/api/auth/reset/route.ts b/src/app/api/auth/reset/route.ts new file mode 100644 index 0000000..8473bb3 --- /dev/null +++ b/src/app/api/auth/reset/route.ts @@ -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 }); +} diff --git a/src/app/api/auth/signup/route.ts b/src/app/api/auth/signup/route.ts new file mode 100644 index 0000000..4c25612 --- /dev/null +++ b/src/app/api/auth/signup/route.ts @@ -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 }); +} diff --git a/src/app/api/award/[badge]/route.ts b/src/app/api/award/[badge]/route.ts new file mode 100644 index 0000000..f9be6b0 --- /dev/null +++ b/src/app/api/award/[badge]/route.ts @@ -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 { + 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(); +} diff --git a/src/app/api/progress/route.ts b/src/app/api/progress/route.ts new file mode 100644 index 0000000..f07a921 --- /dev/null +++ b/src/app/api/progress/route.ts @@ -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 }); +} diff --git a/src/app/api/reports/route.ts b/src/app/api/reports/route.ts new file mode 100644 index 0000000..09a9413 --- /dev/null +++ b/src/app/api/reports/route.ts @@ -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 }); +} diff --git a/src/app/confirm/page.tsx b/src/app/confirm/page.tsx new file mode 100644 index 0000000..559fc30 --- /dev/null +++ b/src/app/confirm/page.tsx @@ -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 ( + <> + +
+
+ {ok ? ( +
+

πŸŽ‰ You're all set{username ? `, ${username}` : ""}!

+

Your email is confirmed and your account is active.

+ + Log in and start drawing + +
+ ) : ( +
+

Hmm, that link didn't work

+

+ This confirmation link is invalid or has expired. Try signing up again, or ask an + admin to activate your account. +

+ + Back to sign up + +
+ )} +
+
+ + ); +} diff --git a/src/app/forgot/ForgotForm.tsx b/src/app/forgot/ForgotForm.tsx new file mode 100644 index 0000000..ee9d88c --- /dev/null +++ b/src/app/forgot/ForgotForm.tsx @@ -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 ( +
+ If that email is registered, a reset link is on its way. (No email service set up? Check the + server console for the link.) +
+ ); + } + + return ( +
+
+ + setEmail(e.target.value)} + required + /> +
+ +
+ ); +} diff --git a/src/app/forgot/page.tsx b/src/app/forgot/page.tsx new file mode 100644 index 0000000..c8e52e1 --- /dev/null +++ b/src/app/forgot/page.tsx @@ -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 ( + <> + +
+
+

Reset your password

+
+ +
+
+
+ + ); +} diff --git a/src/app/globals.css b/src/app/globals.css new file mode 100644 index 0000000..419e657 --- /dev/null +++ b/src/app/globals.css @@ -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; } +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx new file mode 100644 index 0000000..8db110a --- /dev/null +++ b/src/app/layout.tsx @@ -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 ( + + {children} + + ); +} diff --git a/src/app/learn/[level]/[slug]/page.tsx b/src/app/learn/[level]/[slug]/page.tsx new file mode 100644 index 0000000..5d993eb --- /dev/null +++ b/src/app/learn/[level]/[slug]/page.tsx @@ -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 ( + <> + +
+ +
+ + ); +} diff --git a/src/app/learn/page.tsx b/src/app/learn/page.tsx new file mode 100644 index 0000000..7039f53 --- /dev/null +++ b/src/app/learn/page.tsx @@ -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 ( + <> + +
+

Learn to draw 🎨

+

Pick your level and start a lesson. New lessons are added over time.

+ +
+ {LEVELS.map((lvl, idx) => { + const lessons = LESSONS.filter((l) => l.level === lvl.key); + return ( +
+
+

+ {lvl.emoji} {idx + 1}. {lvl.name} +

+ {lessons.length === 0 && Coming soon} +
+

{lvl.blurb}

+ {lessons.length > 0 && ( +
+ {lessons.map((lesson) => ( + +
{lesson.emoji}
+ + Level {lesson.sublevel}: {lesson.title} + +

+ {lesson.steps.length} simple steps +

+ + ))} +
+ )} +
+ ); + })} +
+
+ + ); +} diff --git a/src/app/login/LoginForm.tsx b/src/app/login/LoginForm.tsx new file mode 100644 index 0000000..ac3e20a --- /dev/null +++ b/src/app/login/LoginForm.tsx @@ -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 ( +
+ {error && ( +
+ {error} +
+ )} +
+ + setIdentifier(e.target.value)} + required + /> +
+
+ + setPassword(e.target.value)} + required + /> +
+ +

+ Forgot password?  Β·  New here? Sign up +

+
+ ); +} diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx new file mode 100644 index 0000000..d5b1126 --- /dev/null +++ b/src/app/login/page.tsx @@ -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 ( + <> + +
+
+

Welcome back

+
+ +
+
+
+ + ); +} diff --git a/src/app/page.tsx b/src/app/page.tsx new file mode 100644 index 0000000..277741d --- /dev/null +++ b/src/app/page.tsx @@ -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 ( + <> + +
+ {/* Hero */} +
+
+
+ πŸ†“ Free & open source Β· πŸ”’ No data collected, ever +
+

Learn to draw, one shape at a time

+

+ 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. +

+
+ + 🐟 Try the first lesson + + + Create an account + +
+
+
+ + {/* Slideshow of Early Beginner Level 1 */} +
+
+

Peek at Level 1: Draw a Fish 🐟

+

+ This is our very first Early Beginner lesson. Watch it come together, + step by step, from a few simple shapes. +

+ +
+
+ + {/* Levels */} +
+
+

Five levels of drawing skill

+

+ The levels describe you β€” start where you are and level up. +

+
+ {LEVELS.map((lvl, idx) => ( +
+
{lvl.emoji}
+

+ {idx + 1}. {lvl.name} +

+

+ {lvl.blurb} +

+
+ ))} +
+
+
+ + {/* How it works */} +
+
+

How DrawIt works

+
+
+

πŸ‘£ Step by step

+

Each lesson breaks a picture into tiny, doable steps using only simple shapes.

+
+
+

πŸ… Badges & awards

+

Finish a lesson to earn a shareable badge and a printable award certificate (PDF).

+
+
+

πŸ”’ Private by design

+

No analytics, no trackers, no data sold or shared. Just drawing.

+
+
+
+
+
+ +
+ DrawIt · Free & open source · No data collected. Made for young artists. 🎨 +
+ + ); +} diff --git a/src/app/report/ReportForm.tsx b/src/app/report/ReportForm.tsx new file mode 100644 index 0000000..3aa9e01 --- /dev/null +++ b/src/app/report/ReportForm.tsx @@ -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 ( +
+ βœ… Thanks! Your report was sent to the admins. +
+ ); + } + + return ( +
+ {error &&
{error}
} +
+
+ + +
+
+ + setSublevel(e.target.value)} placeholder="e.g. 1" inputMode="numeric" /> +
+
+ + setStep(e.target.value)} placeholder="e.g. 3" inputMode="numeric" /> +
+
+
+ +