Add step-by-step upload progress UI across all three upload points

Each upload now shows a persistent card with 3 labelled steps and their
live status (pending → active → done / error). Errors include the exact
HTTP status code + raw response body (handles non-JSON from Traefik,
nginx, etc. that return HTML error pages). The card stays visible after
failure so the user can read the diagnostic before dismissing.

Changes per surface:
- src/components/UploadProgress.tsx — new shared step-tracker component
- profile/page.tsx — step card rendered below avatar; safeResponseText()
  reads raw body so a Traefik 413 shows "HTTP 413: <html>..." not just
  "Upload failed"
- memories/page.tsx — fixed toast expands to show all 3 steps; dismissible
  after done/error; same safeResponseText pattern
- home/page.tsx (baby photo) — same fixed toast as memories; 3 steps with
  HTTP codes and raw body on error

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Manohar Gupta 2026-05-28 11:17:01 +05:30
parent ccae6d85d2
commit 51e36633b9
5 changed files with 344 additions and 88 deletions

File diff suppressed because one or more lines are too long

View file

@ -10,6 +10,7 @@ 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 {
@ -103,6 +104,8 @@ 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();
@ -197,46 +200,84 @@ 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;
setUploadingPhoto(true);
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}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ contentType, filename: file.name }),
});
if (!initRes.ok) throw new Error("Failed to get upload URL");
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" });
// 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}`, {
method: "PUT",
body: file,
headers: { "Content-Type": contentType },
method: "PUT", body: file, headers: { "Content-Type": contentType },
});
if (!putRes.ok) throw new Error("Upload failed");
if (!putRes.ok) {
const detail = await safeText(putRes);
setPhotoStep(1, { status: "error", detail: `HTTP ${putRes.status}: ${detail}` });
return;
}
setPhotoStep(1, { status: "done" });
// 3. Save URL to DB
await fetch(`/api/children/${childId}`, {
setPhotoStep(2, { status: "active" });
const patchRes = 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 immediately — no full reload needed
// 4. Update in-memory state immediately
setPhotoError(false);
updateChildImage(childId, publicUrl);
} catch (err) {
console.error("Photo upload failed:", err);
alert("Photo upload failed. Please try again.");
}
setPhotoUploadSteps(prev => prev.map(s =>
s.status === "active" ? { ...s, status: "error", detail: String(err) } : s
));
} finally {
setUploadingPhoto(false);
if (photoInputRef.current) photoInputRef.current.value = "";
}
};
const handleRemovePhoto = async () => {
@ -308,6 +349,26 @@ 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">
<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">

View file

@ -6,6 +6,7 @@ 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 {
@ -59,6 +60,8 @@ 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 [deleteTarget, setDeleteTarget] = useState<string | null>(null);
const [loadingMore, setLoadingMore] = useState(false);
const [activeFolder, setActiveFolder] = useState("");
@ -153,75 +156,110 @@ 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;
setUploading(true);
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);
try {
// Step 1: Get presigned URL (quota gate runs here server-side)
// ── Step 1: reserve slot + quota gate ─────────────────────────────────
setStep(0, { status: "active" });
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) {
if (initRes.status === 402 && initData.reason === "storage_quota_exceeded") {
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") {
setQuotaExceeded(true);
setQuotaBanner({ usedBytes: initData.usedBytes, limitBytes: initData.limitBytes });
setQuotaBanner({ usedBytes: initData.usedBytes ?? 0, limitBytes: initData.limitBytes ?? 0 });
}
setStep(0, { status: "error", detail: `Storage quota exceeded (HTTP 402)` });
} else {
alert("Error: " + (initData.error ?? "Upload failed"));
setStep(0, { status: "error", detail: `HTTP ${initRes.status}: ${detail}` });
}
return;
}
const initData = await initRes.json();
const { key, memoryId, publicUrl } = initData;
setStep(0, { status: "done" });
// Step 2: Upload file to R2 via proxy
// ── Step 2: binary upload via proxy ───────────────────────────────────
setStep(1, { status: "active" });
const putParams = new URLSearchParams({ key, contentType });
const putRes = await fetch(`/api/upload?${putParams}`, {
method: "PUT", body: file, headers: { "Content-Type": contentType },
});
if (!putRes.ok) {
const putErr = await putRes.json().catch(() => ({})) as { error?: string };
throw new Error(putErr.error ?? `Upload failed (${putRes.status})`);
const detail = await safeResponseText(putRes);
setStep(1, { status: "error", detail: `HTTP ${putRes.status}: ${detail}` });
return;
}
setStep(1, { status: "done" });
// Step 3: Assign folder if chosen
// ── Optional: assign folder ───────────────────────────────────────────
if (pendingFolder) {
await fetch(`/api/memories/${memoryId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ description: pendingFolder }),
});
}).catch(() => {});
}
// Step 4: Confirm — server reconciles actual R2 size against quota
// ── Step 3: confirm (HeadObject in R2, quota reconcile) ───────────────
setStep(2, { status: "active" });
const confirmRes = await fetch(`/api/memories/${memoryId}/confirm`, { method: "POST" });
if (!confirmRes.ok && confirmRes.status === 402) {
// Actual file size exceeded quota; server deleted the R2 object and
// marked the row failed. Refresh quota state and skip optimistic update.
if (!confirmRes.ok) {
const detail = await safeResponseText(confirmRes);
if (confirmRes.status === 402) {
setQuotaExceeded(true);
fetch("/api/storage-usage")
.then(r => r.json())
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)` });
} else {
setStep(2, { status: "error", detail: `HTTP ${confirmRes.status}: ${detail}` });
}
return;
}
setStep(2, { status: "done" });
// Step 5: Optimistic update in UI
// ── Optimistic grid update ─────────────────────────────────────────────
const optimistic: Memory = {
id: memoryId, key, url: publicUrl, thumbnailUrl: null,
sizeBytes: file.size, mimeType: file.type, title: 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]);
} catch (err) {
alert("Upload failed: " + err);
// Mark whichever step was active as failed
setUploadSteps(prev => prev.map(s =>
s.status === "active" ? { ...s, status: "error", detail: String(err) } : s
));
} finally {
setUploading(false);
setPendingFolder("");
@ -365,11 +403,23 @@ export default function MemoriesPage() {
</div>
</div>
{/* Upload progress toast */}
{uploading && (
<div className="fixed top-4 inset-x-4 z-50 bg-gray-900/90 text-white text-sm rounded-2xl px-4 py-3 flex items-center gap-3 shadow-xl pointer-events-none">
<div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin flex-shrink-0" />
<span>Uploading photo</span>
{/* 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>
)}
</div>
)}

View file

@ -2,6 +2,7 @@
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 {
@ -15,6 +16,20 @@ 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);
@ -24,12 +39,17 @@ export default function ProfilePage() {
const [avatarUrl, setAvatarUrl] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [uploading, setUploading] = useState(false);
const [saveMsg, setSaveMsg] = useState("");
// Separate upload feedback shown right below the avatar — always visible on mobile
const [uploadMsg, setUploadMsg] = useState<{ text: string; ok: boolean } | null>(null);
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())
@ -48,53 +68,71 @@ export default function ProfilePage() {
const file = e.target.files?.[0];
if (!file) return;
setUploading(true);
setUploadMsg(null);
setSaveMsg("");
const contentType = resolveContentType(file);
setUploadFile({ name: file.name, size: file.size });
setSteps(INITIAL_STEPS);
setUploading(true);
try {
// Step 1: get R2 key + presigned upload URL
// ── Step 1: reserve upload slot ────────────────────────────────────────
setStep(0, { status: "active", label: `Step 1 — Reserving slot (type: ${contentType})` });
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();
if (!initRes.ok) throw new Error(initData.error || "Could not start upload");
const { key, publicUrl: newPublicUrl } = initData;
setStep(0, { status: "done" });
// Step 2: proxy PUT through our server (avoids CORS on direct R2 PUT)
// ── 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}`, {
method: "PUT",
body: file,
headers: { "Content-Type": contentType },
});
if (!putRes.ok) throw new Error("Upload to storage failed — please try again");
if (!putRes.ok) {
const detail = await safeResponseText(putRes);
setStep(1, { status: "error", detail: `HTTP ${putRes.status}: ${detail}` });
return;
}
setStep(1, { status: "done" });
// Step 3: save the new avatarUrl in DB (server cleans up old R2 object)
// ── Step 3: save URL to DB ──────────────────────────────────────────────
setStep(2, { status: "active" });
const patchRes = await fetch("/api/auth/avatar", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ avatarUrl: newPublicUrl }),
});
const patchData = await patchRes.json();
if (!patchRes.ok) throw new Error(patchData.error || "Failed to save photo");
if (!patchRes.ok) {
const detail = await safeResponseText(patchRes);
setStep(2, { status: "error", detail: `HTTP ${patchRes.status}: ${detail}` });
return;
}
setStep(2, { status: "done" });
setAvatarUrl(newPublicUrl);
setAvatarError(false);
setUploadMsg({ text: "✓ Photo updated!", ok: true });
} catch (err) {
setUploadMsg({ text: err instanceof Error ? err.message : "Upload failed", ok: false });
}
// Mark the currently-active step as failed
setSteps(prev => prev.map(s =>
s.status === "active" ? { ...s, status: "error", detail: String(err) } : s
));
} finally {
setUploading(false);
if (fileRef.current) fileRef.current.value = "";
}
};
const handleRemovePhoto = async () => {
setUploadMsg(null);
setSaveMsg("");
try {
const res = await fetch("/api/auth/avatar", { method: "DELETE" });
@ -102,9 +140,9 @@ export default function ProfilePage() {
if (!res.ok) throw new Error(data.error || "Failed to remove photo");
setAvatarUrl(null);
setAvatarError(false);
setUploadMsg({ text: "Photo removed.", ok: true });
setUploadFile(null);
} catch (err) {
setUploadMsg({ text: err instanceof Error ? err.message : "Failed to remove photo", ok: false });
setSaveMsg(err instanceof Error ? err.message : "Failed to remove photo");
}
};
@ -126,12 +164,9 @@ export default function ProfilePage() {
setSaving(false);
};
const initials = name
.split(" ")
.map(w => w[0])
.join("")
.toUpperCase()
.slice(0, 2);
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">
@ -143,7 +178,7 @@ export default function ProfilePage() {
<div className="px-4 pb-24 space-y-4">
{/* Avatar */}
<div className="flex flex-col items-center pt-8 pb-4">
<div className="flex flex-col items-center pt-8 pb-2">
<div className="relative mb-3">
{avatarUrl && !avatarError ? (
<img
@ -157,7 +192,6 @@ export default function ProfilePage() {
{initials || "👤"}
</div>
)}
{/* Upload spinner overlay */}
{uploading && (
<div className="absolute inset-0 rounded-full bg-black/40 flex items-center justify-center">
<div className="w-6 h-6 border-2 border-white border-t-transparent rounded-full animate-spin" />
@ -166,29 +200,19 @@ export default function ProfilePage() {
</div>
<button
onClick={() => fileRef.current?.click()}
onClick={() => { setUploadFile(null); fileRef.current?.click(); }}
disabled={uploading}
className="text-sm font-medium text-rose-500 dark:text-rose-400 disabled:opacity-50"
>
{uploading ? "Uploading…" : "Change Photo"}
</button>
{avatarUrl && !uploading && (
<button
onClick={handleRemovePhoto}
className="text-xs text-gray-400 dark:text-gray-500 mt-0.5 hover:text-red-400 transition-colors"
>
<button onClick={handleRemovePhoto} className="text-xs text-gray-400 mt-0.5 hover:text-red-400 transition-colors">
Remove photo
</button>
)}
{!avatarUrl && !uploading && (
<p className="text-xs text-gray-400 mt-0.5">JPEG, PNG or WebP</p>
)}
{/* Upload result — shown RIGHT HERE below the avatar, always visible on mobile */}
{uploadMsg && (
<p className={`mt-2 text-sm font-medium text-center px-4 ${uploadMsg.ok ? "text-green-600 dark:text-green-400" : "text-red-500"}`}>
{uploadMsg.text}
</p>
<p className="text-xs text-gray-400 mt-0.5">JPEG, PNG, WebP or HEIC</p>
)}
<input
@ -200,6 +224,30 @@ 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

@ -0,0 +1,97 @@
"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>
);
}