Remove UploadProgress debug toast; fix R2 image proxy and memory pipeline

- Delete UploadProgress component (was debug UI, no longer needed)
- All 3 pages (home, memories, profile) now use simple inline error state
  instead of the step-by-step toast
- /api/img proxy: fetch R2 objects server-side to bypass Cloudflare Bot
  Management 503s on pub-xxx.r2.dev cross-origin img requests
- All API responses (memories, children, profile) now return /api/img proxy
  URLs via toProxyUrl() helper in src/lib/r2-proxy.ts
- Fix memory pipeline: vision failure now marks status='ready' instead of
  'failed'; thumbnail failure no longer blocks vision via .catch() separation
- Reset stuck 'processing' memories via debug-migration endpoint
- memories page: replace full-screen overlay with small  badge on tile

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Manohar Gupta 2026-05-28 22:53:16 +05:30
parent 27709dc851
commit d8c9500949
5 changed files with 51 additions and 335 deletions

File diff suppressed because one or more lines are too long

View file

@ -10,7 +10,6 @@ import { getOfflineQueue, processOfflineQueue } from "@/lib/offline-queue";
import { calculateAge, formatTimeAgo } from "@/lib/formatting";
import { hourIST, isTodayIST, fmtTime } from "@/lib/date-ist";
import type { Log, AIChat, ChatSession } from "@/types";
import { UploadProgress, type UploadStep } from "@/components/UploadProgress";
/** Some Android cameras return file.type = "" — detect from extension as fallback. */
function resolveContentType(file: File): string {
@ -104,8 +103,6 @@ export default function HomePage() {
const [uploadingPhoto, setUploadingPhoto] = useState(false);
const [photoError, setPhotoError] = useState(false);
const [showPhotoMenu, setShowPhotoMenu] = useState(false);
const [photoUploadFile, setPhotoUploadFile] = useState<{ name: string; size: number } | null>(null);
const [photoUploadSteps, setPhotoUploadSteps] = useState<UploadStep[]>([]);
const photoInputRef = useRef<HTMLInputElement>(null);
const { theme, toggle: toggleTheme } = useTheme();
const { childId, child, familyId, loading, updateChildImage } = useFamily();
@ -200,81 +197,37 @@ export default function HomePage() {
toggleTheme();
};
const setPhotoStep = (i: number, patch: Partial<UploadStep>) =>
setPhotoUploadSteps(prev => prev.map((s, idx) => idx === i ? { ...s, ...patch } : s));
const safeText = async (res: Response): Promise<string> => {
try {
const t = await res.text();
try { return JSON.parse(t).error || t; } catch { return t.slice(0, 200); }
} catch { return `HTTP ${res.status}`; }
};
const handlePhotoChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file || !childId) return;
const contentType = resolveContentType(file);
setPhotoUploadFile({ name: file.name, size: file.size });
setPhotoUploadSteps([
{ label: "Step 1 — Reserving upload slot", status: "pending" },
{ label: `Step 2 — Uploading (${(file.size/1024).toFixed(0)} KB, ${contentType})`, status: "pending" },
{ label: "Step 3 — Saving baby photo", status: "pending" },
]);
setUploadingPhoto(true);
setShowPhotoMenu(false);
try {
// 1. Get R2 key + public URL from server
setPhotoStep(0, { status: "active" });
const initRes = await fetch(`/api/children/${childId}`, {
const initData = await fetch(`/api/children/${childId}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ contentType, filename: file.name }),
});
if (!initRes.ok) {
const detail = await safeText(initRes);
setPhotoStep(0, { status: "error", detail: `HTTP ${initRes.status}: ${detail}` });
return;
}
const { key, publicUrl } = await initRes.json();
setPhotoStep(0, { status: "done" });
}).then(r => r.json());
if (!initData.key) throw new Error(initData.error || "Upload failed");
const { key, publicUrl } = initData;
// 2. Upload via server proxy — avoids CORS on direct R2 PUT
setPhotoStep(1, { status: "active" });
const putParams = new URLSearchParams({ key, contentType });
const putRes = await fetch(`/api/upload?${putParams}`, {
const putRes = await fetch(`/api/upload?${new URLSearchParams({ key, contentType })}`, {
method: "PUT", body: file, headers: { "Content-Type": contentType },
});
if (!putRes.ok) {
const detail = await safeText(putRes);
setPhotoStep(1, { status: "error", detail: `HTTP ${putRes.status}: ${detail}` });
return;
}
setPhotoStep(1, { status: "done" });
if (!putRes.ok) throw new Error(`Upload failed (${putRes.status})`);
// 3. Save URL to DB
setPhotoStep(2, { status: "active" });
const patchRes = await fetch(`/api/children/${childId}`, {
await fetch(`/api/children/${childId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ imageUrl: publicUrl }),
});
if (!patchRes.ok) {
const detail = await safeText(patchRes);
setPhotoStep(2, { status: "error", detail: `HTTP ${patchRes.status}: ${detail}` });
return;
}
setPhotoStep(2, { status: "done" });
// 4. Update in-memory state with proxy URL (avoids R2 503 on cross-origin img)
const proxyUrl = `/api/img?key=${encodeURIComponent(key)}`;
setPhotoError(false);
updateChildImage(childId, proxyUrl);
updateChildImage(childId, `/api/img?key=${encodeURIComponent(key)}`);
} catch (err) {
setPhotoUploadSteps(prev => prev.map(s =>
s.status === "active" ? { ...s, status: "error", detail: String(err) } : s
));
console.error("Photo upload failed:", err);
} finally {
setUploadingPhoto(false);
if (photoInputRef.current) photoInputRef.current.value = "";
@ -351,26 +304,7 @@ export default function HomePage() {
return (
<div className="min-h-screen bg-gradient-to-br from-rose-50 to-amber-50 dark:from-gray-900 dark:to-gray-800 pb-24">
{/* Baby photo upload progress — fixed toast, stays until dismissed */}
{photoUploadFile && (uploadingPhoto || photoUploadSteps.some(s => s.status === "error" || s.status === "done")) && (
<div className="fixed top-4 inset-x-4 z-50">
<UploadProgress
filename={photoUploadFile.name}
fileSizeBytes={photoUploadFile.size}
steps={photoUploadSteps}
/>
{!uploadingPhoto && (
<button
onClick={() => setPhotoUploadFile(null)}
className="w-full mt-1 text-xs text-white/70 bg-gray-800/80 rounded-xl py-1.5"
>
Dismiss
</button>
)}
</div>
)}
<div className="p-4 flex justify-between items-center">
<div className="p-4 flex justify-between items-center">
<button className="p-2"><Link href="/menu"><svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" /></svg></Link></button>
<div className="flex items-center gap-1">
<Link href="/medical/emergency" className="p-2 text-red-500 hover:text-red-600" title="Emergency Guide">🆘</Link>

View file

@ -6,7 +6,6 @@ import { useFamily } from "@/app/FamilyProvider";
import { Button, ConfirmDialog, Modal } from "@/components/ui";
import { StorageMeter, StorageQuotaBanner } from "@/components/StorageMeter";
import { formatBytes } from "@/lib/format-bytes";
import { UploadProgress, type UploadStep } from "@/components/UploadProgress";
/** Some Android cameras return file.type = "" — detect from extension as fallback. */
function resolveContentType(file: File): string {
@ -60,8 +59,7 @@ export default function MemoriesPage() {
const [nextCursor, setNextCursor] = useState<string | null>(null);
const [selected, setSelected] = useState<Memory | null>(null);
const [uploading, setUploading] = useState(false);
const [uploadFile, setUploadFile] = useState<{ name: string; size: number } | null>(null);
const [uploadSteps, setUploadSteps] = useState<UploadStep[]>([]);
const [uploadError, setUploadError] = useState<string | null>(null);
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
const [loadingMore, setLoadingMore] = useState(false);
const [activeFolder, setActiveFolder] = useState("");
@ -156,70 +154,41 @@ export default function MemoriesPage() {
fileRef.current?.click();
};
const setStep = (i: number, patch: Partial<UploadStep>) =>
setUploadSteps(prev => prev.map((s, idx) => idx === i ? { ...s, ...patch } : s));
const safeResponseText = async (res: Response): Promise<string> => {
try {
const text = await res.text();
try { return JSON.parse(text).error || text; } catch { return text.slice(0, 200); }
} catch { return `HTTP ${res.status}`; }
};
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file || !childId) return;
const contentType = resolveContentType(file);
setUploadFile({ name: file.name, size: file.size });
setUploadSteps([
{ label: "Step 1 — Reserving upload slot", status: "pending" },
{ label: `Step 2 — Uploading to R2 (${(file.size/1024).toFixed(0)} KB, ${contentType})`, status: "pending" },
{ label: "Step 3 — Confirming file in storage", status: "pending" },
]);
setUploading(true);
setUploadError(null);
try {
// ── Step 1: reserve slot + quota gate ─────────────────────────────────
setStep(0, { status: "active" });
// Step 1: reserve slot + quota gate
const initRes = await fetch("/api/upload", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ filename: file.name, contentType, childId, sizeBytes: file.size }),
});
const initData = await initRes.json();
if (!initRes.ok) {
const detail = await safeResponseText(initRes);
if (initRes.status === 402) {
// Quota exceeded — show banner, don't mark as upload error
const initData = await initRes.json().catch(() => ({})) as { reason?: string; usedBytes?: number; limitBytes?: number };
if (initData.reason === "storage_quota_exceeded") {
if (initRes.status === 402 && initData.reason === "storage_quota_exceeded") {
setQuotaExceeded(true);
setQuotaBanner({ usedBytes: initData.usedBytes ?? 0, limitBytes: initData.limitBytes ?? 0 });
}
setStep(0, { status: "error", detail: `Storage quota exceeded (HTTP 402)` });
setUploadError("Storage quota exceeded");
} else {
setStep(0, { status: "error", detail: `HTTP ${initRes.status}: ${detail}` });
setUploadError(initData.error || `Upload failed (${initRes.status})`);
}
return;
}
const initData = await initRes.json();
const { key, memoryId, publicUrl } = initData;
setStep(0, { status: "done" });
const { key, memoryId } = initData;
// ── Step 2: binary upload via proxy ───────────────────────────────────
setStep(1, { status: "active" });
const putParams = new URLSearchParams({ key, contentType });
const putRes = await fetch(`/api/upload?${putParams}`, {
// Step 2: binary upload via proxy
const putRes = await fetch(`/api/upload?${new URLSearchParams({ key, contentType })}`, {
method: "PUT", body: file, headers: { "Content-Type": contentType },
});
if (!putRes.ok) {
const detail = await safeResponseText(putRes);
setStep(1, { status: "error", detail: `HTTP ${putRes.status}: ${detail}` });
return;
}
setStep(1, { status: "done" });
if (!putRes.ok) { setUploadError(`Upload failed (${putRes.status})`); return; }
// ── Optional: assign folder ───────────────────────────────────────────
// Assign folder if chosen
if (pendingFolder) {
await fetch(`/api/memories/${memoryId}`, {
method: "PATCH",
@ -228,39 +197,33 @@ export default function MemoriesPage() {
}).catch(() => {});
}
// ── Step 3: confirm (HeadObject in R2, quota reconcile) ───────────────
setStep(2, { status: "active" });
// Step 3: confirm
const confirmRes = await fetch(`/api/memories/${memoryId}/confirm`, { method: "POST" });
if (!confirmRes.ok) {
const detail = await safeResponseText(confirmRes);
const cd = await confirmRes.json().catch(() => ({})) as { reason?: string; usedBytes?: number; limitBytes?: number; error?: string };
if (confirmRes.status === 402) {
setQuotaExceeded(true);
fetch("/api/storage-usage").then(r => r.json())
.then(d => setQuotaBanner({ usedBytes: d.usedBytes, limitBytes: d.limitBytes }))
.catch(() => {});
setStep(2, { status: "error", detail: `Over quota after actual size check (HTTP 402)` });
setUploadError("Storage quota exceeded");
} else {
setStep(2, { status: "error", detail: `HTTP ${confirmRes.status}: ${detail}` });
setUploadError(cd.error || `Failed to confirm upload (${confirmRes.status})`);
}
return;
}
setStep(2, { status: "done" });
// ── Optimistic grid update ─────────────────────────────────────────────
// Optimistic grid update
const proxyUrl = `/api/img?key=${encodeURIComponent(key)}`;
const optimistic: Memory = {
setMemories(prev => [{
id: memoryId, key, url: proxyUrl, thumbnailUrl: null,
sizeBytes: file.size, mimeType: contentType, title: null,
description: pendingFolder || null, takenAt: null,
visionCaption: null, visionTags: null, isPrivate: false,
processingStatus: "processing", createdAt: new Date().toISOString(),
};
setMemories(prev => [optimistic, ...prev]);
}, ...prev]);
} catch (err) {
// Mark whichever step was active as failed
setUploadSteps(prev => prev.map(s =>
s.status === "active" ? { ...s, status: "error", detail: String(err) } : s
));
setUploadError(err instanceof Error ? err.message : "Upload failed");
} finally {
setUploading(false);
setPendingFolder("");
@ -404,23 +367,11 @@ export default function MemoriesPage() {
</div>
</div>
{/* Upload progress toast — stays visible after error so user can read it */}
{uploadFile && (uploading || uploadSteps.some(s => s.status === "error" || s.status === "done")) && (
<div className="fixed top-4 inset-x-4 z-50">
<UploadProgress
filename={uploadFile.name}
fileSizeBytes={uploadFile.size}
steps={uploadSteps}
/>
{/* Dismiss button — only shown when not actively uploading */}
{!uploading && (
<button
onClick={() => setUploadFile(null)}
className="w-full mt-1 text-xs text-white/70 bg-gray-800/80 rounded-xl py-1.5"
>
Dismiss
</button>
)}
{/* Upload error banner */}
{uploadError && !uploading && (
<div className="fixed top-4 inset-x-4 z-50 bg-red-500 text-white rounded-xl px-4 py-3 flex items-center justify-between shadow-lg">
<span className="text-sm">{uploadError}</span>
<button onClick={() => setUploadError(null)} className="ml-3 text-white/80 hover:text-white text-lg leading-none"></button>
</div>
)}

View file

@ -2,7 +2,6 @@
import { useState, useEffect, useRef } from "react";
import { useRouter } from "next/navigation";
import { UploadProgress, type UploadStep } from "@/components/UploadProgress";
/** Some Android cameras return file.type = "" — detect from extension as fallback. */
function resolveContentType(file: File): string {
@ -16,20 +15,6 @@ function resolveContentType(file: File): string {
return map[ext] || "image/jpeg";
}
/** Read response body as text safely (handles non-JSON from proxies like Traefik). */
async function safeResponseText(res: Response): Promise<string> {
try {
const text = await res.text();
try { return JSON.parse(text).error || text; } catch { return text.slice(0, 200); }
} catch { return `HTTP ${res.status}`; }
}
const INITIAL_STEPS: UploadStep[] = [
{ label: "Step 1 — Reserving upload slot", status: "pending" },
{ label: "Step 2 — Uploading photo to R2", status: "pending" },
{ label: "Step 3 — Saving to your profile", status: "pending" },
];
export default function ProfilePage() {
const router = useRouter();
const fileRef = useRef<HTMLInputElement>(null);
@ -41,15 +26,8 @@ export default function ProfilePage() {
const [saving, setSaving] = useState(false);
const [saveMsg, setSaveMsg] = useState("");
const [avatarError, setAvatarError] = useState(false);
// Upload progress state
const [uploadFile, setUploadFile] = useState<{ name: string; size: number } | null>(null);
const [steps, setSteps] = useState<UploadStep[]>(INITIAL_STEPS);
const [uploading, setUploading] = useState(false);
const setStep = (i: number, patch: Partial<UploadStep>) =>
setSteps(prev => prev.map((s, idx) => idx === i ? { ...s, ...patch } : s));
useEffect(() => {
fetch("/api/auth/profile")
.then(r => r.json())
@ -69,63 +47,40 @@ export default function ProfilePage() {
if (!file) return;
const contentType = resolveContentType(file);
setUploadFile({ name: file.name, size: file.size });
setSteps(INITIAL_STEPS);
setUploading(true);
setSaveMsg("");
try {
// ── Step 1: reserve upload slot ────────────────────────────────────────
setStep(0, { status: "active", label: `Step 1 — Reserving slot (type: ${contentType})` });
// Step 1: reserve upload slot
const initRes = await fetch("/api/auth/avatar", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ contentType, filename: file.name }),
});
if (!initRes.ok) {
const detail = await safeResponseText(initRes);
setStep(0, { status: "error", detail: `HTTP ${initRes.status}: ${detail}` });
return;
}
const initData = await initRes.json();
const { key, publicUrl: newPublicUrl } = initData;
setStep(0, { status: "done" });
if (!initRes.ok) throw new Error(initData.error || `Upload failed (${initRes.status})`);
const { key, publicUrl: r2Url } = initData;
// ── Step 2: upload binary to R2 via proxy ───────────────────────────────
setStep(1, { status: "active", label: `Step 2 — Uploading to R2 (${(file.size / 1024).toFixed(0)} KB)` });
const putParams = new URLSearchParams({ key, contentType });
const putRes = await fetch(`/api/upload?${putParams}`, {
// Step 2: upload binary via proxy
const putRes = await fetch(`/api/upload?${new URLSearchParams({ key, contentType })}`, {
method: "PUT",
body: file,
headers: { "Content-Type": contentType },
});
if (!putRes.ok) {
const detail = await safeResponseText(putRes);
setStep(1, { status: "error", detail: `HTTP ${putRes.status}: ${detail}` });
return;
}
setStep(1, { status: "done" });
if (!putRes.ok) throw new Error(`Upload failed (${putRes.status})`);
// ── Step 3: save URL to DB ──────────────────────────────────────────────
setStep(2, { status: "active" });
// Step 3: save R2 URL to DB
const patchRes = await fetch("/api/auth/avatar", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ avatarUrl: newPublicUrl }),
body: JSON.stringify({ avatarUrl: r2Url }),
});
if (!patchRes.ok) {
const detail = await safeResponseText(patchRes);
setStep(2, { status: "error", detail: `HTTP ${patchRes.status}: ${detail}` });
return;
}
setStep(2, { status: "done" });
if (!patchRes.ok) throw new Error("Failed to save photo");
setAvatarError(false);
setAvatarUrl(`/api/img?key=${encodeURIComponent(key)}`);
} catch (err) {
// Mark the currently-active step as failed
setSteps(prev => prev.map(s =>
s.status === "active" ? { ...s, status: "error", detail: String(err) } : s
));
setSaveMsg(err instanceof Error ? err.message : "Upload failed");
} finally {
setUploading(false);
if (fileRef.current) fileRef.current.value = "";
@ -140,7 +95,6 @@ export default function ProfilePage() {
if (!res.ok) throw new Error(data.error || "Failed to remove photo");
setAvatarUrl(null);
setAvatarError(false);
setUploadFile(null);
} catch (err) {
setSaveMsg(err instanceof Error ? err.message : "Failed to remove photo");
}
@ -165,8 +119,6 @@ export default function ProfilePage() {
};
const initials = name.split(" ").map(w => w[0]).join("").toUpperCase().slice(0, 2);
const uploadDone = steps.every(s => s.status === "done");
const uploadError = steps.some(s => s.status === "error");
return (
<div className="min-h-screen bg-gradient-to-br from-rose-50 to-amber-50 dark:from-gray-900 dark:to-gray-800">
@ -200,7 +152,7 @@ export default function ProfilePage() {
</div>
<button
onClick={() => { setUploadFile(null); fileRef.current?.click(); }}
onClick={() => fileRef.current?.click()}
disabled={uploading}
className="text-sm font-medium text-rose-500 dark:text-rose-400 disabled:opacity-50"
>
@ -224,30 +176,6 @@ export default function ProfilePage() {
/>
</div>
{/* Upload progress — shown right below the avatar, always visible */}
{uploadFile && (
<div className="pb-2">
<UploadProgress
filename={uploadFile.name}
fileSizeBytes={uploadFile.size}
steps={steps}
/>
{uploadDone && (
<p className="text-center text-sm font-medium text-green-600 dark:text-green-400 mt-2">
Photo updated successfully!
</p>
)}
{uploadError && (
<button
onClick={() => setUploadFile(null)}
className="w-full mt-2 text-xs text-gray-400 text-center"
>
Dismiss
</button>
)}
</div>
)}
{/* Form */}
{loading ? (
<div className="text-center py-8 text-gray-400 text-sm">Loading</div>

View file

@ -1,97 +0,0 @@
"use client";
/**
* Shared upload step tracker component.
* Shows each step of the upload pipeline with real-time status.
* Designed to stay visible on mobile without scrolling.
*/
export type StepStatus = "pending" | "active" | "done" | "error";
export interface UploadStep {
label: string;
status: StepStatus;
detail?: string; // shown on error — include HTTP status code + raw response
}
interface Props {
filename: string;
fileSizeBytes: number;
steps: UploadStep[];
/** If true, render as a fixed full-width toast at the top of the screen */
fixed?: boolean;
}
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
const ICONS: Record<StepStatus, string> = {
pending: "○",
active: "◌",
done: "✓",
error: "✗",
};
const COLORS: Record<StepStatus, string> = {
pending: "text-gray-400",
active: "text-blue-500",
done: "text-green-500",
error: "text-red-500",
};
export function UploadProgress({ filename, fileSizeBytes, steps, fixed }: Props) {
const hasError = steps.some(s => s.status === "error");
const allDone = steps.every(s => s.status === "done");
const wrapCls = fixed
? "fixed top-4 inset-x-4 z-50 pointer-events-none"
: "w-full";
return (
<div className={wrapCls}>
<div className={`rounded-2xl p-4 shadow-xl border text-sm ${
hasError
? "bg-red-50 dark:bg-red-900/30 border-red-200 dark:border-red-700"
: allDone
? "bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-700"
: "bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-600"
}`}>
{/* File info header */}
<div className="flex items-center gap-2 mb-3 pb-2 border-b border-gray-100 dark:border-gray-700">
<span className="text-lg">📷</span>
<div className="flex-1 min-w-0">
<p className="font-medium text-gray-800 dark:text-white text-xs truncate">{filename}</p>
<p className="text-xs text-gray-400">{formatBytes(fileSizeBytes)}</p>
</div>
{allDone && <span className="text-green-500 text-lg"></span>}
{hasError && <span className="text-red-500 text-lg"></span>}
{!allDone && !hasError && (
<div className="w-4 h-4 border-2 border-blue-500 border-t-transparent rounded-full animate-spin flex-shrink-0" />
)}
</div>
{/* Steps */}
<div className="space-y-2">
{steps.map((step, i) => (
<div key={i} className="flex items-start gap-2">
<span className={`text-base font-bold flex-shrink-0 mt-0.5 ${COLORS[step.status]} ${step.status === "active" ? "animate-pulse" : ""}`}>
{ICONS[step.status]}
</span>
<div className="flex-1 min-w-0">
<p className={`text-xs font-medium ${COLORS[step.status]}`}>{step.label}</p>
{step.detail && (
<p className="text-xs text-red-500 dark:text-red-400 mt-0.5 break-words leading-tight">
{step.detail}
</p>
)}
</div>
</div>
))}
</div>
</div>
</div>
);
}