UI improvements: add memory form, fix dashboard charts, Neural Archive theme

- Add "New Memory" button with collapsible form (content, category, tags, importance)
- Add share memory with user typeahead via new GET /api/users endpoint
- Fix dashboard charts not rendering (replace $nextTick with setTimeout for x-if timing)
- Redesign theme: warm walnut/amber palette, Playfair Display + JetBrains Mono fonts
- Update Chart.js and D3 graph colors to match warm theme
This commit is contained in:
Viktor Barzin 2026-03-22 21:30:58 +02:00
parent cf9baf4b8e
commit 95dd937765
No known key found for this signature in database
GPG key ID: 0EB088298288D958
6 changed files with 398 additions and 104 deletions

View file

@ -85,6 +85,13 @@ async def auth_check(user: AuthUser = Depends(get_current_user)) -> dict[str, st
return {"status": "ok", "user_id": user.user_id}
@app.get("/api/users")
async def list_users(user: AuthUser = Depends(get_current_user)) -> dict[str, Any]:
"""Return list of known user IDs (excluding current user) for sharing typeahead."""
all_users = sorted(set(_key_to_user.values()))
return {"users": [u for u in all_users if u != user.user_id]}
@app.get("/api/memories/sync", response_model=SyncResponse)
async def sync_memories(
since: Optional[str] = None,

View file

@ -1,34 +1,53 @@
/* Dark theme base */
/* Neural Archive — warm research console theme */
@import url('https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;600;700&family=JetBrains+Mono:wght@400;500&family=Source+Serif+4:wght@400;500;600&display=swap');
:root {
--bg-primary: #0f172a;
--bg-secondary: #1e293b;
--bg-tertiary: #334155;
--text-primary: #f1f5f9;
--text-secondary: #94a3b8;
--accent: #6366f1;
--accent-hover: #818cf8;
--success: #22c55e;
--warning: #f59e0b;
--danger: #ef4444;
--border: #334155;
--bg-primary: #1a1613;
--bg-secondary: #252019;
--bg-tertiary: #302a22;
--text-primary: #e8e0d4;
--text-secondary: #c4b8a8;
--text-muted: #8a7e6e;
--accent: #d4a04a;
--accent-hover: #e8b94a;
--accent-glow: rgba(212, 160, 74, 0.15);
--sage: #7a8b6f;
--sage-dim: rgba(122, 139, 111, 0.2);
--success: #7a8b6f;
--warning: #d4a04a;
--danger: #c4785a;
--border: #3a3228;
--border-accent: rgba(212, 160, 74, 0.3);
}
body {
background: var(--bg-primary);
color: var(--text-primary);
font-family: 'Inter', system-ui, -apple-system, sans-serif;
font-family: 'Source Serif 4', Georgia, serif;
}
/* Paper grain texture */
body::before {
content: '';
position: fixed;
inset: 0;
opacity: 0.03;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
pointer-events: none;
z-index: 0;
}
/* Scrollbar */
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: var(--bg-primary); }
::-webkit-scrollbar-thumb { background: var(--bg-tertiary); border-radius: 3px; }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: var(--text-muted); }
/* Login modal */
.login-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.7);
background: rgba(10, 8, 6, 0.85);
display: flex;
align-items: center;
justify-content: center;
@ -37,25 +56,32 @@ body {
.login-card {
background: var(--bg-secondary);
border: 1px solid var(--border);
border: 1px solid var(--border-accent);
border-radius: 12px;
padding: 2rem;
padding: 2.5rem;
width: 100%;
max-width: 400px;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
box-shadow: 0 25px 60px -12px rgba(0, 0, 0, 0.6), 0 0 40px var(--accent-glow);
}
.login-card h2 {
font-family: 'Playfair Display', serif;
color: var(--accent);
}
/* Tab navigation */
.tab-btn {
padding: 0.5rem 1rem;
padding: 0.5rem 1.25rem;
border-radius: 6px;
font-size: 0.875rem;
font-weight: 500;
transition: all 0.15s;
font-family: 'JetBrains Mono', monospace;
letter-spacing: 0.02em;
transition: all 0.2s;
cursor: pointer;
border: none;
background: transparent;
color: var(--text-secondary);
color: var(--text-muted);
}
.tab-btn:hover {
@ -65,7 +91,8 @@ body {
.tab-btn.active {
background: var(--accent);
color: white;
color: var(--bg-primary);
box-shadow: 0 0 12px var(--accent-glow);
}
/* Cards */
@ -73,13 +100,15 @@ body {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 8px;
padding: 1rem;
transition: border-color 0.15s;
padding: 1rem 1.25rem;
transition: all 0.2s;
cursor: pointer;
position: relative;
}
.memory-card:hover {
border-color: var(--accent);
border-color: var(--border-accent);
box-shadow: 0 2px 16px var(--accent-glow);
}
/* Category badge */
@ -87,45 +116,48 @@ body {
display: inline-block;
padding: 0.125rem 0.5rem;
border-radius: 9999px;
font-size: 0.75rem;
font-size: 0.7rem;
font-weight: 500;
font-family: 'JetBrains Mono', monospace;
}
.badge-category {
background: rgba(99, 102, 241, 0.2);
color: #a5b4fc;
background: rgba(212, 160, 74, 0.15);
color: var(--accent);
}
.badge-sensitive {
background: rgba(239, 68, 68, 0.2);
color: #fca5a5;
background: rgba(196, 120, 90, 0.2);
color: #e8a090;
}
.badge-read {
background: rgba(148, 163, 184, 0.2);
color: #94a3b8;
background: var(--sage-dim);
color: var(--sage);
}
.badge-write {
background: rgba(34, 197, 94, 0.2);
color: #86efac;
background: rgba(212, 160, 74, 0.15);
color: var(--accent);
}
/* Tag pills */
.tag-pill {
display: inline-block;
padding: 0.125rem 0.375rem;
padding: 0.125rem 0.5rem;
border-radius: 4px;
font-size: 0.7rem;
font-family: 'JetBrains Mono', monospace;
background: var(--bg-tertiary);
color: var(--text-secondary);
color: var(--text-muted);
margin-right: 0.25rem;
margin-bottom: 0.25rem;
border: 1px solid transparent;
}
/* Importance bar */
.importance-bar {
height: 4px;
height: 3px;
border-radius: 2px;
background: var(--bg-tertiary);
overflow: hidden;
@ -134,7 +166,7 @@ body {
.importance-fill {
height: 100%;
border-radius: 2px;
background: var(--accent);
background: linear-gradient(90deg, var(--sage), var(--accent));
transition: width 0.3s;
}
@ -146,13 +178,15 @@ body {
padding: 0.5rem 0.75rem;
color: var(--text-primary);
font-size: 0.875rem;
font-family: 'Source Serif 4', Georgia, serif;
width: 100%;
outline: none;
transition: border-color 0.15s;
transition: border-color 0.2s, box-shadow 0.2s;
}
.input-field:focus {
border-color: var(--accent);
box-shadow: 0 0 0 2px var(--accent-glow);
}
textarea.input-field {
@ -166,18 +200,20 @@ textarea.input-field {
border-radius: 6px;
font-size: 0.875rem;
font-weight: 500;
font-family: 'JetBrains Mono', monospace;
cursor: pointer;
border: none;
transition: all 0.15s;
transition: all 0.2s;
}
.btn-primary {
background: var(--accent);
color: white;
color: var(--bg-primary);
}
.btn-primary:hover {
background: var(--accent-hover);
box-shadow: 0 0 16px var(--accent-glow);
}
.btn-danger {
@ -186,7 +222,7 @@ textarea.input-field {
}
.btn-danger:hover {
background: #dc2626;
background: #d4685a;
}
.btn-ghost {
@ -198,6 +234,7 @@ textarea.input-field {
.btn-ghost:hover {
background: var(--bg-tertiary);
color: var(--text-primary);
border-color: var(--border-accent);
}
/* Stats card */
@ -206,18 +243,27 @@ textarea.input-field {
border: 1px solid var(--border);
border-radius: 8px;
padding: 1.25rem;
transition: border-color 0.2s;
}
.stat-card:hover {
border-color: var(--border-accent);
}
.stat-card .stat-value {
font-size: 1.75rem;
font-weight: 700;
font-family: 'Playfair Display', serif;
color: var(--accent);
}
.stat-card .stat-label {
font-size: 0.8rem;
color: var(--text-secondary);
color: var(--text-muted);
margin-top: 0.25rem;
font-family: 'JetBrains Mono', monospace;
text-transform: uppercase;
letter-spacing: 0.05em;
}
/* Chart container */
@ -244,7 +290,7 @@ textarea.input-field {
/* Node detail panel */
.node-detail {
background: var(--bg-secondary);
border: 1px solid var(--border);
border: 1px solid var(--border-accent);
border-radius: 8px;
padding: 1rem;
}
@ -255,7 +301,7 @@ textarea.input-field {
align-items: center;
gap: 0.25rem;
font-size: 0.75rem;
color: var(--text-secondary);
color: var(--text-muted);
}
/* Loading spinner */
@ -289,14 +335,94 @@ input[type="range"]::-webkit-slider-thumb {
border-radius: 50%;
background: var(--accent);
cursor: pointer;
box-shadow: 0 0 6px var(--accent-glow);
}
/* Section header */
.section-title {
font-size: 0.75rem;
font-size: 0.7rem;
font-weight: 600;
font-family: 'JetBrains Mono', monospace;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-secondary);
letter-spacing: 0.08em;
color: var(--text-muted);
margin-bottom: 0.75rem;
}
/* Add memory form */
.add-form-container {
background: var(--bg-secondary);
border: 1px solid var(--border-accent);
border-radius: 8px;
padding: 1.25rem;
margin-bottom: 1rem;
animation: fadeIn 0.2s ease-out;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(-4px); }
to { opacity: 1; transform: translateY(0); }
}
.char-counter {
font-family: 'JetBrains Mono', monospace;
font-size: 0.7rem;
color: var(--text-muted);
}
.char-counter.warn {
color: var(--danger);
}
/* Share form */
.share-form {
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: 6px;
padding: 0.75rem;
margin-top: 0.5rem;
animation: fadeIn 0.15s ease-out;
}
.typeahead-dropdown {
position: absolute;
z-index: 10;
background: var(--bg-primary);
border: 1px solid var(--border-accent);
border-radius: 6px;
max-height: 150px;
overflow-y: auto;
margin-top: 2px;
width: 100%;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
}
.typeahead-item {
padding: 0.375rem 0.75rem;
font-size: 0.85rem;
font-family: 'JetBrains Mono', monospace;
cursor: pointer;
color: var(--text-secondary);
}
.typeahead-item:hover {
background: var(--bg-tertiary);
color: var(--accent);
}
/* Header title */
.app-title {
font-family: 'Playfair Display', serif;
color: var(--accent);
letter-spacing: -0.02em;
}
/* Checkbox styling */
input[type="checkbox"] {
accent-color: var(--accent);
}
/* Select styling */
select.input-field {
cursor: pointer;
}

View file

@ -3,8 +3,22 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Claude Memory</title>
<title>Neural Archive</title>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: {
extend: {
colors: {
walnut: { 900: '#1a1613', 800: '#252019', 700: '#302a22', 600: '#3a3228' },
amber: { 400: '#e8b94a', 500: '#d4a04a' },
sage: { 500: '#7a8b6f' },
cream: { 100: '#e8e0d4', 200: '#c4b8a8', 400: '#8a7e6e' },
}
}
}
}
</script>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
<script src="https://cdn.jsdelivr.net/npm/d3@7"></script>
@ -22,8 +36,8 @@
<template x-if="!$store.app.authenticated" x-data>
<div class="login-backdrop">
<div class="login-card">
<h2 class="text-xl font-bold mb-1">Claude Memory</h2>
<p class="text-sm text-slate-400 mb-6">Enter your API key to continue</p>
<h2 class="text-xl font-bold mb-1">Neural Archive</h2>
<p class="text-sm mb-6" style="color: var(--text-muted)">Enter your API key to continue</p>
<form @submit.prevent="$store.app.login()">
<input
type="password"
@ -33,7 +47,7 @@
autofocus
>
<template x-if="$store.app.loginError">
<p class="text-red-400 text-sm mb-3" x-text="$store.app.loginError"></p>
<p class="text-sm mb-3" style="color: var(--danger)" x-text="$store.app.loginError"></p>
</template>
<button type="submit" class="btn btn-primary w-full" :disabled="$store.app.loginLoading">
<span x-show="!$store.app.loginLoading">Sign In</span>
@ -53,15 +67,15 @@
<!-- Header -->
<div class="flex items-center justify-between mb-6">
<div class="flex items-center gap-3">
<h1 class="text-xl font-bold">Claude Memory</h1>
<span class="text-sm text-slate-400">|</span>
<span class="text-sm text-slate-400" x-text="$store.app.userId"></span>
<h1 class="text-xl font-bold app-title">Neural Archive</h1>
<span style="color: var(--border)">|</span>
<span class="text-sm" style="color: var(--text-muted); font-family: 'JetBrains Mono', monospace" x-text="$store.app.userId"></span>
</div>
<button @click="$store.app.logout()" class="btn btn-ghost text-sm">Logout</button>
</div>
<!-- Tab Navigation -->
<div class="flex gap-1 mb-6 bg-slate-800/50 p-1 rounded-lg w-fit">
<div class="flex gap-1 mb-6 p-1 rounded-lg w-fit" style="background: var(--bg-secondary)">
<button
class="tab-btn"
:class="{ active: $store.app.activeTab === 'memories' }"
@ -87,20 +101,63 @@
<!-- Tab: Memories -->
<div x-show="$store.app.activeTab === 'memories'" x-data="memoriesBrowser()" x-init="init()">
<!-- Filters -->
<!-- Filters + Add button -->
<div class="flex items-center gap-3 mb-4">
<button class="btn btn-primary text-sm" @click="showAddForm = !showAddForm">
<span x-show="!showAddForm">+ New Memory</span>
<span x-show="showAddForm">Cancel</span>
</button>
<select class="input-field" style="width:auto" x-model="selectedCategory" @change="filterByCategory()">
<option value="">All categories</option>
<template x-for="cat in categories" :key="cat">
<option :value="cat" x-text="cat"></option>
</template>
</select>
<span class="text-sm text-slate-400">
<span x-text="memories.length"></span> / <span x-text="total"></span> memories
<span class="text-sm" style="color: var(--text-muted); font-family: 'JetBrains Mono', monospace">
<span x-text="memories.length"></span> / <span x-text="total"></span>
</span>
<span x-show="loading" class="spinner"></span>
</div>
<!-- Add Memory Form -->
<div x-show="showAddForm" class="add-form-container">
<div class="mb-3">
<div class="flex justify-between items-center mb-1">
<label class="section-title" style="margin-bottom:0">Content</label>
<span class="char-counter" :class="{ warn: addFormCharCount > 750 }">
<span x-text="addFormCharCount"></span> / 800
</span>
</div>
<textarea class="input-field" x-model="addForm.content" rows="3" placeholder="What do you want to remember?" maxlength="800"></textarea>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-3 mb-3">
<div>
<label class="section-title">Category</label>
<input type="text" class="input-field" x-model="addForm.category" placeholder="facts" list="category-list">
<datalist id="category-list">
<template x-for="cat in categories" :key="cat">
<option :value="cat"></option>
</template>
</datalist>
</div>
<div>
<label class="section-title">Tags</label>
<input type="text" class="input-field" x-model="addForm.tags" placeholder="tag1, tag2">
</div>
<div>
<label class="section-title">Importance</label>
<div class="flex items-center gap-2">
<input type="range" min="0" max="1" step="0.1" x-model="addForm.importance" class="flex-1">
<span class="text-sm" style="color: var(--text-muted); font-family: 'JetBrains Mono', monospace" x-text="addForm.importance"></span>
</div>
</div>
</div>
<div class="flex gap-2">
<button class="btn btn-primary text-sm" @click="addMemory()" :disabled="!addForm.content.trim()">Save Memory</button>
<button class="btn btn-ghost text-sm" @click="resetAddForm()">Cancel</button>
</div>
</div>
<!-- My Memories -->
<div class="section-title">My Memories</div>
<div class="space-y-2 mb-6">
@ -110,7 +167,7 @@
<div class="flex items-start justify-between gap-3">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
<span class="text-xs text-slate-500">#<span x-text="mem.id"></span></span>
<span class="text-xs" style="color: var(--text-muted); font-family: 'JetBrains Mono', monospace">#<span x-text="mem.id"></span></span>
<span class="badge badge-category" x-text="mem.category"></span>
<template x-if="mem.is_sensitive">
<span class="badge badge-sensitive">SENSITIVE</span>
@ -119,7 +176,7 @@
<p class="text-sm" x-show="expandedId !== mem.id" x-text="preview(mem.content)"></p>
</div>
<div class="flex flex-col items-end gap-1 shrink-0">
<span class="text-xs text-slate-500" x-text="relativeTime(mem.updated_at)"></span>
<span class="text-xs" style="color: var(--text-muted)" x-text="relativeTime(mem.updated_at)"></span>
<div class="importance-bar w-16">
<div class="importance-fill" :style="`width: ${mem.importance * 100}%`"></div>
</div>
@ -134,12 +191,12 @@
</div>
<!-- Expanded -->
<div x-show="expandedId === mem.id" @click.stop class="mt-3 pt-3 border-t border-slate-700">
<div x-show="expandedId === mem.id" @click.stop class="mt-3 pt-3" style="border-top: 1px solid var(--border)">
<!-- View mode -->
<template x-if="editingId !== mem.id">
<div>
<p class="text-sm whitespace-pre-wrap mb-3" x-text="mem.content"></p>
<div class="text-xs text-slate-500 mb-3">
<div class="text-xs mb-3" style="color: var(--text-muted); font-family: 'JetBrains Mono', monospace">
Created: <span x-text="new Date(mem.created_at).toLocaleString()"></span>
&middot; Updated: <span x-text="new Date(mem.updated_at).toLocaleString()"></span>
&middot; Importance: <span x-text="mem.importance"></span>
@ -147,10 +204,11 @@
<div class="flex gap-2">
<button class="btn btn-ghost text-xs" @click.stop="startEdit(mem)" x-show="!mem.is_sensitive">Edit</button>
<button class="btn btn-ghost text-xs" @click.stop="toggleShares(mem.id)">Shares</button>
<button class="btn btn-ghost text-xs" @click.stop="openShareForm(mem.id)">Share</button>
<button class="btn text-xs" style="color: var(--danger)" @click.stop="confirmDelete(mem.id)">Delete</button>
</div>
<!-- Delete confirmation -->
<div x-show="deleteConfirm === mem.id" class="mt-2 p-2 rounded bg-red-900/30 border border-red-800 text-sm">
<div x-show="deleteConfirm === mem.id" class="mt-2 p-2 rounded text-sm" style="background: rgba(196,120,90,0.1); border: 1px solid rgba(196,120,90,0.3)">
Delete this memory?
<button class="btn btn-danger text-xs ml-2" @click.stop="doDelete(mem.id)">Confirm</button>
<button class="btn btn-ghost text-xs ml-1" @click.stop="deleteConfirm = null">Cancel</button>
@ -158,15 +216,36 @@
<!-- Shares info -->
<div x-show="sharesExpanded === mem.id" class="mt-2">
<template x-if="sharesData.length === 0">
<p class="text-xs text-slate-500">Not shared with anyone</p>
<p class="text-xs" style="color: var(--text-muted)">Not shared with anyone</p>
</template>
<template x-for="s in sharesData" :key="s.shared_with">
<div class="text-xs text-slate-400">
Shared with <span class="text-slate-200" x-text="s.shared_with"></span>
<div class="text-xs" style="color: var(--text-secondary)">
Shared with <span style="color: var(--text-primary)" x-text="s.shared_with"></span>
<span class="badge" :class="s.permission === 'write' ? 'badge-write' : 'badge-read'" x-text="s.permission"></span>
</div>
</template>
</div>
<!-- Share form -->
<div x-show="showShareForm === mem.id" class="share-form" @click.stop>
<div class="flex gap-2 mb-2">
<div class="flex-1 relative">
<input type="text" class="input-field" x-model="shareForm.user" placeholder="Username" @input="shareUserFilter = shareForm.user" @focus="shareUserFilter = shareForm.user">
<div x-show="shareForm.user && filteredUsers.length > 0" class="typeahead-dropdown">
<template x-for="u in filteredUsers" :key="u">
<div class="typeahead-item" @click="selectShareUser(u)" x-text="u"></div>
</template>
</div>
</div>
<select class="input-field" style="width:auto" x-model="shareForm.permission">
<option value="read">Read</option>
<option value="write">Write</option>
</select>
</div>
<div class="flex gap-2">
<button class="btn btn-primary text-xs" @click.stop="shareMemory(mem.id)" :disabled="!shareForm.user.trim()">Share</button>
<button class="btn btn-ghost text-xs" @click.stop="closeShareForm()">Cancel</button>
</div>
</div>
</div>
</template>
@ -175,13 +254,13 @@
<div>
<textarea class="input-field mb-2" x-model="editForm.content" rows="4"></textarea>
<div class="flex items-center gap-3 mb-2">
<label class="text-xs text-slate-400">Tags:</label>
<label class="text-xs" style="color: var(--text-muted)">Tags:</label>
<input type="text" class="input-field" x-model="editForm.tags" placeholder="tag1, tag2">
</div>
<div class="flex items-center gap-3 mb-3">
<label class="text-xs text-slate-400">Importance:</label>
<label class="text-xs" style="color: var(--text-muted)">Importance:</label>
<input type="range" min="0" max="1" step="0.1" x-model="editForm.importance" class="flex-1">
<span class="text-xs text-slate-400" x-text="editForm.importance"></span>
<span class="text-xs" style="color: var(--text-muted)" x-text="editForm.importance"></span>
</div>
<div class="flex gap-2">
<button class="btn btn-primary text-xs" @click.stop="saveEdit(mem.id)">Save</button>
@ -212,7 +291,7 @@
<div class="flex items-start justify-between gap-3">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
<span class="text-xs text-slate-500">#<span x-text="mem.id"></span></span>
<span class="text-xs" style="color: var(--text-muted); font-family: 'JetBrains Mono', monospace">#<span x-text="mem.id"></span></span>
<span class="badge badge-category" x-text="mem.category"></span>
<span class="badge" :class="mem.permission === 'write' ? 'badge-write' : 'badge-read'" x-text="mem.permission"></span>
<span class="shared-indicator">
@ -223,7 +302,7 @@
<p class="text-sm" x-show="expandedId !== 's' + mem.id" x-text="preview(mem.content)"></p>
</div>
<div class="flex flex-col items-end gap-1 shrink-0">
<span class="text-xs text-slate-500" x-text="relativeTime(mem.updated_at)"></span>
<span class="text-xs" style="color: var(--text-muted)" x-text="relativeTime(mem.updated_at)"></span>
<div class="importance-bar w-16">
<div class="importance-fill" :style="`width: ${mem.importance * 100}%`"></div>
</div>
@ -234,9 +313,9 @@
<span class="tag-pill" x-text="tag"></span>
</template>
</div>
<div x-show="expandedId === 's' + mem.id" @click.stop class="mt-3 pt-3 border-t border-slate-700">
<div x-show="expandedId === 's' + mem.id" @click.stop class="mt-3 pt-3" style="border-top: 1px solid var(--border)">
<p class="text-sm whitespace-pre-wrap mb-2" x-text="mem.content"></p>
<div class="text-xs text-slate-500">
<div class="text-xs" style="color: var(--text-muted); font-family: 'JetBrains Mono', monospace">
Created: <span x-text="new Date(mem.created_at).toLocaleString()"></span>
&middot; Updated: <span x-text="new Date(mem.updated_at).toLocaleString()"></span>
</div>
@ -280,7 +359,7 @@
<div class="flex items-start justify-between gap-3">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
<span class="text-xs text-slate-500">#<span x-text="mem.id"></span></span>
<span class="text-xs" style="color: var(--text-muted); font-family: 'JetBrains Mono', monospace">#<span x-text="mem.id"></span></span>
<span class="badge badge-category" x-text="mem.category"></span>
<template x-if="mem.is_sensitive">
<span class="badge badge-sensitive">SENSITIVE</span>
@ -295,11 +374,11 @@
<p class="text-sm" x-show="expandedId !== mem.id" x-text="preview(mem.content)"></p>
</div>
<div class="flex flex-col items-end gap-1 shrink-0">
<span class="text-xs text-slate-500" x-text="relativeTime(mem.updated_at)"></span>
<span class="text-xs" style="color: var(--text-muted)" x-text="relativeTime(mem.updated_at)"></span>
<div class="importance-bar w-16">
<div class="importance-fill" :style="`width: ${mem.importance * 100}%`"></div>
</div>
<span class="text-xs text-indigo-400" x-text="'rank: ' + (mem.rank || 0).toFixed(2)"></span>
<span class="text-xs" style="color: var(--accent); font-family: 'JetBrains Mono', monospace" x-text="'rank: ' + (mem.rank || 0).toFixed(2)"></span>
</div>
</div>
<div class="mt-1" x-show="mem.tags">
@ -308,9 +387,9 @@
</template>
</div>
<!-- Expanded -->
<div x-show="expandedId === mem.id" @click.stop class="mt-3 pt-3 border-t border-slate-700">
<div x-show="expandedId === mem.id" @click.stop class="mt-3 pt-3" style="border-top: 1px solid var(--border)">
<p class="text-sm whitespace-pre-wrap mb-2" x-text="mem.content"></p>
<div class="text-xs text-slate-500">
<div class="text-xs" style="color: var(--text-muted); font-family: 'JetBrains Mono', monospace">
Created: <span x-text="new Date(mem.created_at).toLocaleString()"></span>
&middot; Updated: <span x-text="new Date(mem.updated_at).toLocaleString()"></span>
<template x-if="mem.share_permission">
@ -322,14 +401,14 @@
</template>
</div>
<p x-show="query && !loading && results.length === 0" class="text-sm text-slate-500 mt-4">No results found</p>
<p x-show="query && !loading && results.length === 0" class="text-sm mt-4" style="color: var(--text-muted)">No results found</p>
</div>
<!-- Tab: Graph -->
<div x-show="$store.app.activeTab === 'graph'" x-data="graphComponent()" x-init="$watch('$store.app.activeTab', v => { if (v === 'graph' && memories.length === 0) init(); })">
<div class="flex flex-wrap gap-2 mb-4">
<template x-for="cat in categories" :key="cat">
<label class="flex items-center gap-1 text-sm cursor-pointer">
<label class="flex items-center gap-1 text-sm cursor-pointer" style="color: var(--text-secondary)">
<input type="checkbox" :checked="selectedCategories[cat]" @change="toggleCategory(cat)">
<span x-text="cat"></span>
</label>
@ -343,7 +422,7 @@
<template x-if="selectedNode">
<div class="node-detail w-80 shrink-0">
<div class="flex items-center gap-2 mb-2">
<span class="text-xs text-slate-500">#<span x-text="selectedNode.id"></span></span>
<span class="text-xs" style="color: var(--text-muted); font-family: 'JetBrains Mono', monospace">#<span x-text="selectedNode.id"></span></span>
<span class="badge badge-category" x-text="selectedNode.category"></span>
<template x-if="selectedNode.is_sensitive">
<span class="badge badge-sensitive">SENSITIVE</span>
@ -358,7 +437,7 @@
<div class="importance-bar w-full mb-2">
<div class="importance-fill" :style="`width: ${selectedNode.importance * 100}%`"></div>
</div>
<span class="text-xs text-slate-500">Importance: <span x-text="selectedNode.importance"></span></span>
<span class="text-xs" style="color: var(--text-muted); font-family: 'JetBrains Mono', monospace">Importance: <span x-text="selectedNode.importance"></span></span>
<button class="btn btn-ghost text-xs mt-3 block" @click="selectedNode = null">Close</button>
</div>
</template>

View file

@ -9,7 +9,7 @@ function dashboardComponent() {
this.loading = true;
try {
this.stats = await api.get('/api/stats');
this.$nextTick(() => this.renderCharts());
setTimeout(() => this.renderCharts(), 50);
} catch (e) {
console.error('Failed to load stats:', e);
}
@ -36,13 +36,13 @@ function dashboardComponent() {
type: 'doughnut',
data: {
labels,
datasets: [{ data, backgroundColor: colors, borderColor: '#1e293b', borderWidth: 2 }],
datasets: [{ data, backgroundColor: colors, borderColor: '#252019', borderWidth: 2 }],
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { position: 'bottom', labels: { color: '#94a3b8', padding: 12 } },
legend: { position: 'bottom', labels: { color: '#c4b8a8', padding: 12 } },
},
},
});
@ -63,7 +63,7 @@ function dashboardComponent() {
datasets: [{
label: 'Memories',
data,
backgroundColor: '#6366f1',
backgroundColor: '#d4a04a',
borderRadius: 4,
}],
},
@ -71,8 +71,8 @@ function dashboardComponent() {
responsive: true,
maintainAspectRatio: false,
scales: {
x: { ticks: { color: '#94a3b8' }, grid: { color: '#334155' } },
y: { ticks: { color: '#94a3b8' }, grid: { color: '#334155' }, beginAtZero: true },
x: { ticks: { color: '#c4b8a8' }, grid: { color: '#3a3228' } },
y: { ticks: { color: '#c4b8a8' }, grid: { color: '#3a3228' }, beginAtZero: true },
},
plugins: { legend: { display: false } },
},
@ -97,16 +97,16 @@ function dashboardComponent() {
{
label: 'Created',
data: created,
borderColor: '#22c55e',
backgroundColor: 'rgba(34,197,94,0.1)',
borderColor: '#7a8b6f',
backgroundColor: 'rgba(122,139,111,0.1)',
fill: true,
tension: 0.3,
},
{
label: 'Updated',
data: updated,
borderColor: '#f59e0b',
backgroundColor: 'rgba(245,158,11,0.1)',
borderColor: '#d4a04a',
backgroundColor: 'rgba(212,160,74,0.1)',
fill: true,
tension: 0.3,
},
@ -116,10 +116,10 @@ function dashboardComponent() {
responsive: true,
maintainAspectRatio: false,
scales: {
x: { ticks: { color: '#94a3b8', maxTicksLimit: 10 }, grid: { color: '#334155' } },
y: { ticks: { color: '#94a3b8' }, grid: { color: '#334155' }, beginAtZero: true },
x: { ticks: { color: '#c4b8a8', maxTicksLimit: 10 }, grid: { color: '#3a3228' } },
y: { ticks: { color: '#c4b8a8' }, grid: { color: '#3a3228' }, beginAtZero: true },
},
plugins: { legend: { labels: { color: '#94a3b8' } } },
plugins: { legend: { labels: { color: '#c4b8a8' } } },
},
});
},
@ -128,8 +128,8 @@ function dashboardComponent() {
function generateColors(count) {
const palette = [
'#6366f1', '#22c55e', '#f59e0b', '#ef4444', '#06b6d4',
'#ec4899', '#8b5cf6', '#14b8a6', '#f97316', '#64748b',
'#d4a04a', '#7a8b6f', '#c4785a', '#8b7355', '#6b8e8e',
'#b8860b', '#9aad82', '#d4764a', '#7a6b55', '#5f8a8a',
];
const result = [];
for (let i = 0; i < count; i++) result.push(palette[i % palette.length]);

View file

@ -82,7 +82,7 @@ function graphComponent() {
});
const categoryColors = {};
const palette = ['#6366f1', '#22c55e', '#f59e0b', '#ef4444', '#06b6d4', '#ec4899', '#8b5cf6', '#14b8a6', '#f97316', '#64748b'];
const palette = ['#d4a04a', '#7a8b6f', '#c4785a', '#8b7355', '#6b8e8e', '#b8860b', '#9aad82', '#d4764a', '#7a6b55', '#5f8a8a'];
this.categories.forEach((c, i) => categoryColors[c] = palette[i % palette.length]);
const svg = d3.select(container)
@ -110,8 +110,8 @@ function graphComponent() {
.selectAll('line')
.data(links)
.join('line')
.attr('stroke', '#334155')
.attr('stroke-opacity', 0.4)
.attr('stroke', '#3a3228')
.attr('stroke-opacity', 0.5)
.attr('stroke-width', 1);
const node = g.append('g')
@ -119,8 +119,8 @@ function graphComponent() {
.data(nodes)
.join('circle')
.attr('r', d => 5 + d.importance * 15)
.attr('fill', d => categoryColors[d.category] || '#64748b')
.attr('stroke', '#1e293b')
.attr('fill', d => categoryColors[d.category] || '#8b7355')
.attr('stroke', '#252019')
.attr('stroke-width', 1.5)
.attr('cursor', 'pointer')
.on('click', (event, d) => {

View file

@ -15,9 +15,17 @@ function memoriesBrowser() {
sharesExpanded: null,
sharesData: [],
deleteConfirm: null,
// Add memory
showAddForm: false,
addForm: { content: '', category: 'facts', tags: '', importance: 0.5 },
// Share memory
allUsers: [],
showShareForm: null,
shareForm: { user: '', permission: 'read' },
shareUserFilter: '',
async init() {
await Promise.all([this.loadMemories(), this.loadShared(), this.loadCategories()]);
await Promise.all([this.loadMemories(), this.loadShared(), this.loadCategories(), this.loadUsers()]);
},
async loadCategories() {
@ -27,6 +35,13 @@ function memoriesBrowser() {
} catch (e) { console.error('Failed to load categories:', e); }
},
async loadUsers() {
try {
const data = await api.get('/api/users');
this.allUsers = data.users;
} catch (e) { console.error('Failed to load users:', e); }
},
async loadMemories() {
this.loading = true;
try {
@ -68,6 +83,7 @@ function memoriesBrowser() {
this.expandedId = this.expandedId === id ? null : id;
this.editingId = null;
this.sharesExpanded = null;
this.showShareForm = null;
},
startEdit(mem) {
@ -125,6 +141,72 @@ function memoriesBrowser() {
} catch (e) { console.error('Failed to load shares:', e); }
},
// Add memory
resetAddForm() {
this.addForm = { content: '', category: 'facts', tags: '', importance: 0.5 };
this.showAddForm = false;
},
get addFormCharCount() {
return (this.addForm.content || '').length;
},
async addMemory() {
if (!this.addForm.content.trim()) return;
try {
await api.post('/api/memories', {
content: this.addForm.content,
category: this.addForm.category,
tags: this.addForm.tags,
importance: parseFloat(this.addForm.importance),
});
this.resetAddForm();
this.offset = 0;
await Promise.all([this.loadMemories(), this.loadCategories()]);
} catch (e) {
alert('Failed to add memory: ' + e.message);
}
},
// Share memory
openShareForm(memId) {
this.showShareForm = memId;
this.shareForm = { user: '', permission: 'read' };
this.shareUserFilter = '';
},
closeShareForm() {
this.showShareForm = null;
},
get filteredUsers() {
const q = this.shareForm.user.toLowerCase();
if (!q) return this.allUsers.slice(0, 10);
return this.allUsers.filter(u => u.toLowerCase().includes(q));
},
selectShareUser(user) {
this.shareForm.user = user;
},
async shareMemory(memId) {
if (!this.shareForm.user.trim()) return;
try {
await api.post(`/api/memories/${memId}/share`, {
shared_with: this.shareForm.user,
permission: this.shareForm.permission,
});
this.closeShareForm();
// Refresh shares if expanded
if (this.sharesExpanded === memId) {
await this.toggleShares(memId);
await this.toggleShares(memId);
}
} catch (e) {
alert('Share failed: ' + e.message);
}
},
preview(content, len = 100) {
if (!content) return '';
return content.length > len ? content.substring(0, len) + '...' : content;