@seedvault/server 0.1.2 → 0.1.3

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/dist/index.html CHANGED
@@ -1,383 +1,706 @@
1
1
  <!doctype html>
2
2
  <html lang="en">
3
- <head>
4
- <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width, initial-scale=1" />
6
- <title>Seedvault</title>
7
- <style>
8
- * { box-sizing: border-box; margin: 0; }
9
- html {
10
- color-scheme: light dark;
11
- font-family: ui-monospace, "Cascadia Mono", "SF Mono", Menlo, monospace;
12
- font-size: 13px;
13
- }
14
- html, body { height: 100%; }
15
- body {
16
- padding: 12px;
17
- display: flex;
18
- flex-direction: column;
19
- }
20
- .top { display: flex; gap: 8px; margin-bottom: 12px; flex-wrap: wrap; flex-shrink: 0; }
21
- input, select, button {
22
- font: inherit;
23
- padding: 4px 8px;
24
- background: transparent;
25
- color: inherit;
26
- border: 1px solid;
27
- }
28
- #token { min-width: 260px; flex: 1; }
29
- button { cursor: pointer; }
30
- .grid {
31
- display: flex;
32
- gap: 0;
33
- flex: 1;
34
- min-height: 0;
35
- }
36
- .grid > .panel:first-child { flex-shrink: 0; }
37
- .grid > .panel:last-child { flex: 1; min-width: 0; }
38
- .divider {
39
- width: 5px;
40
- cursor: col-resize;
41
- background: transparent;
42
- flex-shrink: 0;
43
- }
44
- .divider:hover, .divider.dragging {
45
- background: color-mix(in srgb, currentColor 20%, transparent);
46
- }
47
- @media (max-width: 600px) {
48
- .grid { flex-direction: column; }
49
- .grid > .panel:first-child { width: auto; }
50
- .divider { display: none; }
51
- }
52
- .panel {
53
- border: 1px solid;
54
- display: flex;
55
- flex-direction: column;
56
- min-height: 0;
57
- overflow: hidden;
58
- }
59
- .panel-head {
60
- padding: 4px 8px;
61
- border-bottom: 1px solid;
62
- font-weight: bold;
63
- font-size: 11px;
64
- text-transform: uppercase;
65
- letter-spacing: 0.05em;
66
- flex-shrink: 0;
67
- }
68
- .panel-body {
69
- flex: 1;
70
- overflow: auto;
71
- }
72
- .tree { list-style: none; padding: 0; }
73
- .tree ul { list-style: none; padding: 0; }
74
- .tree-row {
75
- display: block;
76
- width: 100%;
77
- text-align: left;
78
- border: none;
79
- padding: 3px 8px;
80
- white-space: nowrap;
81
- overflow: hidden;
82
- text-overflow: ellipsis;
83
- cursor: pointer;
84
- }
85
- .tree-row:hover { background: color-mix(in srgb, currentColor 8%, transparent); }
86
- .tree-row.active { font-weight: bold; }
87
- .tree-row .arrow { display: inline-block; width: 1em; text-align: center; }
88
- .tree ul.collapsed { display: none; }
89
- #content {
90
- padding: 8px;
91
- white-space: pre-wrap;
92
- overflow: auto;
93
- line-height: 1.4;
94
- }
95
- #status { margin-top: 8px; font-size: 11px; opacity: 0.6; flex-shrink: 0; }
96
- </style>
97
- </head>
98
- <body>
99
- <div class="top">
100
- <input id="token" type="password" placeholder="API key (&quot;sv_...&quot;)" />
101
- <button id="connect">Load</button>
102
- </div>
103
- <div class="grid">
104
- <section id="nav" class="panel" style="width:220px">
105
- <div class="panel-head">Files</div>
106
- <div class="panel-body"><ul id="files" class="tree"></ul></div>
107
- </section>
108
- <div id="divider" class="divider"></div>
109
- <section class="panel">
110
- <div class="panel-head">Content</div>
111
- <div class="panel-body"><pre id="content"></pre></div>
112
- </section>
113
- </div>
114
- <div id="status"></div>
115
- <script>
116
- const $ = (id) => document.getElementById(id);
117
- const tokenEl = $("token");
118
- const filesEl = $("files");
119
- const contentEl = $("content");
120
- const statusEl = $("status");
121
-
122
- let token = localStorage.getItem("sv-token") || "";
123
- tokenEl.value = token;
124
-
125
- function status(msg) { statusEl.textContent = msg; }
126
-
127
- async function api(url) {
128
- const res = await fetch(url, {
129
- headers: token ? { Authorization: "Bearer " + token } : {},
130
- });
131
- if (!res.ok) {
132
- const body = await res.json().catch(() => ({}));
133
- throw new Error((body.error || "Request failed") + " (" + res.status + ")");
134
- }
135
- return res;
136
- }
137
-
138
- const savedContributor = localStorage.getItem("sv-contributor") || "";
139
- const savedFile = localStorage.getItem("sv-file") || "";
140
-
141
- function buildTree(paths) {
142
- const root = {};
143
- for (const p of paths) {
144
- const parts = p.split("/");
145
- let node = root;
146
- for (const part of parts) {
147
- if (!node[part]) node[part] = {};
148
- node = node[part];
149
- }
150
- node.__file = p;
151
- }
152
- return root;
153
- }
154
-
155
- function countFiles(node) {
156
- let count = 0;
157
- for (const key of Object.keys(node)) {
158
- if (key === "__file") { count++; continue; }
159
- count += countFiles(node[key]);
160
- }
161
- return count;
162
- }
163
-
164
- function renderTree(node, parentUl, username, depth) {
165
- const keys = Object.keys(node).filter((k) => k !== "__file").sort((a, b) => {
166
- const aDir = Object.keys(node[a]).some((k) => k !== "__file");
167
- const bDir = Object.keys(node[b]).some((k) => k !== "__file");
168
- if (aDir !== bDir) return aDir ? -1 : 1;
169
- return a.localeCompare(b);
170
- });
171
- for (const key of keys) {
172
- const child = node[key];
173
- const isFile = child.__file && Object.keys(child).length === 1;
174
- const li = document.createElement("li");
175
- const row = document.createElement("div");
176
- row.className = "tree-row";
177
- row.style.paddingLeft = (depth * 12 + 8) + "px";
178
-
179
- if (isFile) {
180
- row.innerHTML = '<span class="arrow">&nbsp;</span>' + key;
181
- row.dataset.path = child.__file;
182
- row.dataset.contributor = username;
183
- row.onclick = () => loadContent(username, child.__file, row);
184
- } else {
185
- const sub = document.createElement("ul");
186
- const fileCount = countFiles(child);
187
- row.innerHTML = '<span class="arrow">&#9660;</span>' + key + ' <span style="opacity:0.5">(' + fileCount + ')</span>';
188
- row.onclick = () => {
189
- sub.classList.toggle("collapsed");
190
- row.querySelector(".arrow").innerHTML = sub.classList.contains("collapsed") ? "&#9654;" : "&#9660;";
191
- };
192
- renderTree(child, sub, username, depth + 1);
193
- li.appendChild(row);
194
- li.appendChild(sub);
195
- parentUl.appendChild(li);
196
- continue;
197
- }
198
- li.appendChild(row);
199
- parentUl.appendChild(li);
200
- }
201
- }
202
-
203
- function markActiveRow() {
204
- filesEl.querySelectorAll(".tree-row").forEach((r) => r.classList.remove("active"));
205
- const activePath = localStorage.getItem("sv-file");
206
- const activeContributor = localStorage.getItem("sv-contributor");
207
- if (!activePath || !activeContributor) return;
208
- const active = filesEl.querySelector(
209
- '.tree-row[data-contributor="' + CSS.escape(activeContributor) + '"][data-path="' + CSS.escape(activePath) + '"]'
210
- );
211
- if (active) active.classList.add("active");
212
- }
213
-
214
- function getLoadedContributorList(username) {
215
- return filesEl.querySelector(
216
- 'ul[data-contributor="' + CSS.escape(username) + '"][data-loaded="true"]'
217
- );
218
- }
219
-
220
- async function loadContributorFiles(username, sub, opts = {}) {
221
- const silent = !!opts.silent;
222
- sub.innerHTML = "";
223
- if (!silent) status("Loading files...");
224
- const { files } = await (await api("/v1/contributors/" + encodeURIComponent(username) + "/files")).json();
225
- const tree = buildTree(files.map((f) => f.path));
226
- renderTree(tree, sub, username, 1);
227
- markActiveRow();
228
- if (!silent) status(files.length + " file(s)");
229
- // Update contributor row with file count
230
- const row = sub.parentElement && sub.parentElement.querySelector(".tree-row");
231
- if (row) {
232
- const arrow = row.querySelector(".arrow").outerHTML;
233
- row.innerHTML = arrow + username + ' <span style="opacity:0.5">(' + files.length + ')</span>';
234
- }
235
- }
236
-
237
- async function loadContributors() {
238
- filesEl.innerHTML = "";
239
- contentEl.textContent = "";
240
- status("Loading contributors...");
241
- const { contributors } = await (await api("/v1/contributors")).json();
242
-
243
- for (const b of contributors) {
244
- const li = document.createElement("li");
245
- const row = document.createElement("div");
246
- row.className = "tree-row";
247
- row.style.paddingLeft = "8px";
248
- const sub = document.createElement("ul");
249
- sub.dataset.contributor = b.username;
250
- sub.dataset.loaded = "false";
251
- let loaded = false;
252
- row.innerHTML = '<span class="arrow">&#9654;</span>' + b.username;
253
- row.onclick = async () => {
254
- const collapsed = sub.classList.contains("collapsed") || !sub.hasChildNodes();
255
- if (collapsed) {
256
- sub.classList.remove("collapsed");
257
- row.querySelector(".arrow").innerHTML = "&#9660;";
258
- if (!loaded) {
259
- loaded = true;
260
- sub.dataset.loaded = "true";
261
- await loadContributorFiles(b.username, sub);
262
- }
263
- } else {
264
- sub.classList.add("collapsed");
265
- row.querySelector(".arrow").innerHTML = "&#9654;";
266
- }
267
- };
268
- li.appendChild(row);
269
- li.appendChild(sub);
270
- filesEl.appendChild(li);
271
-
272
- if (b.username === savedContributor) {
273
- sub.classList.remove("collapsed");
274
- row.querySelector(".arrow").innerHTML = "&#9660;";
275
- loaded = true;
276
- sub.dataset.loaded = "true";
277
- await loadContributorFiles(b.username, sub);
278
- if (savedFile) {
279
- const match = filesEl.querySelector('[data-path="' + CSS.escape(savedFile) + '"]');
280
- if (match) await loadContent(b.username, savedFile, match);
281
- }
282
- }
283
- }
284
- status(contributors.length + " contributor(s)");
285
- }
286
-
287
- async function loadContent(username, path, row) {
288
- filesEl.querySelectorAll(".tree-row").forEach((r) => r.classList.remove("active"));
289
- row.classList.add("active");
290
- localStorage.setItem("sv-contributor", username);
291
- localStorage.setItem("sv-file", path);
292
- status("Loading " + path + "...");
293
- const encodedPath = path.split("/").map(encodeURIComponent).join("/");
294
- const res = await api("/v1/contributors/" + encodeURIComponent(username) + "/files/" + encodedPath);
295
- contentEl.textContent = await res.text();
296
- status(path);
297
- }
298
-
299
- $("connect").onclick = () => {
300
- token = tokenEl.value.trim();
301
- localStorage.setItem("sv-token", token);
302
- loadContributors().then(() => connectSSE()).catch((e) => status(e.message));
303
- };
304
-
305
- // --- SSE real-time updates ---
306
- let evtSource = null;
307
- const contributorReloadTimers = new Map();
308
-
309
- function scheduleContributorReload(username) {
310
- if (!getLoadedContributorList(username)) return;
311
-
312
- const existing = contributorReloadTimers.get(username);
313
- if (existing) clearTimeout(existing);
314
-
315
- const timer = setTimeout(() => {
316
- contributorReloadTimers.delete(username);
317
- const targetUl = getLoadedContributorList(username);
318
- if (!targetUl) return;
319
- loadContributorFiles(username, targetUl, { silent: true }).catch((e) => status(e.message));
320
- }, 100);
321
-
322
- contributorReloadTimers.set(username, timer);
323
- }
324
-
325
- function connectSSE() {
326
- if (evtSource) evtSource.close();
327
- if (!token) return;
328
-
329
- evtSource = new EventSource("/v1/events?token=" + encodeURIComponent(token));
330
-
331
- evtSource.addEventListener("file_updated", (e) => {
332
- const { contributor, path } = JSON.parse(e.data);
333
- scheduleContributorReload(contributor);
334
- // If this file is currently open, reload its content
335
- if (localStorage.getItem("sv-file") === path && localStorage.getItem("sv-contributor") === contributor) {
336
- const encodedPath = path.split("/").map(encodeURIComponent).join("/");
337
- api("/v1/contributors/" + encodeURIComponent(contributor) + "/files/" + encodedPath)
338
- .then((res) => res.text())
339
- .then((text) => { contentEl.textContent = text; });
340
- }
341
- });
342
-
343
- evtSource.addEventListener("file_deleted", (e) => {
344
- const { contributor, path } = JSON.parse(e.data);
345
- scheduleContributorReload(contributor);
346
- // If this file was being viewed, clear the content
347
- if (localStorage.getItem("sv-file") === path && localStorage.getItem("sv-contributor") === contributor) {
348
- contentEl.textContent = "";
349
- status("File deleted: " + path);
350
- }
351
- });
352
-
353
- evtSource.onerror = () => {
354
- // EventSource auto-reconnects
355
- };
356
- }
357
-
358
- if (token) loadContributors().then(() => connectSSE()).catch((e) => status(e.message));
359
-
360
- // Divider drag
361
- const divider = $("divider");
362
- const nav = $("nav");
363
- const savedNav = localStorage.getItem("sv-nav-w");
364
- if (savedNav) nav.style.width = savedNav + "px";
365
- divider.addEventListener("mousedown", (e) => {
366
- e.preventDefault();
367
- divider.classList.add("dragging");
368
- const onMove = (e) => {
369
- const w = e.clientX - nav.getBoundingClientRect().left;
370
- if (w > 80 && w < window.innerWidth - 120) nav.style.width = w + "px";
371
- };
372
- const onUp = () => {
373
- divider.classList.remove("dragging");
374
- localStorage.setItem("sv-nav-w", nav.offsetWidth);
375
- document.removeEventListener("mousemove", onMove);
376
- document.removeEventListener("mouseup", onUp);
377
- };
378
- document.addEventListener("mousemove", onMove);
379
- document.addEventListener("mouseup", onUp);
380
- });
381
- </script>
382
- </body>
383
- </html>
3
+
4
+ <head>
5
+ <meta charset="utf-8" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
7
+ <title>Seedvault</title>
8
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
9
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
10
+ <link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:ital,wght@0,400;0,500;0,600;1,400&display=swap"
11
+ rel="stylesheet" />
12
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@phosphor-icons/web@2.1.2/src/regular/style.css" />
13
+ <style>
14
+ * {
15
+ box-sizing: border-box;
16
+ margin: 0;
17
+ }
18
+
19
+ html {
20
+ color-scheme: light dark;
21
+ font-family: ui-monospace, monospace;
22
+ font-size: 12px;
23
+ }
24
+
25
+ @media (prefers-color-scheme: dark) {
26
+ html {
27
+ background: rgb(18, 18, 18);
28
+ }
29
+
30
+ #nav {
31
+ color: rgb(204, 204, 204);
32
+ }
33
+
34
+ #content {
35
+ color: rgb(230, 230, 230);
36
+ }
37
+ }
38
+
39
+ html,
40
+ body {
41
+ height: 100%;
42
+ }
43
+
44
+ body {
45
+ padding: 12px;
46
+ display: flex;
47
+ flex-direction: column;
48
+ }
49
+
50
+ .top {
51
+ display: flex;
52
+ gap: 8px;
53
+ margin-bottom: 12px;
54
+ flex-wrap: wrap;
55
+ flex-shrink: 0;
56
+ }
57
+
58
+ input,
59
+ select,
60
+ button {
61
+ font: inherit;
62
+ padding: 4px 8px;
63
+ background: transparent;
64
+ color: inherit;
65
+ border: 1px solid color-mix(in srgb, currentColor 25%, transparent);
66
+ }
67
+
68
+ #token {
69
+ min-width: 260px;
70
+ flex: 1;
71
+ }
72
+
73
+ button {
74
+ cursor: pointer;
75
+ }
76
+
77
+ .grid {
78
+ display: flex;
79
+ gap: 0;
80
+ flex: 1;
81
+ min-height: 0;
82
+ }
83
+
84
+ .grid>.panel:first-child {
85
+ flex-shrink: 0;
86
+ }
87
+
88
+ .grid>.panel:last-child {
89
+ flex: 1;
90
+ min-width: 0;
91
+ }
92
+
93
+ .divider {
94
+ width: 5px;
95
+ cursor: col-resize;
96
+ background: transparent;
97
+ flex-shrink: 0;
98
+ }
99
+
100
+ .divider:hover,
101
+ .divider.dragging {
102
+ background: color-mix(in srgb, currentColor 20%, transparent);
103
+ }
104
+
105
+ @media (max-width: 600px) {
106
+ .grid {
107
+ flex-direction: column;
108
+ }
109
+
110
+ .grid>.panel:first-child {
111
+ width: auto;
112
+ }
113
+
114
+ .divider {
115
+ display: none;
116
+ }
117
+ }
118
+
119
+ .panel {
120
+ border: 1px solid color-mix(in srgb, currentColor 20%, transparent);
121
+ display: flex;
122
+ flex-direction: column;
123
+ min-height: 0;
124
+ overflow: hidden;
125
+ }
126
+
127
+ .panel-head {
128
+ padding: 4px 8px;
129
+ border-bottom: 1px solid color-mix(in srgb, currentColor 20%, transparent);
130
+ font-weight: bold;
131
+ font-size: 11px;
132
+ text-transform: uppercase;
133
+ letter-spacing: 0.05em;
134
+ flex-shrink: 0;
135
+ }
136
+
137
+ #nav-body:focus {
138
+ outline: none;
139
+ }
140
+
141
+ .panel-body {
142
+ flex: 1;
143
+ overflow: auto;
144
+ scrollbar-width: thin;
145
+ scrollbar-color: color-mix(in srgb, currentColor 20%, transparent) transparent;
146
+ }
147
+
148
+ .panel-body::-webkit-scrollbar,
149
+ #content::-webkit-scrollbar {
150
+ width: 6px;
151
+ height: 6px;
152
+ }
153
+
154
+ .panel-body::-webkit-scrollbar-track,
155
+ #content::-webkit-scrollbar-track {
156
+ background: transparent;
157
+ }
158
+
159
+ .panel-body::-webkit-scrollbar-thumb,
160
+ #content::-webkit-scrollbar-thumb {
161
+ background: color-mix(in srgb, currentColor 20%, transparent);
162
+ border-radius: 3px;
163
+ }
164
+
165
+ .panel-body::-webkit-scrollbar-thumb:hover,
166
+ #content::-webkit-scrollbar-thumb:hover {
167
+ background: color-mix(in srgb, currentColor 30%, transparent);
168
+ }
169
+
170
+ .tree {
171
+ list-style: none;
172
+ padding: 0;
173
+ }
174
+
175
+ .tree ul {
176
+ list-style: none;
177
+ padding: 0;
178
+ }
179
+
180
+ .tree-row {
181
+ display: block;
182
+ width: 100%;
183
+ text-align: left;
184
+ border: none;
185
+ padding: 4px 8px;
186
+ white-space: nowrap;
187
+ overflow: hidden;
188
+ text-overflow: ellipsis;
189
+ cursor: pointer;
190
+ }
191
+
192
+ .tree-row:hover {
193
+ background: color-mix(in srgb, currentColor 8%, transparent);
194
+ }
195
+
196
+ .tree-row.active {
197
+ background: CanvasText;
198
+ color: Canvas;
199
+ }
200
+
201
+ .tree-row .arrow {
202
+ display: inline-block;
203
+ width: 1em;
204
+ text-align: center;
205
+ }
206
+
207
+ .tree-row .ph {
208
+ display: inline-block;
209
+ font-size: 14px;
210
+ margin-left: 12px;
211
+ margin-right: 2px;
212
+ vertical-align: -0.15em;
213
+ }
214
+
215
+ .tree ul.collapsed {
216
+ display: none;
217
+ }
218
+
219
+ #content {
220
+ font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
221
+ font-size: 16px;
222
+ padding: 24px 32px;
223
+ overflow: auto;
224
+ line-height: 1.6;
225
+ }
226
+
227
+ #content h1 {
228
+ font-size: 1.75em;
229
+ font-weight: 700;
230
+ margin: 0 0 0.5em;
231
+ line-height: 1.3;
232
+ }
233
+
234
+ #content h2 {
235
+ font-size: 1.4em;
236
+ font-weight: 600;
237
+ margin: 1em 0 0.5em;
238
+ line-height: 1.3;
239
+ }
240
+
241
+ #content h3 {
242
+ font-size: 1.2em;
243
+ font-weight: 600;
244
+ margin: 1em 0 0.5em;
245
+ line-height: 1.3;
246
+ }
247
+
248
+ #content h4,
249
+ #content h5,
250
+ #content h6 {
251
+ font-size: 1.05em;
252
+ font-weight: 600;
253
+ margin: 0.75em 0 0.5em;
254
+ }
255
+
256
+ #content p {
257
+ margin: 0 0 0.75em;
258
+ }
259
+
260
+ #content img {
261
+ max-width: 100%;
262
+ height: auto;
263
+ display: block;
264
+ }
265
+
266
+ #content ul,
267
+ #content ol {
268
+ margin: 0 0 0.75em;
269
+ padding-left: 1.5em;
270
+ }
271
+
272
+ #content pre,
273
+ #content code {
274
+ font-family: ui-monospace, monospace;
275
+ font-size: 0.9em;
276
+ }
277
+
278
+ #content pre {
279
+ background: color-mix(in srgb, currentColor 8%, transparent);
280
+ padding: 10px;
281
+ border-radius: 4px;
282
+ overflow-x: auto;
283
+ margin: 0.5em 0;
284
+ }
285
+
286
+ #content code {
287
+ padding: 0.15em 0.3em;
288
+ border-radius: 3px;
289
+ background: color-mix(in srgb, currentColor 8%, transparent);
290
+ }
291
+
292
+ #content pre code {
293
+ padding: 0;
294
+ background: none;
295
+ }
296
+
297
+ #content blockquote {
298
+ border-left: 4px solid color-mix(in srgb, currentColor 30%, transparent);
299
+ margin: 0.5em 0;
300
+ padding-left: 1em;
301
+ color: color-mix(in srgb, currentColor 80%, transparent);
302
+ }
303
+
304
+ #content a {
305
+ color: inherit;
306
+ text-decoration: underline;
307
+ }
308
+
309
+ #content hr {
310
+ border: none;
311
+ border-top: 1px solid color-mix(in srgb, currentColor 25%, transparent);
312
+ margin: 1.5em 0;
313
+ }
314
+
315
+ #content table {
316
+ border-collapse: collapse;
317
+ margin: 0.5em 0;
318
+ }
319
+
320
+ #content th,
321
+ #content td {
322
+ border: 1px solid color-mix(in srgb, currentColor 20%, transparent);
323
+ padding: 6px 10px;
324
+ text-align: left;
325
+ }
326
+
327
+ #status {
328
+ margin-top: 8px;
329
+ font-size: 11px;
330
+ opacity: 0.6;
331
+ flex-shrink: 0;
332
+ }
333
+ </style>
334
+ </head>
335
+
336
+ <body>
337
+ <div class="top">
338
+ <input id="token" type="password" placeholder="API key (&quot;sv_...&quot;)" />
339
+ <button id="connect">Load</button>
340
+ </div>
341
+ <div class="grid">
342
+ <section id="nav" class="panel" style="width:220px">
343
+ <div class="panel-head">Files</div>
344
+ <div class="panel-body" id="nav-body" tabindex="0">
345
+ <ul id="files" class="tree"></ul>
346
+ </div>
347
+ </section>
348
+ <div id="divider" class="divider"></div>
349
+ <section class="panel">
350
+ <div class="panel-head">Content</div>
351
+ <div class="panel-body">
352
+ <div id="content"></div>
353
+ </div>
354
+ </section>
355
+ </div>
356
+ <div id="status"></div>
357
+ <script type="module">
358
+ const { marked } = await import("https://cdn.jsdelivr.net/npm/marked/lib/marked.esm.js");
359
+ const matter = (await import("https://cdn.jsdelivr.net/npm/gray-matter@4.0.3/+esm")).default;
360
+ const DOMPurify = (await import("https://cdn.jsdelivr.net/npm/dompurify@3.0.9/+esm")).default;
361
+
362
+ function renderMarkdown(raw) {
363
+ if (typeof raw !== "string") return "";
364
+ try {
365
+ const { content } = matter(raw);
366
+ const html = marked.parse(content);
367
+ return DOMPurify.sanitize(html, { USE_PROFILES: { html: true } });
368
+ } catch {
369
+ const escaped = raw
370
+ .replace(/&/g, "&amp;")
371
+ .replace(/</g, "&lt;")
372
+ .replace(/>/g, "&gt;")
373
+ .replace(/"/g, "&quot;");
374
+ return "<pre>" + escaped + "</pre>";
375
+ }
376
+ }
377
+
378
+ const $ = (id) => document.getElementById(id);
379
+ const tokenEl = $("token");
380
+ const filesEl = $("files");
381
+ const contentEl = $("content");
382
+ const statusEl = $("status");
383
+
384
+ let token = localStorage.getItem("sv-token") || "";
385
+ tokenEl.value = token;
386
+
387
+ function status(msg) { statusEl.textContent = msg; }
388
+
389
+ async function api(url, opts = {}) {
390
+ const res = await fetch(url, {
391
+ ...opts,
392
+ headers: {
393
+ ...(token ? { Authorization: "Bearer " + token } : {}),
394
+ ...(opts.headers || {}),
395
+ },
396
+ });
397
+ if (!res.ok) {
398
+ const body = await res.json().catch(() => ({}));
399
+ throw new Error((body.error || "Request failed") + " (" + res.status + ")");
400
+ }
401
+ return res;
402
+ }
403
+
404
+ const savedContributor = localStorage.getItem("sv-contributor") || "";
405
+ const savedFile = localStorage.getItem("sv-file") || "";
406
+
407
+ function buildTree(paths) {
408
+ const root = {};
409
+ for (const p of paths) {
410
+ const parts = p.split("/");
411
+ let node = root;
412
+ for (const part of parts) {
413
+ if (!node[part]) node[part] = {};
414
+ node = node[part];
415
+ }
416
+ node.__file = p;
417
+ }
418
+ return root;
419
+ }
420
+
421
+ function countFiles(node) {
422
+ let count = 0;
423
+ for (const key of Object.keys(node)) {
424
+ if (key === "__file") { count++; continue; }
425
+ count += countFiles(node[key]);
426
+ }
427
+ return count;
428
+ }
429
+
430
+ function renderTree(node, parentUl, username, depth) {
431
+ const keys = Object.keys(node).filter((k) => k !== "__file").sort((a, b) => {
432
+ const aDir = Object.keys(node[a]).some((k) => k !== "__file");
433
+ const bDir = Object.keys(node[b]).some((k) => k !== "__file");
434
+ if (aDir !== bDir) return aDir ? -1 : 1;
435
+ return a.localeCompare(b);
436
+ });
437
+ for (const key of keys) {
438
+ const child = node[key];
439
+ const isFile = child.__file && Object.keys(child).length === 1;
440
+ const li = document.createElement("li");
441
+ const row = document.createElement("div");
442
+ row.className = "tree-row";
443
+ row.style.paddingLeft = (depth * 12 + 8) + "px";
444
+
445
+ if (isFile) {
446
+ row.innerHTML = '<span class="arrow">&nbsp;</span><i class="ph ph-file-text"></i> ' + key;
447
+ row.dataset.path = child.__file;
448
+ row.dataset.contributor = username;
449
+ row.onclick = () => loadContent(username, child.__file, row);
450
+ } else {
451
+ const sub = document.createElement("ul");
452
+ const fileCount = countFiles(child);
453
+ row.innerHTML = '<span class="arrow">&#9660;</span><i class="ph ph-folder"></i> ' + key + ' <span style="opacity:0.5">(' + fileCount + ')</span>';
454
+ row.onclick = () => {
455
+ sub.classList.toggle("collapsed");
456
+ row.querySelector(".arrow").innerHTML = sub.classList.contains("collapsed") ? "&#9654;" : "&#9660;";
457
+ };
458
+ renderTree(child, sub, username, depth + 1);
459
+ li.appendChild(row);
460
+ li.appendChild(sub);
461
+ parentUl.appendChild(li);
462
+ continue;
463
+ }
464
+ li.appendChild(row);
465
+ parentUl.appendChild(li);
466
+ }
467
+ }
468
+
469
+ function markActiveRow() {
470
+ filesEl.querySelectorAll(".tree-row").forEach((r) => r.classList.remove("active"));
471
+ const activePath = localStorage.getItem("sv-file");
472
+ const activeContributor = localStorage.getItem("sv-contributor");
473
+ if (!activePath || !activeContributor) return;
474
+ const active = filesEl.querySelector(
475
+ '.tree-row[data-contributor="' + CSS.escape(activeContributor) + '"][data-path="' + CSS.escape(activePath) + '"]'
476
+ );
477
+ if (active) {
478
+ active.classList.add("active");
479
+ active.scrollIntoView({ block: "nearest", behavior: "smooth" });
480
+ }
481
+ }
482
+
483
+ function getVisibleTreeRows() {
484
+ const rows = [];
485
+ function walk(ul) {
486
+ if (!ul || ul.classList.contains("collapsed")) return;
487
+ for (const li of ul.children) {
488
+ const row = li.querySelector(":scope > .tree-row");
489
+ if (row && row.dataset.path) rows.push(row);
490
+ walk(li.querySelector(":scope > ul"));
491
+ }
492
+ }
493
+ walk(filesEl);
494
+ return rows;
495
+ }
496
+
497
+ function handleNavKeydown(e) {
498
+ if (e.key !== "ArrowUp" && e.key !== "ArrowDown") return;
499
+ if (["INPUT", "TEXTAREA"].includes(document.activeElement?.tagName)) return;
500
+ const rows = getVisibleTreeRows();
501
+ if (rows.length === 0) return;
502
+ const active = filesEl.querySelector(".tree-row.active");
503
+ let idx = active ? rows.indexOf(active) : -1;
504
+ if (e.key === "ArrowDown") {
505
+ idx = idx < rows.length - 1 ? idx + 1 : idx;
506
+ } else {
507
+ idx = idx > 0 ? idx - 1 : 0;
508
+ }
509
+ e.preventDefault();
510
+ rows[idx].click();
511
+ }
512
+
513
+ function getLoadedContributorList(username) {
514
+ return filesEl.querySelector(
515
+ 'ul[data-contributor="' + CSS.escape(username) + '"][data-loaded="true"]'
516
+ );
517
+ }
518
+
519
+ async function loadContributorFiles(username, sub, opts = {}) {
520
+ const silent = !!opts.silent;
521
+ sub.innerHTML = "";
522
+ if (!silent) status("Loading files...");
523
+ const { files } = await (await api("/v1/files?prefix=" + encodeURIComponent(username + "/"))).json();
524
+ const prefix = username + "/";
525
+ const tree = buildTree(files.map((f) => f.path.startsWith(prefix) ? f.path.slice(prefix.length) : f.path));
526
+ renderTree(tree, sub, username, 1);
527
+ markActiveRow();
528
+ if (!silent) status(files.length + " file(s)");
529
+ // Update contributor row with file count
530
+ const row = sub.parentElement && sub.parentElement.querySelector(".tree-row");
531
+ if (row) {
532
+ const arrow = row.querySelector(".arrow").outerHTML;
533
+ row.innerHTML = arrow + '<i class="ph ph-user"></i> ' + username + ' <span style="opacity:0.5">(' + files.length + ')</span>';
534
+ }
535
+ }
536
+
537
+ async function loadContributors() {
538
+ filesEl.innerHTML = "";
539
+ contentEl.innerHTML = "";
540
+ status("Loading contributors...");
541
+ const { contributors } = await (await api("/v1/contributors")).json();
542
+
543
+ for (const b of contributors) {
544
+ const li = document.createElement("li");
545
+ const row = document.createElement("div");
546
+ row.className = "tree-row";
547
+ row.style.paddingLeft = "8px";
548
+ const sub = document.createElement("ul");
549
+ sub.dataset.contributor = b.username;
550
+ sub.dataset.loaded = "false";
551
+ let loaded = false;
552
+ row.innerHTML = '<span class="arrow">&#9654;</span><i class="ph ph-user"></i> ' + b.username;
553
+ row.onclick = async () => {
554
+ const collapsed = sub.classList.contains("collapsed") || !sub.hasChildNodes();
555
+ if (collapsed) {
556
+ sub.classList.remove("collapsed");
557
+ row.querySelector(".arrow").innerHTML = "&#9660;";
558
+ if (!loaded) {
559
+ loaded = true;
560
+ sub.dataset.loaded = "true";
561
+ await loadContributorFiles(b.username, sub);
562
+ }
563
+ } else {
564
+ sub.classList.add("collapsed");
565
+ row.querySelector(".arrow").innerHTML = "&#9654;";
566
+ }
567
+ };
568
+ li.appendChild(row);
569
+ li.appendChild(sub);
570
+ filesEl.appendChild(li);
571
+
572
+ if (b.username === savedContributor) {
573
+ sub.classList.remove("collapsed");
574
+ row.querySelector(".arrow").innerHTML = "&#9660;";
575
+ loaded = true;
576
+ sub.dataset.loaded = "true";
577
+ await loadContributorFiles(b.username, sub);
578
+ if (savedFile) {
579
+ const match = filesEl.querySelector('[data-path="' + CSS.escape(savedFile) + '"]');
580
+ if (match) await loadContent(b.username, savedFile, match);
581
+ }
582
+ }
583
+ }
584
+ status(contributors.length + " contributor(s)");
585
+ }
586
+
587
+ async function loadContent(username, path, row) {
588
+ filesEl.querySelectorAll(".tree-row").forEach((r) => r.classList.remove("active"));
589
+ row.classList.add("active");
590
+ row.scrollIntoView({ block: "nearest", behavior: "smooth" });
591
+ localStorage.setItem("sv-contributor", username);
592
+ localStorage.setItem("sv-file", path);
593
+ status("Loading " + path + "...");
594
+ const res = await api("/v1/sh", {
595
+ method: "POST",
596
+ headers: { "Content-Type": "application/json" },
597
+ body: JSON.stringify({ cmd: 'cat "' + username + "/" + path + '"' }),
598
+ });
599
+ const text = await res.text();
600
+ contentEl.innerHTML = renderMarkdown(text);
601
+ contentEl.parentElement.scrollTop = 0;
602
+ status(path);
603
+ }
604
+
605
+ $("connect").onclick = () => {
606
+ token = tokenEl.value.trim();
607
+ localStorage.setItem("sv-token", token);
608
+ loadContributors().then(() => connectSSE()).catch((e) => status(e.message));
609
+ };
610
+
611
+ // --- SSE real-time updates ---
612
+ let evtSource = null;
613
+ const contributorReloadTimers = new Map();
614
+
615
+ function scheduleContributorReload(username) {
616
+ if (!getLoadedContributorList(username)) return;
617
+
618
+ const existing = contributorReloadTimers.get(username);
619
+ if (existing) clearTimeout(existing);
620
+
621
+ const timer = setTimeout(() => {
622
+ contributorReloadTimers.delete(username);
623
+ const targetUl = getLoadedContributorList(username);
624
+ if (!targetUl) return;
625
+ loadContributorFiles(username, targetUl, { silent: true }).catch((e) => status(e.message));
626
+ }, 100);
627
+
628
+ contributorReloadTimers.set(username, timer);
629
+ }
630
+
631
+ function connectSSE() {
632
+ if (evtSource) evtSource.close();
633
+ if (!token) return;
634
+
635
+ evtSource = new EventSource("/v1/events?token=" + encodeURIComponent(token));
636
+
637
+ evtSource.addEventListener("file_updated", (e) => {
638
+ const { contributor, path } = JSON.parse(e.data);
639
+ scheduleContributorReload(contributor);
640
+ // If this file is currently open, reload its content
641
+ if (localStorage.getItem("sv-file") === path && localStorage.getItem("sv-contributor") === contributor) {
642
+ api("/v1/sh", {
643
+ method: "POST",
644
+ headers: { "Content-Type": "application/json" },
645
+ body: JSON.stringify({ cmd: 'cat "' + contributor + "/" + path + '"' }),
646
+ })
647
+ .then((res) => res.text())
648
+ .then((text) => { contentEl.innerHTML = renderMarkdown(text); });
649
+ }
650
+ });
651
+
652
+ evtSource.addEventListener("file_deleted", (e) => {
653
+ const { contributor, path } = JSON.parse(e.data);
654
+ scheduleContributorReload(contributor);
655
+ // If this file was being viewed, clear the content
656
+ if (localStorage.getItem("sv-file") === path && localStorage.getItem("sv-contributor") === contributor) {
657
+ contentEl.innerHTML = "";
658
+ status("File deleted: " + path);
659
+ }
660
+ });
661
+
662
+ evtSource.onerror = () => {
663
+ // EventSource auto-reconnects
664
+ };
665
+ }
666
+
667
+ if (token) loadContributors().then(() => connectSSE()).catch((e) => status(e.message));
668
+
669
+ // Nav keyboard: up/down to move selection
670
+ document.addEventListener("keydown", (e) => {
671
+ if (e.key !== "ArrowUp" && e.key !== "ArrowDown") return;
672
+ if (["INPUT", "TEXTAREA"].includes(document.activeElement?.tagName)) return;
673
+ if (!$("nav-body").contains(document.activeElement) && document.activeElement !== $("nav-body")) return;
674
+ handleNavKeydown(e);
675
+ });
676
+
677
+ // Focus nav when clicking in it
678
+ $("nav").addEventListener("mousedown", (e) => {
679
+ if (e.target.closest("#nav-body")) $("nav-body").focus();
680
+ });
681
+
682
+ // Divider drag
683
+ const divider = $("divider");
684
+ const nav = $("nav");
685
+ const savedNav = localStorage.getItem("sv-nav-w");
686
+ if (savedNav) nav.style.width = savedNav + "px";
687
+ divider.addEventListener("mousedown", (e) => {
688
+ e.preventDefault();
689
+ divider.classList.add("dragging");
690
+ const onMove = (e) => {
691
+ const w = e.clientX - nav.getBoundingClientRect().left;
692
+ if (w > 80 && w < window.innerWidth - 120) nav.style.width = w + "px";
693
+ };
694
+ const onUp = () => {
695
+ divider.classList.remove("dragging");
696
+ localStorage.setItem("sv-nav-w", nav.offsetWidth);
697
+ document.removeEventListener("mousemove", onMove);
698
+ document.removeEventListener("mouseup", onUp);
699
+ };
700
+ document.addEventListener("mousemove", onMove);
701
+ document.addEventListener("mouseup", onUp);
702
+ });
703
+ </script>
704
+ </body>
705
+
706
+ </html>
package/dist/server.js CHANGED
@@ -2194,6 +2194,7 @@ function createApp(storageRoot) {
2194
2194
  });
2195
2195
  authed.get("/v1/events", (c) => {
2196
2196
  let ctrl;
2197
+ let heartbeat;
2197
2198
  const stream = new ReadableStream({
2198
2199
  start(controller) {
2199
2200
  ctrl = controller;
@@ -2203,8 +2204,18 @@ data: {}
2203
2204
 
2204
2205
  `;
2205
2206
  controller.enqueue(new TextEncoder().encode(msg));
2207
+ heartbeat = setInterval(() => {
2208
+ try {
2209
+ controller.enqueue(new TextEncoder().encode(`:keepalive
2210
+
2211
+ `));
2212
+ } catch {
2213
+ clearInterval(heartbeat);
2214
+ }
2215
+ }, 30000);
2206
2216
  },
2207
2217
  cancel() {
2218
+ clearInterval(heartbeat);
2208
2219
  removeClient(ctrl);
2209
2220
  }
2210
2221
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@seedvault/server",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "seedvault-server": "bin/seedvault-server.mjs"