@seedvault/server 0.1.2 → 0.1.4

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,723 @@
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(fileEntries) {
408
+ const root = {};
409
+ for (const f of fileEntries) {
410
+ const parts = f.path.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 = f;
417
+ }
418
+ return root;
419
+ }
420
+
421
+ function getNewestCtime(node) {
422
+ if (node.__file) return node.__file.originCtime || node.__file.serverCreatedAt || "";
423
+ let newest = "";
424
+ for (const key of Object.keys(node)) {
425
+ if (key === "__file") continue;
426
+ const t = getNewestCtime(node[key]);
427
+ if (t > newest) newest = t;
428
+ }
429
+ return newest;
430
+ }
431
+
432
+ function countFiles(node) {
433
+ let count = 0;
434
+ for (const key of Object.keys(node)) {
435
+ if (key === "__file") { count++; continue; }
436
+ count += countFiles(node[key]);
437
+ }
438
+ return count;
439
+ }
440
+
441
+ function renderTree(node, parentUl, username, depth) {
442
+ const keys = Object.keys(node).filter((k) => k !== "__file").sort((a, b) => {
443
+ const aDir = Object.keys(node[a]).some((k) => k !== "__file");
444
+ const bDir = Object.keys(node[b]).some((k) => k !== "__file");
445
+ if (aDir !== bDir) return aDir ? -1 : 1;
446
+ const aTime = getNewestCtime(node[a]);
447
+ const bTime = getNewestCtime(node[b]);
448
+ if (aTime !== bTime) return bTime.localeCompare(aTime);
449
+ return a.localeCompare(b);
450
+ });
451
+ for (const key of keys) {
452
+ const child = node[key];
453
+ const isFile = child.__file && Object.keys(child).length === 1;
454
+ const li = document.createElement("li");
455
+ const row = document.createElement("div");
456
+ row.className = "tree-row";
457
+ row.style.paddingLeft = (depth * 12 + 8) + "px";
458
+
459
+ if (isFile) {
460
+ row.innerHTML = '<span class="arrow">&nbsp;</span><i class="ph ph-file-text"></i> ' + key;
461
+ row.dataset.path = child.__file.path;
462
+ row.dataset.contributor = username;
463
+ row.onclick = () => loadContent(username, child.__file.path, row);
464
+ } else {
465
+ const sub = document.createElement("ul");
466
+ const fileCount = countFiles(child);
467
+ row.innerHTML = '<span class="arrow">&#9660;</span><i class="ph ph-folder"></i> ' + key + ' <span style="opacity:0.5">(' + fileCount + ')</span>';
468
+ row.onclick = () => {
469
+ sub.classList.toggle("collapsed");
470
+ row.querySelector(".arrow").innerHTML = sub.classList.contains("collapsed") ? "&#9654;" : "&#9660;";
471
+ };
472
+ renderTree(child, sub, username, depth + 1);
473
+ li.appendChild(row);
474
+ li.appendChild(sub);
475
+ parentUl.appendChild(li);
476
+ continue;
477
+ }
478
+ li.appendChild(row);
479
+ parentUl.appendChild(li);
480
+ }
481
+ }
482
+
483
+ function markActiveRow() {
484
+ filesEl.querySelectorAll(".tree-row").forEach((r) => r.classList.remove("active"));
485
+ const activePath = localStorage.getItem("sv-file");
486
+ const activeContributor = localStorage.getItem("sv-contributor");
487
+ if (!activePath || !activeContributor) return;
488
+ const active = filesEl.querySelector(
489
+ '.tree-row[data-contributor="' + CSS.escape(activeContributor) + '"][data-path="' + CSS.escape(activePath) + '"]'
490
+ );
491
+ if (active) {
492
+ active.classList.add("active");
493
+ active.scrollIntoView({ block: "nearest", behavior: "smooth" });
494
+ }
495
+ }
496
+
497
+ function getVisibleTreeRows() {
498
+ const rows = [];
499
+ function walk(ul) {
500
+ if (!ul || ul.classList.contains("collapsed")) return;
501
+ for (const li of ul.children) {
502
+ const row = li.querySelector(":scope > .tree-row");
503
+ if (row && row.dataset.path) rows.push(row);
504
+ walk(li.querySelector(":scope > ul"));
505
+ }
506
+ }
507
+ walk(filesEl);
508
+ return rows;
509
+ }
510
+
511
+ function handleNavKeydown(e) {
512
+ if (e.key !== "ArrowUp" && e.key !== "ArrowDown") return;
513
+ if (["INPUT", "TEXTAREA"].includes(document.activeElement?.tagName)) return;
514
+ const rows = getVisibleTreeRows();
515
+ if (rows.length === 0) return;
516
+ const active = filesEl.querySelector(".tree-row.active");
517
+ let idx = active ? rows.indexOf(active) : -1;
518
+ if (e.key === "ArrowDown") {
519
+ idx = idx < rows.length - 1 ? idx + 1 : idx;
520
+ } else {
521
+ idx = idx > 0 ? idx - 1 : 0;
522
+ }
523
+ e.preventDefault();
524
+ rows[idx].click();
525
+ }
526
+
527
+ function getLoadedContributorList(username) {
528
+ return filesEl.querySelector(
529
+ 'ul[data-contributor="' + CSS.escape(username) + '"][data-loaded="true"]'
530
+ );
531
+ }
532
+
533
+ async function loadContributorFiles(username, sub, opts = {}) {
534
+ const silent = !!opts.silent;
535
+ sub.innerHTML = "";
536
+ if (!silent) status("Loading files...");
537
+ const { files } = await (await api("/v1/files?prefix=" + encodeURIComponent(username + "/"))).json();
538
+ const prefix = username + "/";
539
+ const tree = buildTree(files.map((f) => ({
540
+ ...f,
541
+ path: f.path.startsWith(prefix) ? f.path.slice(prefix.length) : f.path,
542
+ })));
543
+ renderTree(tree, sub, username, 1);
544
+ markActiveRow();
545
+ if (!silent) status(files.length + " file(s)");
546
+ // Update contributor row with file count
547
+ const row = sub.parentElement && sub.parentElement.querySelector(".tree-row");
548
+ if (row) {
549
+ const arrow = row.querySelector(".arrow").outerHTML;
550
+ row.innerHTML = arrow + '<i class="ph ph-user"></i> ' + username + ' <span style="opacity:0.5">(' + files.length + ')</span>';
551
+ }
552
+ }
553
+
554
+ async function loadContributors() {
555
+ filesEl.innerHTML = "";
556
+ contentEl.innerHTML = "";
557
+ status("Loading contributors...");
558
+ const { contributors } = await (await api("/v1/contributors")).json();
559
+
560
+ for (const b of contributors) {
561
+ const li = document.createElement("li");
562
+ const row = document.createElement("div");
563
+ row.className = "tree-row";
564
+ row.style.paddingLeft = "8px";
565
+ const sub = document.createElement("ul");
566
+ sub.dataset.contributor = b.username;
567
+ sub.dataset.loaded = "false";
568
+ let loaded = false;
569
+ row.innerHTML = '<span class="arrow">&#9654;</span><i class="ph ph-user"></i> ' + b.username;
570
+ row.onclick = async () => {
571
+ const collapsed = sub.classList.contains("collapsed") || !sub.hasChildNodes();
572
+ if (collapsed) {
573
+ sub.classList.remove("collapsed");
574
+ row.querySelector(".arrow").innerHTML = "&#9660;";
575
+ if (!loaded) {
576
+ loaded = true;
577
+ sub.dataset.loaded = "true";
578
+ await loadContributorFiles(b.username, sub);
579
+ }
580
+ } else {
581
+ sub.classList.add("collapsed");
582
+ row.querySelector(".arrow").innerHTML = "&#9654;";
583
+ }
584
+ };
585
+ li.appendChild(row);
586
+ li.appendChild(sub);
587
+ filesEl.appendChild(li);
588
+
589
+ if (b.username === savedContributor) {
590
+ sub.classList.remove("collapsed");
591
+ row.querySelector(".arrow").innerHTML = "&#9660;";
592
+ loaded = true;
593
+ sub.dataset.loaded = "true";
594
+ await loadContributorFiles(b.username, sub);
595
+ if (savedFile) {
596
+ const match = filesEl.querySelector('[data-path="' + CSS.escape(savedFile) + '"]');
597
+ if (match) await loadContent(b.username, savedFile, match);
598
+ }
599
+ }
600
+ }
601
+ status(contributors.length + " contributor(s)");
602
+ }
603
+
604
+ async function loadContent(username, path, row) {
605
+ filesEl.querySelectorAll(".tree-row").forEach((r) => r.classList.remove("active"));
606
+ row.classList.add("active");
607
+ row.scrollIntoView({ block: "nearest", behavior: "smooth" });
608
+ localStorage.setItem("sv-contributor", username);
609
+ localStorage.setItem("sv-file", path);
610
+ status("Loading " + path + "...");
611
+ const res = await api("/v1/sh", {
612
+ method: "POST",
613
+ headers: { "Content-Type": "application/json" },
614
+ body: JSON.stringify({ cmd: 'cat "' + username + "/" + path + '"' }),
615
+ });
616
+ const text = await res.text();
617
+ contentEl.innerHTML = renderMarkdown(text);
618
+ contentEl.parentElement.scrollTop = 0;
619
+ status(path);
620
+ }
621
+
622
+ $("connect").onclick = () => {
623
+ token = tokenEl.value.trim();
624
+ localStorage.setItem("sv-token", token);
625
+ loadContributors().then(() => connectSSE()).catch((e) => status(e.message));
626
+ };
627
+
628
+ // --- SSE real-time updates ---
629
+ let evtSource = null;
630
+ const contributorReloadTimers = new Map();
631
+
632
+ function scheduleContributorReload(username) {
633
+ if (!getLoadedContributorList(username)) return;
634
+
635
+ const existing = contributorReloadTimers.get(username);
636
+ if (existing) clearTimeout(existing);
637
+
638
+ const timer = setTimeout(() => {
639
+ contributorReloadTimers.delete(username);
640
+ const targetUl = getLoadedContributorList(username);
641
+ if (!targetUl) return;
642
+ loadContributorFiles(username, targetUl, { silent: true }).catch((e) => status(e.message));
643
+ }, 100);
644
+
645
+ contributorReloadTimers.set(username, timer);
646
+ }
647
+
648
+ function connectSSE() {
649
+ if (evtSource) evtSource.close();
650
+ if (!token) return;
651
+
652
+ evtSource = new EventSource("/v1/events?token=" + encodeURIComponent(token));
653
+
654
+ evtSource.addEventListener("file_updated", (e) => {
655
+ const { contributor, path } = JSON.parse(e.data);
656
+ scheduleContributorReload(contributor);
657
+ // If this file is currently open, reload its content
658
+ if (localStorage.getItem("sv-file") === path && localStorage.getItem("sv-contributor") === contributor) {
659
+ api("/v1/sh", {
660
+ method: "POST",
661
+ headers: { "Content-Type": "application/json" },
662
+ body: JSON.stringify({ cmd: 'cat "' + contributor + "/" + path + '"' }),
663
+ })
664
+ .then((res) => res.text())
665
+ .then((text) => { contentEl.innerHTML = renderMarkdown(text); });
666
+ }
667
+ });
668
+
669
+ evtSource.addEventListener("file_deleted", (e) => {
670
+ const { contributor, path } = JSON.parse(e.data);
671
+ scheduleContributorReload(contributor);
672
+ // If this file was being viewed, clear the content
673
+ if (localStorage.getItem("sv-file") === path && localStorage.getItem("sv-contributor") === contributor) {
674
+ contentEl.innerHTML = "";
675
+ status("File deleted: " + path);
676
+ }
677
+ });
678
+
679
+ evtSource.onerror = () => {
680
+ // EventSource auto-reconnects
681
+ };
682
+ }
683
+
684
+ if (token) loadContributors().then(() => connectSSE()).catch((e) => status(e.message));
685
+
686
+ // Nav keyboard: up/down to move selection
687
+ document.addEventListener("keydown", (e) => {
688
+ if (e.key !== "ArrowUp" && e.key !== "ArrowDown") return;
689
+ if (["INPUT", "TEXTAREA"].includes(document.activeElement?.tagName)) return;
690
+ if (!$("nav-body").contains(document.activeElement) && document.activeElement !== $("nav-body")) return;
691
+ handleNavKeydown(e);
692
+ });
693
+
694
+ // Focus nav when clicking in it
695
+ $("nav").addEventListener("mousedown", (e) => {
696
+ if (e.target.closest("#nav-body")) $("nav-body").focus();
697
+ });
698
+
699
+ // Divider drag
700
+ const divider = $("divider");
701
+ const nav = $("nav");
702
+ const savedNav = localStorage.getItem("sv-nav-w");
703
+ if (savedNav) nav.style.width = savedNav + "px";
704
+ divider.addEventListener("mousedown", (e) => {
705
+ e.preventDefault();
706
+ divider.classList.add("dragging");
707
+ const onMove = (e) => {
708
+ const w = e.clientX - nav.getBoundingClientRect().left;
709
+ if (w > 80 && w < window.innerWidth - 120) nav.style.width = w + "px";
710
+ };
711
+ const onUp = () => {
712
+ divider.classList.remove("dragging");
713
+ localStorage.setItem("sv-nav-w", nav.offsetWidth);
714
+ document.removeEventListener("mousemove", onMove);
715
+ document.removeEventListener("mouseup", onUp);
716
+ };
717
+ document.addEventListener("mousemove", onMove);
718
+ document.addEventListener("mouseup", onUp);
719
+ });
720
+ </script>
721
+ </body>
722
+
723
+ </html>
package/dist/server.js CHANGED
@@ -40,6 +40,17 @@ function initDb(dbPath) {
40
40
  used_at TEXT,
41
41
  used_by TEXT REFERENCES contributors(username)
42
42
  );
43
+
44
+ CREATE TABLE IF NOT EXISTS files (
45
+ contributor TEXT NOT NULL,
46
+ path TEXT NOT NULL,
47
+ origin_ctime TEXT NOT NULL,
48
+ origin_mtime TEXT NOT NULL,
49
+ server_created_at TEXT NOT NULL,
50
+ server_modified_at TEXT NOT NULL,
51
+ PRIMARY KEY (contributor, path),
52
+ FOREIGN KEY (contributor) REFERENCES contributors(username)
53
+ );
43
54
  `);
44
55
  return db;
45
56
  }
@@ -98,6 +109,34 @@ function getInvite(id) {
98
109
  function markInviteUsed(id, usedBy) {
99
110
  getDb().prepare("UPDATE invites SET used_at = ?, used_by = ? WHERE id = ?").run(new Date().toISOString(), usedBy, id);
100
111
  }
112
+ function upsertFileMetadata(contributor, path, originCtime, originMtime) {
113
+ const now = new Date().toISOString();
114
+ getDb().prepare(`INSERT INTO files (contributor, path, origin_ctime, origin_mtime, server_created_at, server_modified_at)
115
+ VALUES (?, ?, ?, ?, ?, ?)
116
+ ON CONFLICT (contributor, path) DO UPDATE SET
117
+ origin_mtime = excluded.origin_mtime,
118
+ server_modified_at = excluded.server_modified_at`).run(contributor, path, originCtime, originMtime, now, now);
119
+ return getFileMetadata(contributor, path);
120
+ }
121
+ function getFileMetadata(contributor, path) {
122
+ return getDb().prepare("SELECT * FROM files WHERE contributor = ? AND path = ?").get(contributor, path);
123
+ }
124
+ function listFileMetadata(contributor, prefix) {
125
+ const map = new Map;
126
+ let rows;
127
+ if (prefix) {
128
+ rows = getDb().prepare("SELECT * FROM files WHERE contributor = ? AND path LIKE ?").all(contributor, prefix + "%");
129
+ } else {
130
+ rows = getDb().prepare("SELECT * FROM files WHERE contributor = ?").all(contributor);
131
+ }
132
+ for (const row of rows) {
133
+ map.set(row.path, row);
134
+ }
135
+ return map;
136
+ }
137
+ function deleteFileMetadata(contributor, path) {
138
+ getDb().prepare("DELETE FROM files WHERE contributor = ? AND path = ?").run(contributor, path);
139
+ }
101
140
 
102
141
  // ../node_modules/.bun/hono@4.11.9/node_modules/hono/dist/compose.js
103
142
  var compose = (middleware, onError, onNotFound) => {
@@ -2128,8 +2167,12 @@ function createApp(storageRoot) {
2128
2167
  return c.json({ error: pathError }, 400);
2129
2168
  }
2130
2169
  const content = await c.req.text();
2170
+ const now = new Date().toISOString();
2171
+ const originCtime = c.req.header("X-Origin-Ctime") || now;
2172
+ const originMtime = c.req.header("X-Origin-Mtime") || now;
2131
2173
  try {
2132
2174
  const result = await writeFileAtomic(storageRoot, parsed.username, parsed.filePath, content);
2175
+ const meta = upsertFileMetadata(parsed.username, parsed.filePath, originCtime, originMtime);
2133
2176
  broadcast("file_updated", {
2134
2177
  contributor: parsed.username,
2135
2178
  path: result.path,
@@ -2137,7 +2180,13 @@ function createApp(storageRoot) {
2137
2180
  modifiedAt: result.modifiedAt
2138
2181
  });
2139
2182
  triggerUpdate();
2140
- return c.json(result);
2183
+ return c.json({
2184
+ ...result,
2185
+ originCtime: meta.origin_ctime,
2186
+ originMtime: meta.origin_mtime,
2187
+ serverCreatedAt: meta.server_created_at,
2188
+ serverModifiedAt: meta.server_modified_at
2189
+ });
2141
2190
  } catch (e) {
2142
2191
  if (e instanceof FileTooLargeError) {
2143
2192
  return c.json({ error: e.message }, 413);
@@ -2160,6 +2209,7 @@ function createApp(storageRoot) {
2160
2209
  }
2161
2210
  try {
2162
2211
  await deleteFile(storageRoot, parsed.username, parsed.filePath);
2212
+ deleteFileMetadata(parsed.username, parsed.filePath);
2163
2213
  broadcast("file_deleted", {
2164
2214
  contributor: parsed.username,
2165
2215
  path: parsed.filePath
@@ -2185,15 +2235,24 @@ function createApp(storageRoot) {
2185
2235
  return c.json({ error: "Contributor not found" }, 404);
2186
2236
  }
2187
2237
  const files = await listFiles(storageRoot, username, subPrefix);
2238
+ const metaMap = listFileMetadata(username, subPrefix);
2188
2239
  return c.json({
2189
- files: files.map((f) => ({
2190
- ...f,
2191
- path: `${username}/${f.path}`
2192
- }))
2240
+ files: files.map((f) => {
2241
+ const meta = metaMap.get(f.path);
2242
+ return {
2243
+ ...f,
2244
+ path: `${username}/${f.path}`,
2245
+ originCtime: meta?.origin_ctime,
2246
+ originMtime: meta?.origin_mtime,
2247
+ serverCreatedAt: meta?.server_created_at,
2248
+ serverModifiedAt: meta?.server_modified_at
2249
+ };
2250
+ })
2193
2251
  });
2194
2252
  });
2195
2253
  authed.get("/v1/events", (c) => {
2196
2254
  let ctrl;
2255
+ let heartbeat;
2197
2256
  const stream = new ReadableStream({
2198
2257
  start(controller) {
2199
2258
  ctrl = controller;
@@ -2203,8 +2262,18 @@ data: {}
2203
2262
 
2204
2263
  `;
2205
2264
  controller.enqueue(new TextEncoder().encode(msg));
2265
+ heartbeat = setInterval(() => {
2266
+ try {
2267
+ controller.enqueue(new TextEncoder().encode(`:keepalive
2268
+
2269
+ `));
2270
+ } catch {
2271
+ clearInterval(heartbeat);
2272
+ }
2273
+ }, 30000);
2206
2274
  },
2207
2275
  cancel() {
2276
+ clearInterval(heartbeat);
2208
2277
  removeClient(ctrl);
2209
2278
  }
2210
2279
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@seedvault/server",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "seedvault-server": "bin/seedvault-server.mjs"