From fa5e27bfd9174b23c643f3df5ac5ce831da4a826 Mon Sep 17 00:00:00 2001 From: Mannu Date: Sun, 24 May 2026 13:32:10 +0530 Subject: [PATCH] feat(profile): working profile photo upload for parent users (mama/daddy) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New POST /api/auth/avatar — accepts multipart FormData, uploads image to R2 under avatars/{userId}/{ts}.ext, saves URL to users.avatar_url - GET /api/auth/profile now returns avatarUrl field - /profile page: real avatar display (image or initials fallback), hidden file input wired to "Change Photo" button, spinner overlay while uploading, inline success/error message; name save and photo upload are independent NOTE: This is the parent user avatar (mama/daddy). The baby profile photo on the homepage card is separate. Co-Authored-By: Claude Sonnet 4.6 --- src/app/api/auth/avatar/route.ts | 78 ++++++++++++++ src/app/api/auth/profile/route.ts | 3 +- src/app/profile/page.tsx | 174 ++++++++++++++++++++---------- 3 files changed, 200 insertions(+), 55 deletions(-) create mode 100644 src/app/api/auth/avatar/route.ts diff --git a/src/app/api/auth/avatar/route.ts b/src/app/api/auth/avatar/route.ts new file mode 100644 index 0000000..b643826 --- /dev/null +++ b/src/app/api/auth/avatar/route.ts @@ -0,0 +1,78 @@ +import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3"; +import { NextRequest, NextResponse } from "next/server"; +import { cookies } from "next/headers"; +import { sql } from "@/db"; + +const ALLOWED = ["image/jpeg", "image/jpg", "image/png", "image/webp", "image/heic"]; +const MAX_BYTES = 5 * 1024 * 1024; // 5 MB + +export async function POST(req: NextRequest) { + // Auth + const cookieStore = await cookies(); + const token = cookieStore.get("tia_session")?.value; + if (!token) return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); + + const sessions = await sql` + SELECT user_id FROM sessions + WHERE session_token = ${token} AND expires > NOW() + LIMIT 1 + `; + const userId = sessions[0]?.user_id; + if (!userId) return NextResponse.json({ error: "Invalid session" }, { status: 401 }); + + // Parse file from multipart + let formData: FormData; + try { + formData = await req.formData(); + } catch { + return NextResponse.json({ error: "Invalid form data" }, { status: 400 }); + } + const file = formData.get("file") as File | null; + if (!file) return NextResponse.json({ error: "No file provided" }, { status: 400 }); + + if (!ALLOWED.includes(file.type)) { + return NextResponse.json({ error: "Unsupported file type. Use JPEG, PNG, or WebP." }, { status: 400 }); + } + if (file.size > MAX_BYTES) { + return NextResponse.json({ error: "File too large (max 5 MB)" }, { status: 400 }); + } + + // R2 config + const accountId = process.env.R2_ACCOUNT_ID; + const accessKey = process.env.R2_ACCESS_KEY_ID; + const secretKey = process.env.R2_SECRET_ACCESS_KEY; + const bucket = process.env.R2_BUCKET_NAME; + const publicUrl = process.env.R2_PUBLIC_URL; + + if (!accountId || !accessKey || !secretKey || !bucket) { + return NextResponse.json({ error: "Storage not configured" }, { status: 500 }); + } + + const ext = (file.name.split(".").pop() || "jpg").toLowerCase(); + const key = `avatars/${userId}/${Date.now()}.${ext}`; + const baseUrl = publicUrl || `https://pub-${accountId}.r2.dev`; + + const client = new S3Client({ + region: "auto", + endpoint: `https://${accountId}.r2.cloudflarestorage.com`, + credentials: { accessKeyId: accessKey, secretAccessKey: secretKey }, + }); + + const bytes = await file.arrayBuffer(); + await client.send(new PutObjectCommand({ + Bucket: bucket, + Key: key, + Body: Buffer.from(bytes), + ContentType: file.type, + })); + + const avatarUrl = `${baseUrl}/${key}`; + + // Save to users table + await sql` + UPDATE users SET avatar_url = ${avatarUrl}, updated_at = NOW() + WHERE id = ${userId} + `; + + return NextResponse.json({ success: true, avatarUrl }); +} diff --git a/src/app/api/auth/profile/route.ts b/src/app/api/auth/profile/route.ts index e987a41..ee9103f 100644 --- a/src/app/api/auth/profile/route.ts +++ b/src/app/api/auth/profile/route.ts @@ -14,7 +14,7 @@ export async function GET() { // Get session and user const sessions = await sql` - SELECT s.user_id, s.expires, u.id, u.email, u.name, u.created_at + SELECT s.user_id, s.expires, u.id, u.email, u.name, u.avatar_url, u.created_at FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.session_token = ${sessionToken} @@ -40,6 +40,7 @@ export async function GET() { id: session.id, email: session.email, name: session.name || "Parent", + avatarUrl: session.avatar_url || null, familyId: members?.[0]?.family_id, familyName: members?.[0]?.family_name, memberSince: session.created_at, diff --git a/src/app/profile/page.tsx b/src/app/profile/page.tsx index 736c2cf..d374454 100644 --- a/src/app/profile/page.tsx +++ b/src/app/profile/page.tsx @@ -1,114 +1,180 @@ "use client"; -import { useState, useEffect } from "react"; +import { useState, useEffect, useRef } from "react"; import { useRouter } from "next/navigation"; export default function ProfilePage() { - const router = useRouter(); - const [name, setName] = useState("Loading..."); - const [email, setEmail] = useState("Loading..."); - const [loading, setLoading] = useState(true); + const router = useRouter(); + const fileRef = useRef(null); + + const [userId, setUserId] = useState(""); + const [name, setName] = useState(""); + const [email, setEmail] = useState(""); + const [avatarUrl, setAvatarUrl] = useState(null); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [uploading, setUploading] = useState(false); + const [saveMsg, setSaveMsg] = useState(""); useEffect(() => { - // Fetch user profile from API fetch("/api/auth/profile") - .then((r) => r.json()) - .then((data) => { + .then(r => r.json()) + .then(data => { if (data.user) { - setName(data.user.name || "Parent"); - setEmail(data.user.email || "parent@example.com"); + setUserId(data.user.id || ""); + setName(data.user.name || ""); + setEmail(data.user.email || ""); + setAvatarUrl(data.user.avatarUrl || null); } setLoading(false); }) - .catch(() => { - setName("Parent"); - setEmail("parent@example.com"); - setLoading(false); - }); + .catch(() => setLoading(false)); }, []); - const saveProfile = async () => { - if (!name.trim()) { - alert("Please enter your name"); - return; - } - setLoading(true); + const handlePhotoChange = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + setUploading(true); + setSaveMsg(""); try { - const res = await fetch("/api/auth/profile", { + const form = new FormData(); + form.append("file", file); + const res = await fetch("/api/auth/avatar", { method: "POST", body: form }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || "Upload failed"); + setAvatarUrl(data.avatarUrl); + setSaveMsg("Photo updated!"); + } catch (err) { + setSaveMsg(err instanceof Error ? err.message : "Upload failed"); + } + setUploading(false); + if (fileRef.current) fileRef.current.value = ""; + }; + + const saveProfile = async () => { + if (!name.trim()) { setSaveMsg("Please enter your name"); return; } + setSaving(true); + setSaveMsg(""); + try { + const res = await fetch("/api/auth/profile", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name }), }); const data = await res.json(); - if (data.success) { - alert("Profile saved!"); - } else { - alert(data.error || "Failed to save profile"); - } - } catch (err) { - alert("Failed to save profile"); + setSaveMsg(data.success ? "Saved!" : data.error || "Save failed"); + } catch { + setSaveMsg("Failed to save"); } - setLoading(false); + setSaving(false); }; + const initials = name + .split(" ") + .map(w => w[0]) + .join("") + .toUpperCase() + .slice(0, 2); + return (
-
- -

Profile

+ {/* Header */} +
+ +

My Profile

-
+
{/* Avatar */} -
-
- 👤 +
+
+ {avatarUrl ? ( + {name} + ) : ( +
+ {initials || "👤"} +
+ )} + {/* Upload spinner overlay */} + {uploading && ( +
+
+
+ )}
- + + +

JPEG, PNG or WebP · max 5 MB

+ +
{/* Form */} {loading ? ( -
Loading...
+
Loading…
) : ( -
+
- + setName(e.target.value)} - className="w-full p-3 bg-white dark:bg-gray-800 rounded-xl border" + onChange={e => setName(e.target.value)} + className="w-full px-3 py-2.5 bg-gray-50 dark:bg-gray-700 rounded-xl border border-gray-200 dark:border-gray-600 text-sm dark:text-white focus:outline-none focus:ring-2 focus:ring-rose-300" + placeholder="Your name" />
- + setEmail(e.target.value)} - className="w-full p-3 bg-white dark:bg-gray-800 rounded-xl border" disabled + className="w-full px-3 py-2.5 bg-gray-100 dark:bg-gray-700/50 rounded-xl border border-gray-200 dark:border-gray-600 text-sm text-gray-400 dark:text-gray-500 cursor-not-allowed" /> -
Email cannot be changed
+

Email cannot be changed

+ {saveMsg && ( +

+ {saveMsg} +

+ )} +
)} - {/* Account Info */} -
-
Account
-
Member since: January 2024
+ {/* Account info */} +
+

Account

+

{email}

); -} \ No newline at end of file +}