Add mood sensing and auto collections to memory cloud
This commit is contained in:
@@ -100,6 +100,44 @@ const SMART_TAG_RULES = [
|
||||
{ tag: 'family', patterns: [/kids/i, /family/i, /home/i] },
|
||||
]
|
||||
|
||||
const TAG_ALIASES = {
|
||||
login: 'auth',
|
||||
oauth: 'auth',
|
||||
permissions: 'auth',
|
||||
permission: 'auth',
|
||||
visual: 'design',
|
||||
theme: 'design',
|
||||
ui: 'design',
|
||||
archive: 'memory',
|
||||
memories: 'memory',
|
||||
deploy: 'infra',
|
||||
deployment: 'infra',
|
||||
cloudflare: 'infra',
|
||||
service: 'infra',
|
||||
openclaw: 'agents',
|
||||
agentboard: 'agents',
|
||||
codex: 'agents',
|
||||
child: 'kidsstories',
|
||||
stories: 'kidsstories',
|
||||
home: 'family',
|
||||
}
|
||||
|
||||
const MOOD_PROFILES = [
|
||||
{ mood: 'focused', score: 0, patterns: [/fix/i, /ship/i, /build/i, /implement/i, /polish/i, /deploy/i, /migrate/i] },
|
||||
{ mood: 'playful', score: 0, patterns: [/fun/i, /mischief/i, /awesome/i, /play/i, /spark/i] },
|
||||
{ mood: 'reflective', score: 0, patterns: [/remember/i, /reflect/i, /journal/i, /learn/i, /insight/i] },
|
||||
{ mood: 'tense', score: 0, patterns: [/blocked/i, /worry/i, /broken/i, /error/i, /issue/i, /frustrat/i] },
|
||||
{ mood: 'warm', score: 0, patterns: [/family/i, /kids/i, /home/i, /story/i, /love/i] },
|
||||
]
|
||||
|
||||
const COLLECTION_RULES = [
|
||||
{ id: 'infra-week', label: 'Infra week', tags: ['infra', 'appwrite', 'auth'] },
|
||||
{ id: 'design-streak', label: 'Design streak', tags: ['design', 'memory'] },
|
||||
{ id: 'family-notes', label: 'Family notes', tags: ['family', 'kidsstories'] },
|
||||
{ id: 'agent-ops', label: 'Agent ops', tags: ['agents', 'appwrite', 'dashboard'] },
|
||||
{ id: 'product-loop', label: 'Product loop', tags: ['product', 'design', 'appwrite'] },
|
||||
]
|
||||
|
||||
const THEME_PROFILES = {
|
||||
nord: { name: 'Nord', accent: '#8fbcbb', accentSoft: 'rgba(143,188,187,0.18)', accentStrong: '#a3d5d3', glow: 'rgba(143,188,187,0.28)' },
|
||||
plum: { name: 'Plum', accent: '#b48ead', accentSoft: 'rgba(180,142,173,0.18)', accentStrong: '#d1a9ca', glow: 'rgba(180,142,173,0.28)' },
|
||||
@@ -111,12 +149,21 @@ function isProbablyNoiseTag(value) {
|
||||
return /^\d{4}-\d{2}-\d{2}$/.test(value) || /^\d+$/.test(value)
|
||||
}
|
||||
|
||||
function normalizeTag(value) {
|
||||
const cleaned = String(value || '').toLowerCase().replace(/[^a-z0-9+-]+/g, '').trim()
|
||||
return TAG_ALIASES[cleaned] || cleaned
|
||||
}
|
||||
|
||||
function generateTags(content, filename) {
|
||||
const tags = []
|
||||
const scores = new Map()
|
||||
const addScore = (tag, amount) => scores.set(tag, (scores.get(tag) || 0) + amount)
|
||||
const addScore = (tag, amount) => {
|
||||
const normalized = normalizeTag(tag)
|
||||
if (!normalized) return
|
||||
scores.set(normalized, (scores.get(normalized) || 0) + amount)
|
||||
}
|
||||
const pushTag = (value) => {
|
||||
const cleaned = String(value || '').toLowerCase().replace(/[^a-z0-9+-]+/g, '').trim()
|
||||
const cleaned = normalizeTag(value)
|
||||
if (!cleaned || cleaned.length < 3 || TAG_STOPWORDS.has(cleaned) || tags.includes(cleaned) || isProbablyNoiseTag(cleaned)) return
|
||||
tags.push(cleaned)
|
||||
}
|
||||
@@ -170,6 +217,35 @@ function buildThemeProfile(content = '', tags = []) {
|
||||
return THEME_PROFILES.nord
|
||||
}
|
||||
|
||||
function detectMood(content = '', tags = []) {
|
||||
const haystack = `${content} ${(tags || []).join(' ')}`.toLowerCase()
|
||||
let best = { mood: 'steady', score: 0 }
|
||||
for (const profile of MOOD_PROFILES) {
|
||||
let score = 0
|
||||
for (const pattern of profile.patterns) {
|
||||
const matches = haystack.match(new RegExp(pattern.source, pattern.flags.includes('g') ? pattern.flags : `${pattern.flags}g`)) || []
|
||||
score += matches.length
|
||||
}
|
||||
if (score > best.score) best = { mood: profile.mood, score }
|
||||
}
|
||||
return best.mood
|
||||
}
|
||||
|
||||
function buildCollections(files = []) {
|
||||
return COLLECTION_RULES.map((rule) => {
|
||||
const matchedFiles = files.filter((file) => rule.tags.some((tag) => (file.tags || []).includes(tag)))
|
||||
if (!matchedFiles.length) return null
|
||||
return {
|
||||
id: rule.id,
|
||||
label: rule.label,
|
||||
count: matchedFiles.length,
|
||||
tags: rule.tags,
|
||||
latest: matchedFiles[0]?.dateLabel,
|
||||
filenames: matchedFiles.slice(0, 6).map((file) => file.filename),
|
||||
}
|
||||
}).filter(Boolean)
|
||||
}
|
||||
|
||||
function formatDateLabel(filename) {
|
||||
const match = filename.match(/^(\d{4})-(\d{2})-(\d{2})\.md$/)
|
||||
if (!match) return filename
|
||||
@@ -285,6 +361,7 @@ async function readMemoryDocument(filename) {
|
||||
|
||||
function buildFileMeta(doc) {
|
||||
const content = doc.content || ''
|
||||
const tags = generateTags(content, doc.filename)
|
||||
const mtimeMs = doc.$updatedAt ? new Date(doc.$updatedAt).getTime() : Date.now()
|
||||
return {
|
||||
filename: doc.filename,
|
||||
@@ -293,7 +370,8 @@ function buildFileMeta(doc) {
|
||||
wordCount: wordCount(content),
|
||||
entryCount: getEntryCount(content),
|
||||
dateLabel: formatDateLabel(doc.filename),
|
||||
tags: generateTags(content, doc.filename),
|
||||
tags,
|
||||
mood: detectMood(content, tags),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -339,8 +417,10 @@ app.get('/api/meta', async (req, res) => {
|
||||
}, { dailyFileCount: 0, totalDailyEntries: 0, totalDailyWords: 0 })
|
||||
|
||||
const theme = buildThemeProfile(mainDoc?.content || '', mainMemory?.tags || [])
|
||||
const collections = buildCollections(dailyFiles)
|
||||
const mood = detectMood(mainDoc?.content || '', mainMemory?.tags || [])
|
||||
|
||||
res.json({ mainMemory, dailyFiles, summary, theme })
|
||||
res.json({ mainMemory: mainMemory ? { ...mainMemory, mood } : null, dailyFiles, summary, theme, collections })
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message })
|
||||
}
|
||||
@@ -370,7 +450,8 @@ app.get('/api/memories/:filename', async (req, res) => {
|
||||
await ensureSync()
|
||||
const doc = await readMemoryDocument(filename)
|
||||
if (!doc) return res.status(404).json({ error: 'File not found' })
|
||||
return res.json({ content: doc.content || '', tags: generateTags(doc.content || '', filename) })
|
||||
const tags = generateTags(doc.content || '', filename)
|
||||
return res.json({ content: doc.content || '', tags, mood: detectMood(doc.content || '', tags) })
|
||||
} catch (error) {
|
||||
return res.status(500).json({ error: error.message })
|
||||
}
|
||||
@@ -381,7 +462,8 @@ app.get('/api/main-memory', async (req, res) => {
|
||||
await ensureSync()
|
||||
const doc = await readMemoryDocument('MEMORY.md')
|
||||
if (!doc) return res.status(404).json({ error: 'MEMORY.md not found' })
|
||||
res.json({ content: doc.content || '', tags: generateTags(doc.content || '', 'MEMORY.md') })
|
||||
const tags = generateTags(doc.content || '', 'MEMORY.md')
|
||||
res.json({ content: doc.content || '', tags, mood: detectMood(doc.content || '', tags) })
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message })
|
||||
}
|
||||
@@ -455,12 +537,14 @@ app.get('/api/search', async (req, res) => {
|
||||
const windowEnd = Math.min(content.length, firstMatchIdx + 120)
|
||||
const snippet = content.slice(windowStart, windowEnd).replace(/\n/g, ' ')
|
||||
|
||||
const tags = generateTags(content, doc.filename)
|
||||
results.push({
|
||||
filename: doc.filename,
|
||||
dateLabel: formatDateLabel(doc.filename),
|
||||
entryCount: getEntryCount(content),
|
||||
wordCount: wordCount(content),
|
||||
tags: generateTags(content, doc.filename),
|
||||
tags,
|
||||
mood: detectMood(content, tags),
|
||||
matchCount: (lower.match(new RegExp(query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g')) || []).length,
|
||||
snippet: `${windowStart > 0 ? '…' : ''}${snippet}${windowEnd < content.length ? '…' : ''}`,
|
||||
matchingLines,
|
||||
|
||||
+54
@@ -152,6 +152,17 @@ button { border: 0; }
|
||||
.result-meta,
|
||||
.theme-copy { color: var(--muted-2); font-size: 0.82rem; }
|
||||
.lead-meta { margin-top: 0.65rem; }
|
||||
.lead-row,
|
||||
.theme-metrics,
|
||||
.viewer-meta,
|
||||
.collection-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
.lead-row { margin-top: 0.75rem; }
|
||||
.lead-side { color: var(--muted); font-size: 0.82rem; }
|
||||
.theme-title { margin-top: 0.35rem; font-size: 1.2rem; font-weight: 600; letter-spacing: -0.03em; }
|
||||
.theme-copy { margin-top: 0.55rem; line-height: 1.55; }
|
||||
|
||||
@@ -186,6 +197,32 @@ button { border: 0; }
|
||||
.search-block,
|
||||
.main-grid { margin-top: 1rem; }
|
||||
.main-grid { grid-template-columns: minmax(0, 1.1fr) minmax(320px, 0.9fr); }
|
||||
.collection-grid {
|
||||
margin-top: 1rem;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 0.8rem;
|
||||
}
|
||||
.collection-card {
|
||||
text-align: left;
|
||||
background: rgba(28,32,36,0.88);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 0.95rem;
|
||||
cursor: pointer;
|
||||
transition: transform 160ms ease, border-color 160ms ease, background 160ms ease;
|
||||
}
|
||||
.collection-card:hover,
|
||||
.collection-card.active {
|
||||
transform: translateY(-2px);
|
||||
border-color: color-mix(in srgb, var(--accent) 50%, var(--border));
|
||||
background: color-mix(in srgb, var(--accent-soft) 55%, rgba(28,32,36,0.92));
|
||||
}
|
||||
.collection-meta {
|
||||
margin-top: 0.45rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.section-heading h2,
|
||||
.compact-heading h2 {
|
||||
margin-top: 0.2rem;
|
||||
@@ -299,6 +336,23 @@ button { border: 0; }
|
||||
background: var(--accent-soft);
|
||||
border-color: color-mix(in srgb, var(--accent) 70%, var(--border));
|
||||
}
|
||||
.mood-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 1.8rem;
|
||||
padding: 0.18rem 0.55rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(255,255,255,0.08);
|
||||
font-size: 0.74rem;
|
||||
color: var(--text);
|
||||
background: rgba(255,255,255,0.03);
|
||||
}
|
||||
.mood-focused { border-color: rgba(129,161,193,0.3); color: #b5d2ee; }
|
||||
.mood-playful { border-color: rgba(180,142,173,0.3); color: #e1c0dd; }
|
||||
.mood-reflective { border-color: rgba(143,188,187,0.3); color: #bfe1df; }
|
||||
.mood-tense { border-color: rgba(208,106,106,0.35); color: #f1b3b3; }
|
||||
.mood-warm { border-color: rgba(208,168,110,0.35); color: #f1d4ab; }
|
||||
.mood-steady { border-color: rgba(156,163,175,0.25); color: #d1d5db; }
|
||||
|
||||
.ghost-btn,
|
||||
.primary-btn,
|
||||
|
||||
+99
-20
@@ -34,6 +34,17 @@ function pickSpark(content) {
|
||||
return clipText(candidate.replace(/^[-*]\s*/, ''))
|
||||
}
|
||||
|
||||
function moodLabel(mood) {
|
||||
return {
|
||||
focused: 'Focused',
|
||||
playful: 'Playful',
|
||||
reflective: 'Reflective',
|
||||
tense: 'Tense',
|
||||
warm: 'Warm',
|
||||
steady: 'Steady',
|
||||
}[mood] || 'Steady'
|
||||
}
|
||||
|
||||
function TagStrip({ tags = [], onPick, activeTag }) {
|
||||
if (!tags.length) return null
|
||||
return (
|
||||
@@ -52,12 +63,17 @@ function TagStrip({ tags = [], onPick, activeTag }) {
|
||||
)
|
||||
}
|
||||
|
||||
function MoodBadge({ mood }) {
|
||||
if (!mood) return null
|
||||
return <span className={`mood-badge mood-${mood}`}>{moodLabel(mood)}</span>
|
||||
}
|
||||
|
||||
function FileCard({ file, onClick, onPickTag, activeTag }) {
|
||||
return (
|
||||
<button className="file-card interactive-card" onClick={onClick}>
|
||||
<div className="file-card-top">
|
||||
<div className="file-date">{formatDate(file.mtimeMs)}</div>
|
||||
<div className="file-age">{formatRelative(file.mtimeMs)}</div>
|
||||
<MoodBadge mood={file.mood} />
|
||||
</div>
|
||||
<div className="file-label">{file.dateLabel}</div>
|
||||
<div className="file-snippet">{file.tags?.[0] ? `Leaning ${file.tags[0]}.` : 'Quiet day in the archive.'}</div>
|
||||
@@ -65,6 +81,7 @@ function FileCard({ file, onClick, onPickTag, activeTag }) {
|
||||
<div className="file-stats">
|
||||
<span>{file.wordCount.toLocaleString()} words</span>
|
||||
<span>{file.entryCount} entries</span>
|
||||
<span>{formatRelative(file.mtimeMs)}</span>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
@@ -76,6 +93,7 @@ function Viewer({ selected, onBack, activeTag, onPickTag }) {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saveState, setSaveState] = useState('idle')
|
||||
const [tags, setTags] = useState(selected.tags || [])
|
||||
const [mood, setMood] = useState(selected.mood || 'steady')
|
||||
const isMainMemory = selected.type === 'main'
|
||||
|
||||
useEffect(() => {
|
||||
@@ -90,6 +108,7 @@ function Viewer({ selected, onBack, activeTag, onPickTag }) {
|
||||
setContent(next)
|
||||
setDraft(next)
|
||||
setTags(data.tags || selected.tags || [])
|
||||
setMood(data.mood || selected.mood || 'steady')
|
||||
setLoading(false)
|
||||
})
|
||||
.catch(() => setLoading(false))
|
||||
@@ -108,6 +127,7 @@ function Viewer({ selected, onBack, activeTag, onPickTag }) {
|
||||
setContent(draft)
|
||||
const refreshed = await fetch(`${API}/api/main-memory`).then((res) => res.json())
|
||||
setTags(refreshed.tags || [])
|
||||
setMood(refreshed.mood || 'steady')
|
||||
setSaveState('saved')
|
||||
setTimeout(() => setSaveState('idle'), 1800)
|
||||
} catch {
|
||||
@@ -123,11 +143,14 @@ function Viewer({ selected, onBack, activeTag, onPickTag }) {
|
||||
<div className="viewer-kicker">{isMainMemory ? 'core memory' : 'daily note'}</div>
|
||||
<div className="viewer-title">{selected.title}</div>
|
||||
</div>
|
||||
{isMainMemory && !loading && (
|
||||
<button className="primary-btn viewer-save" onClick={save} disabled={saveState === 'saving' || draft === content}>
|
||||
{saveState === 'saving' ? 'Saving…' : saveState === 'saved' ? 'Saved' : saveState === 'error' ? 'Retry' : 'Save'}
|
||||
</button>
|
||||
)}
|
||||
<div className="viewer-meta">
|
||||
<MoodBadge mood={mood} />
|
||||
{isMainMemory && !loading && (
|
||||
<button className="primary-btn viewer-save" onClick={save} disabled={saveState === 'saving' || draft === content}>
|
||||
{saveState === 'saving' ? 'Saving…' : saveState === 'saved' ? 'Saved' : saveState === 'error' ? 'Retry' : 'Save'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<TagStrip tags={tags} onPick={onPickTag} activeTag={activeTag} />
|
||||
{loading ? (
|
||||
@@ -162,6 +185,7 @@ function ActivityRail({ files, onSelect, onPickTag, activeTag }) {
|
||||
<TagStrip tags={file.tags} onPick={onPickTag} activeTag={activeTag} />
|
||||
</div>
|
||||
<div className="rail-metrics">
|
||||
<MoodBadge mood={file.mood} />
|
||||
<strong>{file.wordCount.toLocaleString()}</strong>
|
||||
<span>{file.entryCount} entries</span>
|
||||
<div className="rail-bar"><span style={{ width: `${(file.wordCount / maxWords) * 100}%` }} /></div>
|
||||
@@ -173,17 +197,50 @@ function ActivityRail({ files, onSelect, onPickTag, activeTag }) {
|
||||
)
|
||||
}
|
||||
|
||||
function ThemeSwatches({ theme, tags, onPickTag, activeTag }) {
|
||||
function ThemeSwatches({ theme, tags, mood, onPickTag, activeTag }) {
|
||||
return (
|
||||
<section className="panel recap-panel interactive-card">
|
||||
<div className="section-kicker">theme drift</div>
|
||||
<div className="theme-title">{theme?.name || 'Nord'} mode</div>
|
||||
<p className="theme-copy">The color temperature now leans with what lives in core memory, not just a fixed skin.</p>
|
||||
<p className="theme-copy">Color bends to core memory. Mood reads the tone behind it.</p>
|
||||
<div className="theme-metrics">
|
||||
<MoodBadge mood={mood} />
|
||||
</div>
|
||||
<TagStrip tags={tags} onPick={onPickTag} activeTag={activeTag} />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function CollectionsPanel({ collections = [], onOpenCollection, activeCollectionId }) {
|
||||
if (!collections.length) return null
|
||||
return (
|
||||
<section className="panel cluster-panel interactive-card">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<div className="section-kicker">auto collections</div>
|
||||
<h2>Memory is grouping itself.</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="collection-grid">
|
||||
{collections.map((collection) => (
|
||||
<button
|
||||
key={collection.id}
|
||||
className={`collection-card ${activeCollectionId === collection.id ? 'active' : ''}`}
|
||||
onClick={() => onOpenCollection(collection)}
|
||||
>
|
||||
<div className="collection-head">
|
||||
<strong>{collection.label}</strong>
|
||||
<span>{collection.count}</span>
|
||||
</div>
|
||||
<div className="collection-meta">Latest {collection.latest}</div>
|
||||
<TagStrip tags={collection.tags} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const [meta, setMeta] = useState(null)
|
||||
const [selected, setSelected] = useState(null)
|
||||
@@ -196,6 +253,7 @@ export default function App() {
|
||||
const [quickLog, setQuickLog] = useState('')
|
||||
const [logState, setLogState] = useState('idle')
|
||||
const [activeTag, setActiveTag] = useState('')
|
||||
const [activeCollection, setActiveCollection] = useState(null)
|
||||
const [pointer, setPointer] = useState({ x: 18, y: 12 })
|
||||
const searchRef = useRef(null)
|
||||
|
||||
@@ -250,6 +308,12 @@ export default function App() {
|
||||
}
|
||||
}, [activeTag])
|
||||
|
||||
useEffect(() => {
|
||||
if (activeCollection) {
|
||||
setQuery(activeCollection.tags.join(' '))
|
||||
}
|
||||
}, [activeCollection])
|
||||
|
||||
useEffect(() => {
|
||||
if (!query.trim() || query.length < 2) {
|
||||
setSearchResults([])
|
||||
@@ -259,9 +323,18 @@ export default function App() {
|
||||
const t = setTimeout(async () => {
|
||||
setSearching(true)
|
||||
try {
|
||||
const r = await fetch(`${API}/api/search?q=${encodeURIComponent(query)}`)
|
||||
const d = await r.json()
|
||||
setSearchResults(d.results || [])
|
||||
const terms = query.trim().split(/\s+/).filter(Boolean)
|
||||
const requests = await Promise.all(terms.map((term) => fetch(`${API}/api/search?q=${encodeURIComponent(term)}`).then((r) => r.json())))
|
||||
const merged = new Map()
|
||||
for (const payload of requests) {
|
||||
for (const result of payload.results || []) {
|
||||
const existing = merged.get(result.filename)
|
||||
if (!existing || (existing.matchCount || 0) < (result.matchCount || 0)) {
|
||||
merged.set(result.filename, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
setSearchResults(Array.from(merged.values()).sort((a, b) => (b.matchCount || 0) - (a.matchCount || 0)))
|
||||
} catch {
|
||||
setSearchResults([])
|
||||
}
|
||||
@@ -300,7 +373,7 @@ export default function App() {
|
||||
'--pointer-y': `${pointer.y}%`,
|
||||
}), [meta, pointer])
|
||||
|
||||
const openFile = (file) => setSelected({ type: 'daily', filename: file.filename, title: file.dateLabel, tags: file.tags || [] })
|
||||
const openFile = (file) => setSelected({ type: 'daily', filename: file.filename, title: file.dateLabel, tags: file.tags || [], mood: file.mood })
|
||||
|
||||
const submitLog = async (e) => {
|
||||
e.preventDefault()
|
||||
@@ -335,12 +408,12 @@ export default function App() {
|
||||
<div className="hero-copy">
|
||||
<div className="eyebrow">freeclaw memory cloud</div>
|
||||
<h1>Memory with a pulse.</h1>
|
||||
<p className="subtitle">Theme shifts with core memory. Tags are smarter. The archive should feel alive, not embalmed.</p>
|
||||
<p className="subtitle">Now with merged tags, mood sensing, and self-assembling collections.</p>
|
||||
</div>
|
||||
<div className="hero-actions">
|
||||
<button
|
||||
className="ghost-btn"
|
||||
onClick={() => setSelected({ type: 'main', title: 'MEMORY.md', filename: 'MEMORY.md', tags: meta?.mainMemory?.tags || [] })}
|
||||
onClick={() => setSelected({ type: 'main', title: 'MEMORY.md', filename: 'MEMORY.md', tags: meta?.mainMemory?.tags || [], mood: meta?.mainMemory?.mood })}
|
||||
>
|
||||
Open core memory
|
||||
</button>
|
||||
@@ -354,6 +427,10 @@ export default function App() {
|
||||
<div className="section-kicker">latest spark</div>
|
||||
<div className="lead-copy">{spark || 'Loading…'}</div>
|
||||
<div className="lead-meta">{sparkSource || 'recent archive'}</div>
|
||||
<div className="lead-row">
|
||||
<MoodBadge mood={heroStats.latest.mood} />
|
||||
<span className="lead-side">{heroStats.latest.wordCount.toLocaleString()} words</span>
|
||||
</div>
|
||||
<TagStrip tags={heroStats.latest.tags} onPick={setActiveTag} activeTag={activeTag} />
|
||||
</div>
|
||||
|
||||
@@ -364,10 +441,12 @@ export default function App() {
|
||||
<div className="stat-row"><span>review queue</span><strong>{reviewCount ?? '—'}</strong></div>
|
||||
</div>
|
||||
|
||||
<ThemeSwatches theme={meta.theme} tags={meta.mainMemory?.tags} onPickTag={setActiveTag} activeTag={activeTag} />
|
||||
<ThemeSwatches theme={meta.theme} tags={meta.mainMemory?.tags} mood={meta.mainMemory?.mood} onPickTag={setActiveTag} activeTag={activeTag} />
|
||||
</section>
|
||||
)}
|
||||
|
||||
<CollectionsPanel collections={meta?.collections} activeCollectionId={activeCollection?.id} onOpenCollection={(collection) => setActiveCollection((current) => current?.id === collection.id ? null : collection)} />
|
||||
|
||||
<section className="panel cluster-panel interactive-card">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
@@ -411,10 +490,10 @@ export default function App() {
|
||||
<div className="section-heading compact-heading">
|
||||
<div>
|
||||
<div className="section-kicker">search</div>
|
||||
<h2>{activeTag ? `Filtering on ${activeTag}` : 'Find threads fast.'}</h2>
|
||||
<h2>{activeCollection ? activeCollection.label : activeTag ? `Filtering on ${activeTag}` : 'Find threads fast.'}</h2>
|
||||
</div>
|
||||
{activeTag && (
|
||||
<button className="ghost-btn small-btn" onClick={() => { setActiveTag(''); setQuery('') }}>Clear tag</button>
|
||||
{(activeTag || activeCollection) && (
|
||||
<button className="ghost-btn small-btn" onClick={() => { setActiveTag(''); setActiveCollection(null); setQuery('') }}>Clear filters</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="search-section">
|
||||
@@ -434,11 +513,11 @@ export default function App() {
|
||||
<button
|
||||
key={result.filename}
|
||||
className="result-item interactive-card"
|
||||
onClick={() => setSelected({ type: 'daily', filename: result.filename, title: result.dateLabel, tags: result.tags || [] })}
|
||||
onClick={() => setSelected({ type: 'daily', filename: result.filename, title: result.dateLabel, tags: result.tags || [], mood: result.mood })}
|
||||
>
|
||||
<div className="result-head">
|
||||
<div className="result-name">{result.dateLabel}</div>
|
||||
<div className="result-meta">{result.matchCount} matches</div>
|
||||
<MoodBadge mood={result.mood} />
|
||||
</div>
|
||||
<div className="result-snippet">{result.snippet}</div>
|
||||
<TagStrip tags={result.tags} onPick={setActiveTag} activeTag={activeTag} />
|
||||
|
||||
Reference in New Issue
Block a user