memory-card.tsx
1 'use client' 2 3 import { useState } from 'react' 4 import type { MemoryEntry } from '@/types' 5 import { AgentAvatar } from '@/components/agents/agent-avatar' 6 import { deriveMemoryScope, getMemoryScopeLabel, getMemoryTier } from '@/lib/memory-presentation' 7 import { timeAgoShort } from '@/lib/time-format' 8 9 interface Props { 10 entry: MemoryEntry 11 active?: boolean 12 agentName?: string | null 13 agentAvatarSeed?: string | null 14 agentAvatarUrl?: string | null 15 onClick: () => void 16 } 17 18 export function MemoryCard({ entry, active, agentName, agentAvatarSeed, agentAvatarUrl, onClick }: Props) { 19 const [now] = useState(() => Date.now()) 20 const scope = deriveMemoryScope(entry) 21 const tier = getMemoryTier(entry) 22 const isDreamOrigin = entry.category === 'dream_reflection' 23 || (entry.metadata as Record<string, unknown> | undefined)?.origin === 'dream' 24 25 return ( 26 <div 27 onClick={onClick} 28 className={`relative py-3 px-4 cursor-pointer rounded-[14px] 29 transition-all duration-200 active:scale-[0.98] 30 ${active 31 ? 'bg-accent-soft border border-accent-bright/10' 32 : 'bg-transparent border border-transparent hover:bg-white/[0.02] hover:border-white/[0.03]'}`} 33 > 34 {active && ( 35 <div className="absolute left-0 top-3 bottom-3 w-[2.5px] rounded-full bg-accent-bright" /> 36 )} 37 <div className="flex items-center gap-2"> 38 <span className="shrink-0 text-[9px] font-700 uppercase tracking-wider text-accent-bright/70 bg-accent-soft px-1.5 py-0.5 rounded-[5px]"> 39 {entry.category || 'note'} 40 </span> 41 {isDreamOrigin && ( 42 <span className="shrink-0 text-[9px] font-700 uppercase tracking-wider text-violet-300/70 bg-violet-400/10 px-1.5 py-0.5 rounded-[5px]"> 43 dream 44 </span> 45 )} 46 {entry.pinned && ( 47 <svg width="10" height="10" viewBox="0 0 24 24" fill="currentColor" className="shrink-0 text-amber-400/80"> 48 <path d="M16 2l-4 4-4-4-2 2 4 4-5 5v1h1l5-5 4 4 2-2-4-4 4-4z" transform="rotate(45 12 12)" /> 49 </svg> 50 )} 51 <span className="font-display text-[13px] font-600 truncate flex-1 tracking-[-0.01em]">{entry.title}</span> 52 <span className="text-[10px] text-text-3/60 shrink-0 tabular-nums font-mono"> 53 {timeAgoShort(entry.updatedAt || entry.createdAt, now)} 54 </span> 55 </div> 56 <div className="text-[12px] text-text-2/40 mt-1 line-clamp-3 leading-relaxed"> 57 {entry.content || '(empty)'} 58 </div> 59 <div className="mt-2 flex flex-wrap items-center gap-1.5"> 60 <span className="px-1.5 py-0.5 rounded-[5px] text-[9px] font-700 uppercase tracking-[0.08em] bg-white/[0.04] text-text-3/75"> 61 {getMemoryScopeLabel(scope)} 62 </span> 63 <span className={`px-1.5 py-0.5 rounded-[5px] text-[9px] font-700 uppercase tracking-[0.08em] ${ 64 tier === 'working' 65 ? 'bg-amber-400/10 text-amber-300' 66 : tier === 'archive' 67 ? 'bg-sky-400/10 text-sky-300' 68 : 'bg-emerald-400/10 text-emerald-300' 69 }`}> 70 {tier} 71 </span> 72 </div> 73 {(entry.image?.path || entry.imagePath) && ( 74 <div className="mt-2 w-10 h-10 rounded-[6px] overflow-hidden bg-white/[0.04] shrink-0"> 75 {/* eslint-disable-next-line @next/next/no-img-element */} 76 <img 77 src={ 78 (entry.image?.path || entry.imagePath || '').startsWith('data/memory-images/') 79 ? `/api/memory-images/${(entry.image?.path || entry.imagePath || '').split('/').pop()}` 80 : (entry.image?.path || entry.imagePath || '') 81 } 82 alt="" 83 className="w-full h-full object-cover" 84 /> 85 </div> 86 )} 87 {(entry.references?.length || entry.linkedMemoryIds?.length || entry.image?.path || entry.imagePath) && ( 88 <div className="flex items-center gap-2 mt-1.5 text-[10px] text-text-3/35"> 89 {entry.references?.length ? <span>{entry.references.length} ref{entry.references.length === 1 ? '' : 's'}</span> : null} 90 {entry.linkedMemoryIds?.length ? <span>{entry.linkedMemoryIds.length} linked</span> : null} 91 {(entry.image?.path || entry.imagePath) ? <span>image</span> : null} 92 </div> 93 )} 94 {agentName ? ( 95 <div className="flex items-center gap-1.5 mt-1.5"> 96 <AgentAvatar seed={agentAvatarSeed || null} avatarUrl={agentAvatarUrl} name={agentName} size={16} /> 97 <span className="text-[10px] text-text-3/60 truncate">{agentName}</span> 98 </div> 99 ) : !entry.agentId ? ( 100 <div className="flex items-center gap-1 mt-1.5"> 101 <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" className="text-text-3/50"> 102 <circle cx="12" cy="12" r="10" /><line x1="2" y1="12" x2="22" y2="12" /> 103 <path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /> 104 </svg> 105 <span className="text-[10px] text-text-3/50">Global</span> 106 </div> 107 ) : null} 108 </div> 109 ) 110 }