diff --git a/src/app/api/account/route.ts b/src/app/api/account/route.ts new file mode 100644 index 0000000..1d6005c --- /dev/null +++ b/src/app/api/account/route.ts @@ -0,0 +1,44 @@ +import { NextResponse } from "next/server"; +import { getCurrentUser } from "@/lib/session"; +import { setDisplayName, setAvatar, setEmail, setPassword, checkPassword } from "@/lib/users"; +import { tooLarge } from "@/lib/drawings"; +import { EMAIL_RE } from "@/lib/validate"; + +export async function POST(req: Request) { + const user = await getCurrentUser(); + if (!user) return NextResponse.json({ error: "Not signed in." }, { status: 401 }); + + let body: { action?: string; displayName?: string; avatar?: string; email?: string; current?: string; next?: string }; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid request." }, { status: 400 }); + } + + switch (body.action) { + case "profile": { + if (typeof body.displayName === "string") setDisplayName(user.id, body.displayName.slice(0, 40)); + if (typeof body.avatar === "string") { + if (body.avatar && tooLarge(body.avatar)) return NextResponse.json({ error: "Image invalid or too large." }, { status: 413 }); + setAvatar(user.id, body.avatar); + } + return NextResponse.json({ ok: true }); + } + case "email": { + const email = (body.email || "").trim(); + if (!EMAIL_RE.test(email)) return NextResponse.json({ error: "Please enter a valid email." }, { status: 400 }); + if (!setEmail(user.id, email)) return NextResponse.json({ error: "That email is already in use." }, { status: 409 }); + return NextResponse.json({ ok: true }); + } + case "password": { + const current = body.current || ""; + const next = body.next || ""; + if (!checkPassword(user, current)) return NextResponse.json({ error: "Current password is incorrect." }, { status: 403 }); + if (next.length < 6) return NextResponse.json({ error: "New password must be at least 6 characters." }, { status: 400 }); + setPassword(user.id, next); + return NextResponse.json({ ok: true }); + } + default: + return NextResponse.json({ error: "Unknown action." }, { status: 400 }); + } +} diff --git a/src/app/profile/AccountSettings.tsx b/src/app/profile/AccountSettings.tsx new file mode 100644 index 0000000..e32195c --- /dev/null +++ b/src/app/profile/AccountSettings.tsx @@ -0,0 +1,128 @@ +"use client"; +import { useRef, useState } from "react"; +import { useRouter } from "next/navigation"; +import { fileToDataURL } from "@/components/drawingPersistence"; + +const PRESETS = [ + { e: "🐟", bg: "#bfe9ff" }, { e: "🐼", bg: "#ececf2" }, { e: "🌸", bg: "#ffe0ef" }, { e: "🦄", bg: "#f3d9ff" }, + { e: "🐱", bg: "#ffe1c4" }, { e: "🐶", bg: "#ffe9b3" }, { e: "🐸", bg: "#d6f5cf" }, { e: "🦊", bg: "#ffd9c2" }, + { e: "🐧", bg: "#d9e6ff" }, { e: "🐢", bg: "#d9f0dd" }, { e: "🦋", bg: "#e7dbff" }, { e: "⭐", bg: "#fff0c2" }, +]; +function presetUrl(e: string, bg: string): string { + const svg = `${e}`; + return `data:image/svg+xml,${encodeURIComponent(svg)}`; +} + +const avatarBox: React.CSSProperties = { width: 56, height: 56, borderRadius: "50%", overflow: "hidden", cursor: "pointer", background: "#fff", padding: 0 }; + +export default function AccountSettings({ + user, + drawings, +}: { + user: { username: string; displayName: string; email: string; avatar: string }; + drawings: { id: number; image: string }[]; +}) { + const router = useRouter(); + const [displayName, setDisplayName] = useState(user.displayName); + const [avatar, setAvatar] = useState(user.avatar); + const [profileMsg, setProfileMsg] = useState(""); + const [email, setEmail] = useState(user.email); + const [emailMsg, setEmailMsg] = useState(""); + const [cur, setCur] = useState(""); + const [next, setNext] = useState(""); + const [pwMsg, setPwMsg] = useState(""); + const uploadRef = useRef(null); + + async function post(body: Record) { + return fetch("/api/account", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }); + } + async function saveProfile() { + setProfileMsg("Saving…"); + const r = await post({ action: "profile", displayName, avatar }); + if (r.ok) { setProfileMsg("Saved! 🎉"); router.refresh(); } else setProfileMsg((await r.json()).error || "Couldn't save."); + } + async function saveEmail() { + setEmailMsg("Saving…"); + const r = await post({ action: "email", email }); + if (r.ok) { setEmailMsg("Email updated!"); router.refresh(); } else setEmailMsg((await r.json()).error || "Couldn't update."); + } + async function savePassword() { + setPwMsg("Saving…"); + const r = await post({ action: "password", current: cur, next }); + if (r.ok) { setPwMsg("Password changed!"); setCur(""); setNext(""); } else setPwMsg((await r.json()).error || "Couldn't change."); + } + async function onUpload(e: React.ChangeEvent) { + const f = e.target.files?.[0]; + if (!f) return; + try { setAvatar(await fileToDataURL(f, 256)); } catch { /* ignore */ } + if (uploadRef.current) uploadRef.current.value = ""; + } + + return ( +
+ {/* Avatar + display name */} +
+

Profile

+
+
+ {avatar ? avatar : {(displayName || user.username).slice(0, 1).toUpperCase()}} +
+
+ + setDisplayName(e.target.value)} placeholder={user.username} /> +
+
+ +

Pick a cute avatar

+
+ {PRESETS.map((p) => { + const url = presetUrl(p.e, p.bg); + return ; + })} + + + {avatar && } +
+ + {drawings.length > 0 && ( + <> +

…or use one of your drawings

+
+ {drawings.slice(0, 12).map((d) => ( + + ))} +
+ + )} + +
+ + {profileMsg && {profileMsg}} +
+
+ + {/* Email */} +
+

Email

+
setEmail(e.target.value)} />
+
+ + {emailMsg && {emailMsg}} +
+
+ + {/* Password */} +
+

Password

+
setCur(e.target.value)} />
+
setNext(e.target.value)} placeholder="At least 6 characters" />
+
+ + {pwMsg && {pwMsg}} +
+
+
+ ); +} diff --git a/src/app/profile/ProfileGallery.tsx b/src/app/profile/ProfileGallery.tsx index 64ee551..c877c06 100644 --- a/src/app/profile/ProfileGallery.tsx +++ b/src/app/profile/ProfileGallery.tsx @@ -19,6 +19,7 @@ interface Group { export default function ProfileGallery({ groups }: { groups: Group[] }) { const router = useRouter(); const [busyId, setBusyId] = useState(null); + const [lightbox, setLightbox] = useState(null); async function remove(id: number) { if (!window.confirm("Delete this drawing? This can't be undone.")) return; @@ -62,7 +63,7 @@ export default function ProfileGallery({ groups }: { groups: Group[] }) { > {g.items.map((d) => (
- {`${g.title} + {`${g.title} setLightbox(d.image)} style={{ width: "100%", display: "block", aspectRatio: "4 / 3", objectFit: "contain", background: "#fff", cursor: "zoom-in" }} />
@@ -84,6 +85,24 @@ export default function ProfileGallery({ groups }: { groups: Group[] }) {
))} + + {lightbox && ( +
setLightbox(null)} + role="dialog" + aria-modal="true" + style={{ position: "fixed", inset: 0, background: "rgba(20,16,30,0.82)", display: "grid", placeItems: "center", padding: 20, zIndex: 100, cursor: "zoom-out" }} + > + Drawing full size + +
+ )}
); } diff --git a/src/app/profile/page.tsx b/src/app/profile/page.tsx index 7babb9d..3c54bf5 100644 --- a/src/app/profile/page.tsx +++ b/src/app/profile/page.tsx @@ -44,7 +44,7 @@ export default async function ProfilePage() { // Earned badges → rich cards const earnedKeys = new Set(listBadges(user.id).map((b) => b.badge_key)); const awardedAt = new Map(listBadges(user.id).map((b) => [b.badge_key, b.awarded_at])); - const earned = LESSONS.filter((l) => earnedKeys.has(l.badgeKey)).map((l) => { + const earned = LESSONS.filter((l) => l.badgeKey && earnedKeys.has(l.badgeKey)).map((l) => { const groupKey = `${l.level}-${l.sublevel}`; const dates = datesByBadge.get(l.badgeKey) ?? [awardedAt.get(l.badgeKey) ?? ""]; return { @@ -57,8 +57,8 @@ export default async function ProfilePage() { }; }); - // Badges still to collect - const locked = LESSONS.filter((l) => !earnedKeys.has(l.badgeKey)).map((l) => ({ + // Badges still to collect (only badge-bearing lessons; one per subject) + const locked = LESSONS.filter((l) => l.badgeKey && !earnedKeys.has(l.badgeKey)).map((l) => ({ name: l.badgeName, emoji: l.emoji, })); @@ -67,9 +67,24 @@ export default async function ProfilePage() { <>
-

{user.username}'s profile

+
+
+
+ {user.avatar ? ( + avatar + ) : ( + {(user.display_name || user.username).slice(0, 1).toUpperCase()} + )} +
+
+

{user.display_name || user.username}

+ {user.display_name &&

@{user.username}

} +
+
+ ⚙️ Settings +
-
+

🏅 My badges

{earned.length === 0 ? (
diff --git a/src/app/profile/settings/page.tsx b/src/app/profile/settings/page.tsx new file mode 100644 index 0000000..e0181c3 --- /dev/null +++ b/src/app/profile/settings/page.tsx @@ -0,0 +1,37 @@ +import { redirect } from "next/navigation"; +import Link from "next/link"; +import SiteNav from "@/components/SiteNav"; +import AccountSettings from "../AccountSettings"; +import { getCurrentUser } from "@/lib/session"; +import { listFinalDrawings } from "@/lib/drawings"; + +export const dynamic = "force-dynamic"; +export const metadata = { title: "Settings · DrawIt" }; + +export default async function SettingsPage() { + const user = await getCurrentUser(); + if (!user) redirect("/login"); + + const drawings = listFinalDrawings(user.id).map((d) => ({ id: d.id, image: d.image })); + + return ( + <> + +
+
+

Settings

+ ← Back to profile +
+ +
+ + ); +} diff --git a/src/components/NavLinks.tsx b/src/components/NavLinks.tsx index 00bebf7..f1eacba 100644 --- a/src/components/NavLinks.tsx +++ b/src/components/NavLinks.tsx @@ -6,7 +6,7 @@ import LogoutButton from "./LogoutButton"; export default function NavLinks({ user, }: { - user: { username: string; isAdmin: boolean } | null; + user: { username: string; isAdmin: boolean; displayName?: string; avatar?: string } | null; }) { const pathname = usePathname() || "/"; @@ -34,8 +34,13 @@ export default function NavLinks({ Report a problem - - 🖼️ {user.username} + + {user.avatar ? ( + + ) : ( + "🖼️" + )}{" "} + {user.displayName || user.username} diff --git a/src/components/ShadingRunner.tsx b/src/components/ShadingRunner.tsx index 3df5f2c..12c5847 100644 --- a/src/components/ShadingRunner.tsx +++ b/src/components/ShadingRunner.tsx @@ -10,6 +10,7 @@ import { saveFinalDrawing, exportCanvasToDataURL, fileToDataURL, + loadDrawingAsLayer, } from "./drawingPersistence"; interface StepData { n: number; title: string; instruction: string; tip: string; guideSvg: string; } @@ -49,7 +50,7 @@ export default function ShadingRunner({ const stageRef = useRef(null); const strokesRef = useRef([]); const drawingRef = useRef(null); - const baseImgRef = useRef(null); + const baseImgRef = useRef(null); const saveTimer = useRef | undefined>(undefined); const [toneIdx, setToneIdx] = useState(0); const [size, setSize] = useState(SIZES[1]); @@ -111,9 +112,7 @@ export default function ShadingRunner({ let alive = true; loadProgressDrawing(meta.level, meta.sublevel).then((img) => { if (!img || !alive) return; - const im = new Image(); - im.onload = () => { baseImgRef.current = im; redraw(); }; - im.src = img; + loadDrawingAsLayer(img).then((cv) => { if (alive) { baseImgRef.current = cv; redraw(); } }); }); return () => { alive = false; }; }, [loggedIn, alreadyEarned, meta.level, meta.sublevel, redraw]); diff --git a/src/components/SiteNav.tsx b/src/components/SiteNav.tsx index a774e5f..d0a3d08 100644 --- a/src/components/SiteNav.tsx +++ b/src/components/SiteNav.tsx @@ -10,7 +10,7 @@ export default async function SiteNav() { 🐟 DrawIt - +
); diff --git a/src/components/TraceRunner.tsx b/src/components/TraceRunner.tsx index 2bbf2fd..3ed82ec 100644 --- a/src/components/TraceRunner.tsx +++ b/src/components/TraceRunner.tsx @@ -4,7 +4,7 @@ import Link from "next/link"; import { useRouter } from "next/navigation"; import { badgeSvgMarkup, badgeFullSvg } from "./BadgeArt"; import LineDraw from "./LineDraw"; -import { loadProgressDrawing, saveProgressDrawing, saveFinalDrawing, exportCanvasToDataURL, fileToDataURL } from "./drawingPersistence"; +import { loadProgressDrawing, saveProgressDrawing, saveFinalDrawing, exportCanvasToDataURL, fileToDataURL, loadDrawingAsLayer } from "./drawingPersistence"; interface StepData { n: number; title: string; instruction: string; tip: string; lines: string[] } interface Meta { @@ -41,7 +41,7 @@ export default function TraceRunner({ meta, steps, loggedIn, username, completed const stageRef = useRef(null); const strokesRef = useRef([]); const drawingRef = useRef(null); - const baseImgRef = useRef(null); + const baseImgRef = useRef(null); const saveTimer = useRef | undefined>(undefined); const [color, setColor] = useState(COLORS[0]); const [size, setSize] = useState(SIZES[1]); @@ -77,7 +77,7 @@ export default function TraceRunner({ meta, steps, loggedIn, username, completed // Use this lesson's own saved canvas if any; otherwise carry over the previous lesson's drawing const src = img || carryImage; if (!src || !alive) return; - const im = new Image(); im.onload = () => { baseImgRef.current = im; redraw(); }; im.src = src; + loadDrawingAsLayer(src).then((cv) => { if (alive) { baseImgRef.current = cv; redraw(); } }); }); return () => { alive = false; }; }, [loggedIn, alreadyEarned, meta.level, meta.sublevel, carryImage, redraw]); diff --git a/src/components/drawingPersistence.ts b/src/components/drawingPersistence.ts index 082399a..59ee201 100644 --- a/src/components/drawingPersistence.ts +++ b/src/components/drawingPersistence.ts @@ -60,6 +60,37 @@ export function exportCanvasToDataURL(canvas: HTMLCanvasElement, maxW = 800, whi return out.toDataURL("image/png"); } +/** + * Load a saved drawing as a transparent layer: the white background is knocked out so only the + * strokes remain. This lets a restored/imported drawing sit over the guide + line video without + * hiding them — works whether the image was saved on white or already transparent. + */ +export function loadDrawingAsLayer(dataUrl: string): Promise { + return new Promise((resolve, reject) => { + const im = new Image(); + im.onerror = reject; + im.onload = () => { + const c = document.createElement("canvas"); + c.width = im.naturalWidth || 800; + c.height = im.naturalHeight || 600; + const ctx = c.getContext("2d")!; + ctx.drawImage(im, 0, 0); + try { + const id = ctx.getImageData(0, 0, c.width, c.height); + const d = id.data; + for (let i = 0; i < d.length; i += 4) { + if (d[i] > 244 && d[i + 1] > 244 && d[i + 2] > 244) d[i + 3] = 0; + } + ctx.putImageData(id, 0, 0); + } catch { + /* cross-origin shouldn't happen for our own data URLs */ + } + resolve(c); + }; + im.src = dataUrl; + }); +} + /** Read an uploaded image File into a downscaled JPEG data URL (keeps photos small). */ export function fileToDataURL(file: File, maxW = 1000): Promise { return new Promise((resolve, reject) => { diff --git a/src/lib/db.ts b/src/lib/db.ts index 9d9aa07..6047425 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -36,6 +36,12 @@ function createConnection(): Database.Database { return db; } +/** Add a column to an existing table if it isn't there yet (simple forward migration). */ +function addColumn(db: Database.Database, table: string, col: string, type: string) { + const cols = db.prepare(`PRAGMA table_info(${table})`).all() as { name: string }[]; + if (!cols.some((c) => c.name === col)) db.exec(`ALTER TABLE ${table} ADD COLUMN ${col} ${type}`); +} + function migrate(db: Database.Database) { db.exec(` CREATE TABLE IF NOT EXISTS users ( @@ -128,6 +134,10 @@ function migrate(db: Database.Database) { CREATE INDEX IF NOT EXISTS idx_drawings_progress ON drawings(user_id, level, sublevel, status); CREATE INDEX IF NOT EXISTS idx_completions_user ON completions(user_id); `); + + // Profile fields (added later — safe for existing databases) + addColumn(db, "users", "display_name", "TEXT"); + addColumn(db, "users", "avatar", "TEXT"); } export function getDb(): Database.Database { diff --git a/src/lib/types.ts b/src/lib/types.ts index 5892e8c..1fd86ac 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -11,6 +11,8 @@ export interface User { level: string; status: Status; email_verified: number; // 0 | 1 + display_name: string | null; + avatar: string | null; // data URL created_at: string; } @@ -23,6 +25,8 @@ export interface PublicUser { level: string; status: Status; email_verified: boolean; + display_name: string | null; + avatar: string | null; created_at: string; } @@ -47,6 +51,8 @@ export function toPublicUser(u: User): PublicUser { level: u.level, status: u.status, email_verified: !!u.email_verified, + display_name: u.display_name ?? null, + avatar: u.avatar ?? null, created_at: u.created_at, }; } diff --git a/src/lib/users.ts b/src/lib/users.ts index 8a5be5f..b3e129a 100644 --- a/src/lib/users.ts +++ b/src/lib/users.ts @@ -74,6 +74,23 @@ export function checkPassword(user: User, password: string): boolean { return verifyPassword(password, user.password_salt, user.password_hash); } +export function setDisplayName(userId: number, displayName: string): void { + getDb().prepare("UPDATE users SET display_name = ? WHERE id = ?").run(displayName.trim() || null, userId); +} + +export function setAvatar(userId: number, avatar: string): void { + getDb().prepare("UPDATE users SET avatar = ? WHERE id = ?").run(avatar || null, userId); +} + +/** Change email. Returns false if the new email is already used by someone else. */ +export function setEmail(userId: number, email: string): boolean { + const e = email.trim(); + const existing = getUserByEmail(e); + if (existing && existing.id !== userId) return false; + getDb().prepare("UPDATE users SET email = ? WHERE id = ?").run(e, userId); + return true; +} + export function activateUser(userId: number): void { getDb() .prepare("UPDATE users SET status = 'active', email_verified = 1 WHERE id = ?") diff --git a/tsconfig.json b/tsconfig.json index b258e13..c6fe7be 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -14,6 +14,7 @@ "jsx": "preserve", "incremental": true, "plugins": [{ "name": "next" }], + "baseUrl": ".", "paths": { "@/*": ["./src/*"] }