memorix 0.5.2 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -42,6 +42,15 @@ const i18n = {
42
42
  noObsTitle: 'No Observations',
43
43
  noObsDesc: 'Use memorix_store to create observations',
44
44
  untitled: 'Untitled',
45
+ exportData: 'Export',
46
+ deleteObs: 'Delete',
47
+ deleteConfirm: 'Delete observation #%id%?',
48
+ deleted: 'Deleted',
49
+ narrative: 'Narrative',
50
+ facts: 'Facts',
51
+ concepts: 'Concepts',
52
+ files: 'Files Modified',
53
+ clickToExpand: 'Click to expand',
45
54
 
46
55
  // Retention
47
56
  memoryRetention: 'Memory Retention',
@@ -102,6 +111,15 @@ const i18n = {
102
111
  noObsTitle: '暂无观察记录',
103
112
  noObsDesc: '使用 memorix_store 创建观察记录',
104
113
  untitled: '无标题',
114
+ exportData: '导出',
115
+ deleteObs: '删除',
116
+ deleteConfirm: '确认删除观察 #%id%?',
117
+ deleted: '已删除',
118
+ narrative: '叙述',
119
+ facts: '事实',
120
+ concepts: '概念',
121
+ files: '相关文件',
122
+ clickToExpand: '点击展开',
105
123
 
106
124
  // Retention
107
125
  memoryRetention: '记忆衰减',
@@ -241,9 +259,15 @@ document.querySelectorAll('.nav-btn').forEach(btn => {
241
259
  // API Client
242
260
  // ============================================================
243
261
 
262
+ let selectedProject = ''; // empty = current project (default)
263
+
244
264
  async function api(endpoint) {
245
265
  try {
246
- const res = await fetch(`/api/${endpoint}`);
266
+ const sep = endpoint.includes('?') ? '&' : '?';
267
+ const url = selectedProject
268
+ ? `/api/${endpoint}${sep}project=${encodeURIComponent(selectedProject)}`
269
+ : `/api/${endpoint}`;
270
+ const res = await fetch(url);
247
271
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
248
272
  return await res.json();
249
273
  } catch (err) {
@@ -252,6 +276,49 @@ async function api(endpoint) {
252
276
  }
253
277
  }
254
278
 
279
+ // ============================================================
280
+ // Project Switcher
281
+ // ============================================================
282
+
283
+ async function initProjectSwitcher() {
284
+ const select = document.getElementById('project-select');
285
+ if (!select) return;
286
+
287
+ // Fetch project list
288
+ try {
289
+ const res = await fetch('/api/projects');
290
+ const projects = await res.json();
291
+ if (!Array.isArray(projects) || projects.length === 0) {
292
+ select.innerHTML = '<option value="">No projects</option>';
293
+ return;
294
+ }
295
+
296
+ select.innerHTML = '';
297
+ for (const p of projects) {
298
+ const opt = document.createElement('option');
299
+ opt.value = p.isCurrent ? '' : p.id;
300
+ opt.textContent = p.name + (p.isCurrent ? ' ●' : '');
301
+ opt.title = p.id;
302
+ if (p.isCurrent) opt.selected = true;
303
+ select.appendChild(opt);
304
+ }
305
+ } catch {
306
+ select.innerHTML = '<option value="">Error</option>';
307
+ }
308
+
309
+ // Switch handler
310
+ select.addEventListener('change', () => {
311
+ selectedProject = select.value;
312
+ // Clear all cached pages and reload current
313
+ Object.keys(loaded).forEach(k => delete loaded[k]);
314
+ loadPage(currentPage);
315
+ });
316
+ }
317
+
318
+ document.addEventListener('DOMContentLoaded', () => {
319
+ initProjectSwitcher();
320
+ });
321
+
255
322
  // ============================================================
256
323
  // Page Loaders
257
324
  // ============================================================
@@ -711,9 +778,15 @@ async function loadObservations() {
711
778
  const types = [...new Set(allObservations.map(o => o.type).filter(Boolean))];
712
779
 
713
780
  container.innerHTML = `
714
- <div class="page-header">
715
- <h1 class="page-title">${t('observations')}</h1>
716
- <p class="page-subtitle">${allObservations.length} ${t('observationsStored')}</p>
781
+ <div class="page-header" style="display:flex;align-items:center;justify-content:space-between;">
782
+ <div>
783
+ <h1 class="page-title">${t('observations')}</h1>
784
+ <p class="page-subtitle">${allObservations.length} ${t('observationsStored')}</p>
785
+ </div>
786
+ <button class="export-btn" id="btn-export" title="${t('exportData')}">
787
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M8 2v8M4 7l4 4 4-4M2 12v2h12v-2"/></svg>
788
+ ${t('exportData')}
789
+ </button>
717
790
  </div>
718
791
 
719
792
  <div class="search-bar">
@@ -725,6 +798,12 @@ async function loadObservations() {
725
798
  <div class="obs-grid" id="obs-list"></div>
726
799
  `;
727
800
 
801
+ // Export handler
802
+ document.getElementById('btn-export').addEventListener('click', () => {
803
+ const sep = selectedProject ? `?project=${encodeURIComponent(selectedProject)}` : '';
804
+ window.open(`/api/export${sep}`, '_blank');
805
+ });
806
+
728
807
  document.getElementById('obs-search').addEventListener('input', (e) => {
729
808
  obsFilter = e.target.value.toLowerCase();
730
809
  renderObsList();
@@ -773,25 +852,32 @@ function renderObsList() {
773
852
  }
774
853
 
775
854
  list.innerHTML = filtered.map(obs => `
776
- <div class="obs-card">
777
- <div class="obs-card-header">
855
+ <div class="obs-card" data-obs-id="${obs.id}">
856
+ <div class="obs-card-header" onclick="toggleObsDetail(${obs.id})">
778
857
  <span class="obs-card-id">#${obs.id}</span>
779
858
  <span class="type-badge" data-type="${obs.type || 'unknown'}">
780
859
  ${typeIcons[obs.type] || '❓'} ${obs.type || 'unknown'}
781
860
  </span>
782
861
  <span class="obs-card-title">${escapeHtml(obs.title || t('untitled'))}</span>
862
+ <span class="obs-expand-icon">▼</span>
783
863
  </div>
784
864
  <div class="obs-card-meta">
785
865
  <span>📁 ${escapeHtml(obs.entityName || 'unknown')}</span>
786
866
  ${obs.createdAt ? `<span>🕐 ${formatTime(obs.createdAt)}</span>` : ''}
787
867
  ${obs.accessCount ? `<span>👁 ${obs.accessCount}</span>` : ''}
788
868
  </div>
789
- ${obs.narrative ? `<div class="obs-card-narrative">${escapeHtml(obs.narrative)}</div>` : ''}
790
- ${obs.facts && obs.facts.length > 0 ? `
791
- <div class="obs-card-facts">
792
- ${obs.facts.map(f => `<span class="fact-tag">${escapeHtml(f)}</span>`).join('')}
869
+ <div class="obs-detail" id="obs-detail-${obs.id}" style="display:none;">
870
+ ${obs.narrative ? `<div class="obs-detail-section"><label>${t('narrative')}</label><div class="obs-card-narrative">${escapeHtml(obs.narrative)}</div></div>` : ''}
871
+ ${obs.facts && obs.facts.length > 0 ? `<div class="obs-detail-section"><label>${t('facts')}</label><div class="obs-card-facts">${obs.facts.map(f => `<span class="fact-tag">${escapeHtml(f)}</span>`).join('')}</div></div>` : ''}
872
+ ${obs.concepts && obs.concepts.length > 0 ? `<div class="obs-detail-section"><label>${t('concepts')}</label><div class="obs-card-facts">${obs.concepts.map(c => `<span class="fact-tag concept-tag">${escapeHtml(c)}</span>`).join('')}</div></div>` : ''}
873
+ ${obs.filesModified && obs.filesModified.length > 0 ? `<div class="obs-detail-section"><label>${t('files')}</label><div class="obs-card-facts">${obs.filesModified.map(f => `<span class="fact-tag file-tag">${escapeHtml(f)}</span>`).join('')}</div></div>` : ''}
874
+ <div class="obs-detail-actions">
875
+ <button class="delete-btn" onclick="deleteObs(${obs.id}, event)">
876
+ <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 4h12M5 4V3a1 1 0 011-1h4a1 1 0 011 1v1M6 7v5M10 7v5M3 4l1 9a1 1 0 001 1h6a1 1 0 001-1l1-9"/></svg>
877
+ ${t('deleteObs')}
878
+ </button>
793
879
  </div>
794
- ` : ''}
880
+ </div>
795
881
  </div>
796
882
  `).join('');
797
883
  }
@@ -882,6 +968,52 @@ async function loadRetention() {
882
968
  `;
883
969
  }
884
970
 
971
+ // ============================================================
972
+ // Observation Interactions
973
+ // ============================================================
974
+
975
+ function toggleObsDetail(id) {
976
+ const detail = document.getElementById(`obs-detail-${id}`);
977
+ const card = detail?.closest('.obs-card');
978
+ if (!detail || !card) return;
979
+
980
+ const isOpen = detail.style.display !== 'none';
981
+ detail.style.display = isOpen ? 'none' : 'block';
982
+ card.classList.toggle('expanded', !isOpen);
983
+
984
+ // Rotate expand icon
985
+ const icon = card.querySelector('.obs-expand-icon');
986
+ if (icon) icon.style.transform = isOpen ? '' : 'rotate(180deg)';
987
+ }
988
+
989
+ async function deleteObs(id, event) {
990
+ event?.stopPropagation();
991
+ const msg = t('deleteConfirm').replace('%id%', id);
992
+ if (!confirm(msg)) return;
993
+
994
+ try {
995
+ const sep = selectedProject ? `?project=${encodeURIComponent(selectedProject)}` : '';
996
+ const res = await fetch(`/api/observations/${id}${sep}`, { method: 'DELETE' });
997
+ const data = await res.json();
998
+ if (data.ok) {
999
+ // Remove from local array and re-render
1000
+ allObservations = allObservations.filter(o => o.id !== id);
1001
+ renderObsList();
1002
+ // Update counter in header
1003
+ const subtitle = document.querySelector('#page-observations .page-subtitle');
1004
+ if (subtitle) subtitle.textContent = `${allObservations.length} ${t('observationsStored')}`;
1005
+ } else {
1006
+ alert(data.error || 'Delete failed');
1007
+ }
1008
+ } catch (err) {
1009
+ alert('Delete failed: ' + err.message);
1010
+ }
1011
+ }
1012
+
1013
+ // Make functions globally accessible for onclick handlers
1014
+ window.toggleObsDetail = toggleObsDetail;
1015
+ window.deleteObs = deleteObs;
1016
+
885
1017
  // ============================================================
886
1018
  // Utilities
887
1019
  // ============================================================
@@ -18,6 +18,11 @@
18
18
  <div class="sidebar-brand">
19
19
  <img src="/logo.png" alt="Memorix" class="brand-logo" />
20
20
  </div>
21
+ <div class="project-switcher">
22
+ <select id="project-select" title="Switch project / 切换项目">
23
+ <option value="">Loading...</option>
24
+ </select>
25
+ </div>
21
26
  <div class="sidebar-nav">
22
27
  <button class="nav-btn active" data-page="dashboard" title="Dashboard">
23
28
  <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
@@ -1045,4 +1045,201 @@ body {
1045
1045
  .page {
1046
1046
  padding: 16px;
1047
1047
  }
1048
+ }
1049
+
1050
+ /* ============================================================
1051
+ * Project Switcher
1052
+ * ============================================================ */
1053
+
1054
+ .project-switcher {
1055
+ padding: 4px 8px 8px;
1056
+ display: flex;
1057
+ justify-content: center;
1058
+ }
1059
+
1060
+ .project-switcher select {
1061
+ width: 44px;
1062
+ height: 28px;
1063
+ background: var(--bg-card);
1064
+ color: var(--text-primary);
1065
+ border: 1px solid var(--border-subtle);
1066
+ border-radius: 6px;
1067
+ font-size: 10px;
1068
+ font-family: var(--font-mono);
1069
+ cursor: pointer;
1070
+ padding: 0 2px;
1071
+ text-align: center;
1072
+ -webkit-appearance: none;
1073
+ -moz-appearance: none;
1074
+ appearance: none;
1075
+ transition: var(--transition-fast);
1076
+ overflow: hidden;
1077
+ text-overflow: ellipsis;
1078
+ }
1079
+
1080
+ .project-switcher select:hover {
1081
+ border-color: var(--accent-cyan);
1082
+ box-shadow: var(--glow-cyan);
1083
+ }
1084
+
1085
+ .project-switcher select:focus {
1086
+ outline: none;
1087
+ border-color: var(--accent-cyan);
1088
+ }
1089
+
1090
+ .project-switcher select option {
1091
+ background: var(--bg-surface);
1092
+ color: var(--text-primary);
1093
+ font-size: 12px;
1094
+ padding: 4px 8px;
1095
+ }
1096
+
1097
+ /* ============================================================
1098
+ * Export Button
1099
+ * ============================================================ */
1100
+
1101
+ .export-btn {
1102
+ display: flex;
1103
+ align-items: center;
1104
+ gap: 6px;
1105
+ padding: 8px 16px;
1106
+ background: var(--bg-card);
1107
+ border: 1px solid var(--border-subtle);
1108
+ border-radius: 8px;
1109
+ color: var(--text-secondary);
1110
+ font-size: 13px;
1111
+ font-family: var(--font-sans);
1112
+ cursor: pointer;
1113
+ transition: all 0.2s;
1114
+ }
1115
+
1116
+ .export-btn:hover {
1117
+ background: var(--bg-card-hover);
1118
+ border-color: var(--accent-cyan);
1119
+ color: var(--accent-cyan);
1120
+ box-shadow: var(--glow-cyan);
1121
+ }
1122
+
1123
+ /* ============================================================
1124
+ * Observation Card Enhancements
1125
+ * ============================================================ */
1126
+
1127
+ .obs-card {
1128
+ cursor: pointer;
1129
+ transition: all 0.25s ease;
1130
+ }
1131
+
1132
+ .obs-card:hover {
1133
+ border-color: var(--border-medium);
1134
+ transform: translateY(-1px);
1135
+ }
1136
+
1137
+ .obs-card.expanded {
1138
+ border-color: var(--accent-cyan);
1139
+ box-shadow: var(--glow-cyan);
1140
+ }
1141
+
1142
+ .obs-card-header {
1143
+ display: flex;
1144
+ align-items: center;
1145
+ gap: 8px;
1146
+ position: relative;
1147
+ }
1148
+
1149
+ .obs-expand-icon {
1150
+ margin-left: auto;
1151
+ font-size: 10px;
1152
+ color: var(--text-muted);
1153
+ transition: transform 0.25s ease;
1154
+ flex-shrink: 0;
1155
+ }
1156
+
1157
+ .obs-card:hover .obs-expand-icon {
1158
+ color: var(--text-secondary);
1159
+ }
1160
+
1161
+ /* Observation Detail Section */
1162
+ .obs-detail {
1163
+ margin-top: 12px;
1164
+ padding-top: 12px;
1165
+ border-top: 1px solid var(--border-subtle);
1166
+ animation: slideDown 0.2s ease;
1167
+ }
1168
+
1169
+ @keyframes slideDown {
1170
+ from {
1171
+ opacity: 0;
1172
+ transform: translateY(-8px);
1173
+ }
1174
+
1175
+ to {
1176
+ opacity: 1;
1177
+ transform: translateY(0);
1178
+ }
1179
+ }
1180
+
1181
+ .obs-detail-section {
1182
+ margin-bottom: 12px;
1183
+ }
1184
+
1185
+ .obs-detail-section label {
1186
+ display: block;
1187
+ font-size: 11px;
1188
+ font-weight: 600;
1189
+ color: var(--text-muted);
1190
+ text-transform: uppercase;
1191
+ letter-spacing: 0.5px;
1192
+ margin-bottom: 6px;
1193
+ }
1194
+
1195
+ .obs-detail-section .obs-card-narrative {
1196
+ -webkit-line-clamp: unset;
1197
+ white-space: pre-wrap;
1198
+ max-height: none;
1199
+ }
1200
+
1201
+ /* Tag variants */
1202
+ .concept-tag {
1203
+ background: rgba(168, 85, 247, 0.1) !important;
1204
+ border-color: rgba(168, 85, 247, 0.2) !important;
1205
+ color: var(--accent-purple) !important;
1206
+ }
1207
+
1208
+ .file-tag {
1209
+ background: rgba(59, 130, 246, 0.1) !important;
1210
+ border-color: rgba(59, 130, 246, 0.2) !important;
1211
+ color: var(--accent-blue) !important;
1212
+ font-family: var(--font-mono);
1213
+ font-size: 11px !important;
1214
+ }
1215
+
1216
+ /* Delete Button */
1217
+ .obs-detail-actions {
1218
+ display: flex;
1219
+ justify-content: flex-end;
1220
+ margin-top: 12px;
1221
+ padding-top: 8px;
1222
+ border-top: 1px solid var(--border-subtle);
1223
+ }
1224
+
1225
+ .delete-btn {
1226
+ display: flex;
1227
+ align-items: center;
1228
+ gap: 5px;
1229
+ padding: 6px 12px;
1230
+ background: transparent;
1231
+ border: 1px solid rgba(239, 68, 68, 0.2);
1232
+ border-radius: 6px;
1233
+ color: var(--accent-red);
1234
+ font-size: 12px;
1235
+ font-family: var(--font-sans);
1236
+ cursor: pointer;
1237
+ transition: all 0.2s;
1238
+ opacity: 0.7;
1239
+ }
1240
+
1241
+ .delete-btn:hover {
1242
+ background: rgba(239, 68, 68, 0.1);
1243
+ border-color: var(--accent-red);
1244
+ opacity: 1;
1048
1245
  }