G1 — Design System: 14 UI primitives (Button, Card, Modal, Sheet, Input, Textarea, Select, EmptyState, LoadingShimmer, ConfirmDialog, WashiTape, Badge, Avatar, Tabs), PageTransition with Framer Motion, sun/moon CSS vars, Caveat font, /dev/components visual showcase. G2 — Memories Pipeline: R2 presigned uploads, Sharp thumbnail generation, LiteLLM vision captions + pgvector embeddings, CSS masonry gallery with infinite scroll, private toggle, semantic search fallback to ILIKE. G3 — Medical: dose log + correction audit trail, IAP vaccine bulk import, emergency escalation page, pediatrician phone in settings. G4 — AI Brain: keyword guardrail → LLM classifier → structured DB tool-use (7 tools) → memory search → general parenting handler; ai_usage table; 22-case medical bypass safety test suite. DB migrations: 0011_memories, 0012_medical_doses, 0013_ai_usage. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
344 lines
13 KiB
TypeScript
344 lines
13 KiB
TypeScript
"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<Memory[]>([]);
|
|
const [nextCursor, setNextCursor] = useState<string | null>(null);
|
|
const [selected, setSelected] = useState<Memory | null>(null);
|
|
const [uploading, setUploading] = useState(false);
|
|
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
|
|
const [loadingMore, setLoadingMore] = useState(false);
|
|
const [query, setQuery] = useState("");
|
|
const [searchResults, setSearchResults] = useState<Memory[] | null>(null);
|
|
const [searching, setSearching] = useState(false);
|
|
const fileRef = useRef<HTMLInputElement>(null);
|
|
const loaderRef = useRef<HTMLDivElement>(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<HTMLInputElement>) => {
|
|
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 (
|
|
<div className="min-h-screen bg-gradient-to-br from-rose-50 to-amber-50 dark:from-gray-900 dark:to-gray-800">
|
|
{/* 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>
|
|
|
|
{/* Search */}
|
|
<form onSubmit={handleSearch} className="px-4 pt-4 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"
|
|
/>
|
|
<Button type="submit" size="sm" loading={searching}>Search</Button>
|
|
{searchResults !== null && (
|
|
<Button type="button" variant="ghost" size="sm" onClick={() => { setSearchResults(null); setQuery(""); }}>Clear</Button>
|
|
)}
|
|
</form>
|
|
|
|
{/* Gallery */}
|
|
<div className="p-4">
|
|
{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."}
|
|
/>
|
|
) : (
|
|
<>
|
|
{/* CSS masonry: 2 cols mobile, 3 tablet, 4 desktop */}
|
|
<div style={{ columns: "2", gap: "8px" }} className="sm:columns-3 lg:columns-4">
|
|
{displayMemories.map(mem => (
|
|
<MemoryCard
|
|
key={mem.id}
|
|
memory={mem}
|
|
rotation={stableRotation(mem.id)}
|
|
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>
|
|
</div>
|
|
|
|
{/* Full-screen viewer */}
|
|
{selected && (
|
|
<MemoryViewer
|
|
memory={selected}
|
|
onClose={() => 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);
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
<ConfirmDialog
|
|
open={!!deleteTarget}
|
|
onClose={() => setDeleteTarget(null)}
|
|
onConfirm={() => deleteTarget && handleDelete(deleteTarget)}
|
|
title="Delete this photo?"
|
|
description="This removes the photo permanently from your memories."
|
|
confirmLabel="Delete"
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function MemoryCard({ memory, rotation, onClick }: { memory: Memory; rotation: number; 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" }}
|
|
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"
|
|
onLoad={() => setLoaded(true)}
|
|
loading="lazy"
|
|
/>
|
|
{memory.processingStatus === "processing" && (
|
|
<div className="absolute bottom-1 right-1">
|
|
<Badge variant="info" size="sm">Processing…</Badge>
|
|
</div>
|
|
)}
|
|
{memory.isPrivate && (
|
|
<div className="absolute bottom-1 left-1">
|
|
<Badge variant="default" size="sm">🔒</Badge>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function MemoryViewer({ memory, onClose, onDelete, onUpdate }: {
|
|
memory: Memory;
|
|
onClose: () => void;
|
|
onDelete: () => void;
|
|
onUpdate: (updated: Partial<Memory> & { 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 (
|
|
<div className="fixed inset-0 bg-black/95 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 gap-3">
|
|
{/* Private toggle */}
|
|
<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"}`}
|
|
>
|
|
{toggling ? "…" : isPrivate ? "🔒 Private" : "🌐 Visible"}
|
|
</button>
|
|
<button onClick={onDelete} className="text-red-400 hover:text-red-300 text-sm">🗑 Delete</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>
|
|
|
|
{/* Caption + tags */}
|
|
{(memory.visionCaption || memory.visionTags) && (
|
|
<div className="p-4 bg-black/60 flex-shrink-0 max-h-40 overflow-y-auto">
|
|
{memory.visionCaption && (
|
|
<p className="text-white/90 text-sm leading-relaxed mb-2">{memory.visionCaption}</p>
|
|
)}
|
|
{memory.visionTags && memory.visionTags.length > 0 && (
|
|
<div className="flex flex-wrap gap-1">
|
|
{memory.visionTags.map((tag, i) => (
|
|
<span key={i} className="text-xs bg-white/10 text-white/70 px-2 py-0.5 rounded-full">#{tag}</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|