"use client"; import { useState, useEffect, useRef, useCallback } from "react"; import Link from "next/link"; import { useFamily } from "../FamilyProvider"; import { EmptyState, Button, Badge, ConfirmDialog } from "@/components/ui"; 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; } // Stable rotation seeded from memory id function stableRotation(id: string): number { let h = 0; for (let i = 0; i < id.length; i++) h = (h * 31 + id.charCodeAt(i)) & 0xffffffff; return ((h % 300) - 150) / 100; // -1.5 to 1.5 deg } 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 [deleteTarget, setDeleteTarget] = useState(null); const [loadingMore, setLoadingMore] = useState(false); const [query, setQuery] = useState(""); const [searchResults, setSearchResults] = useState(null); const [searching, setSearching] = useState(false); const fileRef = useRef(null); const loaderRef = useRef(null); const fetchMemories = useCallback(async (cursor?: string) => { if (!childId) return; try { const params = new URLSearchParams({ childId, limit: "30" }); 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]); // Infinite scroll 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 handleUpload = async (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file || !childId) return; setUploading(true); try { // Step 1: Get presigned URL + memoryId const initRes = await fetch("/api/upload", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ filename: file.name, contentType: file.type, childId, sizeBytes: file.size }), }); const { uploadUrl, memoryId, publicUrl, error } = await initRes.json(); if (error) { alert("Error: " + error); return; } // Step 2: Upload directly to R2 await fetch(uploadUrl, { method: "PUT", body: file, headers: { "Content-Type": file.type } }); // Step 3: Confirm upload β†’ fires thumbnail + vision pipeline await fetch(`/api/memories/${memoryId}/confirm`, { method: "POST" }); // Optimistic add const optimistic: Memory = { id: memoryId, key: "", url: publicUrl, thumbnailUrl: null, sizeBytes: file.size, mimeType: file.type, title: null, description: null, takenAt: null, visionCaption: null, visionTags: null, isPrivate: false, processingStatus: "processing", createdAt: new Date().toISOString(), }; setMemories(prev => [optimistic, ...prev]); } catch (err) { alert("Upload failed: " + err); } setUploading(false); 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 displayMemories = searchResults !== null ? searchResults : memories; return (
{/* Header */}
←

Memories

πŸ“Έ
{/* Search */}
setQuery(e.target.value)} placeholder="Search photos... (e.g. 'cake', 'park')" className="flex-1 px-3 py-2 rounded-xl border dark:border-gray-600 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 && ( )}
{/* Gallery */}
{displayMemories.length === 0 ? ( ) : ( <> {/* CSS masonry: 2 cols mobile, 3 tablet, 4 desktop */}
{displayMemories.map(mem => ( setSelected(mem)} /> ))}
{/* Infinite scroll trigger */}
{loadingMore && "Loading more..."}
)}
{/* Upload FAB */}
{/* Full-screen 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" />
); } function MemoryCard({ memory, rotation, onClick }: { memory: Memory; rotation: number; onClick: () => void }) { const [loaded, setLoaded] = useState(false); const src = memory.thumbnailUrl || memory.url; return (
{/* Washi tape strip */}
{/* Blur placeholder */}
{memory.title setLoaded(true)} loading="lazy" /> {memory.processingStatus === "processing" && (
Processing…
)} {memory.isPrivate && (
πŸ”’
)}
); } function MemoryViewer({ memory, onClose, onDelete, onUpdate }: { memory: Memory; onClose: () => void; onDelete: () => void; onUpdate: (updated: Partial & { id: string }) => void; }) { const [isPrivate, setIsPrivate] = useState(memory.isPrivate); const [toggling, setToggling] = 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); } }; return (
{/* Top bar */}
{/* Private toggle */}
{/* Image */}
{memory.title
{/* Caption + tags */} {(memory.visionCaption || memory.visionTags) && (
{memory.visionCaption && (

{memory.visionCaption}

)} {memory.visionTags && memory.visionTags.length > 0 && (
{memory.visionTags.map((tag, i) => ( #{tag} ))}
)}
)}
); }