@seedvault/server 0.1.4 → 0.2.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.
Files changed (3) hide show
  1. package/dist/index.html +617 -59
  2. package/dist/server.js +445 -423
  3. package/package.json +1 -1
package/dist/index.html CHANGED
@@ -74,6 +74,33 @@
74
74
  cursor: pointer;
75
75
  }
76
76
 
77
+ #menu-toggle {
78
+ display: none;
79
+ font-size: 18px;
80
+ padding: 4px 10px;
81
+ }
82
+
83
+ #nav-close {
84
+ display: none;
85
+ float: right;
86
+ border: none;
87
+ font-size: 16px;
88
+ padding: 0 4px;
89
+ line-height: 1;
90
+ }
91
+
92
+ #backdrop {
93
+ display: none;
94
+ position: fixed;
95
+ inset: 0;
96
+ background: rgba(0, 0, 0, 0.4);
97
+ z-index: 90;
98
+ }
99
+
100
+ #backdrop.visible {
101
+ display: block;
102
+ }
103
+
77
104
  .grid {
78
105
  display: flex;
79
106
  gap: 0;
@@ -103,8 +130,32 @@
103
130
  }
104
131
 
105
132
  @media (max-width: 600px) {
133
+ #menu-toggle {
134
+ display: block;
135
+ }
136
+
137
+ #nav-close {
138
+ display: block;
139
+ }
140
+
106
141
  .grid {
107
142
  flex-direction: column;
143
+ position: relative;
144
+ }
145
+
146
+ #nav {
147
+ position: fixed;
148
+ top: 0;
149
+ left: -100vw;
150
+ width: 100vw !important;
151
+ height: 100%;
152
+ z-index: 100;
153
+ background: Canvas;
154
+ transition: transform 0.2s ease;
155
+ }
156
+
157
+ #nav.open {
158
+ transform: translateX(100vw);
108
159
  }
109
160
 
110
161
  .grid>.panel:first-child {
@@ -178,17 +229,31 @@
178
229
  }
179
230
 
180
231
  .tree-row {
181
- display: block;
232
+ display: flex;
233
+ align-items: baseline;
182
234
  width: 100%;
183
235
  text-align: left;
184
236
  border: none;
185
237
  padding: 4px 8px;
186
238
  white-space: nowrap;
187
239
  overflow: hidden;
188
- text-overflow: ellipsis;
189
240
  cursor: pointer;
190
241
  }
191
242
 
243
+ .tree-row .name {
244
+ overflow: hidden;
245
+ text-overflow: ellipsis;
246
+ flex: 1;
247
+ min-width: 0;
248
+ }
249
+
250
+ .tree-row .ctime {
251
+ flex-shrink: 0;
252
+ margin-left: 8px;
253
+ opacity: 0.45;
254
+ font-size: 10px;
255
+ }
256
+
192
257
  .tree-row:hover {
193
258
  background: color-mix(in srgb, currentColor 8%, transparent);
194
259
  }
@@ -329,18 +394,256 @@
329
394
  font-size: 11px;
330
395
  opacity: 0.6;
331
396
  flex-shrink: 0;
397
+ display: flex;
398
+ align-items: center;
399
+ }
400
+
401
+ #status-text {
402
+ flex: 1;
403
+ }
404
+
405
+ #activity-btn {
406
+ border: none;
407
+ font-size: 11px;
408
+ padding: 0 4px;
409
+ opacity: 0.6;
410
+ display: flex;
411
+ align-items: center;
412
+ gap: 3px;
413
+ }
414
+
415
+ #activity-btn:hover {
416
+ opacity: 1;
417
+ }
418
+
419
+ #activity-modal {
420
+ display: none;
421
+ position: fixed;
422
+ inset: 0;
423
+ z-index: 300;
424
+ }
425
+
426
+ #activity-modal.open {
427
+ display: flex;
428
+ }
429
+
430
+ #activity-overlay {
431
+ position: absolute;
432
+ inset: 0;
433
+ background: rgba(0, 0, 0, 0.4);
434
+ }
435
+
436
+ #activity-dialog {
437
+ position: absolute;
438
+ inset: 0;
439
+ background: Canvas;
440
+ color: CanvasText;
441
+ display: flex;
442
+ flex-direction: column;
443
+ overflow: hidden;
444
+ z-index: 1;
445
+ }
446
+
447
+ #activity-dialog-head {
448
+ padding: 8px 12px;
449
+ border-bottom: 1px solid color-mix(in srgb, currentColor 20%, transparent);
450
+ font-weight: bold;
451
+ font-size: 11px;
452
+ text-transform: uppercase;
453
+ letter-spacing: 0.05em;
454
+ display: flex;
455
+ align-items: center;
456
+ flex-shrink: 0;
457
+ }
458
+
459
+ #activity-dialog-head button {
460
+ margin-left: auto;
461
+ border: none;
462
+ font-size: 16px;
463
+ padding: 0 4px;
464
+ line-height: 1;
465
+ }
466
+
467
+ #activity-dialog-body {
468
+ flex: 1;
469
+ overflow-y: auto;
470
+ scrollbar-width: thin;
471
+ scrollbar-color: color-mix(in srgb, currentColor 20%, transparent) transparent;
472
+ }
473
+
474
+ .activity-list {
475
+ padding: 16px 0;
476
+ margin: 0;
477
+ list-style: none;
478
+ }
479
+
480
+ .activity-item {
481
+ display: grid;
482
+ grid-template-columns: 90px 20px 1fr;
483
+ gap: 0 8px;
484
+ min-height: 40px;
485
+ }
486
+
487
+ .activity-time-col {
488
+ text-align: right;
489
+ padding-top: 6px;
490
+ }
491
+
492
+ .activity-time {
493
+ font-size: 11px;
494
+ opacity: 0.7;
495
+ white-space: nowrap;
496
+ }
497
+
498
+ .activity-date {
499
+ font-size: 10px;
500
+ opacity: 0.35;
501
+ }
502
+
503
+ .activity-dot-col {
504
+ position: relative;
505
+ display: flex;
506
+ flex-direction: column;
507
+ align-items: center;
508
+ }
509
+
510
+ .activity-dot-col::after {
511
+ content: '';
512
+ position: absolute;
513
+ left: 50%;
514
+ top: 0;
515
+ bottom: 0;
516
+ width: 1px;
517
+ transform: translateX(-50%);
518
+ background: color-mix(in srgb, currentColor 15%, transparent);
519
+ }
520
+
521
+ .activity-item:first-child .activity-dot-col::after {
522
+ top: 13px;
523
+ }
524
+
525
+ .activity-item:last-child .activity-dot-col::after {
526
+ bottom: calc(100% - 14px);
527
+ }
528
+
529
+ .activity-dot {
530
+ width: 5px;
531
+ height: 5px;
532
+ border-radius: 50%;
533
+ background: CanvasText;
534
+ opacity: 0.35;
535
+ flex-shrink: 0;
536
+ margin-top: 11px;
537
+ position: relative;
538
+ z-index: 1;
539
+ }
540
+
541
+ .activity-line {
542
+ display: none;
543
+ }
544
+
545
+ .activity-content {
546
+ padding: 6px 0 0;
547
+ min-width: 0;
548
+ }
549
+
550
+ .activity-action {
551
+ font-family: ui-monospace, monospace;
552
+ font-size: 11px;
553
+ }
554
+
555
+ .activity-by {
556
+ font-size: 11px;
557
+ opacity: 0.45;
558
+ }
559
+
560
+ .activity-contributor {
561
+ font-weight: 600;
562
+ opacity: 1;
563
+ }
564
+
565
+ .activity-detail {
566
+ margin-top: 2px;
567
+ font-size: 11px;
568
+ opacity: 0.45;
569
+ font-family: ui-monospace, monospace;
570
+ word-break: break-all;
571
+ }
572
+
573
+ .activity-diff {
574
+ margin-top: 4px;
575
+ font-family: ui-monospace, monospace;
576
+ font-size: 11px;
577
+ line-height: 1.4;
578
+ white-space: pre-wrap;
579
+ word-break: break-all;
580
+ padding: 4px 6px;
581
+ background: color-mix(in srgb, currentColor 4%, transparent);
582
+ border-radius: 3px;
583
+ overflow-x: auto;
584
+ }
585
+
586
+ .activity-diff .diff-add {
587
+ color: #22863a;
588
+ }
589
+
590
+ .activity-diff .diff-del {
591
+ color: #cb2431;
592
+ }
593
+
594
+ .activity-diff .diff-hunk {
595
+ color: #6f42c1;
596
+ opacity: 0.7;
597
+ }
598
+
599
+ @media (prefers-color-scheme: dark) {
600
+ .activity-diff .diff-add {
601
+ color: #85e89d;
602
+ }
603
+
604
+ .activity-diff .diff-del {
605
+ color: #f97583;
606
+ }
607
+
608
+ .activity-diff .diff-hunk {
609
+ color: #b392f0;
610
+ }
611
+ }
612
+
613
+ .activity-diff-truncated {
614
+ font-size: 10px;
615
+ opacity: 0.5;
616
+ font-style: italic;
617
+ }
618
+
619
+ #activity-load-more {
620
+ width: 100%;
621
+ padding: 8px;
622
+ border: none;
623
+ border-top: 1px solid color-mix(in srgb, currentColor 10%, transparent);
624
+ font-size: 11px;
625
+ text-transform: uppercase;
626
+ letter-spacing: 0.05em;
627
+ opacity: 0.6;
628
+ }
629
+
630
+ #activity-load-more:hover {
631
+ opacity: 1;
632
+ background: color-mix(in srgb, currentColor 5%, transparent);
332
633
  }
333
634
  </style>
334
635
  </head>
335
636
 
336
637
  <body>
337
638
  <div class="top">
639
+ <button id="menu-toggle" aria-label="Toggle navigation">&#9776;</button>
338
640
  <input id="token" type="password" placeholder="API key (&quot;sv_...&quot;)" />
339
641
  <button id="connect">Load</button>
340
642
  </div>
643
+ <div id="backdrop"></div>
341
644
  <div class="grid">
342
645
  <section id="nav" class="panel" style="width:220px">
343
- <div class="panel-head">Files</div>
646
+ <div class="panel-head">Files<button id="nav-close" aria-label="Close navigation">&times;</button></div>
344
647
  <div class="panel-body" id="nav-body" tabindex="0">
345
648
  <ul id="files" class="tree"></ul>
346
649
  </div>
@@ -353,11 +656,22 @@
353
656
  </div>
354
657
  </section>
355
658
  </div>
356
- <div id="status"></div>
659
+ <div id="activity-modal">
660
+ <div id="activity-overlay"></div>
661
+ <div id="activity-dialog">
662
+ <div id="activity-dialog-head">Activity Log<button id="activity-close" aria-label="Close">&times;</button></div>
663
+ <div id="activity-dialog-body"></div>
664
+ </div>
665
+ </div>
666
+ <div id="status">
667
+ <span id="status-text"></span>
668
+ <button id="activity-btn" aria-label="Activity log"><i class="ph ph-pulse"></i> Activity Log</button>
669
+ </div>
357
670
  <script type="module">
358
671
  const { marked } = await import("https://cdn.jsdelivr.net/npm/marked/lib/marked.esm.js");
359
672
  const matter = (await import("https://cdn.jsdelivr.net/npm/gray-matter@4.0.3/+esm")).default;
360
673
  const DOMPurify = (await import("https://cdn.jsdelivr.net/npm/dompurify@3.0.9/+esm")).default;
674
+ const morphdom = (await import("https://cdn.jsdelivr.net/npm/morphdom@2.7.4/+esm")).default;
361
675
 
362
676
  function renderMarkdown(raw) {
363
677
  if (typeof raw !== "string") return "";
@@ -379,12 +693,28 @@
379
693
  const tokenEl = $("token");
380
694
  const filesEl = $("files");
381
695
  const contentEl = $("content");
382
- const statusEl = $("status");
696
+ const statusTextEl = $("status-text");
697
+ const backdropEl = $("backdrop");
698
+ const menuToggleEl = $("menu-toggle");
699
+
700
+ function closeSidebar() {
701
+ $("nav").classList.remove("open");
702
+ backdropEl.classList.remove("visible");
703
+ }
704
+
705
+ menuToggleEl.addEventListener("click", () => {
706
+ const nav = $("nav");
707
+ nav.classList.toggle("open");
708
+ backdropEl.classList.toggle("visible", nav.classList.contains("open"));
709
+ });
710
+
711
+ backdropEl.addEventListener("click", closeSidebar);
712
+ $("nav-close").addEventListener("click", closeSidebar);
383
713
 
384
714
  let token = localStorage.getItem("sv-token") || "";
385
715
  tokenEl.value = token;
386
716
 
387
- function status(msg) { statusEl.textContent = msg; }
717
+ function status(msg) { statusTextEl.textContent = msg; }
388
718
 
389
719
  async function api(url, opts = {}) {
390
720
  const res = await fetch(url, {
@@ -404,6 +734,15 @@
404
734
  const savedContributor = localStorage.getItem("sv-contributor") || "";
405
735
  const savedFile = localStorage.getItem("sv-file") || "";
406
736
 
737
+ function getExpandedKeys() {
738
+ try {
739
+ return new Set(JSON.parse(localStorage.getItem("sv-expanded") || "[]"));
740
+ } catch { return new Set(); }
741
+ }
742
+ function saveExpandedKeys(keys) {
743
+ localStorage.setItem("sv-expanded", JSON.stringify([...keys]));
744
+ }
745
+
407
746
  function buildTree(fileEntries) {
408
747
  const root = {};
409
748
  for (const f of fileEntries) {
@@ -418,8 +757,18 @@
418
757
  return root;
419
758
  }
420
759
 
760
+ function formatCtime(iso) {
761
+ if (!iso) return "";
762
+ const d = new Date(iso);
763
+ const mon = d.toLocaleString(undefined, { month: "short" });
764
+ const day = d.getDate();
765
+ const now = new Date();
766
+ if (d.getFullYear() === now.getFullYear()) return mon + " " + day;
767
+ return mon + " " + day + ", " + d.getFullYear();
768
+ }
769
+
421
770
  function getNewestCtime(node) {
422
- if (node.__file) return node.__file.originCtime || node.__file.serverCreatedAt || "";
771
+ if (node.__file) return node.__file.createdAt || "";
423
772
  let newest = "";
424
773
  for (const key of Object.keys(node)) {
425
774
  if (key === "__file") continue;
@@ -438,7 +787,7 @@
438
787
  return count;
439
788
  }
440
789
 
441
- function renderTree(node, parentUl, username, depth) {
790
+ function renderTree(node, parentUl, username, depth, pathPrefix = "") {
442
791
  const keys = Object.keys(node).filter((k) => k !== "__file").sort((a, b) => {
443
792
  const aDir = Object.keys(node[a]).some((k) => k !== "__file");
444
793
  const bDir = Object.keys(node[b]).some((k) => k !== "__file");
@@ -450,26 +799,30 @@
450
799
  });
451
800
  for (const key of keys) {
452
801
  const child = node[key];
802
+ const nodePath = pathPrefix ? pathPrefix + "/" + key : key;
453
803
  const isFile = child.__file && Object.keys(child).length === 1;
454
804
  const li = document.createElement("li");
805
+ li.dataset.key = nodePath;
455
806
  const row = document.createElement("div");
456
807
  row.className = "tree-row";
457
808
  row.style.paddingLeft = (depth * 12 + 8) + "px";
458
809
 
459
810
  if (isFile) {
460
- row.innerHTML = '<span class="arrow">&nbsp;</span><i class="ph ph-file-text"></i> ' + key;
811
+ const ctime = child.__file.createdAt || "";
812
+ row.innerHTML = '<span class="name"><span class="arrow">&nbsp;</span><i class="ph ph-file-text"></i> ' + key + '</span><span class="ctime">' + formatCtime(ctime) + '</span>';
461
813
  row.dataset.path = child.__file.path;
462
814
  row.dataset.contributor = username;
463
- row.onclick = () => loadContent(username, child.__file.path, row);
815
+ row.dataset.action = "open";
464
816
  } else {
465
817
  const sub = document.createElement("ul");
466
818
  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);
819
+ const expanded = getExpandedKeys().has(username + ":" + nodePath);
820
+ const arrow = expanded ? "&#9660;" : "&#9654;";
821
+ if (!expanded) sub.classList.add("collapsed");
822
+ row.innerHTML = '<span class="name"><span class="arrow">' + arrow + '</span><i class="ph ph-folder"></i> ' + key + ' <span style="opacity:0.5">(' + fileCount + ')</span></span>';
823
+ row.dataset.action = "toggle";
824
+ row.dataset.toggleKey = username + ":" + nodePath;
825
+ renderTree(child, sub, username, depth + 1, nodePath);
473
826
  li.appendChild(row);
474
827
  li.appendChild(sub);
475
828
  parentUl.appendChild(li);
@@ -530,9 +883,46 @@
530
883
  );
531
884
  }
532
885
 
886
+ filesEl.addEventListener("click", (e) => {
887
+ const row = e.target.closest(".tree-row");
888
+ if (!row) return;
889
+ const action = row.dataset.action;
890
+ if (action === "open") {
891
+ loadContent(row.dataset.contributor, row.dataset.path, row);
892
+ } else if (action === "toggle") {
893
+ const sub = row.nextElementSibling;
894
+ if (!sub) return;
895
+ sub.classList.toggle("collapsed");
896
+ const arrow = row.querySelector(".arrow");
897
+ if (arrow) arrow.innerHTML = sub.classList.contains("collapsed") ? "&#9654;" : "&#9660;";
898
+ const keys = getExpandedKeys();
899
+ const toggleKey = row.dataset.toggleKey;
900
+ if (toggleKey) {
901
+ if (sub.classList.contains("collapsed")) keys.delete(toggleKey);
902
+ else keys.add(toggleKey);
903
+ saveExpandedKeys(keys);
904
+ }
905
+ } else if (action === "contributor") {
906
+ const sub = row.nextElementSibling;
907
+ if (!sub) return;
908
+ const collapsed = sub.classList.contains("collapsed");
909
+ const keys = getExpandedKeys();
910
+ const toggleKey = "contributor:" + row.dataset.contributor;
911
+ if (collapsed) {
912
+ sub.classList.remove("collapsed");
913
+ row.querySelector(".arrow").innerHTML = "&#9660;";
914
+ keys.add(toggleKey);
915
+ } else {
916
+ sub.classList.add("collapsed");
917
+ row.querySelector(".arrow").innerHTML = "&#9654;";
918
+ keys.delete(toggleKey);
919
+ }
920
+ saveExpandedKeys(keys);
921
+ }
922
+ });
923
+
533
924
  async function loadContributorFiles(username, sub, opts = {}) {
534
925
  const silent = !!opts.silent;
535
- sub.innerHTML = "";
536
926
  if (!silent) status("Loading files...");
537
927
  const { files } = await (await api("/v1/files?prefix=" + encodeURIComponent(username + "/"))).json();
538
928
  const prefix = username + "/";
@@ -540,14 +930,24 @@
540
930
  ...f,
541
931
  path: f.path.startsWith(prefix) ? f.path.slice(prefix.length) : f.path,
542
932
  })));
543
- renderTree(tree, sub, username, 1);
933
+ const tmp = document.createElement("ul");
934
+ renderTree(tree, tmp, username, 1);
935
+ if (sub.hasChildNodes()) {
936
+ morphdom(sub, tmp, {
937
+ childrenOnly: true,
938
+ getNodeKey(node) {
939
+ return node.dataset?.key || "";
940
+ },
941
+ });
942
+ } else {
943
+ sub.append(...tmp.childNodes);
944
+ }
544
945
  markActiveRow();
545
946
  if (!silent) status(files.length + " file(s)");
546
- // Update contributor row with file count
547
947
  const row = sub.parentElement && sub.parentElement.querySelector(".tree-row");
548
948
  if (row) {
549
949
  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>';
950
+ row.innerHTML = '<span class="name">' + arrow + '<i class="ph ph-user"></i> ' + username + ' <span style="opacity:0.5">(' + files.length + ')</span></span>';
551
951
  }
552
952
  }
553
953
 
@@ -556,6 +956,7 @@
556
956
  contentEl.innerHTML = "";
557
957
  status("Loading contributors...");
558
958
  const { contributors } = await (await api("/v1/contributors")).json();
959
+ const expandedKeys = getExpandedKeys();
559
960
 
560
961
  for (const b of contributors) {
561
962
  const li = document.createElement("li");
@@ -564,40 +965,28 @@
564
965
  row.style.paddingLeft = "8px";
565
966
  const sub = document.createElement("ul");
566
967
  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
- };
968
+ sub.dataset.loaded = "true";
969
+ const expanded = expandedKeys.has("contributor:" + b.username);
970
+ if (!expanded) sub.classList.add("collapsed");
971
+ const arrow = expanded ? "&#9660;" : "&#9654;";
972
+ row.innerHTML = '<span class="name"><span class="arrow">' + arrow + '</span><i class="ph ph-user"></i> ' + b.username + '</span>';
973
+ row.dataset.action = "contributor";
974
+ row.dataset.contributor = b.username;
585
975
  li.appendChild(row);
586
976
  li.appendChild(sub);
587
977
  filesEl.appendChild(li);
978
+ }
588
979
 
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
- }
980
+ await Promise.all(contributors.map((b) => {
981
+ const sub = getLoadedContributorList(b.username);
982
+ return sub ? loadContributorFiles(b.username, sub, { silent: true }) : null;
983
+ }));
984
+
985
+ if (savedFile && savedContributor) {
986
+ const match = filesEl.querySelector('[data-path="' + CSS.escape(savedFile) + '"]');
987
+ if (match) await loadContent(savedContributor, savedFile, match);
600
988
  }
989
+
601
990
  status(contributors.length + " contributor(s)");
602
991
  }
603
992
 
@@ -607,12 +996,10 @@
607
996
  row.scrollIntoView({ block: "nearest", behavior: "smooth" });
608
997
  localStorage.setItem("sv-contributor", username);
609
998
  localStorage.setItem("sv-file", path);
999
+ closeSidebar();
610
1000
  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
- });
1001
+ const encodedPath = path.split("/").map(encodeURIComponent).join("/");
1002
+ const res = await api("/v1/files/" + encodeURIComponent(username) + "/" + encodedPath);
616
1003
  const text = await res.text();
617
1004
  contentEl.innerHTML = renderMarkdown(text);
618
1005
  contentEl.parentElement.scrollTop = 0;
@@ -656,11 +1043,8 @@
656
1043
  scheduleContributorReload(contributor);
657
1044
  // If this file is currently open, reload its content
658
1045
  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
- })
1046
+ const encodedPath = path.split("/").map(encodeURIComponent).join("/");
1047
+ api("/v1/files/" + encodeURIComponent(contributor) + "/" + encodedPath)
664
1048
  .then((res) => res.text())
665
1049
  .then((text) => { contentEl.innerHTML = renderMarkdown(text); });
666
1050
  }
@@ -676,6 +1060,10 @@
676
1060
  }
677
1061
  });
678
1062
 
1063
+ evtSource.addEventListener("activity", (e) => {
1064
+ handleActivitySSE(e);
1065
+ });
1066
+
679
1067
  evtSource.onerror = () => {
680
1068
  // EventSource auto-reconnects
681
1069
  };
@@ -717,6 +1105,176 @@
717
1105
  document.addEventListener("mousemove", onMove);
718
1106
  document.addEventListener("mouseup", onUp);
719
1107
  });
1108
+
1109
+ // --- Activity modal ---
1110
+
1111
+ const activityModal = $("activity-modal");
1112
+ const activityBody = $("activity-dialog-body");
1113
+ const ACTIVITY_PAGE_SIZE = 1000;
1114
+ let activitySeenIds = new Set();
1115
+ let activityOffset = 0;
1116
+ let activityHasMore = false;
1117
+
1118
+ function openActivityModal() {
1119
+ activitySeenIds = new Set();
1120
+ activityOffset = 0;
1121
+ activityBody.innerHTML = "";
1122
+ activityModal.classList.add("open");
1123
+ loadActivityPage();
1124
+ }
1125
+
1126
+ function closeActivityModal() {
1127
+ activityModal.classList.remove("open");
1128
+ }
1129
+
1130
+ function handleActivitySSE(ev) {
1131
+ if (!activityModal.classList.contains("open")) return;
1132
+ const event = JSON.parse(ev.detail || ev.data);
1133
+ if (activitySeenIds.has(event.id)) return;
1134
+ activitySeenIds.add(event.id);
1135
+ activityOffset++;
1136
+ let list = activityBody.querySelector(".activity-list");
1137
+ if (!list) {
1138
+ const empty = activityBody.querySelector("p");
1139
+ if (empty) empty.remove();
1140
+ list = document.createElement("ul");
1141
+ list.className = "activity-list";
1142
+ activityBody.prepend(list);
1143
+ }
1144
+ list.insertAdjacentHTML("afterbegin", renderActivityItems([event]));
1145
+ }
1146
+
1147
+ $("activity-btn").addEventListener("click", openActivityModal);
1148
+ $("activity-close").addEventListener("click", closeActivityModal);
1149
+ $("activity-overlay").addEventListener("click", closeActivityModal);
1150
+
1151
+ document.addEventListener("keydown", (e) => {
1152
+ if (e.key === "Escape" && activityModal.classList.contains("open")) {
1153
+ closeActivityModal();
1154
+ }
1155
+ });
1156
+
1157
+ function formatActivityTime(iso) {
1158
+ if (!iso) return { time: "", date: "" };
1159
+ const d = new Date(iso);
1160
+ const hh = String(d.getHours()).padStart(2, "0");
1161
+ const mm = String(d.getMinutes()).padStart(2, "0");
1162
+ const ss = String(d.getSeconds()).padStart(2, "0");
1163
+ const ms = String(d.getMilliseconds()).padStart(3, "0");
1164
+ const mon = d.toLocaleString(undefined, { month: "short" });
1165
+ const day = d.getDate();
1166
+ const yr = d.getFullYear();
1167
+ const now = new Date();
1168
+ const dateStr = yr === now.getFullYear()
1169
+ ? mon + " " + day
1170
+ : mon + " " + day + ", " + yr;
1171
+ return {
1172
+ time: hh + ":" + mm + ":" + ss + "." + ms,
1173
+ date: dateStr,
1174
+ };
1175
+ }
1176
+
1177
+ function parseDetail(raw) {
1178
+ if (!raw) return null;
1179
+ try { return JSON.parse(raw); } catch { return null; }
1180
+ }
1181
+
1182
+ function esc(str) {
1183
+ return String(str)
1184
+ .replace(/&/g, "&amp;")
1185
+ .replace(/</g, "&lt;")
1186
+ .replace(/>/g, "&gt;")
1187
+ .replace(/"/g, "&quot;");
1188
+ }
1189
+
1190
+ function formatDetail(detail) {
1191
+ if (!detail || typeof detail !== "object") return "";
1192
+ return Object.entries(detail)
1193
+ .filter(([k]) => k !== "diff" && k !== "diff_truncated")
1194
+ .map(([k, v]) => k + "=" + v)
1195
+ .join(" ");
1196
+ }
1197
+
1198
+ function renderDiff(raw) {
1199
+ if (!raw) return "";
1200
+ const lines = raw.split("\n");
1201
+ const htmlLines = lines.map((line) => {
1202
+ const escaped = esc(line);
1203
+ if (line.startsWith("@@")) return '<span class="diff-hunk">' + escaped + '</span>';
1204
+ if (line.startsWith("+")) return '<span class="diff-add">' + escaped + '</span>';
1205
+ if (line.startsWith("-")) return '<span class="diff-del">' + escaped + '</span>';
1206
+ return escaped;
1207
+ });
1208
+ return htmlLines.join("\n");
1209
+ }
1210
+
1211
+ function renderActivityItems(events) {
1212
+ return events.map((ev) => {
1213
+ const { time, date } = formatActivityTime(ev.created_at);
1214
+ const parsed = parseDetail(ev.detail);
1215
+ const detail = formatDetail(parsed);
1216
+ const diff = parsed?.diff || null;
1217
+ const truncated = parsed?.diff_truncated || false;
1218
+
1219
+ let content = '<span class="activity-action">' + esc(ev.action) + '</span>' +
1220
+ ' <span class="activity-by">by</span>' +
1221
+ ' <span class="activity-contributor">' + esc(ev.contributor) + '</span>';
1222
+ if (detail) content += '<div class="activity-detail">' + esc(detail) + '</div>';
1223
+ if (diff) {
1224
+ content += '<div class="activity-diff">' + renderDiff(diff) + '</div>';
1225
+ if (truncated) content += '<span class="activity-diff-truncated">diff truncated</span>';
1226
+ }
1227
+
1228
+ return '<li class="activity-item">' +
1229
+ '<div class="activity-time-col">' +
1230
+ '<div class="activity-time">' + time + '</div>' +
1231
+ '<div class="activity-date">' + date + '</div>' +
1232
+ '</div>' +
1233
+ '<div class="activity-dot-col">' +
1234
+ '<div class="activity-dot"></div>' +
1235
+ '<div class="activity-line"></div>' +
1236
+ '</div>' +
1237
+ '<div class="activity-content">' + content + '</div>' +
1238
+ '</li>';
1239
+ }).join("");
1240
+ }
1241
+
1242
+ async function loadActivityPage() {
1243
+ const existingBtn = activityBody.querySelector("#activity-load-more");
1244
+ if (existingBtn) existingBtn.remove();
1245
+
1246
+ const limit = ACTIVITY_PAGE_SIZE;
1247
+ const offset = activityOffset;
1248
+ const url = "/v1/activity?limit=" + (limit + 1) + "&offset=" + offset;
1249
+ const { events } = await (await api(url)).json();
1250
+
1251
+ activityHasMore = events.length > limit;
1252
+ const page = activityHasMore ? events.slice(0, limit) : events;
1253
+ for (const ev of page) activitySeenIds.add(ev.id);
1254
+ activityOffset += page.length;
1255
+
1256
+ let list = activityBody.querySelector(".activity-list");
1257
+ if (!list) {
1258
+ list = document.createElement("ul");
1259
+ list.className = "activity-list";
1260
+ activityBody.appendChild(list);
1261
+ }
1262
+
1263
+ if (activitySeenIds.size === 0) {
1264
+ activityBody.innerHTML = '<p style="padding:12px;opacity:0.5;font-size:12px">No activity.</p>';
1265
+ return;
1266
+ }
1267
+
1268
+ list.insertAdjacentHTML("beforeend", renderActivityItems(page));
1269
+
1270
+ if (activityHasMore) {
1271
+ const btn = document.createElement("button");
1272
+ btn.id = "activity-load-more";
1273
+ btn.textContent = "Load more";
1274
+ btn.addEventListener("click", () => loadActivityPage());
1275
+ activityBody.appendChild(btn);
1276
+ }
1277
+ }
720
1278
  </script>
721
1279
  </body>
722
1280