claude-memory-admin 1.0.0
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.
- package/LICENSE +26 -0
- package/README.md +218 -0
- package/bin/claude-memory-admin.mjs +62 -0
- package/package.json +63 -0
- package/public/app.mjs +1156 -0
- package/public/assets/bitspire-logo.webp +0 -0
- package/public/graph.mjs +518 -0
- package/public/index.html +66 -0
- package/public/styles.css +402 -0
- package/server.mjs +239 -0
- package/src/model.mjs +241 -0
- package/src/mutate.mjs +414 -0
- package/src/parse.mjs +255 -0
- package/src/projects.mjs +190 -0
- package/src/search.mjs +127 -0
- package/src/stats.mjs +203 -0
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
:root {
|
|
2
|
+
--bg: #f7f7f5;
|
|
3
|
+
--panel: #ffffff;
|
|
4
|
+
--panel-2: #fbfbfa;
|
|
5
|
+
--border: #e3e1dc;
|
|
6
|
+
--text: #21201c;
|
|
7
|
+
--muted: #78756e;
|
|
8
|
+
--accent: #b85c38;
|
|
9
|
+
--accent-soft: #f5e9e3;
|
|
10
|
+
--danger: #b3261e;
|
|
11
|
+
--danger-soft: #fbeae8;
|
|
12
|
+
--ok: #3f7d58;
|
|
13
|
+
--warn: #a86a12;
|
|
14
|
+
--warn-soft: #fbf1de;
|
|
15
|
+
--shadow: 0 1px 2px rgba(0,0,0,.06), 0 8px 24px rgba(0,0,0,.06);
|
|
16
|
+
--mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
@media (prefers-color-scheme: dark) {
|
|
20
|
+
:root {
|
|
21
|
+
--bg: #191917;
|
|
22
|
+
--panel: #21211e;
|
|
23
|
+
--panel-2: #262622;
|
|
24
|
+
--border: #37362f;
|
|
25
|
+
--text: #eceae4;
|
|
26
|
+
--muted: #9c988d;
|
|
27
|
+
--accent: #e08c68;
|
|
28
|
+
--accent-soft: #3a2b23;
|
|
29
|
+
--danger: #f08a80;
|
|
30
|
+
--danger-soft: #3a2320;
|
|
31
|
+
--ok: #7fbf99;
|
|
32
|
+
--warn: #e0b062;
|
|
33
|
+
--warn-soft: #3a2f1c;
|
|
34
|
+
--shadow: 0 1px 2px rgba(0,0,0,.4), 0 8px 24px rgba(0,0,0,.3);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
* { box-sizing: border-box; }
|
|
39
|
+
|
|
40
|
+
body {
|
|
41
|
+
margin: 0;
|
|
42
|
+
background: var(--bg);
|
|
43
|
+
color: var(--text);
|
|
44
|
+
font: 15px/1.55 ui-sans-serif, -apple-system, "Segoe UI", Roboto, sans-serif;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
.mono { font-family: var(--mono); font-size: 12px; }
|
|
48
|
+
.muted { color: var(--muted); }
|
|
49
|
+
code { font-family: var(--mono); font-size: .9em; }
|
|
50
|
+
|
|
51
|
+
#app { display: grid; grid-template-columns: 300px 1fr; height: 100vh; }
|
|
52
|
+
|
|
53
|
+
/* ---------- sidebar ---------- */
|
|
54
|
+
|
|
55
|
+
#sidebar {
|
|
56
|
+
background: var(--panel);
|
|
57
|
+
border-right: 1px solid var(--border);
|
|
58
|
+
display: flex;
|
|
59
|
+
flex-direction: column;
|
|
60
|
+
overflow: hidden;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
.sidebar-head { padding: 18px 18px 12px; border-bottom: 1px solid var(--border); }
|
|
64
|
+
.sidebar-head h1 { margin: 0; font-size: 15px; letter-spacing: -.01em; }
|
|
65
|
+
.sidebar-head p { margin: 4px 0 10px; word-break: break-all; }
|
|
66
|
+
|
|
67
|
+
.toggle { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--muted); cursor: pointer; }
|
|
68
|
+
|
|
69
|
+
#project-list { overflow-y: auto; padding: 8px; flex: 1; }
|
|
70
|
+
|
|
71
|
+
.project {
|
|
72
|
+
display: block; width: 100%; text-align: left;
|
|
73
|
+
border: 1px solid transparent; border-radius: 8px;
|
|
74
|
+
background: none; color: inherit; font: inherit;
|
|
75
|
+
padding: 8px 10px; margin-bottom: 2px; cursor: pointer;
|
|
76
|
+
}
|
|
77
|
+
.project:hover { background: var(--panel-2); }
|
|
78
|
+
.project.active { background: var(--accent-soft); border-color: var(--accent); }
|
|
79
|
+
.project.dim { opacity: .5; }
|
|
80
|
+
.project-row { display: flex; align-items: baseline; gap: 8px; }
|
|
81
|
+
.project-name { font-weight: 550; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
82
|
+
.project-count { font-family: var(--mono); font-size: 11px; color: var(--muted); }
|
|
83
|
+
.project-path { display: block; font-family: var(--mono); font-size: 10.5px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
84
|
+
|
|
85
|
+
.dot { width: 7px; height: 7px; border-radius: 50%; display: inline-block; flex: none; }
|
|
86
|
+
.dot.ok { background: var(--ok); }
|
|
87
|
+
.dot.warn { background: var(--warn); }
|
|
88
|
+
.dot.none { background: var(--border); }
|
|
89
|
+
|
|
90
|
+
/* ---------- main ---------- */
|
|
91
|
+
|
|
92
|
+
#main { overflow-y: auto; }
|
|
93
|
+
.empty { padding: 15vh 40px; text-align: center; color: var(--muted); }
|
|
94
|
+
.empty h2 { color: var(--text); }
|
|
95
|
+
|
|
96
|
+
#project-head {
|
|
97
|
+
padding: 20px 28px 0;
|
|
98
|
+
border-bottom: 1px solid var(--border);
|
|
99
|
+
position: sticky; top: 0; background: var(--bg); z-index: 5;
|
|
100
|
+
}
|
|
101
|
+
#project-title { margin: 0; font-size: 20px; letter-spacing: -.02em; }
|
|
102
|
+
#project-sub { margin: 4px 0 14px; word-break: break-all; }
|
|
103
|
+
|
|
104
|
+
#tabs { display: flex; gap: 2px; }
|
|
105
|
+
.tab {
|
|
106
|
+
border: none; background: none; font: inherit; color: var(--muted);
|
|
107
|
+
padding: 8px 12px; cursor: pointer; border-bottom: 2px solid transparent; margin-bottom: -1px;
|
|
108
|
+
}
|
|
109
|
+
.tab:hover { color: var(--text); }
|
|
110
|
+
.tab.active { color: var(--text); border-bottom-color: var(--accent); font-weight: 550; }
|
|
111
|
+
.tab .badge { font-family: var(--mono); font-size: 10px; padding: 1px 5px; border-radius: 99px; background: var(--border); margin-left: 5px; }
|
|
112
|
+
.tab .badge.warn { background: var(--warn-soft); color: var(--warn); }
|
|
113
|
+
|
|
114
|
+
#tab-content { padding: 22px 28px 60px; }
|
|
115
|
+
|
|
116
|
+
/* ---------- memory list + detail ---------- */
|
|
117
|
+
|
|
118
|
+
.split { display: grid; grid-template-columns: minmax(280px, 380px) 1fr; gap: 24px; align-items: start; }
|
|
119
|
+
@media (max-width: 1000px) { .split { grid-template-columns: 1fr; } }
|
|
120
|
+
|
|
121
|
+
/* The memory list is long, so the detail pane sticks: scrolling the list must
|
|
122
|
+
never leave the reader staring at a blank column. */
|
|
123
|
+
#detail { position: sticky; top: 18px; max-height: calc(100vh - 150px); overflow-y: auto; }
|
|
124
|
+
@media (max-width: 1000px) { #detail { position: static; max-height: none; } }
|
|
125
|
+
|
|
126
|
+
.section-label {
|
|
127
|
+
font-size: 11px; text-transform: uppercase; letter-spacing: .08em;
|
|
128
|
+
color: var(--muted); margin: 18px 0 8px; font-weight: 600;
|
|
129
|
+
}
|
|
130
|
+
.section-label:first-child { margin-top: 0; }
|
|
131
|
+
|
|
132
|
+
.mem {
|
|
133
|
+
display: block; width: 100%; text-align: left; cursor: pointer;
|
|
134
|
+
background: var(--panel); border: 1px solid var(--border); border-radius: 10px;
|
|
135
|
+
padding: 10px 12px; margin-bottom: 6px; font: inherit; color: inherit;
|
|
136
|
+
}
|
|
137
|
+
.mem:hover { border-color: var(--accent); }
|
|
138
|
+
.mem.active { border-color: var(--accent); background: var(--accent-soft); }
|
|
139
|
+
.mem-top { display: flex; align-items: center; gap: 8px; margin-bottom: 3px; }
|
|
140
|
+
.mem-name { font-weight: 550; font-size: 13.5px; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
141
|
+
.mem-desc { font-size: 12.5px; color: var(--muted); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
|
142
|
+
|
|
143
|
+
.badge {
|
|
144
|
+
font-size: 10px; font-family: var(--mono); padding: 2px 6px; border-radius: 4px;
|
|
145
|
+
background: var(--border); color: var(--muted); flex: none; text-transform: lowercase;
|
|
146
|
+
}
|
|
147
|
+
.badge.type-project { background: #dbeafe; color: #1e40af; }
|
|
148
|
+
.badge.type-feedback { background: #fae8d5; color: #92400e; }
|
|
149
|
+
.badge.type-user { background: #e6e0f8; color: #5b21b6; }
|
|
150
|
+
.badge.type-reference { background: #d9f2e4; color: #14532d; }
|
|
151
|
+
.badge.status-orphan { background: var(--warn-soft); color: var(--warn); }
|
|
152
|
+
.badge.status-referenced { background: var(--warn-soft); color: var(--warn); }
|
|
153
|
+
@media (prefers-color-scheme: dark) {
|
|
154
|
+
.badge.type-project { background: #1e3a5f; color: #93c5fd; }
|
|
155
|
+
.badge.type-feedback { background: #4a3016; color: #fcd9a8; }
|
|
156
|
+
.badge.type-user { background: #332a5c; color: #c4b5fd; }
|
|
157
|
+
.badge.type-reference { background: #1c3a2a; color: #86efac; }
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
.card { background: var(--panel); border: 1px solid var(--border); border-radius: 12px; padding: 20px 22px; box-shadow: var(--shadow); }
|
|
161
|
+
.detail-head { display: flex; align-items: flex-start; gap: 12px; margin-bottom: 4px; }
|
|
162
|
+
.detail-head h3 { margin: 0; font-size: 17px; flex: 1; letter-spacing: -.01em; }
|
|
163
|
+
.detail-desc { color: var(--muted); margin: 0 0 14px; font-size: 13.5px; }
|
|
164
|
+
|
|
165
|
+
.meta { display: grid; grid-template-columns: auto 1fr; gap: 3px 14px; font-family: var(--mono); font-size: 11.5px; padding: 12px 0; border-top: 1px solid var(--border); border-bottom: 1px solid var(--border); margin-bottom: 16px; }
|
|
166
|
+
.meta dt { color: var(--muted); }
|
|
167
|
+
.meta dd { margin: 0; word-break: break-all; }
|
|
168
|
+
|
|
169
|
+
.body { font-size: 14px; overflow-wrap: anywhere; }
|
|
170
|
+
.body h1, .body h2, .body h3 { letter-spacing: -.01em; margin: 1.3em 0 .5em; font-size: 15px; }
|
|
171
|
+
.body h1 { font-size: 17px; }
|
|
172
|
+
.body pre { background: var(--panel-2); border: 1px solid var(--border); border-radius: 8px; padding: 12px; overflow-x: auto; font-size: 12px; }
|
|
173
|
+
.body :not(pre) > code { background: var(--panel-2); border: 1px solid var(--border); border-radius: 4px; padding: 1px 4px; }
|
|
174
|
+
.body ul, .body ol { padding-left: 22px; }
|
|
175
|
+
.body li { margin: 3px 0; }
|
|
176
|
+
.body table { border-collapse: collapse; font-size: 12.5px; display: block; overflow-x: auto; }
|
|
177
|
+
.body th, .body td { border: 1px solid var(--border); padding: 5px 9px; text-align: left; }
|
|
178
|
+
.body a { color: var(--accent); }
|
|
179
|
+
.body blockquote { border-left: 3px solid var(--border); margin: 1em 0; padding-left: 14px; color: var(--muted); }
|
|
180
|
+
|
|
181
|
+
.wikilink { color: var(--accent); background: var(--accent-soft); border: 1px solid transparent; border-radius: 4px; padding: 0 4px; cursor: pointer; font: inherit; font-size: .95em; }
|
|
182
|
+
.wikilink:hover { border-color: var(--accent); }
|
|
183
|
+
.wikilink.dangling { color: var(--danger); background: var(--danger-soft); text-decoration: line-through; cursor: help; }
|
|
184
|
+
|
|
185
|
+
.links { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px; }
|
|
186
|
+
.chip { font-size: 12px; padding: 3px 9px; border-radius: 99px; border: 1px solid var(--border); background: var(--panel-2); cursor: pointer; font-family: var(--mono); color: inherit; }
|
|
187
|
+
.chip:hover { border-color: var(--accent); }
|
|
188
|
+
.chip.dead { color: var(--danger); border-color: var(--danger); cursor: default; }
|
|
189
|
+
|
|
190
|
+
/* ---------- buttons ---------- */
|
|
191
|
+
|
|
192
|
+
.btn { font: inherit; font-size: 13px; padding: 6px 13px; border-radius: 8px; border: 1px solid var(--border); background: var(--panel); color: var(--text); cursor: pointer; }
|
|
193
|
+
.btn:hover { border-color: var(--muted); }
|
|
194
|
+
.btn.danger { color: var(--danger); border-color: var(--danger); background: var(--danger-soft); }
|
|
195
|
+
.btn.danger:hover { background: var(--danger); color: #fff; }
|
|
196
|
+
.btn.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
|
|
197
|
+
.btn.small { font-size: 12px; padding: 3px 9px; }
|
|
198
|
+
|
|
199
|
+
/* ---------- index view ---------- */
|
|
200
|
+
|
|
201
|
+
.index-line { display: grid; grid-template-columns: 44px 1fr; gap: 12px; font-family: var(--mono); font-size: 12px; padding: 2px 6px; border-radius: 4px; }
|
|
202
|
+
.index-line:hover { background: var(--panel-2); }
|
|
203
|
+
.index-line .ln { color: var(--muted); text-align: right; user-select: none; }
|
|
204
|
+
.index-line .content { white-space: pre-wrap; overflow-wrap: anywhere; }
|
|
205
|
+
.index-line.kind-heading .content { font-weight: 700; color: var(--accent); }
|
|
206
|
+
.index-line.kind-index .content { cursor: pointer; }
|
|
207
|
+
.index-line.kind-index:hover .content { text-decoration: underline; }
|
|
208
|
+
.index-line.dangling { background: var(--danger-soft); }
|
|
209
|
+
|
|
210
|
+
/* ---------- health + trash ---------- */
|
|
211
|
+
|
|
212
|
+
.issue { border: 1px solid var(--border); border-left: 3px solid var(--warn); border-radius: 8px; background: var(--panel); padding: 10px 14px; margin-bottom: 8px; display: flex; gap: 12px; align-items: flex-start; }
|
|
213
|
+
.issue.bad { border-left-color: var(--danger); }
|
|
214
|
+
.issue-body { flex: 1; min-width: 0; }
|
|
215
|
+
.issue-title { font-weight: 550; font-size: 13.5px; }
|
|
216
|
+
.issue-detail { font-family: var(--mono); font-size: 11.5px; color: var(--muted); overflow-wrap: anywhere; }
|
|
217
|
+
|
|
218
|
+
/* ---------- graph ---------- */
|
|
219
|
+
|
|
220
|
+
#graph-wrap { background: var(--panel); border: 1px solid var(--border); border-radius: 12px; overflow: hidden; position: relative; }
|
|
221
|
+
#graph-wrap svg { display: block; width: 100%; height: 620px; cursor: grab; }
|
|
222
|
+
.legend { position: absolute; bottom: 12px; left: 14px; display: flex; flex-wrap: wrap; gap: 12px; font-size: 11px; color: var(--muted); background: var(--panel); padding: 6px 10px; border: 1px solid var(--border); border-radius: 8px; }
|
|
223
|
+
.legend span { display: flex; align-items: center; gap: 5px; }
|
|
224
|
+
.legend i { width: 9px; height: 9px; border-radius: 50%; display: inline-block; }
|
|
225
|
+
.node circle { cursor: pointer; stroke-width: 1.5px; }
|
|
226
|
+
.node text { font-size: 9px; fill: var(--muted); pointer-events: none; }
|
|
227
|
+
.node.hi circle { stroke: var(--accent); stroke-width: 2.5px; }
|
|
228
|
+
.node.hi text { fill: var(--text); font-weight: 600; }
|
|
229
|
+
|
|
230
|
+
/* ---------- modal + toast ---------- */
|
|
231
|
+
|
|
232
|
+
.modal-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,.45); display: flex; align-items: center; justify-content: center; padding: 24px; z-index: 100; }
|
|
233
|
+
.modal { background: var(--panel); border-radius: 14px; box-shadow: var(--shadow); max-width: 620px; width: 100%; max-height: 82vh; display: flex; flex-direction: column; }
|
|
234
|
+
.modal header { padding: 18px 22px 0; }
|
|
235
|
+
.modal h3 { margin: 0 0 4px; font-size: 16px; }
|
|
236
|
+
.modal .modal-body { padding: 14px 22px; overflow-y: auto; }
|
|
237
|
+
.modal footer { padding: 14px 22px 18px; display: flex; justify-content: flex-end; gap: 8px; border-top: 1px solid var(--border); }
|
|
238
|
+
|
|
239
|
+
.will { margin: 0 0 14px; }
|
|
240
|
+
.will-title { font-size: 12px; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); margin-bottom: 5px; font-weight: 600; }
|
|
241
|
+
.will-item { font-family: var(--mono); font-size: 11.5px; padding: 5px 9px; border-radius: 6px; background: var(--panel-2); border: 1px solid var(--border); margin-bottom: 4px; overflow-wrap: anywhere; }
|
|
242
|
+
.will-item.remove { border-left: 3px solid var(--danger); }
|
|
243
|
+
.will-item.keep { border-left: 3px solid var(--warn); }
|
|
244
|
+
|
|
245
|
+
#toast-root { position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%); z-index: 200; display: flex; flex-direction: column; gap: 8px; align-items: center; }
|
|
246
|
+
.toast { background: var(--text); color: var(--bg); padding: 9px 16px; border-radius: 99px; font-size: 13px; box-shadow: var(--shadow); display: flex; gap: 12px; align-items: center; }
|
|
247
|
+
.toast.err { background: var(--danger); color: #fff; }
|
|
248
|
+
.toast button { background: none; border: 1px solid currentColor; color: inherit; border-radius: 99px; font: inherit; font-size: 12px; padding: 1px 9px; cursor: pointer; }
|
|
249
|
+
|
|
250
|
+
/* ---------- collapsible sidebar ---------- */
|
|
251
|
+
|
|
252
|
+
/* One column, not "0 1fr": with the sidebar removed from the flow the main
|
|
253
|
+
pane would otherwise land in the zero-width first column. */
|
|
254
|
+
#app.collapsed { grid-template-columns: 1fr; }
|
|
255
|
+
#app.collapsed #sidebar { display: none; }
|
|
256
|
+
|
|
257
|
+
.icon-btn {
|
|
258
|
+
float: right; background: none; border: 1px solid var(--border); border-radius: 6px;
|
|
259
|
+
color: var(--muted); cursor: pointer; font-size: 10px; line-height: 1;
|
|
260
|
+
padding: 4px 6px; margin-left: 8px;
|
|
261
|
+
}
|
|
262
|
+
.icon-btn:hover { color: var(--text); border-color: var(--muted); }
|
|
263
|
+
|
|
264
|
+
/* Slim always-visible handle to bring the sidebar back. */
|
|
265
|
+
.expand-tab {
|
|
266
|
+
position: fixed; top: 14px; left: 0; z-index: 20;
|
|
267
|
+
background: var(--panel); border: 1px solid var(--border); border-left: none;
|
|
268
|
+
border-radius: 0 8px 8px 0; color: var(--muted); cursor: pointer;
|
|
269
|
+
font-size: 10px; padding: 8px 7px;
|
|
270
|
+
}
|
|
271
|
+
.expand-tab:hover { color: var(--text); }
|
|
272
|
+
|
|
273
|
+
.project-head-row { display: flex; align-items: center; gap: 12px; }
|
|
274
|
+
.project-head-row h2 { flex: 1; }
|
|
275
|
+
|
|
276
|
+
/* ---------- delete dialog: cascade picker ---------- */
|
|
277
|
+
|
|
278
|
+
.cascade { border: 1px solid var(--border); border-radius: 8px; overflow: hidden; }
|
|
279
|
+
.cascade-row {
|
|
280
|
+
display: flex; gap: 10px; align-items: flex-start;
|
|
281
|
+
padding: 9px 11px; border-bottom: 1px solid var(--border); cursor: pointer;
|
|
282
|
+
}
|
|
283
|
+
.cascade-row:last-child { border-bottom: none; }
|
|
284
|
+
.cascade-row:hover { background: var(--panel-2); }
|
|
285
|
+
.cascade-row input { margin: 3px 0 0; flex: none; }
|
|
286
|
+
.cascade-row.on { background: var(--danger-soft); }
|
|
287
|
+
.cascade-text { min-width: 0; }
|
|
288
|
+
.cascade-name { display: block; font-size: 13px; font-weight: 550; }
|
|
289
|
+
.cascade-detail { display: block; font-family: var(--mono); font-size: 11px; color: var(--muted); overflow-wrap: anywhere; }
|
|
290
|
+
.cascade-head { display: flex; justify-content: space-between; align-items: baseline; gap: 10px; margin-bottom: 5px; }
|
|
291
|
+
.link-btn { background: none; border: none; color: var(--accent); font: inherit; font-size: 12px; cursor: pointer; padding: 0; text-decoration: underline; }
|
|
292
|
+
|
|
293
|
+
/* ---------- graph controls ---------- */
|
|
294
|
+
|
|
295
|
+
.graph-toolbar {
|
|
296
|
+
position: absolute; top: 12px; right: 14px; display: flex; gap: 6px; align-items: center;
|
|
297
|
+
background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 5px 8px;
|
|
298
|
+
}
|
|
299
|
+
.graph-toolbar button {
|
|
300
|
+
background: none; border: 1px solid var(--border); border-radius: 6px; color: var(--muted);
|
|
301
|
+
font: inherit; font-size: 12px; cursor: pointer; padding: 2px 8px; min-width: 26px;
|
|
302
|
+
}
|
|
303
|
+
.graph-toolbar button:hover { color: var(--text); border-color: var(--muted); }
|
|
304
|
+
.graph-toolbar label { font-size: 11px; color: var(--muted); display: flex; align-items: center; gap: 5px; }
|
|
305
|
+
.graph-toolbar input[type=range] { width: 84px; }
|
|
306
|
+
|
|
307
|
+
#graph-wrap svg { height: 640px; }
|
|
308
|
+
.node circle.hit { fill: transparent; stroke: none; }
|
|
309
|
+
.node.dim { opacity: .18; }
|
|
310
|
+
.node.dim text { display: none; }
|
|
311
|
+
.edge.dim { opacity: .05; }
|
|
312
|
+
|
|
313
|
+
/* ---------- search ---------- */
|
|
314
|
+
|
|
315
|
+
#search {
|
|
316
|
+
width: 100%; font: inherit; font-size: 13px; padding: 6px 10px; margin-bottom: 10px;
|
|
317
|
+
border: 1px solid var(--border); border-radius: 8px;
|
|
318
|
+
background: var(--panel-2); color: var(--text);
|
|
319
|
+
}
|
|
320
|
+
#search:focus { outline: none; border-color: var(--accent); }
|
|
321
|
+
|
|
322
|
+
#search-head {
|
|
323
|
+
display: flex; align-items: center; gap: 12px;
|
|
324
|
+
padding: 20px 28px 14px; border-bottom: 1px solid var(--border);
|
|
325
|
+
position: sticky; top: 0; background: var(--bg); z-index: 5;
|
|
326
|
+
}
|
|
327
|
+
#search-head h2 { margin: 0; font-size: 18px; flex: 1; letter-spacing: -.02em; }
|
|
328
|
+
#search-results { padding: 20px 28px 60px; }
|
|
329
|
+
|
|
330
|
+
.result-group { margin-bottom: 26px; }
|
|
331
|
+
.result {
|
|
332
|
+
display: block; width: 100%; text-align: left; font: inherit; color: inherit; cursor: pointer;
|
|
333
|
+
background: var(--panel); border: 1px solid var(--border); border-radius: 10px;
|
|
334
|
+
padding: 11px 14px; margin-bottom: 6px;
|
|
335
|
+
}
|
|
336
|
+
.result:hover { border-color: var(--accent); }
|
|
337
|
+
.result-top { display: flex; align-items: center; gap: 8px; margin-bottom: 3px; }
|
|
338
|
+
.result-name { font-weight: 550; font-size: 13.5px; flex: 1; }
|
|
339
|
+
.result-snippet, .result-where { display: block; }
|
|
340
|
+
.result-snippet { font-size: 12.5px; color: var(--muted); }
|
|
341
|
+
.result-snippet mark, .result-name mark { background: var(--accent-soft); color: var(--accent); border-radius: 3px; padding: 0 1px; }
|
|
342
|
+
.result-where { font-family: var(--mono); font-size: 10.5px; color: var(--muted); }
|
|
343
|
+
|
|
344
|
+
/* ---------- prune ---------- */
|
|
345
|
+
|
|
346
|
+
.meter { background: var(--panel); border: 1px solid var(--border); border-radius: 12px; padding: 18px 20px; margin-bottom: 22px; }
|
|
347
|
+
.meter-top { display: flex; align-items: baseline; gap: 10px; margin-bottom: 10px; }
|
|
348
|
+
.meter-value { font-size: 24px; font-weight: 600; letter-spacing: -.02em; }
|
|
349
|
+
.meter-unit { color: var(--muted); font-size: 13px; flex: 1; }
|
|
350
|
+
.meter-bar { height: 9px; border-radius: 99px; background: var(--panel-2); border: 1px solid var(--border); overflow: hidden; }
|
|
351
|
+
.meter-fill { height: 100%; background: var(--ok); }
|
|
352
|
+
.meter-fill.over { background: var(--warn); }
|
|
353
|
+
.meter-fill.way-over { background: var(--danger); }
|
|
354
|
+
.meter-note { font-size: 12.5px; color: var(--muted); margin-top: 9px; }
|
|
355
|
+
.meter-facts { display: flex; flex-wrap: wrap; gap: 18px; margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--border); font-size: 12px; }
|
|
356
|
+
.meter-facts b { font-weight: 600; }
|
|
357
|
+
|
|
358
|
+
.prune-bar { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; flex-wrap: wrap; }
|
|
359
|
+
.prune-bar select { font: inherit; font-size: 12.5px; padding: 4px 8px; border-radius: 7px; border: 1px solid var(--border); background: var(--panel); color: var(--text); }
|
|
360
|
+
.prune-bar .spacer { flex: 1; }
|
|
361
|
+
|
|
362
|
+
.prune-row {
|
|
363
|
+
display: flex; gap: 10px; align-items: center; cursor: pointer;
|
|
364
|
+
background: var(--panel); border: 1px solid var(--border); border-radius: 9px;
|
|
365
|
+
padding: 8px 12px; margin-bottom: 4px;
|
|
366
|
+
}
|
|
367
|
+
.prune-row:hover { border-color: var(--accent); }
|
|
368
|
+
.prune-row.on { background: var(--danger-soft); border-color: var(--danger); }
|
|
369
|
+
.prune-row input { flex: none; }
|
|
370
|
+
.prune-main { flex: 1; min-width: 0; }
|
|
371
|
+
.prune-name { display: block; font-size: 13px; font-weight: 550; }
|
|
372
|
+
.prune-desc { display: block; font-size: 12px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
373
|
+
.prune-facts { display: flex; gap: 12px; font-family: var(--mono); font-size: 11px; color: var(--muted); flex: none; }
|
|
374
|
+
.prune-facts .warn { color: var(--warn); }
|
|
375
|
+
|
|
376
|
+
.dupe { background: var(--panel); border: 1px solid var(--border); border-radius: 10px; padding: 12px 14px; margin-bottom: 8px; }
|
|
377
|
+
.dupe-head { display: flex; align-items: baseline; gap: 8px; margin-bottom: 8px; font-size: 12px; color: var(--muted); }
|
|
378
|
+
.dupe-score { font-family: var(--mono); color: var(--warn); font-weight: 600; }
|
|
379
|
+
.dupe-pair { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
|
|
380
|
+
@media (max-width: 860px) { .dupe-pair { grid-template-columns: 1fr; } }
|
|
381
|
+
.dupe-side { border: 1px solid var(--border); border-radius: 8px; padding: 9px 11px; cursor: pointer; background: var(--panel-2); }
|
|
382
|
+
.dupe-side:hover { border-color: var(--accent); }
|
|
383
|
+
.dupe-side .n { font-size: 13px; font-weight: 550; }
|
|
384
|
+
.dupe-side .d { font-size: 12px; color: var(--muted); }
|
|
385
|
+
|
|
386
|
+
/* ---------- credit ---------- */
|
|
387
|
+
|
|
388
|
+
.sidebar-foot { border-top: 1px solid var(--border); padding: 14px 18px; }
|
|
389
|
+
.sidebar-foot a {
|
|
390
|
+
display: flex; flex-direction: column; gap: 6px;
|
|
391
|
+
color: var(--muted); text-decoration: none; font-size: 11px;
|
|
392
|
+
}
|
|
393
|
+
.sidebar-foot a:hover { color: var(--text); }
|
|
394
|
+
.sidebar-foot img {
|
|
395
|
+
width: 96px; height: auto; opacity: .75;
|
|
396
|
+
/* The mark is black on transparent, so it disappears on a dark ground. */
|
|
397
|
+
transition: opacity .15s;
|
|
398
|
+
}
|
|
399
|
+
.sidebar-foot a:hover img { opacity: 1; }
|
|
400
|
+
@media (prefers-color-scheme: dark) {
|
|
401
|
+
.sidebar-foot img { filter: invert(1); }
|
|
402
|
+
}
|
package/server.mjs
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
// Local-only HTTP server: static frontend plus a small JSON API over the
|
|
2
|
+
// memory store. Binds 127.0.0.1 and never serves anything outside ./public
|
|
3
|
+
// and the two vendored browser bundles.
|
|
4
|
+
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import http from 'node:http';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
import { spawn } from 'node:child_process';
|
|
10
|
+
|
|
11
|
+
import { listProjects, projectsRoot } from './src/projects.mjs';
|
|
12
|
+
import { buildProject } from './src/model.mjs';
|
|
13
|
+
import { searchAll } from './src/search.mjs';
|
|
14
|
+
import {
|
|
15
|
+
deleteIndexLine,
|
|
16
|
+
deleteMemories,
|
|
17
|
+
deleteMemory,
|
|
18
|
+
deletePreview,
|
|
19
|
+
deleteProject,
|
|
20
|
+
projectDeletePreview,
|
|
21
|
+
removeWikilink,
|
|
22
|
+
restoreMemory,
|
|
23
|
+
} from './src/mutate.mjs';
|
|
24
|
+
|
|
25
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
26
|
+
const PUBLIC = path.join(here, 'public');
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Locate the browser builds of the two dependencies.
|
|
30
|
+
*
|
|
31
|
+
* A hard-coded ./node_modules path breaks as soon as the package is installed
|
|
32
|
+
* globally and npm hoists dependencies somewhere else. import.meta.resolve
|
|
33
|
+
* follows the same resolution the runtime would, and picks the `import`
|
|
34
|
+
* condition, which is what gives the ESM build rather than the CommonJS one.
|
|
35
|
+
*/
|
|
36
|
+
function resolveVendor(specifier) {
|
|
37
|
+
try {
|
|
38
|
+
return fileURLToPath(import.meta.resolve(specifier));
|
|
39
|
+
} catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const VENDOR = {
|
|
45
|
+
'/vendor/marked.js': resolveVendor('marked'),
|
|
46
|
+
'/vendor/purify.js': resolveVendor('dompurify'),
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
let ROOT = projectsRoot();
|
|
50
|
+
|
|
51
|
+
const MIME = {
|
|
52
|
+
'.html': 'text/html; charset=utf-8',
|
|
53
|
+
'.mjs': 'text/javascript; charset=utf-8',
|
|
54
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
55
|
+
'.css': 'text/css; charset=utf-8',
|
|
56
|
+
'.svg': 'image/svg+xml',
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
function sendJson(res, status, body) {
|
|
60
|
+
const payload = JSON.stringify(body);
|
|
61
|
+
res.writeHead(status, {
|
|
62
|
+
'content-type': 'application/json; charset=utf-8',
|
|
63
|
+
'cache-control': 'no-store',
|
|
64
|
+
});
|
|
65
|
+
res.end(payload);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function sendFile(res, file) {
|
|
69
|
+
fs.readFile(file, (err, data) => {
|
|
70
|
+
if (err) {
|
|
71
|
+
res.writeHead(404, { 'content-type': 'text/plain' });
|
|
72
|
+
res.end('Not found');
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
res.writeHead(200, {
|
|
76
|
+
'content-type': MIME[path.extname(file)] || 'application/octet-stream',
|
|
77
|
+
'cache-control': 'no-store',
|
|
78
|
+
});
|
|
79
|
+
res.end(data);
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function readBody(req) {
|
|
84
|
+
return new Promise((resolve, reject) => {
|
|
85
|
+
const chunks = [];
|
|
86
|
+
let size = 0;
|
|
87
|
+
req.on('data', (chunk) => {
|
|
88
|
+
size += chunk.length;
|
|
89
|
+
if (size > 1_000_000) {
|
|
90
|
+
reject(new Error('Request body too large'));
|
|
91
|
+
req.destroy();
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
chunks.push(chunk);
|
|
95
|
+
});
|
|
96
|
+
req.on('end', () => {
|
|
97
|
+
const text = Buffer.concat(chunks).toString('utf8');
|
|
98
|
+
if (!text) return resolve({});
|
|
99
|
+
try {
|
|
100
|
+
resolve(JSON.parse(text));
|
|
101
|
+
} catch {
|
|
102
|
+
reject(new Error('Invalid JSON body'));
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
req.on('error', reject);
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** A slug is only ever accepted if it names a directory we actually discovered. */
|
|
110
|
+
function requireSlug(slug) {
|
|
111
|
+
const known = listProjects(ROOT).some((p) => p.slug === slug);
|
|
112
|
+
if (!known) throw new Error(`Unknown project: ${slug}`);
|
|
113
|
+
return slug;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function handleApi(req, res, url) {
|
|
117
|
+
const segments = url.pathname.split('/').filter(Boolean); // ['api', 'projects', ...]
|
|
118
|
+
|
|
119
|
+
if (segments.length === 2 && segments[1] === 'projects' && req.method === 'GET') {
|
|
120
|
+
return sendJson(res, 200, { root: ROOT, projects: listProjects(ROOT) });
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (segments.length === 2 && segments[1] === 'search' && req.method === 'GET') {
|
|
124
|
+
return sendJson(res, 200, searchAll(ROOT, url.searchParams.get('q') || ''));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (segments[1] !== 'projects' || segments.length < 3) {
|
|
128
|
+
return sendJson(res, 404, { error: 'Unknown endpoint' });
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const slug = requireSlug(decodeURIComponent(segments[2]));
|
|
132
|
+
const action = segments.slice(3).join('/');
|
|
133
|
+
|
|
134
|
+
if (!action && req.method === 'GET') {
|
|
135
|
+
return sendJson(res, 200, buildProject(ROOT, slug));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (action === 'delete-preview' && req.method === 'POST') {
|
|
139
|
+
const body = await readBody(req);
|
|
140
|
+
return sendJson(res, 200, deletePreview(ROOT, slug, body.file));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (action === 'delete' && req.method === 'POST') {
|
|
144
|
+
const body = await readBody(req);
|
|
145
|
+
return sendJson(res, 200, deleteMemory(ROOT, slug, body.file, body.alsoDelete || []));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (action === 'delete-many' && req.method === 'POST') {
|
|
149
|
+
const body = await readBody(req);
|
|
150
|
+
return sendJson(res, 200, deleteMemories(ROOT, slug, body.files || [], { label: body.label || null }));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (action === 'project/delete-preview' && req.method === 'POST') {
|
|
154
|
+
return sendJson(res, 200, projectDeletePreview(ROOT, slug));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (action === 'project/delete' && req.method === 'POST') {
|
|
158
|
+
return sendJson(res, 200, deleteProject(ROOT, slug));
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (action === 'wikilink/remove' && req.method === 'POST') {
|
|
162
|
+
const body = await readBody(req);
|
|
163
|
+
return sendJson(res, 200, removeWikilink(ROOT, slug, body.file, body.target));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (action === 'restore' && req.method === 'POST') {
|
|
167
|
+
const body = await readBody(req);
|
|
168
|
+
return sendJson(res, 200, restoreMemory(ROOT, slug, body.id));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (action === 'index-line/delete' && req.method === 'POST') {
|
|
172
|
+
const body = await readBody(req);
|
|
173
|
+
return sendJson(res, 200, deleteIndexLine(ROOT, slug, body.lineIndex, body.expectedText));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return sendJson(res, 404, { error: 'Unknown endpoint' });
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const server = http.createServer((req, res) => {
|
|
180
|
+
const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
|
|
181
|
+
|
|
182
|
+
if (url.pathname.startsWith('/api/')) {
|
|
183
|
+
handleApi(req, res, url).catch((err) => {
|
|
184
|
+
sendJson(res, 400, { error: err.message });
|
|
185
|
+
});
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (VENDOR[url.pathname]) return sendFile(res, VENDOR[url.pathname]);
|
|
190
|
+
|
|
191
|
+
const requested = url.pathname === '/' ? '/index.html' : url.pathname;
|
|
192
|
+
const resolved = path.join(PUBLIC, path.normalize(requested).replace(/^(\.\.[/\\])+/, ''));
|
|
193
|
+
if (!resolved.startsWith(PUBLIC)) {
|
|
194
|
+
res.writeHead(403, { 'content-type': 'text/plain' });
|
|
195
|
+
res.end('Forbidden');
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
sendFile(res, resolved);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
export function startServer({ port, root, open = true } = {}) {
|
|
202
|
+
if (root) {
|
|
203
|
+
process.env.MEMORY_ROOT = root;
|
|
204
|
+
ROOT = projectsRoot();
|
|
205
|
+
}
|
|
206
|
+
// Number(undefined) is NaN, which ?? does not catch - check PORT is set first.
|
|
207
|
+
const listenPort = port ?? (process.env.PORT ? Number(process.env.PORT) : 4173);
|
|
208
|
+
|
|
209
|
+
for (const [route, file] of Object.entries(VENDOR)) {
|
|
210
|
+
if (!file) {
|
|
211
|
+
console.error(`Could not resolve the dependency behind ${route}. Try reinstalling the package.`);
|
|
212
|
+
process.exit(1);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
server.on('error', (err) => {
|
|
217
|
+
if (err.code === 'EADDRINUSE') {
|
|
218
|
+
console.error(`Port ${listenPort} is already in use. Pass --port to pick another.`);
|
|
219
|
+
process.exit(1);
|
|
220
|
+
}
|
|
221
|
+
throw err;
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
server.listen(listenPort, '127.0.0.1', () => {
|
|
225
|
+
const address = `http://localhost:${listenPort}`;
|
|
226
|
+
console.log(`Memory Admin -> ${address}`);
|
|
227
|
+
console.log(`Reading -> ${ROOT}`);
|
|
228
|
+
if (open && !process.env.NO_OPEN) {
|
|
229
|
+
const opener = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
|
|
230
|
+
spawn(opener, [address], { stdio: 'ignore', detached: true }).unref();
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
return server;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Running `node server.mjs` directly still just works.
|
|
237
|
+
if (process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1])) {
|
|
238
|
+
startServer();
|
|
239
|
+
}
|