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 <noreply@anthropic.com>
This commit is contained in:
parent
0e047e110f
commit
704faf070b
1 changed files with 264 additions and 140 deletions
|
|
@ -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,13 +34,6 @@ 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<Memory[]>([]);
|
||||
|
|
@ -37,6 +42,9 @@ export default function MemoriesPage() {
|
|||
const [uploading, setUploading] = useState(false);
|
||||
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);
|
||||
|
|
@ -46,24 +54,17 @@ export default function MemoriesPage() {
|
|||
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 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<HTMLInputElement>) => {
|
||||
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<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-gradient-to-br from-rose-50 to-amber-50 dark:from-gray-900 dark:to-gray-800">
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||
{/* Header */}
|
||||
<div className="p-4 flex items-center gap-3 border-b bg-white/70 dark:bg-gray-800/70 backdrop-blur-sm sticky top-0 z-10">
|
||||
<Link href="/menu" className="text-rose-500 text-xl p-1">←</Link>
|
||||
<h1 className="text-lg font-bold dark:text-white">Memories</h1>
|
||||
<span className="text-lg">📸</span>
|
||||
<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 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-lg font-bold dark:text-white">Memories 📸</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<form onSubmit={handleSearch} className="px-4 pt-4 flex gap-2">
|
||||
<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... (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"
|
||||
/>
|
||||
<Button type="submit" size="sm" loading={searching}>Search</Button>
|
||||
{searchResults !== null && (
|
||||
|
|
@ -183,50 +200,105 @@ export default function MemoriesPage() {
|
|||
)}
|
||||
</form>
|
||||
|
||||
{/* Gallery */}
|
||||
<div className="p-4">
|
||||
{/* Folder pills */}
|
||||
<div className="px-4 py-2 flex gap-2 overflow-x-auto no-scrollbar">
|
||||
{FOLDERS.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>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Gallery grid */}
|
||||
<div className="px-3 pb-24">
|
||||
{displayMemories.length === 0 ? (
|
||||
<EmptyState
|
||||
icon="📷"
|
||||
title={searchResults !== null ? "No photos found" : "No memories yet"}
|
||||
description={searchResults !== null ? "Try a different search term." : "Tap + to capture your first precious moment."}
|
||||
/>
|
||||
<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>
|
||||
) : (
|
||||
<>
|
||||
{/* CSS masonry: 2 cols mobile, 3 tablet, 4 desktop */}
|
||||
<div style={{ columns: "2", gap: "8px" }} className="sm:columns-3 lg:columns-4">
|
||||
<div className="grid grid-cols-3 gap-0.5">
|
||||
{displayMemories.map(mem => (
|
||||
<MemoryCard
|
||||
key={mem.id}
|
||||
memory={mem}
|
||||
rotation={stableRotation(mem.id)}
|
||||
onClick={() => setSelected(mem)}
|
||||
/>
|
||||
<MemoryTile key={mem.id} memory={mem} onClick={() => setSelected(mem)} />
|
||||
))}
|
||||
</div>
|
||||
{/* Infinite scroll trigger */}
|
||||
)}
|
||||
|
||||
<div ref={loaderRef} className="py-4 text-center text-sm text-gray-400">
|
||||
{loadingMore && "Loading more..."}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Upload FAB */}
|
||||
<div className="fixed bottom-6 right-6 z-20">
|
||||
<label className={`w-14 h-14 rounded-full shadow-lg flex items-center justify-center cursor-pointer transition-colors ${uploading ? "bg-gray-300 dark:bg-gray-600" : "bg-rose-400 hover:bg-rose-500"}`}>
|
||||
<span className="text-white text-2xl font-light">{uploading ? "…" : "+"}</span>
|
||||
<input ref={fileRef} type="file" accept="image/*" onChange={handleUpload} className="hidden" disabled={uploading} />
|
||||
</label>
|
||||
<button
|
||||
onClick={handleUploadClick}
|
||||
disabled={uploading}
|
||||
className={`w-14 h-14 rounded-full shadow-xl flex items-center justify-center text-white text-2xl font-light transition-colors ${
|
||||
uploading ? "bg-gray-400" : "bg-rose-400 hover:bg-rose-500 active:bg-rose-600"
|
||||
}`}
|
||||
>
|
||||
{uploading ? "…" : "+"}
|
||||
</button>
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleUpload}
|
||||
className="hidden"
|
||||
disabled={uploading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Full-screen viewer */}
|
||||
{/* Folder picker modal */}
|
||||
<Modal open={showFolderPicker} onClose={() => setShowFolderPicker(false)} title="Add to folder" maxWidth="sm">
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{FOLDERS.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>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 text-center mt-3">Tap "All" to upload without a folder</p>
|
||||
</Modal>
|
||||
|
||||
{/* Fullscreen viewer */}
|
||||
{selected && (
|
||||
<MemoryViewer
|
||||
memory={selected}
|
||||
onClose={() => 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,51 +312,47 @@ export default function MemoriesPage() {
|
|||
title="Delete this photo?"
|
||||
description="This removes the photo permanently from your memories."
|
||||
confirmLabel="Delete"
|
||||
variant="danger"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div
|
||||
className="relative break-inside-avoid mb-2 cursor-pointer group"
|
||||
style={{ transform: `rotate(${rotation}deg)`, transformOrigin: "center" }}
|
||||
<button
|
||||
className="relative w-full aspect-square bg-gray-100 dark:bg-gray-800 overflow-hidden"
|
||||
onClick={onClick}
|
||||
>
|
||||
{/* Washi tape strip */}
|
||||
<div
|
||||
className="absolute -top-1 left-1/2 -translate-x-1/2 h-3 w-16 bg-rose-200/80 dark:bg-rose-900/60 z-10"
|
||||
style={{ transform: `rotate(${-rotation}deg)` }}
|
||||
/>
|
||||
<div className={`rounded-lg overflow-hidden shadow-sm group-hover:shadow-md transition-shadow bg-gray-200 dark:bg-gray-700`}>
|
||||
{/* Blur placeholder */}
|
||||
<div className={`transition-opacity duration-300 ${loaded ? "opacity-0" : "opacity-100"} absolute inset-0 bg-gray-200 dark:bg-gray-700`} />
|
||||
<img
|
||||
src={src}
|
||||
alt={memory.title || "Memory"}
|
||||
className="w-full block"
|
||||
className={`w-full h-full object-cover transition-opacity duration-300 ${loaded ? "opacity-100" : "opacity-0"}`}
|
||||
onLoad={() => setLoaded(true)}
|
||||
loading="lazy"
|
||||
/>
|
||||
{!loaded && (
|
||||
<div className="absolute inset-0 bg-gray-200 dark:bg-gray-700 animate-pulse" />
|
||||
)}
|
||||
{memory.processingStatus === "processing" && (
|
||||
<div className="absolute bottom-1 right-1">
|
||||
<Badge variant="info" size="sm">Processing…</Badge>
|
||||
<div className="absolute inset-0 bg-black/30 flex items-center justify-center">
|
||||
<span className="text-white text-xs bg-black/50 px-2 py-0.5 rounded-full">Processing…</span>
|
||||
</div>
|
||||
)}
|
||||
{memory.isPrivate && (
|
||||
<div className="absolute bottom-1 left-1">
|
||||
<Badge variant="default" size="sm">🔒</Badge>
|
||||
</div>
|
||||
<div className="absolute top-1 right-1 text-xs">🔒</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Fullscreen viewer ────────────────────────────────────────────────────────
|
||||
|
||||
function MemoryViewer({ memory, onClose, onDelete, onUpdate }: {
|
||||
memory: Memory;
|
||||
onClose: () => void;
|
||||
|
|
@ -293,6 +361,8 @@ function MemoryViewer({ memory, onClose, onDelete, onUpdate }: {
|
|||
}) {
|
||||
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 (
|
||||
<div className="fixed inset-0 bg-black/95 z-50 flex flex-col">
|
||||
<div className="fixed inset-0 bg-black z-50 flex flex-col">
|
||||
{/* Top bar */}
|
||||
<div className="flex items-center justify-between p-4 flex-shrink-0">
|
||||
<button onClick={onClose} className="text-white/80 hover:text-white text-sm">✕ Close</button>
|
||||
<div className="flex items-center justify-between px-4 py-3 flex-shrink-0 bg-black/80">
|
||||
<button onClick={onClose} className="text-white/80 hover:text-white p-1">✕</button>
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Private toggle */}
|
||||
<button
|
||||
onClick={() => setFolderEditing(true)}
|
||||
className="text-sm text-white/70 hover:text-white flex items-center gap-1"
|
||||
>
|
||||
{currentFolder ? `${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-600 text-gray-200" : "bg-gray-800 text-gray-400 hover:text-white"}`}
|
||||
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 ? "🔒 Private" : "🌐 Visible"}
|
||||
{toggling ? "…" : isPrivate ? "🔒" : "🌐"}
|
||||
</button>
|
||||
<button onClick={onDelete} className="text-red-400 hover:text-red-300 text-sm">🗑 Delete</button>
|
||||
<button onClick={onDelete} className="text-red-400 hover:text-red-300 p-1">🗑</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Image */}
|
||||
<div className="flex-1 flex items-center justify-center px-4 overflow-hidden">
|
||||
<img src={memory.url} alt={memory.title || "Memory"} className="max-w-full max-h-full object-contain" />
|
||||
<div className="flex-1 flex items-center justify-center px-2 min-h-0" onClick={() => setShowMeta(s => !s)}>
|
||||
<img
|
||||
src={memory.url}
|
||||
alt={memory.title || "Memory"}
|
||||
className="max-w-full max-h-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Caption + tags */}
|
||||
{(memory.visionCaption || memory.visionTags) && (
|
||||
<div className="p-4 bg-black/60 flex-shrink-0 max-h-40 overflow-y-auto">
|
||||
{/* Caption/tags — tap image to toggle */}
|
||||
{showMeta && (memory.visionCaption || (memory.visionTags && memory.visionTags.length > 0)) && (
|
||||
<div className="px-4 py-3 bg-black/80 flex-shrink-0">
|
||||
{memory.visionCaption && (
|
||||
<p className="text-white/90 text-sm leading-relaxed mb-2">{memory.visionCaption}</p>
|
||||
)}
|
||||
|
|
@ -348,6 +441,37 @@ function MemoryViewer({ memory, onClose, onDelete, onUpdate }: {
|
|||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Folder assignment modal */}
|
||||
{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"
|
||||
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 mb-3">
|
||||
{FOLDERS.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"
|
||||
: "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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue