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:
parent
afae041208
commit
5235e26cad
3 changed files with 101 additions and 23 deletions
|
|
@ -14,7 +14,7 @@ interface FamilyContextType {
|
|||
loading: boolean;
|
||||
tier: "free" | "pro";
|
||||
memberCount: number;
|
||||
updateChildImage: (childId: string, imageUrl: string) => void;
|
||||
updateChildImage: (childId: string, imageUrl: string | null) => void;
|
||||
}
|
||||
|
||||
const FamilyContext = createContext<FamilyContextType>({
|
||||
|
|
@ -123,7 +123,7 @@ export function FamilyProvider({ children: providerChildren }: { children: React
|
|||
fetchFamilyData();
|
||||
}, [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));
|
||||
setChild(prev => prev?.id === cId ? { ...prev, imageUrl } : prev);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { NextResponse } from "next/server";
|
||||
import { sql } from "@/db";
|
||||
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";
|
||||
|
||||
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 { imageUrl } = body;
|
||||
const { imageUrl } = body; // may be a URL string or null (to remove)
|
||||
|
||||
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(
|
||||
`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 });
|
||||
|
|
|
|||
|
|
@ -89,6 +89,7 @@ export default function HomePage() {
|
|||
const [vaccineReminders, setVaccineReminders] = useState<any[]>([]);
|
||||
const [aiChips, setAiChips] = useState<string[]>([]);
|
||||
const [uploadingPhoto, setUploadingPhoto] = useState(false);
|
||||
const [showPhotoMenu, setShowPhotoMenu] = useState(false);
|
||||
const photoInputRef = useRef<HTMLInputElement>(null);
|
||||
const { theme, toggle: toggleTheme } = useTheme();
|
||||
const { childId, child, familyId, loading, updateChildImage } = useFamily();
|
||||
|
|
@ -223,6 +224,24 @@ export default function HomePage() {
|
|||
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 q = question || aiInput;
|
||||
if (!q.trim() || aiLoading) return;
|
||||
|
|
@ -288,12 +307,16 @@ export default function HomePage() {
|
|||
</div>
|
||||
|
||||
<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 */}
|
||||
<div className="relative flex-shrink-0">
|
||||
<button
|
||||
onClick={() => photoInputRef.current?.click()}
|
||||
onClick={() => {
|
||||
if (child?.imageUrl) setShowPhotoMenu(m => !m);
|
||||
else photoInputRef.current?.click();
|
||||
}}
|
||||
disabled={uploadingPhoto}
|
||||
className="relative flex-shrink-0 group"
|
||||
title="Change photo"
|
||||
className="relative group"
|
||||
title={child?.imageUrl ? "Photo options" : "Add photo"}
|
||||
>
|
||||
{child?.imageUrl
|
||||
? <img src={child.imageUrl} alt={child?.name} className="w-16 h-16 rounded-full object-cover" />
|
||||
|
|
@ -307,6 +330,28 @@ export default function HomePage() {
|
|||
</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 */}
|
||||
<input
|
||||
ref={photoInputRef}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue