Add baby profile photo change from home screen

- New PATCH /api/children/[id] — updates image_url in children table
- New POST /api/children/[id] — returns presigned R2 URL for profile
  photos (stored under profiles/{childId}/ prefix, no memories row)
- FamilyProvider: expose updateChildImage() so UI updates instantly
  without a full re-fetch after upload
- Home page baby card: photo avatar is now a separate tap target from
  the growth link. Tap photo → file picker → upload to R2 → save URL.
  Camera overlay (📷) appears on hover/tap;  shown while uploading.
  Tapping name/age/arrow still navigates to growth as before.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Manohar Gupta 2026-05-24 00:51:45 +05:30
parent 5297ab76ba
commit c732d2d7c2
3 changed files with 175 additions and 9 deletions

View file

@ -14,6 +14,7 @@ interface FamilyContextType {
loading: boolean; loading: boolean;
tier: "free" | "pro"; tier: "free" | "pro";
memberCount: number; memberCount: number;
updateChildImage: (childId: string, imageUrl: string) => void;
} }
const FamilyContext = createContext<FamilyContextType>({ const FamilyContext = createContext<FamilyContextType>({
@ -25,6 +26,7 @@ const FamilyContext = createContext<FamilyContextType>({
loading: true, loading: true,
tier: "free", tier: "free",
memberCount: 2, memberCount: 2,
updateChildImage: () => {},
}); });
export function useFamily() { export function useFamily() {
@ -121,6 +123,11 @@ export function FamilyProvider({ children: providerChildren }: { children: React
fetchFamilyData(); fetchFamilyData();
}, [router]); }, [router]);
const updateChildImage = (cId: string, imageUrl: string) => {
setChildren(prev => prev.map(c => c.id === cId ? { ...c, imageUrl } : c));
setChild(prev => prev?.id === cId ? { ...prev, imageUrl } : prev);
};
return ( return (
<FamilyContext.Provider <FamilyContext.Provider
value={{ value={{
@ -132,6 +139,7 @@ export function FamilyProvider({ children: providerChildren }: { children: React
loading, loading,
tier, tier,
memberCount, memberCount,
updateChildImage,
}} }}
> >
{providerChildren} {providerChildren}

View file

@ -0,0 +1,95 @@
import { NextResponse } from "next/server";
import { sql } from "@/db";
import { requireFamily } from "@/lib/auth";
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
const ALLOWED_TYPES = ["image/jpeg", "image/jpg", "image/png", "image/webp", "image/heic"];
async function ownershipCheck(childId: string, familyId: string) {
const rows = await sql.unsafe(
`SELECT id FROM children WHERE id = $1 AND family_id = $2`,
[childId, familyId]
);
return rows.length > 0;
}
// POST — get a presigned R2 URL for uploading a child profile photo
// (no memories row — this is purely for profile avatars)
export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const auth = await requireFamily();
if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
const familyId = auth.session!.familyId!;
const { id } = await params;
if (!(await ownershipCheck(id, familyId))) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
const body = await request.json();
const { contentType, filename } = body;
if (!contentType || !ALLOWED_TYPES.includes(contentType)) {
return NextResponse.json({ error: "Unsupported file type" }, { status: 400 });
}
const accountId = process.env.R2_ACCOUNT_ID;
const accessKeyId = 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 || !accessKeyId || !secretKey || !bucket) {
return NextResponse.json({ error: "R2 not configured" }, { status: 500 });
}
const ext = filename?.split(".").pop()?.toLowerCase() || "jpg";
const r2Key = `profiles/${id}/${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, secretAccessKey: secretKey },
});
const uploadUrl = await getSignedUrl(
client,
new PutObjectCommand({ Bucket: bucket, Key: r2Key, ContentType: contentType }),
{ expiresIn: 3600 }
);
return NextResponse.json({ uploadUrl, publicUrl: `${baseUrl}/${r2Key}` });
}
// PATCH — update child profile fields (imageUrl, and extensible for name/etc later)
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const auth = await requireFamily();
if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status });
const familyId = auth.session!.familyId!;
const { id } = await params;
if (!(await ownershipCheck(id, familyId))) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
const body = await request.json();
const { imageUrl } = body;
if (imageUrl !== undefined) {
await sql.unsafe(
`UPDATE children SET image_url = $1 WHERE id = $2`,
[imageUrl, id]
);
}
return NextResponse.json({ success: true });
}

View file

@ -1,6 +1,6 @@
"use client"; "use client";
import { useState, useEffect } from "react"; import { useState, useEffect, useRef } from "react";
import Link from "next/link"; import Link from "next/link";
import { useTheme } from "./ThemeProvider"; import { useTheme } from "./ThemeProvider";
import { useFamily } from "./FamilyProvider"; import { useFamily } from "./FamilyProvider";
@ -88,8 +88,10 @@ export default function HomePage() {
const [logsLoading, setLogsLoading] = useState(true); const [logsLoading, setLogsLoading] = useState(true);
const [vaccineReminders, setVaccineReminders] = useState<any[]>([]); const [vaccineReminders, setVaccineReminders] = useState<any[]>([]);
const [aiChips, setAiChips] = useState<string[]>([]); const [aiChips, setAiChips] = useState<string[]>([]);
const [uploadingPhoto, setUploadingPhoto] = useState(false);
const photoInputRef = useRef<HTMLInputElement>(null);
const { theme, toggle: toggleTheme } = useTheme(); const { theme, toggle: toggleTheme } = useTheme();
const { childId, child, familyId, loading } = useFamily(); const { childId, child, familyId, loading, updateChildImage } = useFamily();
const stage = useStageCheck(child?.birthDate ?? null); const stage = useStageCheck(child?.birthDate ?? null);
useEffect(() => { useEffect(() => {
@ -181,6 +183,39 @@ export default function HomePage() {
toggleTheme(); toggleTheme();
}; };
const handlePhotoChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file || !childId) return;
setUploadingPhoto(true);
try {
// 1. Get presigned R2 URL
const presignRes = await fetch(`/api/children/${childId}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ contentType: file.type, filename: file.name }),
});
if (!presignRes.ok) throw new Error("Failed to get upload URL");
const { uploadUrl, publicUrl } = await presignRes.json();
// 2. Upload directly to R2
await fetch(uploadUrl, { method: "PUT", body: file, headers: { "Content-Type": file.type } });
// 3. Save URL to DB
await fetch(`/api/children/${childId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ imageUrl: publicUrl }),
});
// 4. Update in-memory state immediately — no full reload needed
updateChildImage(childId, publicUrl);
} catch (err) {
console.error("Photo upload failed:", err);
}
setUploadingPhoto(false);
if (photoInputRef.current) photoInputRef.current.value = "";
};
const handleAiChat = async (question?: string) => { const handleAiChat = async (question?: string) => {
const q = question || aiInput; const q = question || aiInput;
if (!q.trim() || aiLoading) return; if (!q.trim() || aiLoading) return;
@ -245,16 +280,44 @@ export default function HomePage() {
<p className="text-gray-600 dark:text-gray-300">How is {child?.name || "your baby"} doing today?</p> <p className="text-gray-600 dark:text-gray-300">How is {child?.name || "your baby"} doing today?</p>
</div> </div>
<Link href="/growth" className="mx-4 mb-4 p-4 bg-white dark:bg-gray-800 rounded-2xl shadow-md block"> <div className="mx-4 mb-4 p-4 bg-white dark:bg-gray-800 rounded-2xl shadow-md flex items-center gap-4">
<div className="flex items-center gap-4"> {/* Avatar — tap to change photo */}
<button
onClick={() => photoInputRef.current?.click()}
disabled={uploadingPhoto}
className="relative flex-shrink-0 group"
title="Change photo"
>
{child?.imageUrl {child?.imageUrl
? <img src={child.imageUrl} alt={child.name} className="w-16 h-16 rounded-full object-cover" /> ? <img src={child.imageUrl} alt={child?.name} className="w-16 h-16 rounded-full object-cover" />
: <div className="w-16 h-16 bg-rose-100 dark:bg-rose-900 rounded-full flex items-center justify-center text-2xl">👶</div> : <div className="w-16 h-16 bg-rose-100 dark:bg-rose-900 rounded-full flex items-center justify-center text-2xl">👶</div>
} }
<div className="flex-1"><div className="text-lg font-semibold">{child?.name || "Baby"}</div><div className="text-gray-500 dark:text-gray-400">{calculateAge(child?.birthDate || "")}</div></div> {/* Camera overlay */}
<div className="text-2xl"></div> <div className={`absolute inset-0 rounded-full flex items-center justify-center transition-opacity ${
uploadingPhoto ? "bg-black/40 opacity-100" : "bg-black/0 opacity-0 group-hover:opacity-100 group-active:opacity-100"
}`}>
<span className="text-white text-lg">{uploadingPhoto ? "⏳" : "📷"}</span>
</div> </div>
</button>
{/* Hidden file input */}
<input
ref={photoInputRef}
type="file"
accept="image/jpeg,image/jpg,image/png,image/webp,image/heic"
className="hidden"
onChange={handlePhotoChange}
/>
{/* Name + age — tap to go to growth */}
<Link href="/growth" className="flex-1 flex items-center gap-2">
<div className="flex-1">
<div className="text-lg font-semibold">{child?.name || "Baby"}</div>
<div className="text-sm text-gray-500 dark:text-gray-400">{calculateAge(child?.birthDate || "")}</div>
</div>
<div className="text-2xl text-gray-400"></div>
</Link> </Link>
</div>
{pendingCount > 0 && ( {pendingCount > 0 && (
<button <button