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:
parent
5297ab76ba
commit
c732d2d7c2
3 changed files with 175 additions and 9 deletions
|
|
@ -14,6 +14,7 @@ interface FamilyContextType {
|
|||
loading: boolean;
|
||||
tier: "free" | "pro";
|
||||
memberCount: number;
|
||||
updateChildImage: (childId: string, imageUrl: string) => void;
|
||||
}
|
||||
|
||||
const FamilyContext = createContext<FamilyContextType>({
|
||||
|
|
@ -25,6 +26,7 @@ const FamilyContext = createContext<FamilyContextType>({
|
|||
loading: true,
|
||||
tier: "free",
|
||||
memberCount: 2,
|
||||
updateChildImage: () => {},
|
||||
});
|
||||
|
||||
export function useFamily() {
|
||||
|
|
@ -121,6 +123,11 @@ export function FamilyProvider({ children: providerChildren }: { children: React
|
|||
fetchFamilyData();
|
||||
}, [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 (
|
||||
<FamilyContext.Provider
|
||||
value={{
|
||||
|
|
@ -132,6 +139,7 @@ export function FamilyProvider({ children: providerChildren }: { children: React
|
|||
loading,
|
||||
tier,
|
||||
memberCount,
|
||||
updateChildImage,
|
||||
}}
|
||||
>
|
||||
{providerChildren}
|
||||
|
|
|
|||
95
src/app/api/children/[id]/route.ts
Normal file
95
src/app/api/children/[id]/route.ts
Normal 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 });
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import Link from "next/link";
|
||||
import { useTheme } from "./ThemeProvider";
|
||||
import { useFamily } from "./FamilyProvider";
|
||||
|
|
@ -88,8 +88,10 @@ export default function HomePage() {
|
|||
const [logsLoading, setLogsLoading] = useState(true);
|
||||
const [vaccineReminders, setVaccineReminders] = useState<any[]>([]);
|
||||
const [aiChips, setAiChips] = useState<string[]>([]);
|
||||
const [uploadingPhoto, setUploadingPhoto] = useState(false);
|
||||
const photoInputRef = useRef<HTMLInputElement>(null);
|
||||
const { theme, toggle: toggleTheme } = useTheme();
|
||||
const { childId, child, familyId, loading } = useFamily();
|
||||
const { childId, child, familyId, loading, updateChildImage } = useFamily();
|
||||
const stage = useStageCheck(child?.birthDate ?? null);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -181,6 +183,39 @@ export default function HomePage() {
|
|||
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 q = question || aiInput;
|
||||
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>
|
||||
</div>
|
||||
|
||||
<Link href="/growth" className="mx-4 mb-4 p-4 bg-white dark:bg-gray-800 rounded-2xl shadow-md block">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="mx-4 mb-4 p-4 bg-white dark:bg-gray-800 rounded-2xl shadow-md 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
|
||||
? <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="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>
|
||||
<div className="text-2xl">→</div>
|
||||
{/* Camera overlay */}
|
||||
<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>
|
||||
</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>
|
||||
</div>
|
||||
|
||||
{pendingCount > 0 && (
|
||||
<button
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue