"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 = { 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([]); const [nextCursor, setNextCursor] = useState(null); const [selected, setSelected] = useState(null); const [uploading, setUploading] = useState(false); const [uploadFile, setUploadFile] = useState<{ name: string; size: number } | null>(null); const [uploadSteps, setUploadSteps] = useState([]); const [deleteTarget, setDeleteTarget] = useState(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(null); const [searching, setSearching] = useState(false); // Custom folders const [customFolders, setCustomFolders] = useState([]); 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(null); const loaderRef = useRef(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) => setUploadSteps(prev => prev.map((s, idx) => idx === i ? { ...s, ...patch } : s)); const safeResponseText = async (res: Response): Promise => { 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) => { 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>((acc, m) => { if (m.description) acc[m.description] = (acc[m.description] || 0) + 1; return acc; }, {}); return (
{/* Header */}
←

Memories πŸ“Έ

{/* Storage meter β€” only visible when approaching or exceeded */}
{/* Storage quota banner β€” shown when upload is blocked */} {quotaBanner && (
)} {/* Search */}
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" /> {searchResults !== null && ( )}
{/* Folder pills */}
{allFolders.map(folder => { const count = folder.id === "" ? memories.length : (folderCounts[folder.id] || 0); const isActive = activeFolder === folder.id; return ( ); })} {/* New folder button */}
{/* Gallery grid */}
{displayMemories.length === 0 ? (
πŸ“·

{searchResults !== null ? "No photos found" : activeFolder ? "No photos in this folder yet" : "No memories yet"}

{searchResults !== null ? "Try a different search term." : "Tap + to add your first photo."}

) : (
{displayMemories.map(mem => ( f.id === mem.description) ?? null} onClick={() => setSelected(mem)} /> ))}
)}
{loadingMore && "Loading more..."}
{/* Upload progress toast β€” stays visible after error so user can read it */} {uploadFile && (uploading || uploadSteps.some(s => s.status === "error" || s.status === "done")) && (
{/* Dismiss button β€” only shown when not actively uploading */} {!uploading && ( )}
)} {/* Upload FAB β€” disabled when storage quota is exceeded */}
{/* Folder picker modal */} setShowFolderPicker(false)} title="Add to folder" maxWidth="sm">
{allFolders.map(folder => ( ))}

Tap "All" to upload without a folder

{/* New folder modal */} { setShowNewFolder(false); setNewFolderName(""); setNewFolderEmoji("πŸ“"); }} title="Create folder" maxWidth="sm">

Choose an emoji

{EMOJI_OPTIONS.map(em => ( ))}

Folder name

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" />
{/* Fullscreen viewer */} {selected && ( 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); }} /> )} setDeleteTarget(null)} onConfirm={() => deleteTarget && handleDelete(deleteTarget)} title="Delete this photo?" description="This removes the photo permanently from your memories." confirmLabel="Delete" variant="danger" />
); } // ─── 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 (
{/* Folder label */} {folder && folder.id && (

{folder.emoji} {folder.label}

)}
); } // ─── Fullscreen viewer ──────────────────────────────────────────────────────── function MemoryViewer({ memory, allFolders, onClose, onDelete, onUpdate }: { memory: Memory; allFolders: Folder[]; onClose: () => void; onDelete: () => void; onUpdate: (updated: Partial & { 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 (
e.target === e.currentTarget && onClose()}> {/* Top bar */}
{/* Image area */}
{memory.title
{/* Zoom hint */} {showChrome && !zoomed && (
tap to hide UI Β· tap again to zoom
)} {/* Caption/tags */} {showChrome && !zoomed && (memory.visionCaption || (memory.visionTags && memory.visionTags.length > 0)) && (
{memory.visionCaption && (

{memory.visionCaption}

)} {memory.visionTags && memory.visionTags.length > 0 && (
{memory.visionTags.map((tag, i) => ( #{tag} ))}
)}
)} {/* Folder assignment bottom sheet */} {folderEditing && (
setFolderEditing(false)}>
e.stopPropagation()}>

Move to folder

{allFolders.map(folder => ( ))}
)}
); }