feat(homepage): remove baby photo option + fix R2 orphan cleanup

- Tapping avatar when a photo exists shows a mini menu: Change photo / Remove photo
- Tapping avatar when no photo exists goes straight to file picker (no menu)
- handleRemovePhoto: PATCHes imageUrl=null, deletes file from R2, clears UI
- PATCH /api/children/[id]: fetches old image_url before update, deletes the
  old R2 object (profiles/ prefix only) when it changes — no more orphaned files
- updateChildImage in FamilyProvider now accepts string | null

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Manohar Gupta 2026-05-24 13:44:10 +05:30
parent afae041208
commit 5235e26cad
3 changed files with 101 additions and 23 deletions

View file

@ -14,7 +14,7 @@ interface FamilyContextType {
loading: boolean; loading: boolean;
tier: "free" | "pro"; tier: "free" | "pro";
memberCount: number; memberCount: number;
updateChildImage: (childId: string, imageUrl: string) => void; updateChildImage: (childId: string, imageUrl: string | null) => void;
} }
const FamilyContext = createContext<FamilyContextType>({ const FamilyContext = createContext<FamilyContextType>({
@ -123,7 +123,7 @@ export function FamilyProvider({ children: providerChildren }: { children: React
fetchFamilyData(); fetchFamilyData();
}, [router]); }, [router]);
const updateChildImage = (cId: string, imageUrl: string) => { const updateChildImage = (cId: string, imageUrl: string | null) => {
setChildren(prev => prev.map(c => c.id === cId ? { ...c, imageUrl } : c)); setChildren(prev => prev.map(c => c.id === cId ? { ...c, imageUrl } : c));
setChild(prev => prev?.id === cId ? { ...prev, imageUrl } : prev); setChild(prev => prev?.id === cId ? { ...prev, imageUrl } : prev);
}; };

View file

@ -1,7 +1,7 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { sql } from "@/db"; import { sql } from "@/db";
import { requireFamily } from "@/lib/auth"; import { requireFamily } from "@/lib/auth";
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3"; import { S3Client, PutObjectCommand, DeleteObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
const ALLOWED_TYPES = ["image/jpeg", "image/jpg", "image/png", "image/webp", "image/heic"]; const ALLOWED_TYPES = ["image/jpeg", "image/jpg", "image/png", "image/webp", "image/heic"];
@ -82,13 +82,46 @@ export async function PATCH(
} }
const body = await request.json(); const body = await request.json();
const { imageUrl } = body; const { imageUrl } = body; // may be a URL string or null (to remove)
if (imageUrl !== undefined) { if (imageUrl !== undefined) {
// Fetch the current image URL so we can delete it from R2 after update
const existing = await sql.unsafe(
`SELECT image_url FROM children WHERE id = $1`,
[id]
);
const oldImageUrl: string | null = existing[0]?.image_url ?? null;
// Update DB (imageUrl === null clears the photo)
await sql.unsafe( await sql.unsafe(
`UPDATE children SET image_url = $1 WHERE id = $2`, `UPDATE children SET image_url = $1 WHERE id = $2`,
[imageUrl, id] [imageUrl ?? null, id]
); );
// Delete old R2 object if it's a profiles/ key (not an external URL)
if (oldImageUrl && oldImageUrl !== imageUrl) {
try {
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 baseUrl = process.env.R2_PUBLIC_URL || `https://pub-${accountId}.r2.dev`;
if (accountId && accessKeyId && secretKey && bucket && oldImageUrl.startsWith(baseUrl + "/")) {
const oldKey = oldImageUrl.slice(baseUrl.length + 1);
if (oldKey.startsWith("profiles/")) {
const client = new S3Client({
region: "auto",
endpoint: `https://${accountId}.r2.cloudflarestorage.com`,
credentials: { accessKeyId, secretAccessKey: secretKey },
});
await client.send(new DeleteObjectCommand({ Bucket: bucket, Key: oldKey }));
}
}
} catch (e) {
console.error("Failed to delete old profile image from R2:", e);
// Non-fatal — DB is already updated
}
}
} }
return NextResponse.json({ success: true }); return NextResponse.json({ success: true });

View file

@ -89,6 +89,7 @@ export default function HomePage() {
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 [uploadingPhoto, setUploadingPhoto] = useState(false);
const [showPhotoMenu, setShowPhotoMenu] = useState(false);
const photoInputRef = useRef<HTMLInputElement>(null); const photoInputRef = useRef<HTMLInputElement>(null);
const { theme, toggle: toggleTheme } = useTheme(); const { theme, toggle: toggleTheme } = useTheme();
const { childId, child, familyId, loading, updateChildImage } = useFamily(); const { childId, child, familyId, loading, updateChildImage } = useFamily();
@ -223,6 +224,24 @@ export default function HomePage() {
if (photoInputRef.current) photoInputRef.current.value = ""; if (photoInputRef.current) photoInputRef.current.value = "";
}; };
const handleRemovePhoto = async () => {
setShowPhotoMenu(false);
if (!childId) return;
setUploadingPhoto(true);
try {
await fetch(`/api/children/${childId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ imageUrl: null }),
});
updateChildImage(childId, null);
} catch (err) {
console.error("Remove photo failed:", err);
alert("Failed to remove photo.");
}
setUploadingPhoto(false);
};
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;
@ -288,24 +307,50 @@ export default function HomePage() {
</div> </div>
<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="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 */} {/* Avatar — tap to change/remove photo */}
<button <div className="relative flex-shrink-0">
onClick={() => photoInputRef.current?.click()} <button
disabled={uploadingPhoto} onClick={() => {
className="relative flex-shrink-0 group" if (child?.imageUrl) setShowPhotoMenu(m => !m);
title="Change photo" else photoInputRef.current?.click();
> }}
{child?.imageUrl disabled={uploadingPhoto}
? <img src={child.imageUrl} alt={child?.name} className="w-16 h-16 rounded-full object-cover" /> className="relative group"
: <div className="w-16 h-16 bg-rose-100 dark:bg-rose-900 rounded-full flex items-center justify-center text-2xl">👶</div> title={child?.imageUrl ? "Photo options" : "Add photo"}
} >
{/* Camera overlay */} {child?.imageUrl
<div className={`absolute inset-0 rounded-full flex items-center justify-center transition-opacity ${ ? <img src={child.imageUrl} alt={child?.name} className="w-16 h-16 rounded-full object-cover" />
uploadingPhoto ? "bg-black/40 opacity-100" : "bg-black/0 opacity-0 group-hover:opacity-100 group-active:opacity-100" : <div className="w-16 h-16 bg-rose-100 dark:bg-rose-900 rounded-full flex items-center justify-center text-2xl">👶</div>
}`}> }
<span className="text-white text-lg">{uploadingPhoto ? "⏳" : "📷"}</span> {/* Camera overlay */}
</div> <div className={`absolute inset-0 rounded-full flex items-center justify-center transition-opacity ${
</button> 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>
{/* Photo options menu */}
{showPhotoMenu && (
<>
<div className="fixed inset-0 z-40" onClick={() => setShowPhotoMenu(false)} />
<div className="absolute left-0 top-[68px] z-50 bg-white dark:bg-gray-800 rounded-xl shadow-xl border border-gray-100 dark:border-gray-700 overflow-hidden w-44">
<button
onClick={() => { setShowPhotoMenu(false); photoInputRef.current?.click(); }}
className="w-full flex items-center gap-2.5 px-3 py-2.5 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700"
>
<span>📷</span> Change photo
</button>
<button
onClick={handleRemovePhoto}
className="w-full flex items-center gap-2.5 px-3 py-2.5 text-sm text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 border-t border-gray-100 dark:border-gray-700"
>
<span>🗑</span> Remove photo
</button>
</div>
</>
)}
</div>
{/* Hidden file input */} {/* Hidden file input */}
<input <input