[Feature] Profile settings and avatar #8
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}'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">
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 & badges">
|
||||
🖼️ {user.username}
|
||||
<Link href="/profile" className={cls("/profile")} aria-current={current("/profile")} title="My profile, drawings & 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 />
|
||||
</>
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 = ?")
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [{ "name": "next" }],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user