feat(memories): smaller title, tap-to-zoom viewer, custom folder creation
- Header title reduced to text-xs (~50% smaller) - Tap image in viewer to zoom 2x; tap again to zoom out - Tap outside image area to toggle UI chrome (top bar / captions) - "New folder" pill at end of folder row and in upload picker - Custom folders saved to localStorage (tia_custom_folders) - Custom folders appear in folder pills, upload picker, and move-to-folder sheet - Emoji picker (15 options) + name input for new folders Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
704faf070b
commit
fb45250a27
1 changed files with 147 additions and 71 deletions
|
|
@ -5,7 +5,7 @@ import Link from "next/link";
|
|||
import { useFamily } from "../FamilyProvider";
|
||||
import { Button, ConfirmDialog, Modal } from "@/components/ui";
|
||||
|
||||
const FOLDERS = [
|
||||
const PRESET_FOLDERS = [
|
||||
{ id: "", label: "All", emoji: "🌟" },
|
||||
{ id: "first-steps", label: "First Steps", emoji: "👣" },
|
||||
{ id: "bath-time", label: "Bath Time", emoji: "🛁" },
|
||||
|
|
@ -17,6 +17,10 @@ const FOLDERS = [
|
|||
{ id: "smiles", label: "Smiles", emoji: "😊" },
|
||||
];
|
||||
|
||||
const EMOJI_OPTIONS = ["📁","🌈","⭐","🎵","🏖","🎈","🐣","💛","🌸","🦋","🐾","🎀","🌙","🏠","🎠"];
|
||||
|
||||
interface Folder { id: string; label: string; emoji: string; }
|
||||
|
||||
interface Memory {
|
||||
id: string;
|
||||
key: string;
|
||||
|
|
@ -48,9 +52,39 @@ export default function MemoriesPage() {
|
|||
const [query, setQuery] = useState("");
|
||||
const [searchResults, setSearchResults] = useState<Memory[] | null>(null);
|
||||
const [searching, setSearching] = useState(false);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Custom folders
|
||||
const [customFolders, setCustomFolders] = useState<Folder[]>([]);
|
||||
const [showNewFolder, setShowNewFolder] = useState(false);
|
||||
const [newFolderName, setNewFolderName] = useState("");
|
||||
const [newFolderEmoji, setNewFolderEmoji] = useState("📁");
|
||||
|
||||
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 {}
|
||||
}, []);
|
||||
|
||||
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 {
|
||||
|
|
@ -66,7 +100,6 @@ export default function MemoriesPage() {
|
|||
|
||||
useEffect(() => { if (childId) fetchMemories(); }, [childId, fetchMemories]);
|
||||
|
||||
// Infinite scroll
|
||||
useEffect(() => {
|
||||
if (!loaderRef.current) return;
|
||||
const observer = new IntersectionObserver(entries => {
|
||||
|
|
@ -79,9 +112,7 @@ export default function MemoriesPage() {
|
|||
return () => observer.disconnect();
|
||||
}, [nextCursor, loadingMore, fetchMemories]);
|
||||
|
||||
const handleUploadClick = () => {
|
||||
setShowFolderPicker(true);
|
||||
};
|
||||
const handleUploadClick = () => setShowFolderPicker(true);
|
||||
|
||||
const handleFolderChosen = (folderId: string) => {
|
||||
setPendingFolder(folderId);
|
||||
|
|
@ -92,10 +123,8 @@ export default function MemoriesPage() {
|
|||
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
|
||||
const initRes = await fetch("/api/upload", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
|
|
@ -104,19 +133,15 @@ export default function MemoriesPage() {
|
|||
const { key, memoryId, publicUrl, error } = await initRes.json();
|
||||
if (error) { alert("Error: " + error); return; }
|
||||
|
||||
// Step 2: Upload via server proxy
|
||||
const putParams = new URLSearchParams({ key, contentType: file.type });
|
||||
const putRes = await fetch(`/api/upload?${putParams}`, {
|
||||
method: "PUT",
|
||||
body: file,
|
||||
headers: { "Content-Type": file.type },
|
||||
method: "PUT", body: file, headers: { "Content-Type": file.type },
|
||||
});
|
||||
if (!putRes.ok) {
|
||||
const e = await putRes.json().catch(() => ({})) as { error?: string };
|
||||
throw new Error(e.error ?? `Upload failed (${putRes.status})`);
|
||||
}
|
||||
|
||||
// Step 2.5: Assign folder (description) if one was chosen
|
||||
if (pendingFolder) {
|
||||
await fetch(`/api/memories/${memoryId}`, {
|
||||
method: "PATCH",
|
||||
|
|
@ -125,7 +150,6 @@ export default function MemoriesPage() {
|
|||
});
|
||||
}
|
||||
|
||||
// Step 3: Confirm → fires thumbnail + vision pipeline
|
||||
await fetch(`/api/memories/${memoryId}/confirm`, { method: "POST" });
|
||||
|
||||
const optimistic: Memory = {
|
||||
|
|
@ -137,7 +161,6 @@ export default function MemoriesPage() {
|
|||
};
|
||||
setMemories(prev => [optimistic, ...prev]);
|
||||
} catch (err) { alert("Upload failed: " + err); }
|
||||
|
||||
setUploading(false);
|
||||
setPendingFolder("");
|
||||
if (fileRef.current) fileRef.current.value = "";
|
||||
|
|
@ -182,7 +205,7 @@ export default function MemoriesPage() {
|
|||
<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>
|
||||
<h1 className="text-xs font-semibold dark:text-white">Memories 📸</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -202,7 +225,7 @@ export default function MemoriesPage() {
|
|||
|
||||
{/* Folder pills */}
|
||||
<div className="px-4 py-2 flex gap-2 overflow-x-auto no-scrollbar">
|
||||
{FOLDERS.map(folder => {
|
||||
{allFolders.map(folder => {
|
||||
const count = folder.id === "" ? memories.length : (folderCounts[folder.id] || 0);
|
||||
const isActive = activeFolder === folder.id;
|
||||
return (
|
||||
|
|
@ -218,13 +241,19 @@ export default function MemoriesPage() {
|
|||
<span>{folder.emoji}</span>
|
||||
<span>{folder.label}</span>
|
||||
{count > 0 && (
|
||||
<span className={`text-xs ${isActive ? "text-white/80" : "text-gray-400"}`}>
|
||||
{count}
|
||||
</span>
|
||||
<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 */}
|
||||
|
|
@ -246,7 +275,6 @@ export default function MemoriesPage() {
|
|||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={loaderRef} className="py-4 text-center text-sm text-gray-400">
|
||||
{loadingMore && "Loading more..."}
|
||||
</div>
|
||||
|
|
@ -263,39 +291,80 @@ export default function MemoriesPage() {
|
|||
>
|
||||
{uploading ? "…" : "+"}
|
||||
</button>
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleUpload}
|
||||
className="hidden"
|
||||
disabled={uploading}
|
||||
/>
|
||||
<input ref={fileRef} type="file" accept="image/*" onChange={handleUpload} className="hidden" disabled={uploading} />
|
||||
</div>
|
||||
|
||||
{/* 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 => (
|
||||
{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>
|
||||
<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 => {
|
||||
|
|
@ -323,12 +392,8 @@ export default function MemoriesPage() {
|
|||
function MemoryTile({ memory, onClick }: { memory: Memory; onClick: () => void }) {
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const src = memory.thumbnailUrl || memory.url;
|
||||
|
||||
return (
|
||||
<button
|
||||
className="relative w-full aspect-square bg-gray-100 dark:bg-gray-800 overflow-hidden"
|
||||
onClick={onClick}
|
||||
>
|
||||
<button className="relative w-full aspect-square bg-gray-100 dark:bg-gray-800 overflow-hidden" onClick={onClick}>
|
||||
<img
|
||||
src={src}
|
||||
alt={memory.title || "Memory"}
|
||||
|
|
@ -336,32 +401,30 @@ function MemoryTile({ memory, onClick }: { memory: Memory; onClick: () => void }
|
|||
onLoad={() => setLoaded(true)}
|
||||
loading="lazy"
|
||||
/>
|
||||
{!loaded && (
|
||||
<div className="absolute inset-0 bg-gray-200 dark:bg-gray-700 animate-pulse" />
|
||||
)}
|
||||
{!loaded && <div className="absolute inset-0 bg-gray-200 dark:bg-gray-700 animate-pulse" />}
|
||||
{memory.processingStatus === "processing" && (
|
||||
<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 top-1 right-1 text-xs">🔒</div>
|
||||
)}
|
||||
{memory.isPrivate && <div className="absolute top-1 right-1 text-xs">🔒</div>}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Fullscreen viewer ────────────────────────────────────────────────────────
|
||||
|
||||
function MemoryViewer({ memory, onClose, onDelete, onUpdate }: {
|
||||
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 [showMeta, setShowMeta] = useState(false);
|
||||
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 () => {
|
||||
|
|
@ -390,19 +453,27 @@ function MemoryViewer({ memory, onClose, onDelete, onUpdate }: {
|
|||
setFolderEditing(false);
|
||||
};
|
||||
|
||||
const currentFolder = FOLDERS.find(f => f.id === memory.description);
|
||||
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">
|
||||
<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-black/80">
|
||||
<button onClick={onClose} className="text-white/80 hover:text-white p-1">✕</button>
|
||||
<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.emoji} ${currentFolder.label}` : "📁 Folder"}
|
||||
{currentFolder && currentFolder.id ? `${currentFolder.emoji} ${currentFolder.label}` : "📁 Folder"}
|
||||
</button>
|
||||
<button
|
||||
onClick={togglePrivate}
|
||||
|
|
@ -417,18 +488,29 @@ function MemoryViewer({ memory, onClose, onDelete, onUpdate }: {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Image */}
|
||||
<div className="flex-1 flex items-center justify-center px-2 min-h-0" onClick={() => setShowMeta(s => !s)}>
|
||||
{/* 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"}
|
||||
className="max-w-full max-h-full object-contain"
|
||||
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>
|
||||
|
||||
{/* 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">
|
||||
{/* 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>
|
||||
)}
|
||||
|
|
@ -442,25 +524,19 @@ function MemoryViewer({ memory, onClose, onDelete, onUpdate }: {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Folder assignment modal */}
|
||||
{/* 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"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<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 mb-3">
|
||||
{FOLDERS.map(folder => (
|
||||
<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"
|
||||
? "bg-rose-100 dark:bg-rose-900 ring-2 ring-rose-400"
|
||||
: "bg-gray-100 dark:bg-gray-800"
|
||||
}`}
|
||||
>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue