@seedvault/server 0.1.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.
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env bun
2
+ import { join, dirname } from "path";
3
+ import { fileURLToPath } from "url";
4
+ const __dirname = dirname(fileURLToPath(import.meta.url));
5
+ await import(join(__dirname, "..", "dist", "server.js"));
@@ -0,0 +1,383 @@
1
+ <!doctype html>
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>