From 704faf070bc94a37c9192155404d49d59fbba13b Mon Sep 17 00:00:00 2001 From: Mannu Date: Mon, 18 May 2026 22:19:07 +0530 Subject: [PATCH] feat(memories): redesign gallery with folders, grid view, and fullscreen viewer - 9 recommended folders (First Steps, Bath Time, Feeding, etc.) stored as description - 3-column square grid layout replacing masonry+rotation - Folder picker modal before upload; PATCH assigns folder after upload - Fullscreen MemoryViewer with tap-to-toggle captions and folder reassignment - Loading shimmer per tile, processing overlay, lazy loading - Removed all floating/rotation elements causing overflow Co-Authored-By: Claude Sonnet 4.6 --- src/app/memories/page.tsx | 404 +++++++++++++++++++++++++------------- 1 file changed, 264 insertions(+), 140 deletions(-) diff --git a/src/app/memories/page.tsx b/src/app/memories/page.tsx index 5b2cec4..0a1b118 100644 --- a/src/app/memories/page.tsx +++ b/src/app/memories/page.tsx @@ -3,7 +3,19 @@ import { useState, useEffect, useRef, useCallback } from "react"; import Link from "next/link"; import { useFamily } from "../FamilyProvider"; -import { EmptyState, Button, Badge, ConfirmDialog } from "@/components/ui"; +import { Button, ConfirmDialog, Modal } from "@/components/ui"; + +const 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: "😊" }, +]; interface Memory { id: string; @@ -22,48 +34,37 @@ interface Memory { 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 [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 [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); - const fileRef = useRef(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" }); + const params = new URLSearchParams({ childId, limit: "60" }); if (cursor) params.set("cursor", cursor); - const res = await fetch(`/api/memories?${params}`); + const res = await fetch(`/api/memories?${params}`); const data = await res.json(); - if (cursor) { - setMemories(prev => [...prev, ...(data.items || [])]); - } else { - setMemories(data.items || []); - } + if (cursor) setMemories(prev => [...prev, ...(data.items || [])]); + else setMemories(data.items || []); setNextCursor(data.nextCursor || null); - } catch (err) { - console.error("Failed to fetch memories:", err); - } + } catch (err) { console.error("Failed to fetch memories:", err); } }, [childId]); - useEffect(() => { - if (childId) fetchMemories(); - }, [childId, fetchMemories]); + useEffect(() => { if (childId) fetchMemories(); }, [childId, fetchMemories]); // Infinite scroll useEffect(() => { @@ -78,13 +79,23 @@ export default function MemoriesPage() { return () => observer.disconnect(); }, [nextCursor, loadingMore, fetchMemories]); + const handleUploadClick = () => { + setShowFolderPicker(true); + }; + + const handleFolderChosen = (folderId: string) => { + setPendingFolder(folderId); + setShowFolderPicker(false); + fileRef.current?.click(); + }; + const handleUpload = async (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file || !childId) return; setUploading(true); try { - // Step 1: Get key + memoryId from server + // Step 1: Get key + memoryId const initRes = await fetch("/api/upload", { method: "POST", headers: { "Content-Type": "application/json" }, @@ -93,7 +104,7 @@ export default function MemoriesPage() { const { key, memoryId, publicUrl, error } = await initRes.json(); if (error) { alert("Error: " + error); return; } - // Step 2: Upload via server proxy β€” avoids cross-origin CORS restriction on R2 + // Step 2: Upload via server proxy const putParams = new URLSearchParams({ key, contentType: file.type }); const putRes = await fetch(`/api/upload?${putParams}`, { method: "PUT", @@ -105,31 +116,30 @@ export default function MemoriesPage() { throw new Error(e.error ?? `Upload failed (${putRes.status})`); } - // Step 3: Confirm upload β†’ fires thumbnail + vision pipeline + // Step 2.5: Assign folder (description) if one was chosen + if (pendingFolder) { + await fetch(`/api/memories/${memoryId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ description: pendingFolder }), + }); + } + + // Step 3: Confirm β†’ 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(), + id: memoryId, key, url: publicUrl, thumbnailUrl: null, + sizeBytes: file.size, mimeType: file.type, 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) { - alert("Upload failed: " + err); - } + } catch (err) { alert("Upload failed: " + err); } + setUploading(false); + setPendingFolder(""); if (fileRef.current) fileRef.current.value = ""; }; @@ -152,30 +162,37 @@ export default function MemoriesPage() { }); const data = await res.json(); setSearchResults(data.items || []); - } catch { - setSearchResults([]); - } + } catch { setSearchResults([]); } setSearching(false); }; - const displayMemories = searchResults !== null ? searchResults : memories; + const baseList = searchResults !== null ? searchResults : memories; + const displayMemories = activeFolder + ? baseList.filter(m => m.description === activeFolder) + : baseList; + + const folderCounts = memories.reduce>((acc, m) => { + if (m.description) acc[m.description] = (acc[m.description] || 0) + 1; + return acc; + }, {}); return ( -
+
{/* Header */} -
- ← -

Memories

- πŸ“Έ +
+
+ ← +

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" + 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 && ( @@ -183,50 +200,105 @@ export default function MemoriesPage() { )}
- {/* Gallery */} -
+ {/* Folder pills */} +
+ {FOLDERS.map(folder => { + const count = folder.id === "" ? memories.length : (folderCounts[folder.id] || 0); + const isActive = activeFolder === folder.id; + return ( + + ); + })} +
+ + {/* 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."} +

+
) : ( - <> - {/* CSS masonry: 2 cols mobile, 3 tablet, 4 desktop */} -
- {displayMemories.map(mem => ( - setSelected(mem)} - /> - ))} -
- {/* Infinite scroll trigger */} -
- {loadingMore && "Loading more..."} -
- +
+ {displayMemories.map(mem => ( + setSelected(mem)} /> + ))} +
)} + +
+ {loadingMore && "Loading more..."} +
{/* Upload FAB */}
- + +
- {/* Full-screen viewer */} + {/* Folder picker modal */} + setShowFolderPicker(false)} title="Add to folder" maxWidth="sm"> +
+ {FOLDERS.map(folder => ( + + ))} +
+

Tap "All" to upload without a folder

+
+ + {/* Fullscreen viewer */} {selected && ( setSelected(null)} onDelete={() => setDeleteTarget(selected.id)} - onUpdate={(updated) => { + onUpdate={updated => { setMemories(prev => prev.map(m => m.id === updated.id ? { ...m, ...updated } : m)); setSelected(prev => prev?.id === updated.id ? { ...prev, ...updated } : prev); }} @@ -240,59 +312,57 @@ export default function MemoriesPage() { title="Delete this photo?" description="This removes the photo permanently from your memories." confirmLabel="Delete" + variant="danger" />
); } -function MemoryCard({ memory, rotation, onClick }: { memory: Memory; rotation: number; onClick: () => void }) { +// ─── Grid tile ──────────────────────────────────────────────────────────────── + +function MemoryTile({ memory, onClick }: { memory: Memory; onClick: () => void }) { const [loaded, setLoaded] = useState(false); const src = memory.thumbnailUrl || memory.url; return ( -
- {/* Washi tape strip */} -
setLoaded(true)} + loading="lazy" /> -
- {/* Blur placeholder */} -
- {memory.title setLoaded(true)} - loading="lazy" - /> - {memory.processingStatus === "processing" && ( -
- Processing… -
- )} - {memory.isPrivate && ( -
- πŸ”’ -
- )} -
-
+ {!loaded && ( +
+ )} + {memory.processingStatus === "processing" && ( +
+ Processing… +
+ )} + {memory.isPrivate && ( +
πŸ”’
+ )} + ); } +// ─── Fullscreen viewer ──────────────────────────────────────────────────────── + 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 [isPrivate, setIsPrivate] = useState(memory.isPrivate); + const [toggling, setToggling] = useState(false); + const [showMeta, setShowMeta] = useState(false); + const [folderEditing, setFolderEditing] = useState(false); const togglePrivate = async () => { setToggling(true); @@ -305,37 +375,60 @@ function MemoryViewer({ memory, onClose, onDelete, onUpdate }: { }); setIsPrivate(newVal); onUpdate({ id: memory.id, isPrivate: newVal, ...(newVal ? { visionCaption: null, visionTags: null } : {}) }); - } finally { - setToggling(false); - } + } 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 = FOLDERS.find(f => f.id === memory.description); + return ( -
+
{/* Top bar */} -
- +
+
- {/* Private toggle */} + - +
{/* Image */} -
- {memory.title +
setShowMeta(s => !s)}> + {memory.title
- {/* Caption + tags */} - {(memory.visionCaption || memory.visionTags) && ( -
+ {/* Caption/tags β€” tap image to toggle */} + {showMeta && (memory.visionCaption || (memory.visionTags && memory.visionTags.length > 0)) && ( +
{memory.visionCaption && (

{memory.visionCaption}

)} @@ -348,6 +441,37 @@ function MemoryViewer({ memory, onClose, onDelete, onUpdate }: { )}
)} + + {/* Folder assignment modal */} + {folderEditing && ( +
setFolderEditing(false)} + > +
e.stopPropagation()} + > +

Move to folder

+
+ {FOLDERS.map(folder => ( + + ))} +
+
+
+ )}
); }