tia/src/app/(app)/memories/page.tsx
Mannu 51e36633b9 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>
2026-05-28 11:17:01 +05:30

719 lines
31 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useState, useEffect, useRef, useCallback } from "react";
import Link from "next/link";
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 {
if (file.type && file.type !== "application/octet-stream") return file.type;
const ext = file.name.split(".").pop()?.toLowerCase() ?? "";
const map: Record<string, string> = {
jpg: "image/jpeg", jpeg: "image/jpeg",
png: "image/png", webp: "image/webp",
heic: "image/heic", heif: "image/heic",
gif: "image/gif",
};
return map[ext] || "image/jpeg";
}
const PRESET_FOLDERS = [
{ id: "", label: "All", emoji: "🌟" },
{ id: "first-steps", label: "First Steps", emoji: "👣" },
{ id: "bath-time", label: "Bath Time", emoji: "🛁" },
{ id: "feeding", label: "Feeding", emoji: "🍼" },
{ id: "family", label: "Family", emoji: "👨‍👩‍👧" },
{ id: "milestones", label: "Milestones", emoji: "🏆" },
{ id: "birthday", label: "Birthday", emoji: "🎂" },
{ id: "outings", label: "Outings", emoji: "🌳" },
{ id: "smiles", label: "Smiles", emoji: "😊" },
];
const EMOJI_OPTIONS = ["📁","🌈","⭐","🎵","🏖","🎈","🐣","💛","🌸","🦋","🐾","🎀","🌙","🏠","🎠"];
interface Folder { id: string; label: string; emoji: string; }
interface Memory {
id: string;
key: string;
url: string;
thumbnailUrl: string | null;
sizeBytes: number | null;
mimeType: string | null;
title: string | null;
description: string | null;
takenAt: string | null;
visionCaption: string | null;
visionTags: string[] | null;
isPrivate: boolean;
processingStatus: "uploading" | "processing" | "ready" | "failed";
createdAt: string;
}
export default function MemoriesPage() {
const { childId } = useFamily();
const [memories, setMemories] = useState<Memory[]>([]);
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("");
const [showFolderPicker, setShowFolderPicker] = useState(false);
const [pendingFolder, setPendingFolder] = useState("");
const [query, setQuery] = useState("");
const [searchResults, setSearchResults] = useState<Memory[] | null>(null);
const [searching, setSearching] = useState(false);
// Custom folders
const [customFolders, setCustomFolders] = useState<Folder[]>([]);
const [showNewFolder, setShowNewFolder] = useState(false);
const [newFolderName, setNewFolderName] = useState("");
const [newFolderEmoji, setNewFolderEmoji] = useState("📁");
// Quota state — fetched on mount, refreshed after any 402 response
const [quotaExceeded, setQuotaExceeded] = useState(false);
const [quotaBanner, setQuotaBanner] = useState<{ usedBytes: number; limitBytes: number } | null>(null);
const fileRef = useRef<HTMLInputElement>(null);
const loaderRef = useRef<HTMLDivElement>(null);
useEffect(() => {
try {
const saved = localStorage.getItem("tia_custom_folders");
if (saved) setCustomFolders(JSON.parse(saved));
} catch {}
}, []);
// Fetch storage quota on mount so we can disable the FAB before the user
// tries to upload and hits a 402 (better UX than a failed upload).
useEffect(() => {
fetch("/api/storage-usage")
.then(r => r.json())
.then(d => {
if (d.exceeded) {
setQuotaExceeded(true);
setQuotaBanner({ usedBytes: d.usedBytes, limitBytes: d.limitBytes });
}
})
.catch(() => {});
}, []);
const allFolders: Folder[] = [...PRESET_FOLDERS, ...customFolders];
const handleCreateFolder = () => {
const trimmed = newFolderName.trim();
if (!trimmed) return;
const id = trimmed.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, "") || `folder-${Date.now()}`;
if (allFolders.some(f => f.id === id)) { alert("A folder with this name already exists."); return; }
const folder: Folder = { id, label: trimmed, emoji: newFolderEmoji };
const updated = [...customFolders, folder];
setCustomFolders(updated);
localStorage.setItem("tia_custom_folders", JSON.stringify(updated));
setNewFolderName("");
setNewFolderEmoji("📁");
setShowNewFolder(false);
};
const fetchMemories = useCallback(async (cursor?: string) => {
if (!childId) return;
try {
const params = new URLSearchParams({ childId, limit: "60" });
if (cursor) params.set("cursor", cursor);
const res = await fetch(`/api/memories?${params}`);
const data = await res.json();
if (cursor) setMemories(prev => [...prev, ...(data.items || [])]);
else setMemories(data.items || []);
setNextCursor(data.nextCursor || null);
} catch (err) { console.error("Failed to fetch memories:", err); }
}, [childId]);
useEffect(() => { if (childId) fetchMemories(); }, [childId, fetchMemories]);
useEffect(() => {
if (!loaderRef.current) return;
const observer = new IntersectionObserver(entries => {
if (entries[0].isIntersecting && nextCursor && !loadingMore) {
setLoadingMore(true);
fetchMemories(nextCursor).finally(() => setLoadingMore(false));
}
});
observer.observe(loaderRef.current);
return () => observer.disconnect();
}, [nextCursor, loadingMore, fetchMemories]);
const handleUploadClick = () => setShowFolderPicker(true);
const handleFolderChosen = (folderId: string) => {
setPendingFolder(folderId);
setShowFolderPicker(false);
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);
try {
// ── 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 }),
});
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") {
setQuotaExceeded(true);
setQuotaBanner({ usedBytes: initData.usedBytes ?? 0, limitBytes: initData.limitBytes ?? 0 });
}
setStep(0, { status: "error", detail: `Storage quota exceeded (HTTP 402)` });
} else {
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: 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 detail = await safeResponseText(putRes);
setStep(1, { status: "error", detail: `HTTP ${putRes.status}: ${detail}` });
return;
}
setStep(1, { status: "done" });
// ── Optional: assign folder ───────────────────────────────────────────
if (pendingFolder) {
await fetch(`/api/memories/${memoryId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ description: pendingFolder }),
}).catch(() => {});
}
// ── 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) {
const detail = await safeResponseText(confirmRes);
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)` });
} else {
setStep(2, { status: "error", detail: `HTTP ${confirmRes.status}: ${detail}` });
}
return;
}
setStep(2, { status: "done" });
// ── Optimistic grid update ─────────────────────────────────────────────
const optimistic: Memory = {
id: memoryId, key, url: publicUrl, 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]);
} catch (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("");
if (fileRef.current) fileRef.current.value = "";
}
};
const handleDelete = async (id: string) => {
await fetch(`/api/memories/${id}`, { method: "DELETE" });
setMemories(prev => prev.filter(m => m.id !== id));
if (selected?.id === id) setSelected(null);
setDeleteTarget(null);
};
const handleSearch = async (e: React.FormEvent) => {
e.preventDefault();
if (!query.trim()) { setSearchResults(null); return; }
setSearching(true);
try {
const res = await fetch("/api/memories/search", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ query: query.trim(), childId }),
});
const data = await res.json();
setSearchResults(data.items || []);
} catch { setSearchResults([]); }
setSearching(false);
};
const baseList = searchResults !== null ? searchResults : memories;
const displayMemories = (activeFolder
? baseList.filter(m => m.description === activeFolder)
: baseList
).filter(m => m.url || m.thumbnailUrl);
const folderCounts = memories.reduce<Record<string, number>>((acc, m) => {
if (m.description) acc[m.description] = (acc[m.description] || 0) + 1;
return acc;
}, {});
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
{/* Header */}
<div className="sticky top-0 z-10 bg-white dark:bg-gray-900 border-b border-gray-100 dark:border-gray-800 px-4 py-3 space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Link href="/menu" className="text-gray-500 dark:text-gray-400 p-1"></Link>
<h1 className="text-xs font-semibold dark:text-white">Memories 📸</h1>
</div>
</div>
{/* Storage meter — only visible when approaching or exceeded */}
<StorageMeter compact className="px-1" />
</div>
{/* Storage quota banner — shown when upload is blocked */}
{quotaBanner && (
<div className="px-4 pt-3">
<StorageQuotaBanner
usedBytes={quotaBanner.usedBytes}
limitBytes={quotaBanner.limitBytes}
usedFormatted={formatBytes(quotaBanner.usedBytes)}
limitFormatted={formatBytes(quotaBanner.limitBytes)}
/>
</div>
)}
{/* Search */}
<form onSubmit={handleSearch} className="px-4 pt-3 pb-1 flex gap-2">
<input
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="Search photos..."
className="flex-1 px-3 py-2 rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-sm dark:text-white dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-rose-300"
/>
<Button type="submit" size="sm" loading={searching}>Search</Button>
{searchResults !== null && (
<Button type="button" variant="ghost" size="sm" onClick={() => { setSearchResults(null); setQuery(""); }}>Clear</Button>
)}
</form>
{/* Folder pills */}
<div className="px-4 py-2 flex gap-2 overflow-x-auto scrollbar-hide">
{allFolders.map(folder => {
const count = folder.id === "" ? memories.length : (folderCounts[folder.id] || 0);
const isActive = activeFolder === folder.id;
return (
<button
key={folder.id}
onClick={() => setActiveFolder(folder.id)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full text-sm whitespace-nowrap flex-shrink-0 transition-colors ${
isActive
? "bg-rose-400 text-white"
: "bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 border border-gray-200 dark:border-gray-700"
}`}
>
<span>{folder.emoji}</span>
<span>{folder.label}</span>
{count > 0 && (
<span className={`text-xs ${isActive ? "text-white/80" : "text-gray-400"}`}>{count}</span>
)}
</button>
);
})}
{/* New folder button */}
<button
onClick={() => setShowNewFolder(true)}
className="flex items-center gap-1 px-3 py-1.5 rounded-full text-sm whitespace-nowrap flex-shrink-0 bg-white dark:bg-gray-800 text-gray-400 dark:text-gray-500 border border-dashed border-gray-300 dark:border-gray-600 hover:border-rose-300 hover:text-rose-400 transition-colors"
>
<span>+</span>
<span>New</span>
</button>
</div>
{/* Gallery grid */}
<div className="px-3 pb-24">
{displayMemories.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 text-center gap-3">
<span className="text-6xl">📷</span>
<p className="font-medium text-gray-500 dark:text-gray-400">
{searchResults !== null ? "No photos found" : activeFolder ? "No photos in this folder yet" : "No memories yet"}
</p>
<p className="text-sm text-gray-400 dark:text-gray-500">
{searchResults !== null ? "Try a different search term." : "Tap + to add your first photo."}
</p>
</div>
) : (
<div className="grid grid-cols-4 gap-1 pt-1">
{displayMemories.map(mem => (
<MemoryTile
key={mem.id}
memory={mem}
folder={allFolders.find(f => f.id === mem.description) ?? null}
onClick={() => setSelected(mem)}
/>
))}
</div>
)}
<div ref={loaderRef} className="py-4 text-center text-sm text-gray-400">
{loadingMore && "Loading more..."}
</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>
)}
</div>
)}
{/* Upload FAB — disabled when storage quota is exceeded */}
<div className="fixed bottom-24 right-6 z-50">
<button
onClick={quotaExceeded ? undefined : handleUploadClick}
disabled={uploading || quotaExceeded}
title={quotaExceeded ? "Storage full — delete some memories or upgrade to upload more" : undefined}
className={`w-14 h-14 rounded-full shadow-xl flex items-center justify-center text-white text-2xl font-light transition-colors ${
uploading || quotaExceeded ? "bg-gray-400 cursor-not-allowed" : "bg-rose-400 hover:bg-rose-500 active:bg-rose-600"
}`}
>
{uploading ? "…" : quotaExceeded ? "⊘" : "+"}
</button>
<input ref={fileRef} type="file" accept="image/*" onChange={handleUpload} className="hidden" disabled={uploading || quotaExceeded} />
</div>
{/* Folder picker modal */}
<Modal open={showFolderPicker} onClose={() => setShowFolderPicker(false)} title="Add to folder" maxWidth="sm">
<div className="grid grid-cols-3 gap-2">
{allFolders.map(folder => (
<button
key={folder.id}
onClick={() => handleFolderChosen(folder.id)}
className="flex flex-col items-center gap-1 p-3 rounded-xl bg-gray-50 dark:bg-gray-700 hover:bg-rose-50 dark:hover:bg-rose-900/30 transition-colors"
>
<span className="text-2xl">{folder.emoji}</span>
<span className="text-xs text-center text-gray-600 dark:text-gray-300 font-medium leading-tight">{folder.label}</span>
</button>
))}
<button
onClick={() => { setShowFolderPicker(false); setShowNewFolder(true); }}
className="flex flex-col items-center gap-1 p-3 rounded-xl border-2 border-dashed border-gray-200 dark:border-gray-600 hover:border-rose-300 dark:hover:border-rose-700 text-gray-400 hover:text-rose-400 transition-colors"
>
<span className="text-2xl"></span>
<span className="text-xs font-medium">New folder</span>
</button>
</div>
<p className="text-xs text-gray-400 text-center mt-3">Tap "All" to upload without a folder</p>
</Modal>
{/* New folder modal */}
<Modal open={showNewFolder} onClose={() => { setShowNewFolder(false); setNewFolderName(""); setNewFolderEmoji("📁"); }} title="Create folder" maxWidth="sm">
<div className="space-y-3">
<div>
<p className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-2">Choose an emoji</p>
<div className="flex flex-wrap gap-2">
{EMOJI_OPTIONS.map(em => (
<button
key={em}
onClick={() => setNewFolderEmoji(em)}
className={`text-xl p-1.5 rounded-lg transition-colors ${
newFolderEmoji === em
? "bg-rose-100 dark:bg-rose-900 ring-2 ring-rose-400"
: "bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600"
}`}
>
{em}
</button>
))}
</div>
</div>
<div>
<p className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Folder name</p>
<input
autoFocus
value={newFolderName}
onChange={e => setNewFolderName(e.target.value)}
onKeyDown={e => e.key === "Enter" && handleCreateFolder()}
placeholder="e.g. Park days"
maxLength={30}
className="w-full px-3 py-2 rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-sm dark:text-white focus:outline-none focus:ring-2 focus:ring-rose-300"
/>
</div>
<div className="flex gap-2 pt-1">
<Button variant="secondary" fullWidth onClick={() => { setShowNewFolder(false); setNewFolderName(""); setNewFolderEmoji("📁"); }}>Cancel</Button>
<Button variant="primary" fullWidth onClick={handleCreateFolder} disabled={!newFolderName.trim()}>
{newFolderEmoji} Create
</Button>
</div>
</div>
</Modal>
{/* Fullscreen viewer */}
{selected && (
<MemoryViewer
memory={selected}
allFolders={allFolders}
onClose={() => setSelected(null)}
onDelete={() => setDeleteTarget(selected.id)}
onUpdate={updated => {
setMemories(prev => prev.map(m => m.id === updated.id ? { ...m, ...updated } : m));
setSelected(prev => prev?.id === updated.id ? { ...prev, ...updated } : prev);
}}
/>
)}
<ConfirmDialog
open={!!deleteTarget}
onClose={() => setDeleteTarget(null)}
onConfirm={() => deleteTarget && handleDelete(deleteTarget)}
title="Delete this photo?"
description="This removes the photo permanently from your memories."
confirmLabel="Delete"
variant="danger"
/>
</div>
);
}
// ─── Grid tile ────────────────────────────────────────────────────────────────
function MemoryTile({ memory, folder, onClick }: { memory: Memory; folder: Folder | null; onClick: () => void }) {
const [loaded, setLoaded] = useState(false);
const [imgError, setImgError] = useState(false);
const src = memory.thumbnailUrl || memory.url;
if (imgError) return null;
return (
<div className="flex flex-col gap-0.5">
<button
className="relative w-full aspect-square rounded-xl overflow-hidden group bg-gray-100 dark:bg-gray-800 shadow-sm ring-1 ring-black/5 dark:ring-white/5"
onClick={onClick}
>
<img
src={src}
alt={memory.title || "Memory"}
className={`w-full h-full object-cover transition-all duration-300 group-hover:scale-110 ${loaded ? "opacity-100" : "opacity-0"}`}
onLoad={() => setLoaded(true)}
onError={() => setImgError(true)}
loading="lazy"
/>
{!loaded && <div className="absolute inset-0 bg-gray-200 dark:bg-gray-700 animate-pulse rounded-xl" />}
{/* hover overlay */}
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/25 transition-colors duration-200" />
{/* hover expand icon */}
<div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-200">
<span className="text-white text-lg drop-shadow"></span>
</div>
{memory.processingStatus === "processing" && (
<div className="absolute inset-0 bg-black/40 flex items-center justify-center rounded-xl">
<span className="text-white text-[9px] bg-black/50 px-1.5 py-0.5 rounded-full">Processing</span>
</div>
)}
{memory.isPrivate && <span className="absolute top-1 right-1 text-[9px]">🔒</span>}
</button>
{/* Folder label */}
{folder && folder.id && (
<p className="text-[9px] text-center text-gray-400 dark:text-gray-500 truncate leading-tight px-0.5">
{folder.emoji} {folder.label}
</p>
)}
</div>
);
}
// ─── Fullscreen viewer ────────────────────────────────────────────────────────
function MemoryViewer({ memory, allFolders, onClose, onDelete, onUpdate }: {
memory: Memory;
allFolders: Folder[];
onClose: () => void;
onDelete: () => void;
onUpdate: (updated: Partial<Memory> & { id: string }) => void;
}) {
const [isPrivate, setIsPrivate] = useState(memory.isPrivate);
const [toggling, setToggling] = useState(false);
const [showChrome, setShowChrome] = useState(true);
const [zoomed, setZoomed] = useState(false);
const [folderEditing, setFolderEditing] = useState(false);
const togglePrivate = async () => {
setToggling(true);
const newVal = !isPrivate;
try {
await fetch(`/api/memories/${memory.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ isPrivate: newVal }),
});
setIsPrivate(newVal);
onUpdate({ id: memory.id, isPrivate: newVal, ...(newVal ? { visionCaption: null, visionTags: null } : {}) });
} finally { setToggling(false); }
};
const assignFolder = async (folderId: string) => {
try {
await fetch(`/api/memories/${memory.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ description: folderId || null }),
});
onUpdate({ id: memory.id, description: folderId || null });
} catch (err) { console.error("Failed to assign folder:", err); }
setFolderEditing(false);
};
const currentFolder = allFolders.find(f => f.id === memory.description);
const handleImageTap = () => {
if (zoomed) {
setZoomed(false);
} else {
setShowChrome(s => !s);
}
};
return (
<div className="fixed inset-0 bg-black z-50 flex flex-col" onClick={e => e.target === e.currentTarget && onClose()}>
{/* Top bar */}
<div className={`flex items-center justify-between px-4 py-3 flex-shrink-0 bg-gradient-to-b from-black/70 to-transparent transition-opacity duration-200 ${showChrome ? "opacity-100" : "opacity-0 pointer-events-none"}`}>
<button onClick={onClose} className="text-white/90 hover:text-white p-1 text-lg"></button>
<div className="flex items-center gap-3">
<button
onClick={() => setFolderEditing(true)}
className="text-sm text-white/70 hover:text-white flex items-center gap-1"
>
{currentFolder && currentFolder.id ? `${currentFolder.emoji} ${currentFolder.label}` : "📁 Folder"}
</button>
<button
onClick={togglePrivate}
disabled={toggling}
className={`text-sm px-3 py-1 rounded-full transition-colors ${
isPrivate ? "bg-gray-700 text-gray-200" : "bg-gray-800 text-gray-400 hover:text-white"
}`}
>
{toggling ? "…" : isPrivate ? "🔒" : "🌐"}
</button>
<button onClick={onDelete} className="text-red-400 hover:text-red-300 p-1">🗑</button>
</div>
</div>
{/* Image area */}
<div
className="flex-1 flex items-center justify-center min-h-0 overflow-hidden"
onClick={handleImageTap}
>
<img
src={memory.url}
alt={memory.title || "Memory"}
style={{ transform: zoomed ? "scale(2)" : "scale(1)", transition: "transform 0.25s ease-out" }}
className={`max-w-full max-h-full object-contain ${zoomed ? "cursor-zoom-out" : "cursor-zoom-in"}`}
/>
</div>
{/* Zoom hint */}
{showChrome && !zoomed && (
<div className="absolute bottom-20 left-1/2 -translate-x-1/2 text-white/40 text-xs pointer-events-none">
tap to hide UI · tap again to zoom
</div>
)}
{/* Caption/tags */}
{showChrome && !zoomed && (memory.visionCaption || (memory.visionTags && memory.visionTags.length > 0)) && (
<div className="px-4 py-3 bg-gradient-to-t from-black/80 to-transparent flex-shrink-0">
{memory.visionCaption && (
<p className="text-white/90 text-sm leading-relaxed mb-2">{memory.visionCaption}</p>
)}
{memory.visionTags && memory.visionTags.length > 0 && (
<div className="flex flex-wrap gap-1">
{memory.visionTags.map((tag, i) => (
<span key={i} className="text-xs bg-white/10 text-white/70 px-2 py-0.5 rounded-full">#{tag}</span>
))}
</div>
)}
</div>
)}
{/* Folder assignment bottom sheet */}
{folderEditing && (
<div className="absolute inset-0 bg-black/70 flex items-end z-10" onClick={() => setFolderEditing(false)}>
<div className="w-full bg-white dark:bg-gray-900 rounded-t-2xl p-4 max-h-[70vh] overflow-y-auto" onClick={e => e.stopPropagation()}>
<h3 className="font-semibold text-center mb-3 dark:text-white">Move to folder</h3>
<div className="grid grid-cols-4 gap-2">
{allFolders.map(folder => (
<button
key={folder.id}
onClick={() => assignFolder(folder.id)}
className={`flex flex-col items-center gap-1 p-2 rounded-xl transition-colors ${
memory.description === folder.id
? "bg-rose-100 dark:bg-rose-900 ring-2 ring-rose-400"
: "bg-gray-100 dark:bg-gray-800"
}`}
>
<span className="text-xl">{folder.emoji}</span>
<span className="text-xs text-center text-gray-600 dark:text-gray-300 leading-tight">{folder.label}</span>
</button>
))}
</div>
</div>
</div>
)}
</div>
);
}