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:
parent
27709dc851
commit
d8c9500949
5 changed files with 51 additions and 335 deletions
File diff suppressed because one or more lines are too long
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue