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>
719 lines
31 KiB
TypeScript
719 lines
31 KiB
TypeScript
"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>
|
||
);
|
||
}
|