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:
parent
ccae6d85d2
commit
51e36633b9
5 changed files with 344 additions and 88 deletions
File diff suppressed because one or more lines are too long
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
97
src/components/UploadProgress.tsx
Normal file
97
src/components/UploadProgress.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue