[Feature] Profile settings and avatar #8

Merged
iamdoubz merged 2 commits from feature_settings_avatar into main 2026-06-20 12:52:35 -05:00
14 changed files with 329 additions and 17 deletions
+44
View File
@@ -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 });
}
}
+128
View File
@@ -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 = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><rect width="100" height="100" rx="50" fill="${bg}"/><text x="50" y="72" font-size="58" text-anchor="middle">${e}</text></svg>`;
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<HTMLInputElement | null>(null);
async function post(body: Record<string, unknown>) {
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<HTMLInputElement>) {
const f = e.target.files?.[0];
if (!f) return;
try { setAvatar(await fileToDataURL(f, 256)); } catch { /* ignore */ }
if (uploadRef.current) uploadRef.current.value = "";
}
return (
<div className="stack">
{/* Avatar + display name */}
<div className="card">
<h2 style={{ marginTop: 0 }}>Profile</h2>
<div className="row" style={{ gap: 16, alignItems: "center" }}>
<div style={{ width: 80, height: 80, borderRadius: "50%", overflow: "hidden", background: "#eee", flex: "0 0 auto", display: "grid", placeItems: "center", border: "2px solid var(--line)" }}>
{avatar ? <img src={avatar} alt="avatar" style={{ width: "100%", height: "100%", objectFit: "cover" }} /> : <span style={{ fontSize: "2rem", fontWeight: 800, color: "var(--ink-soft)" }}>{(displayName || user.username).slice(0, 1).toUpperCase()}</span>}
</div>
<div className="field" style={{ flex: 1, marginBottom: 0 }}>
<label htmlFor="dn">Display name</label>
<input id="dn" value={displayName} maxLength={40} onChange={(e) => setDisplayName(e.target.value)} placeholder={user.username} />
</div>
</div>
<p className="muted" style={{ margin: "16px 0 6px", fontWeight: 700, fontSize: "0.9rem" }}>Pick a cute avatar</p>
<div className="row" style={{ gap: 8, flexWrap: "wrap" }}>
{PRESETS.map((p) => {
const url = presetUrl(p.e, p.bg);
return <button key={p.e} onClick={() => setAvatar(url)} title="Use this avatar" style={{ ...avatarBox, border: avatar === url ? "3px solid var(--primary)" : "2px solid var(--line)" }}><img src={url} alt="" style={{ width: "100%", height: "100%" }} /></button>;
})}
<input ref={uploadRef} type="file" accept="image/*" onChange={onUpload} style={{ display: "none" }} />
<button className="btn secondary" style={{ minHeight: 56, padding: "0 14px", boxShadow: "none" }} onClick={() => uploadRef.current?.click()}>📷 Upload</button>
{avatar && <button className="btn ghost" style={{ minHeight: 56, padding: "0 12px" }} onClick={() => setAvatar("")}>Remove</button>}
</div>
{drawings.length > 0 && (
<>
<p className="muted" style={{ margin: "16px 0 6px", fontWeight: 700, fontSize: "0.9rem" }}>or use one of your drawings</p>
<div className="row" style={{ gap: 8, flexWrap: "wrap" }}>
{drawings.slice(0, 12).map((d) => (
<button key={d.id} onClick={() => setAvatar(d.image)} title="Use this drawing" style={{ ...avatarBox, border: avatar === d.image ? "3px solid var(--primary)" : "2px solid var(--line)" }}>
<img src={d.image} alt="" style={{ width: "100%", height: "100%", objectFit: "cover" }} />
</button>
))}
</div>
</>
)}
<div className="row" style={{ marginTop: 16, alignItems: "center", gap: 12 }}>
<button className="btn" onClick={saveProfile}>Save profile</button>
{profileMsg && <span className="muted">{profileMsg}</span>}
</div>
</div>
{/* Email */}
<div className="card">
<h2 style={{ marginTop: 0 }}>Email</h2>
<div className="field"><label htmlFor="em">Email address</label><input id="em" type="email" value={email} onChange={(e) => setEmail(e.target.value)} /></div>
<div className="row" style={{ alignItems: "center", gap: 12 }}>
<button className="btn" onClick={saveEmail}>Update email</button>
{emailMsg && <span className="muted">{emailMsg}</span>}
</div>
</div>
{/* Password */}
<div className="card">
<h2 style={{ marginTop: 0 }}>Password</h2>
<div className="field"><label htmlFor="cp">Current password</label><input id="cp" type="password" autoComplete="current-password" value={cur} onChange={(e) => setCur(e.target.value)} /></div>
<div className="field"><label htmlFor="np">New password</label><input id="np" type="password" autoComplete="new-password" value={next} onChange={(e) => setNext(e.target.value)} placeholder="At least 6 characters" /></div>
<div className="row" style={{ alignItems: "center", gap: 12 }}>
<button className="btn" onClick={savePassword}>Change password</button>
{pwMsg && <span className="muted">{pwMsg}</span>}
</div>
</div>
</div>
);
}
+20 -1
View File
@@ -19,6 +19,7 @@ interface Group {
export default function ProfileGallery({ groups }: { groups: Group[] }) {
const router = useRouter();
const [busyId, setBusyId] = useState<number | null>(null);
const [lightbox, setLightbox] = useState<string | null>(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) => (
<div key={d.id} style={{ border: "1px solid var(--line)", borderRadius: 12, overflow: "hidden", background: "#fff" }}>
<img src={d.image} alt={`${g.title} drawing`} style={{ width: "100%", display: "block", aspectRatio: "4 / 3", objectFit: "contain", background: "#fff" }} />
<img src={d.image} alt={`${g.title} drawing`} onClick={() => setLightbox(d.image)} style={{ width: "100%", display: "block", aspectRatio: "4 / 3", objectFit: "contain", background: "#fff", cursor: "zoom-in" }} />
<div style={{ padding: "8px 10px" }}>
<div className="row" style={{ justifyContent: "space-between", alignItems: "center" }}>
<span className="pill role" style={{ fontSize: "0.72rem" }}>
@@ -84,6 +85,24 @@ export default function ProfileGallery({ groups }: { groups: Group[] }) {
</div>
</div>
))}
{lightbox && (
<div
onClick={() => 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" }}
>
<img src={lightbox} alt="Drawing full size" style={{ maxWidth: "100%", maxHeight: "90vh", borderRadius: 12, background: "#fff", boxShadow: "0 12px 40px rgba(0,0,0,0.5)" }} />
<button
onClick={(e) => { e.stopPropagation(); setLightbox(null); }}
aria-label="Close"
style={{ position: "fixed", top: 16, right: 20, fontSize: "1.8rem", lineHeight: 1, background: "rgba(255,255,255,0.9)", border: "none", borderRadius: "50%", width: 44, height: 44, cursor: "pointer", fontWeight: 800 }}
>
×
</button>
</div>
)}
</div>
);
}
+20 -5
View File
@@ -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() {
<>
<SiteNav />
<main className="container page">
<h1>{user.username}&apos;s profile</h1>
<div className="row" style={{ justifyContent: "space-between", alignItems: "center", flexWrap: "wrap", gap: 12 }}>
<div className="row" style={{ gap: 14, alignItems: "center" }}>
<div style={{ width: 64, height: 64, borderRadius: "50%", overflow: "hidden", background: "#eee", display: "grid", placeItems: "center", border: "2px solid var(--line)", flex: "0 0 auto" }}>
{user.avatar ? (
<img src={user.avatar} alt="avatar" style={{ width: "100%", height: "100%", objectFit: "cover" }} />
) : (
<span style={{ fontSize: "1.6rem", fontWeight: 800, color: "var(--ink-soft)" }}>{(user.display_name || user.username).slice(0, 1).toUpperCase()}</span>
)}
</div>
<div>
<h1 style={{ margin: 0 }}>{user.display_name || user.username}</h1>
{user.display_name && <p className="muted" style={{ margin: 0 }}>@{user.username}</p>}
</div>
</div>
<Link className="btn secondary" href="/profile/settings"> Settings</Link>
</div>
<section style={{ marginTop: 10 }}>
<section style={{ marginTop: 18 }}>
<h2>🏅 My badges</h2>
{earned.length === 0 ? (
<div className="card center">
+37
View File
@@ -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 (
<>
<SiteNav />
<main className="container page form-narrow">
<div className="row" style={{ justifyContent: "space-between", alignItems: "center" }}>
<h1 style={{ margin: 0 }}>Settings</h1>
<Link className="btn ghost" href="/profile"> Back to profile</Link>
</div>
<AccountSettings
user={{
username: user.username,
displayName: user.display_name ?? "",
email: user.email,
avatar: user.avatar ?? "",
}}
drawings={drawings}
/>
</main>
</>
);
}
+8 -3
View File
@@ -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({
<Link href="/report" className={cls("/report")} aria-current={current("/report")}>
Report a problem
</Link>
<Link href="/profile" className={cls("/profile")} aria-current={current("/profile")} title="My drawings &amp; badges">
🖼 {user.username}
<Link href="/profile" className={cls("/profile")} aria-current={current("/profile")} title="My profile, drawings &amp; badges" style={{ display: "inline-flex", alignItems: "center", gap: 6 }}>
{user.avatar ? (
<img src={user.avatar} alt="" style={{ width: 24, height: 24, borderRadius: "50%", objectFit: "cover" }} />
) : (
"🖼️"
)}{" "}
{user.displayName || user.username}
</Link>
<LogoutButton />
</>
+3 -4
View File
@@ -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<HTMLDivElement | null>(null);
const strokesRef = useRef<Stroke[]>([]);
const drawingRef = useRef<Stroke | null>(null);
const baseImgRef = useRef<HTMLImageElement | null>(null);
const baseImgRef = useRef<HTMLCanvasElement | null>(null);
const saveTimer = useRef<ReturnType<typeof setTimeout> | 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]);
+1 -1
View File
@@ -10,7 +10,7 @@ export default async function SiteNav() {
<Link href="/" className="brand">
<span className="logo">🐟</span> DrawIt
</Link>
<NavLinks user={user ? { username: user.username, isAdmin: user.role === "admin" } : null} />
<NavLinks user={user ? { username: user.username, isAdmin: user.role === "admin", displayName: user.display_name ?? "", avatar: user.avatar ?? "" } : null} />
</div>
</nav>
);
+3 -3
View File
@@ -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<HTMLDivElement | null>(null);
const strokesRef = useRef<Stroke[]>([]);
const drawingRef = useRef<Stroke | null>(null);
const baseImgRef = useRef<HTMLImageElement | null>(null);
const baseImgRef = useRef<HTMLCanvasElement | null>(null);
const saveTimer = useRef<ReturnType<typeof setTimeout> | 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]);
+31
View File
@@ -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<HTMLCanvasElement> {
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<string> {
return new Promise((resolve, reject) => {
+10
View File
@@ -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 {
+6
View File
@@ -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,
};
}
+17
View File
@@ -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 = ?")
+1
View File
@@ -14,6 +14,7 @@
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}