diff --git a/src/app/FamilyProvider.tsx b/src/app/FamilyProvider.tsx index f9efa7b..22d8890 100644 --- a/src/app/FamilyProvider.tsx +++ b/src/app/FamilyProvider.tsx @@ -14,6 +14,7 @@ interface FamilyContextType { loading: boolean; tier: "free" | "pro"; memberCount: number; + updateChildImage: (childId: string, imageUrl: string) => void; } const FamilyContext = createContext({ @@ -25,6 +26,7 @@ const FamilyContext = createContext({ 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 ( {providerChildren} diff --git a/src/app/api/children/[id]/route.ts b/src/app/api/children/[id]/route.ts new file mode 100644 index 0000000..68a5ca5 --- /dev/null +++ b/src/app/api/children/[id]/route.ts @@ -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 }); +} diff --git a/src/app/page.tsx b/src/app/page.tsx index 2e60bb3..55284dd 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -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([]); const [aiChips, setAiChips] = useState([]); + const [uploadingPhoto, setUploadingPhoto] = useState(false); + const photoInputRef = useRef(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) => { + 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() {

How is {child?.name || "your baby"} doing today?

- -
+
+ {/* Avatar — tap to change photo */} +
- + {/* Camera overlay */} +
+ {uploadingPhoto ? "⏳" : "📷"} +
+ + + {/* Hidden file input */} + + + {/* Name + age — tap to go to growth */} + +
+
{child?.name || "Baby"}
+
{calculateAge(child?.birthDate || "")}
+
+
+ +
{pendingCount > 0 && (