feat(profile): working profile photo upload for parent users (mama/daddy)
- 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 <noreply@anthropic.com>
This commit is contained in:
parent
f37e5bfad4
commit
fa5e27bfd9
3 changed files with 200 additions and 55 deletions
78
src/app/api/auth/avatar/route.ts
Normal file
78
src/app/api/auth/avatar/route.ts
Normal file
|
|
@ -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 });
|
||||||
|
}
|
||||||
|
|
@ -14,7 +14,7 @@ export async function GET() {
|
||||||
|
|
||||||
// Get session and user
|
// Get session and user
|
||||||
const sessions = await sql`
|
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
|
FROM sessions s
|
||||||
JOIN users u ON u.id = s.user_id
|
JOIN users u ON u.id = s.user_id
|
||||||
WHERE s.session_token = ${sessionToken}
|
WHERE s.session_token = ${sessionToken}
|
||||||
|
|
@ -40,6 +40,7 @@ export async function GET() {
|
||||||
id: session.id,
|
id: session.id,
|
||||||
email: session.email,
|
email: session.email,
|
||||||
name: session.name || "Parent",
|
name: session.name || "Parent",
|
||||||
|
avatarUrl: session.avatar_url || null,
|
||||||
familyId: members?.[0]?.family_id,
|
familyId: members?.[0]?.family_id,
|
||||||
familyName: members?.[0]?.family_name,
|
familyName: members?.[0]?.family_name,
|
||||||
memberSince: session.created_at,
|
memberSince: session.created_at,
|
||||||
|
|
|
||||||
|
|
@ -1,114 +1,180 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
export default function ProfilePage() {
|
export default function ProfilePage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [name, setName] = useState("Loading...");
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
const [email, setEmail] = useState("Loading...");
|
|
||||||
const [loading, setLoading] = useState(true);
|
const [userId, setUserId] = useState<string>("");
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [avatarUrl, setAvatarUrl] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [saveMsg, setSaveMsg] = useState("");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Fetch user profile from API
|
|
||||||
fetch("/api/auth/profile")
|
fetch("/api/auth/profile")
|
||||||
.then((r) => r.json())
|
.then(r => r.json())
|
||||||
.then((data) => {
|
.then(data => {
|
||||||
if (data.user) {
|
if (data.user) {
|
||||||
setName(data.user.name || "Parent");
|
setUserId(data.user.id || "");
|
||||||
setEmail(data.user.email || "parent@example.com");
|
setName(data.user.name || "");
|
||||||
|
setEmail(data.user.email || "");
|
||||||
|
setAvatarUrl(data.user.avatarUrl || null);
|
||||||
}
|
}
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => setLoading(false));
|
||||||
setName("Parent");
|
|
||||||
setEmail("parent@example.com");
|
|
||||||
setLoading(false);
|
|
||||||
});
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const saveProfile = async () => {
|
const handlePhotoChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
if (!name.trim()) {
|
const file = e.target.files?.[0];
|
||||||
alert("Please enter your name");
|
if (!file) return;
|
||||||
return;
|
|
||||||
}
|
setUploading(true);
|
||||||
setLoading(true);
|
setSaveMsg("");
|
||||||
try {
|
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",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ name }),
|
body: JSON.stringify({ name }),
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (data.success) {
|
setSaveMsg(data.success ? "Saved!" : data.error || "Save failed");
|
||||||
alert("Profile saved!");
|
} catch {
|
||||||
} else {
|
setSaveMsg("Failed to save");
|
||||||
alert(data.error || "Failed to save profile");
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
alert("Failed to save profile");
|
|
||||||
}
|
}
|
||||||
setLoading(false);
|
setSaving(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const initials = name
|
||||||
|
.split(" ")
|
||||||
|
.map(w => w[0])
|
||||||
|
.join("")
|
||||||
|
.toUpperCase()
|
||||||
|
.slice(0, 2);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gradient-to-br from-rose-50 to-amber-50 dark:from-gray-900 dark:to-gray-800">
|
<div className="min-h-screen bg-gradient-to-br from-rose-50 to-amber-50 dark:from-gray-900 dark:to-gray-800">
|
||||||
<div className="p-4 flex items-center gap-4">
|
{/* Header */}
|
||||||
<button onClick={() => router.back()} className="p-2">←</button>
|
<div className="sticky top-0 z-10 bg-white/80 dark:bg-gray-900/80 backdrop-blur-sm border-b border-gray-100 dark:border-gray-800 px-4 py-3 flex items-center gap-3">
|
||||||
<h1 className="text-xl font-bold">Profile</h1>
|
<button onClick={() => router.back()} className="text-gray-500 dark:text-gray-400 p-1">←</button>
|
||||||
|
<h1 className="text-sm font-semibold dark:text-white">My Profile</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="px-4 space-y-4">
|
<div className="px-4 pb-24 space-y-4">
|
||||||
{/* Avatar */}
|
{/* Avatar */}
|
||||||
<div className="flex flex-col items-center py-8">
|
<div className="flex flex-col items-center pt-8 pb-4">
|
||||||
<div className="w-24 h-24 bg-rose-100 rounded-full flex items-center justify-center text-4xl mb-4">
|
<div className="relative mb-3">
|
||||||
👤
|
{avatarUrl ? (
|
||||||
|
<img
|
||||||
|
src={avatarUrl}
|
||||||
|
alt={name}
|
||||||
|
className="w-24 h-24 rounded-full object-cover ring-4 ring-white dark:ring-gray-800 shadow-md"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-24 h-24 rounded-full bg-gradient-to-br from-rose-300 to-amber-300 flex items-center justify-center text-white text-2xl font-bold shadow-md ring-4 ring-white dark:ring-gray-800">
|
||||||
|
{initials || "👤"}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Upload spinner overlay */}
|
||||||
|
{uploading && (
|
||||||
|
<div className="absolute inset-0 rounded-full bg-black/40 flex items-center justify-center">
|
||||||
|
<div className="w-6 h-6 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<button className="text-rose-500 text-sm">Change Photo</button>
|
|
||||||
|
<button
|
||||||
|
onClick={() => fileRef.current?.click()}
|
||||||
|
disabled={uploading}
|
||||||
|
className="text-sm font-medium text-rose-500 dark:text-rose-400 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{uploading ? "Uploading…" : "Change Photo"}
|
||||||
|
</button>
|
||||||
|
<p className="text-xs text-gray-400 mt-0.5">JPEG, PNG or WebP · max 5 MB</p>
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref={fileRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/jpeg,image/jpg,image/png,image/webp"
|
||||||
|
onChange={handlePhotoChange}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Form */}
|
{/* Form */}
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="text-center py-8 text-gray-400">Loading...</div>
|
<div className="text-center py-8 text-gray-400 text-sm">Loading…</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-sm p-4 space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1">Name</label>
|
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Name</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={e => setName(e.target.value)}
|
||||||
className="w-full p-3 bg-white dark:bg-gray-800 rounded-xl border"
|
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"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1">Email</label>
|
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Email</label>
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
|
||||||
className="w-full p-3 bg-white dark:bg-gray-800 rounded-xl border"
|
|
||||||
disabled
|
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"
|
||||||
/>
|
/>
|
||||||
<div className="text-xs text-gray-400 mt-1">Email cannot be changed</div>
|
<p className="text-xs text-gray-400 mt-1">Email cannot be changed</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{saveMsg && (
|
||||||
|
<p className={`text-xs text-center font-medium ${saveMsg === "Saved!" || saveMsg === "Photo updated!" ? "text-green-600 dark:text-green-400" : "text-red-500"}`}>
|
||||||
|
{saveMsg}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={saveProfile}
|
onClick={saveProfile}
|
||||||
className="w-full p-3 bg-rose-400 text-white rounded-xl mt-4"
|
disabled={saving || !name.trim()}
|
||||||
|
className="w-full py-3 bg-rose-400 text-white rounded-xl font-medium text-sm disabled:opacity-50 active:scale-95 transition-transform"
|
||||||
>
|
>
|
||||||
Save Changes
|
{saving ? "Saving…" : "Save Changes"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Account Info */}
|
{/* Account info */}
|
||||||
<div className="p-4 bg-white dark:bg-gray-800 rounded-xl mt-6">
|
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-sm p-4">
|
||||||
<div className="font-medium mb-2">Account</div>
|
<p className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Account</p>
|
||||||
<div className="text-sm text-gray-500">Member since: January 2024</div>
|
<p className="text-sm text-gray-700 dark:text-gray-300">{email}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue