@seedvault/server 0.1.5 → 0.2.1

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 +1073 -231
  2. package/dist/server.js +241 -7
  3. package/package.json +1 -1
package/dist/index.html CHANGED
@@ -6,34 +6,83 @@
6
6
  <meta name="viewport" content="width=device-width, initial-scale=1" />
7
7
  <title>Seedvault</title>
8
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"
9
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="anonymous" />
10
+ <link
11
+ href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:ital,wght@0,400;0,500;1,400&display=swap"
11
12
  rel="stylesheet" />
12
13
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@phosphor-icons/web@2.1.2/src/regular/style.css" />
13
14
  <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;
15
+ /* ── Variables ── */
16
+
17
+ :root {
18
+ --bg: #F5F3EE;
19
+ --surface: #FFFFFF;
20
+ --surface-hover: #F0EDE6;
21
+ --surface-active: rgba(62, 124, 83, 0.07);
22
+ --border: #E2DED6;
23
+ --border-subtle: #ECEAE4;
24
+ --text: #1D1D1B;
25
+ --text-secondary: #7D7870;
26
+ --text-tertiary: #B0A99E;
27
+ --accent: #3E7C53;
28
+ --accent-hover: #346A47;
29
+ --accent-text: #FFFFFF;
30
+ --highlight: #C4903D;
31
+ --highlight-dim: rgba(196, 144, 61, 0.10);
32
+ --diff-add: #2E7D42;
33
+ --diff-del: #C62828;
34
+ --diff-hunk: #6A42C1;
35
+ --code-bg: rgba(0, 0, 0, 0.035);
36
+ --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.04);
37
+ --shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
38
+ --radius: 3px;
39
+ --radius-sm: 2px;
40
+ --font-body: system-ui, -apple-system, sans-serif;
41
+ --font-mono: 'IBM Plex Mono', ui-monospace, monospace;
42
+ --transition: 150ms ease;
23
43
  }
24
44
 
25
45
  @media (prefers-color-scheme: dark) {
26
- html {
27
- background: rgb(18, 18, 18);
46
+ :root {
47
+ --bg: #0A0A09;
48
+ --surface: #111110;
49
+ --surface-hover: #1A1A18;
50
+ --surface-active: rgba(107, 175, 126, 0.08);
51
+ --border: #222220;
52
+ --border-subtle: #1A1A18;
53
+ --text: #D4D0C8;
54
+ --text-secondary: #8E887E;
55
+ --text-tertiary: #5A554D;
56
+ --accent: #6BAF7E;
57
+ --accent-hover: #82C494;
58
+ --accent-text: #0A0A09;
59
+ --highlight: #D4A853;
60
+ --highlight-dim: rgba(212, 168, 83, 0.12);
61
+ --diff-add: #81C784;
62
+ --diff-del: #EF9A9A;
63
+ --diff-hunk: #B39DDB;
64
+ --code-bg: rgba(255, 255, 255, 0.04);
65
+ --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.2);
66
+ --shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
28
67
  }
68
+ }
29
69
 
30
- #nav {
31
- color: rgb(204, 204, 204);
32
- }
70
+ /* ── Reset & Base ── */
33
71
 
34
- #content {
35
- color: rgb(230, 230, 230);
36
- }
72
+ *,
73
+ *::before,
74
+ *::after {
75
+ box-sizing: border-box;
76
+ margin: 0;
77
+ }
78
+
79
+ html {
80
+ font-family: var(--font-body);
81
+ font-size: 13px;
82
+ background: var(--bg);
83
+ color: var(--text);
84
+ -webkit-font-smoothing: antialiased;
85
+ -moz-osx-font-smoothing: grayscale;
37
86
  }
38
87
 
39
88
  html,
@@ -42,133 +91,194 @@
42
91
  }
43
92
 
44
93
  body {
45
- padding: 12px;
46
94
  display: flex;
47
95
  flex-direction: column;
96
+ overflow: hidden;
48
97
  }
49
98
 
50
- .top {
99
+ /* ── Top Bar ── */
100
+
101
+ .top-bar {
51
102
  display: flex;
52
- gap: 8px;
53
- margin-bottom: 12px;
54
- flex-wrap: wrap;
103
+ align-items: center;
104
+ justify-content: space-between;
105
+ gap: 12px;
106
+ padding: 0 16px;
107
+ height: 52px;
55
108
  flex-shrink: 0;
109
+ border-bottom: 1px solid var(--border);
110
+ background: var(--surface);
111
+ }
112
+
113
+ .top-bar-left {
114
+ display: flex;
115
+ align-items: center;
116
+ gap: 12px;
117
+ }
118
+
119
+ #contributor-select {
120
+ font-family: var(--font-body);
121
+ font-size: 13px;
122
+ font-weight: 500;
123
+ padding: 6px 28px 6px 10px;
124
+ min-width: 120px;
125
+ cursor: pointer;
126
+ appearance: none;
127
+ -webkit-appearance: none;
128
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%237D7870'/%3E%3C/svg%3E");
129
+ background-repeat: no-repeat;
130
+ background-position: right 10px center;
131
+ }
132
+
133
+ #contributor-select:focus {
134
+ border-color: var(--accent);
135
+ }
136
+
137
+ #contributor-select:disabled {
138
+ opacity: 0.5;
139
+ cursor: default;
140
+ }
141
+
142
+ @media (prefers-color-scheme: dark) {
143
+ #contributor-select {
144
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%238E887E'/%3E%3C/svg%3E");
145
+ }
146
+ }
147
+
148
+ .top-bar-right {
149
+ display: flex;
150
+ align-items: center;
151
+ gap: 8px;
56
152
  }
57
153
 
58
154
  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);
155
+ select {
156
+ font-family: var(--font-mono);
157
+ font-size: 12px;
158
+ padding: 6px 10px;
159
+ background: var(--bg);
160
+ color: var(--text);
161
+ border: 1px solid var(--border);
162
+ border-radius: var(--radius-sm);
163
+ outline: none;
164
+ transition: border-color var(--transition);
165
+ }
166
+
167
+ input:focus {
168
+ border-color: var(--accent);
169
+ }
170
+
171
+ input::placeholder {
172
+ color: var(--text-tertiary);
66
173
  }
67
174
 
68
175
  #token {
69
- min-width: 260px;
70
- flex: 1;
176
+ width: 240px;
71
177
  }
72
178
 
73
179
  button {
180
+ font-family: var(--font-mono);
181
+ font-size: 11px;
182
+ font-weight: 500;
74
183
  cursor: pointer;
184
+ border: none;
185
+ background: transparent;
186
+ color: var(--text);
187
+ padding: 0;
188
+ transition: color var(--transition), background var(--transition), opacity var(--transition);
189
+ }
190
+
191
+ .btn-primary {
192
+ padding: 6px 14px;
193
+ background: var(--accent);
194
+ color: var(--accent-text);
195
+ border-radius: var(--radius-sm);
196
+ }
197
+
198
+ .btn-primary:hover {
199
+ background: var(--accent-hover);
75
200
  }
76
201
 
77
202
  #menu-toggle {
78
203
  display: none;
204
+ padding: 6px 8px;
79
205
  font-size: 18px;
80
- padding: 4px 10px;
206
+ border-radius: var(--radius-sm);
81
207
  }
82
208
 
209
+ #menu-toggle:hover {
210
+ background: var(--surface-hover);
211
+ }
212
+
213
+ /* ── Mobile Nav ── */
214
+
83
215
  #nav-close {
84
216
  display: none;
85
- float: right;
86
- border: none;
217
+ padding: 4px;
87
218
  font-size: 16px;
88
- padding: 0 4px;
89
- line-height: 1;
219
+ border-radius: var(--radius-sm);
220
+ opacity: 0.5;
221
+ }
222
+
223
+ #nav-close:hover {
224
+ opacity: 1;
225
+ background: var(--surface-hover);
90
226
  }
91
227
 
92
228
  #backdrop {
93
229
  display: none;
94
230
  position: fixed;
95
231
  inset: 0;
96
- background: rgba(0, 0, 0, 0.4);
232
+ background: rgba(0, 0, 0, 0.35);
97
233
  z-index: 90;
234
+ backdrop-filter: blur(2px);
235
+ -webkit-backdrop-filter: blur(2px);
98
236
  }
99
237
 
100
238
  #backdrop.visible {
101
239
  display: block;
102
240
  }
103
241
 
242
+ /* ── Grid Layout ── */
243
+
104
244
  .grid {
105
245
  display: flex;
106
- gap: 0;
107
246
  flex: 1;
108
247
  min-height: 0;
109
248
  }
110
249
 
111
250
  .grid>.panel:first-child {
112
251
  flex-shrink: 0;
252
+ background: var(--surface);
253
+ border-right: 1px solid var(--border);
113
254
  }
114
255
 
115
256
  .grid>.panel:last-child {
116
257
  flex: 1;
117
258
  min-width: 0;
259
+ background: var(--bg);
118
260
  }
119
261
 
120
262
  .divider {
121
- width: 5px;
263
+ width: 4px;
122
264
  cursor: col-resize;
123
265
  background: transparent;
124
266
  flex-shrink: 0;
267
+ position: relative;
268
+ z-index: 2;
269
+ margin-left: -2px;
270
+ margin-right: -2px;
125
271
  }
126
272
 
127
273
  .divider:hover,
128
274
  .divider.dragging {
129
- background: color-mix(in srgb, currentColor 20%, transparent);
275
+ background: var(--accent);
276
+ opacity: 0.3;
130
277
  }
131
278
 
132
- @media (max-width: 600px) {
133
- #menu-toggle {
134
- display: block;
135
- }
136
-
137
- #nav-close {
138
- display: block;
139
- }
140
-
141
- .grid {
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);
159
- }
160
-
161
- .grid>.panel:first-child {
162
- width: auto;
163
- }
164
-
165
- .divider {
166
- display: none;
167
- }
168
- }
279
+ /* ── Panels ── */
169
280
 
170
281
  .panel {
171
- border: 1px solid color-mix(in srgb, currentColor 20%, transparent);
172
282
  display: flex;
173
283
  flex-direction: column;
174
284
  min-height: 0;
@@ -176,13 +286,18 @@
176
286
  }
177
287
 
178
288
  .panel-head {
179
- padding: 4px 8px;
180
- border-bottom: 1px solid color-mix(in srgb, currentColor 20%, transparent);
181
- font-weight: bold;
289
+ padding: 10px 14px;
290
+ border-bottom: 1px solid var(--border-subtle);
291
+ font-family: var(--font-mono);
182
292
  font-size: 11px;
293
+ font-weight: 600;
183
294
  text-transform: uppercase;
184
- letter-spacing: 0.05em;
295
+ letter-spacing: 0.06em;
296
+ color: var(--text-secondary);
185
297
  flex-shrink: 0;
298
+ display: flex;
299
+ align-items: center;
300
+ justify-content: space-between;
186
301
  }
187
302
 
188
303
  #nav-body:focus {
@@ -193,34 +308,46 @@
193
308
  flex: 1;
194
309
  overflow: auto;
195
310
  scrollbar-width: thin;
196
- scrollbar-color: color-mix(in srgb, currentColor 20%, transparent) transparent;
311
+ scrollbar-color: var(--border) transparent;
312
+ }
313
+
314
+ .grid>.panel:last-child>.panel-body {
315
+ overflow: auto;
197
316
  }
198
317
 
318
+ /* ── Scrollbars ── */
319
+
199
320
  .panel-body::-webkit-scrollbar,
200
- #content::-webkit-scrollbar {
321
+ #content::-webkit-scrollbar,
322
+ #activity-dialog-body::-webkit-scrollbar {
201
323
  width: 6px;
202
324
  height: 6px;
203
325
  }
204
326
 
205
327
  .panel-body::-webkit-scrollbar-track,
206
- #content::-webkit-scrollbar-track {
328
+ #content::-webkit-scrollbar-track,
329
+ #activity-dialog-body::-webkit-scrollbar-track {
207
330
  background: transparent;
208
331
  }
209
332
 
210
333
  .panel-body::-webkit-scrollbar-thumb,
211
- #content::-webkit-scrollbar-thumb {
212
- background: color-mix(in srgb, currentColor 20%, transparent);
334
+ #content::-webkit-scrollbar-thumb,
335
+ #activity-dialog-body::-webkit-scrollbar-thumb {
336
+ background: var(--border);
213
337
  border-radius: 3px;
214
338
  }
215
339
 
216
340
  .panel-body::-webkit-scrollbar-thumb:hover,
217
- #content::-webkit-scrollbar-thumb:hover {
218
- background: color-mix(in srgb, currentColor 30%, transparent);
341
+ #content::-webkit-scrollbar-thumb:hover,
342
+ #activity-dialog-body::-webkit-scrollbar-thumb:hover {
343
+ background: var(--text-tertiary);
219
344
  }
220
345
 
346
+ /* ── File Tree ── */
347
+
221
348
  .tree {
222
349
  list-style: none;
223
- padding: 0;
350
+ padding: 4px 0;
224
351
  }
225
352
 
226
353
  .tree ul {
@@ -233,11 +360,15 @@
233
360
  align-items: baseline;
234
361
  width: 100%;
235
362
  text-align: left;
236
- border: none;
237
- padding: 4px 8px;
363
+ padding: 5px 12px;
238
364
  white-space: nowrap;
239
365
  overflow: hidden;
240
366
  cursor: pointer;
367
+ border-left: 2px solid transparent;
368
+ border-radius: 0;
369
+ font-family: var(--font-mono);
370
+ font-size: 11px;
371
+ transition: background var(--transition), border-color var(--transition), color var(--transition);
241
372
  }
242
373
 
243
374
  .tree-row .name {
@@ -250,204 +381,729 @@
250
381
  .tree-row .ctime {
251
382
  flex-shrink: 0;
252
383
  margin-left: 8px;
253
- opacity: 0.45;
384
+ color: var(--text-tertiary);
254
385
  font-size: 10px;
386
+ font-family: var(--font-mono);
255
387
  }
256
388
 
257
389
  .tree-row:hover {
258
- background: color-mix(in srgb, currentColor 8%, transparent);
390
+ background: var(--surface-hover);
259
391
  }
260
392
 
261
393
  .tree-row.active {
262
- background: CanvasText;
263
- color: Canvas;
394
+ background: var(--text);
395
+ border-left-color: transparent;
396
+ color: var(--bg);
397
+ font-weight: 500;
264
398
  }
265
399
 
266
400
  .tree-row .arrow {
267
401
  display: inline-block;
268
402
  width: 1em;
269
403
  text-align: center;
404
+ font-size: 9px;
405
+ color: var(--text-tertiary);
406
+ transition: color var(--transition);
407
+ }
408
+
409
+ .tree-row:hover .arrow {
410
+ color: var(--text-secondary);
270
411
  }
271
412
 
272
413
  .tree-row .ph {
273
414
  display: inline-block;
274
415
  font-size: 14px;
275
- margin-left: 12px;
276
- margin-right: 2px;
277
- vertical-align: -0.15em;
416
+ margin-left: 6px;
417
+ margin-right: 4px;
418
+ vertical-align: -0.12em;
419
+ color: var(--text-tertiary);
420
+ }
421
+
422
+ .tree-row.active .ph {
423
+ color: var(--bg);
424
+ }
425
+
426
+ .tree-row.active .ctime {
427
+ color: var(--bg);
428
+ opacity: 0.6;
429
+ }
430
+
431
+ .tree-row.active .arrow {
432
+ color: var(--bg);
278
433
  }
279
434
 
280
435
  .tree ul.collapsed {
281
436
  display: none;
282
437
  }
283
438
 
439
+ /* ── Item Header ── */
440
+
441
+ .item-header {
442
+ padding: 24px 0 20px;
443
+ margin-bottom: 20px;
444
+ border-bottom: 1px solid var(--border);
445
+ }
446
+
447
+ .item-header-title {
448
+ font-family: var(--font-body);
449
+ font-size: 5rem;
450
+ font-weight: 500;
451
+ line-height: 1.05;
452
+ letter-spacing: -0.01em;
453
+ color: var(--text);
454
+ overflow-wrap: break-word;
455
+ word-break: break-word;
456
+ }
457
+
458
+ .item-header-path {
459
+ font-size: 11px;
460
+ font-family: var(--font-mono);
461
+ color: var(--text-tertiary);
462
+ margin-bottom: 6px;
463
+ }
464
+
465
+ .item-header-meta {
466
+ display: flex;
467
+ flex-direction: column;
468
+ gap: 6px;
469
+ margin-top: 14px;
470
+ font-size: 12px;
471
+ }
472
+
473
+ .item-header-meta .meta-field {
474
+ display: flex;
475
+ align-items: center;
476
+ gap: 6px;
477
+ }
478
+
479
+ .item-header-meta .meta-label {
480
+ font-weight: 600;
481
+ text-transform: uppercase;
482
+ font-size: 10px;
483
+ letter-spacing: 0.04em;
484
+ color: var(--text-tertiary);
485
+ }
486
+
487
+ .item-header-meta .meta-value {
488
+ font-family: var(--font-mono);
489
+ font-size: 11px;
490
+ color: var(--text-secondary);
491
+ }
492
+
493
+ .item-header-meta .meta-tag {
494
+ display: inline-block;
495
+ padding: 2px 8px;
496
+ border-radius: 99px;
497
+ background: var(--surface-hover);
498
+ border: 1px solid var(--border-subtle);
499
+ font-size: 11px;
500
+ color: var(--text-secondary);
501
+ }
502
+
503
+ /* ── Empty State ── */
504
+
505
+ .empty-state {
506
+ display: flex;
507
+ flex-direction: column;
508
+ align-items: center;
509
+ justify-content: center;
510
+ height: 100%;
511
+ color: var(--text-tertiary);
512
+ gap: 12px;
513
+ user-select: none;
514
+ }
515
+
516
+ .empty-state .ph {
517
+ font-size: 48px;
518
+ opacity: 0.4;
519
+ }
520
+
521
+ .empty-state p {
522
+ font-size: 14px;
523
+ font-weight: 500;
524
+ }
525
+
526
+ /* ── Content / Markdown ── */
527
+
284
528
  #content {
285
- font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
529
+ font-family: var(--font-body);
286
530
  font-size: 16px;
287
- padding: 24px 32px;
288
- overflow: auto;
289
- line-height: 1.6;
531
+ padding: 28px 32px;
532
+ line-height: 1.5;
533
+ color: var(--text);
534
+ max-width: 544px;
535
+ margin: 0 auto;
290
536
  }
291
537
 
292
538
  #content h1 {
293
- font-size: 1.75em;
294
- font-weight: 700;
295
- margin: 0 0 0.5em;
296
- line-height: 1.3;
539
+ font-size: 2rem;
540
+ font-weight: 600;
541
+ margin: 1rem 0 0.5rem;
542
+ line-height: 2.5rem;
543
+ letter-spacing: 0.02rem;
297
544
  }
298
545
 
299
546
  #content h2 {
300
- font-size: 1.4em;
547
+ font-size: 1.6rem;
301
548
  font-weight: 600;
302
- margin: 1em 0 0.5em;
303
- line-height: 1.3;
549
+ margin: 1rem 0 0.3rem;
550
+ line-height: 2.5rem;
551
+ letter-spacing: 0.02rem;
304
552
  }
305
553
 
306
554
  #content h3 {
307
- font-size: 1.2em;
555
+ font-size: 1.2rem;
308
556
  font-weight: 600;
309
- margin: 1em 0 0.5em;
310
- line-height: 1.3;
557
+ margin: 0.2rem 0 0;
558
+ line-height: 2rem;
559
+ letter-spacing: 0.02rem;
311
560
  }
312
561
 
313
562
  #content h4,
314
563
  #content h5,
315
564
  #content h6 {
316
- font-size: 1.05em;
565
+ font-size: 1em;
317
566
  font-weight: 600;
318
- margin: 0.75em 0 0.5em;
567
+ margin: 0.8em 0 0.4em;
319
568
  }
320
569
 
321
570
  #content p {
322
- margin: 0 0 0.75em;
571
+ margin: 0 0 0.8em;
323
572
  }
324
573
 
325
574
  #content img {
326
575
  max-width: 100%;
327
576
  height: auto;
328
577
  display: block;
578
+ border-radius: var(--radius-sm);
329
579
  }
330
580
 
331
581
  #content ul,
332
582
  #content ol {
333
- margin: 0 0 0.75em;
334
- padding-left: 1.5em;
583
+ margin: 0 0 0.8em;
584
+ padding-left: 1.4em;
585
+ }
586
+
587
+ #content li {
588
+ margin-bottom: 0.2em;
335
589
  }
336
590
 
337
591
  #content pre,
338
592
  #content code {
339
- font-family: ui-monospace, monospace;
340
- font-size: 0.9em;
593
+ font-family: var(--font-mono);
594
+ font-size: 0.88em;
341
595
  }
342
596
 
343
597
  #content pre {
344
- background: color-mix(in srgb, currentColor 8%, transparent);
345
- padding: 10px;
346
- border-radius: 4px;
598
+ background: var(--code-bg);
599
+ padding: 14px 16px;
600
+ border-radius: var(--radius);
347
601
  overflow-x: auto;
348
- margin: 0.5em 0;
602
+ margin: 0.6em 0 1em;
603
+ border: 1px solid var(--border-subtle);
604
+ line-height: 1.5;
349
605
  }
350
606
 
351
607
  #content code {
352
- padding: 0.15em 0.3em;
353
- border-radius: 3px;
354
- background: color-mix(in srgb, currentColor 8%, transparent);
608
+ padding: 0.15em 0.35em;
609
+ border-radius: var(--radius-sm);
610
+ background: var(--code-bg);
355
611
  }
356
612
 
357
613
  #content pre code {
358
614
  padding: 0;
359
615
  background: none;
616
+ border: none;
360
617
  }
361
618
 
362
619
  #content blockquote {
363
- border-left: 4px solid color-mix(in srgb, currentColor 30%, transparent);
364
- margin: 0.5em 0;
365
- padding-left: 1em;
366
- color: color-mix(in srgb, currentColor 80%, transparent);
620
+ border-left: 3px solid var(--accent);
621
+ margin: 0.6em 0 1em;
622
+ padding: 0.3em 0 0.3em 1em;
623
+ color: var(--text-secondary);
624
+ }
625
+
626
+ #content blockquote p:last-child {
627
+ margin-bottom: 0;
367
628
  }
368
629
 
369
630
  #content a {
370
- color: inherit;
631
+ color: var(--accent);
371
632
  text-decoration: underline;
633
+ text-decoration-color: rgba(62, 124, 83, 0.3);
634
+ text-underline-offset: 2px;
635
+ transition: text-decoration-color var(--transition);
636
+ }
637
+
638
+ #content a:hover {
639
+ text-decoration-color: var(--accent);
640
+ }
641
+
642
+ @media (prefers-color-scheme: dark) {
643
+ #content a {
644
+ text-decoration-color: rgba(107, 175, 126, 0.3);
645
+ }
372
646
  }
373
647
 
374
648
  #content hr {
375
649
  border: none;
376
- border-top: 1px solid color-mix(in srgb, currentColor 25%, transparent);
377
- margin: 1.5em 0;
650
+ border-top: 1px solid var(--border);
651
+ margin: 2em 0;
378
652
  }
379
653
 
380
654
  #content table {
381
655
  border-collapse: collapse;
382
- margin: 0.5em 0;
656
+ margin: 0.6em 0 1em;
657
+ font-size: 0.92em;
383
658
  }
384
659
 
385
660
  #content th,
386
661
  #content td {
387
- border: 1px solid color-mix(in srgb, currentColor 20%, transparent);
388
- padding: 6px 10px;
662
+ border: 1px solid var(--border);
663
+ padding: 8px 12px;
389
664
  text-align: left;
390
665
  }
391
666
 
667
+ #content th {
668
+ background: var(--code-bg);
669
+ font-weight: 600;
670
+ }
671
+
672
+ /* ── Status Bar ── */
673
+
392
674
  #status {
393
- margin-top: 8px;
675
+ padding: 0 16px;
676
+ height: 32px;
394
677
  font-size: 11px;
395
- opacity: 0.6;
678
+ color: var(--text-tertiary);
679
+ flex-shrink: 0;
680
+ display: flex;
681
+ align-items: center;
682
+ border-top: 1px solid var(--border-subtle);
683
+ background: var(--surface);
684
+ }
685
+
686
+ #status-text {
687
+ flex: 1;
688
+ font-family: var(--font-mono);
689
+ font-size: 11px;
690
+ }
691
+
692
+ #activity-btn {
693
+ font-size: 11px;
694
+ font-weight: 500;
695
+ padding: 4px 8px;
696
+ border-radius: var(--radius-sm);
697
+ color: var(--text-secondary);
698
+ display: flex;
699
+ align-items: center;
700
+ gap: 4px;
701
+ }
702
+
703
+ #activity-btn:hover {
704
+ color: var(--text);
705
+ background: var(--surface-hover);
706
+ }
707
+
708
+ /* ── Activity Panel ── */
709
+
710
+ #activity-modal {
711
+ display: none;
712
+ position: fixed;
713
+ inset: 0;
714
+ z-index: 300;
715
+ }
716
+
717
+ #activity-modal.open {
718
+ display: flex;
719
+ }
720
+
721
+ #activity-overlay {
722
+ position: absolute;
723
+ inset: 0;
724
+ background: rgba(0, 0, 0, 0.3);
725
+ backdrop-filter: blur(2px);
726
+ -webkit-backdrop-filter: blur(2px);
727
+ animation: fadeIn 0.2s ease;
728
+ }
729
+
730
+ #activity-dialog {
731
+ position: absolute;
732
+ inset: 0;
733
+ background: var(--surface);
734
+ color: var(--text);
735
+ display: flex;
736
+ flex-direction: column;
737
+ overflow: hidden;
738
+ z-index: 1;
739
+ animation: fadeIn 0.2s ease;
740
+ }
741
+
742
+ @keyframes fadeIn {
743
+ from {
744
+ opacity: 0;
745
+ }
746
+
747
+ to {
748
+ opacity: 1;
749
+ }
750
+ }
751
+
752
+
753
+ #activity-dialog-head {
754
+ padding: 10px 16px;
755
+ border-bottom: 1px solid var(--border);
756
+ font-size: 12px;
757
+ font-weight: 600;
758
+ display: flex;
759
+ align-items: center;
760
+ flex-shrink: 0;
761
+ color: var(--text-secondary);
762
+ }
763
+
764
+ #activity-dialog-head span {
765
+ display: flex;
766
+ align-items: center;
767
+ gap: 6px;
768
+ }
769
+
770
+ #activity-dialog-head .ph {
771
+ font-size: 16px;
772
+ color: var(--accent);
773
+ }
774
+
775
+ #activity-dialog-head button {
776
+ margin-left: auto;
777
+ padding: 4px 6px;
778
+ font-size: 16px;
779
+ border-radius: var(--radius-sm);
780
+ color: var(--text-tertiary);
781
+ }
782
+
783
+ #activity-dialog-head button:hover {
784
+ color: var(--text);
785
+ background: var(--surface-hover);
786
+ }
787
+
788
+ #activity-dialog-body {
789
+ flex: 1;
790
+ overflow-y: auto;
791
+ scrollbar-width: thin;
792
+ scrollbar-color: var(--border) transparent;
793
+ }
794
+
795
+ .activity-list {
796
+ padding: 12px 0;
797
+ margin: 0;
798
+ list-style: none;
799
+ }
800
+
801
+ .activity-item {
802
+ display: grid;
803
+ grid-template-columns: 90px 20px 1fr;
804
+ gap: 0 8px;
805
+ min-height: 40px;
806
+ }
807
+
808
+ .activity-time-col {
809
+ text-align: right;
810
+ padding-top: 6px;
811
+ }
812
+
813
+ .activity-time {
814
+ font-size: 11px;
815
+ font-family: var(--font-mono);
816
+ color: var(--text-secondary);
817
+ white-space: nowrap;
818
+ }
819
+
820
+ .activity-date {
821
+ font-size: 10px;
822
+ color: var(--text-tertiary);
823
+ }
824
+
825
+ .activity-dot-col {
826
+ position: relative;
827
+ display: flex;
828
+ flex-direction: column;
829
+ align-items: center;
830
+ }
831
+
832
+ .activity-dot-col::after {
833
+ content: '';
834
+ position: absolute;
835
+ left: 50%;
836
+ top: 0;
837
+ bottom: 0;
838
+ width: 1px;
839
+ transform: translateX(-50%);
840
+ background: var(--border);
841
+ }
842
+
843
+ .activity-item:first-child .activity-dot-col::after {
844
+ top: 13px;
845
+ }
846
+
847
+ .activity-item:last-child .activity-dot-col::after {
848
+ bottom: calc(100% - 14px);
849
+ }
850
+
851
+ .activity-dot {
852
+ width: 6px;
853
+ height: 6px;
854
+ border-radius: 50%;
855
+ background: var(--accent);
856
+ opacity: 0.5;
396
857
  flex-shrink: 0;
858
+ margin-top: 10px;
859
+ position: relative;
860
+ z-index: 1;
861
+ }
862
+
863
+ .activity-line {
864
+ display: none;
865
+ }
866
+
867
+ .activity-content {
868
+ padding: 6px 16px 8px 0;
869
+ min-width: 0;
870
+ }
871
+
872
+ .activity-action {
873
+ font-family: var(--font-mono);
874
+ font-size: 11px;
875
+ font-weight: 500;
876
+ }
877
+
878
+ .activity-by {
879
+ font-size: 11px;
880
+ color: var(--text-tertiary);
881
+ }
882
+
883
+ .activity-contributor {
884
+ font-weight: 600;
885
+ color: var(--text-secondary);
886
+ }
887
+
888
+ .activity-detail {
889
+ margin-top: 3px;
890
+ font-size: 11px;
891
+ color: var(--text-tertiary);
892
+ font-family: var(--font-mono);
893
+ word-break: break-all;
894
+ }
895
+
896
+ .activity-diff {
897
+ margin-top: 6px;
898
+ font-family: var(--font-mono);
899
+ font-size: 11px;
900
+ line-height: 1.45;
901
+ white-space: pre-wrap;
902
+ word-break: break-all;
903
+ padding: 8px 10px;
904
+ background: var(--code-bg);
905
+ border-radius: var(--radius-sm);
906
+ border: 1px solid var(--border-subtle);
907
+ overflow-x: auto;
908
+ }
909
+
910
+ .activity-diff .diff-add {
911
+ color: var(--diff-add);
912
+ }
913
+
914
+ .activity-diff .diff-del {
915
+ color: var(--diff-del);
916
+ }
917
+
918
+ .activity-diff .diff-hunk {
919
+ color: var(--diff-hunk);
920
+ opacity: 0.7;
921
+ }
922
+
923
+ .activity-diff-truncated {
924
+ font-size: 10px;
925
+ color: var(--text-tertiary);
926
+ font-style: italic;
927
+ margin-top: 2px;
928
+ }
929
+
930
+ #activity-load-more {
931
+ width: 100%;
932
+ padding: 10px;
933
+ border-top: 1px solid var(--border-subtle);
934
+ font-size: 11px;
935
+ font-weight: 500;
936
+ text-transform: uppercase;
937
+ letter-spacing: 0.06em;
938
+ color: var(--text-tertiary);
939
+ }
940
+
941
+ #activity-load-more:hover {
942
+ color: var(--text);
943
+ background: var(--surface-hover);
944
+ }
945
+
946
+ /* ── Responsive ── */
947
+
948
+ @media (max-width: 600px) {
949
+ .top-bar {
950
+ padding: 0 12px;
951
+ height: 48px;
952
+ gap: 8px;
953
+ }
954
+
955
+ .top-bar-right {
956
+ flex: 1;
957
+ min-width: 0;
958
+ }
959
+
960
+ #contributor-select {
961
+ min-width: 100px;
962
+ flex-shrink: 1;
963
+ }
964
+
965
+ #token {
966
+ width: auto;
967
+ flex: 1;
968
+ min-width: 80px;
969
+ }
970
+
971
+ #menu-toggle {
972
+ display: flex;
973
+ align-items: center;
974
+ }
975
+
976
+ #nav-close {
977
+ display: flex;
978
+ align-items: center;
979
+ }
980
+
981
+ .grid {
982
+ flex-direction: column;
983
+ position: relative;
984
+ }
985
+
986
+ #nav {
987
+ position: fixed;
988
+ top: 0;
989
+ left: -100vw;
990
+ width: 100vw !important;
991
+ height: 100%;
992
+ z-index: 100;
993
+ background: var(--surface);
994
+ transition: transform 0.2s ease;
995
+ border-right: none !important;
996
+ }
997
+
998
+ #nav.open {
999
+ transform: translateX(100vw);
1000
+ }
1001
+
1002
+ .grid>.panel:first-child {
1003
+ width: auto;
1004
+ }
1005
+
1006
+ .divider {
1007
+ display: none;
1008
+ }
1009
+
1010
+ #content {
1011
+ padding: 20px 16px;
1012
+ }
1013
+
1014
+ .item-header-title {
1015
+ font-size: 3rem;
1016
+ }
397
1017
  }
398
1018
  </style>
399
1019
  </head>
400
1020
 
401
1021
  <body>
402
- <div class="top">
403
- <button id="menu-toggle" aria-label="Toggle navigation">&#9776;</button>
404
- <input id="token" type="password" placeholder="API key (&quot;sv_...&quot;)" />
405
- <button id="connect">Load</button>
406
- </div>
1022
+ <header class="top-bar">
1023
+ <div class="top-bar-left">
1024
+ <button id="menu-toggle" aria-label="Toggle navigation"><i class="ph ph-list"></i></button>
1025
+ <select id="contributor-select" disabled>
1026
+ <option value="">Contributors</option>
1027
+ </select>
1028
+ </div>
1029
+ <div class="top-bar-right">
1030
+ <input id="token" type="password" placeholder="API key" />
1031
+ <button id="connect" class="btn-primary">Connect</button>
1032
+ </div>
1033
+ </header>
407
1034
  <div id="backdrop"></div>
408
1035
  <div class="grid">
409
- <section id="nav" class="panel" style="width:220px">
410
- <div class="panel-head">Files<button id="nav-close" aria-label="Close navigation">&times;</button></div>
1036
+ <section id="nav" class="panel" style="width:260px">
1037
+ <div class="panel-head"><span>Files</span><button id="nav-close" aria-label="Close navigation"><i
1038
+ class="ph ph-x"></i></button></div>
411
1039
  <div class="panel-body" id="nav-body" tabindex="0">
412
1040
  <ul id="files" class="tree"></ul>
413
1041
  </div>
414
1042
  </section>
415
1043
  <div id="divider" class="divider"></div>
416
1044
  <section class="panel">
417
- <div class="panel-head">Content</div>
418
1045
  <div class="panel-body">
419
- <div id="content"></div>
1046
+ <div id="content">
1047
+ <div id="item-header" class="item-header" hidden>
1048
+ <div class="item-header-path" id="item-path"></div>
1049
+ <div class="item-header-title" id="item-title"></div>
1050
+ <div class="item-header-meta" id="item-meta"></div>
1051
+ </div>
1052
+ <div id="content-body">
1053
+ <div class="empty-state">
1054
+ <i class="ph ph-plant"></i>
1055
+ <p>Select a file to view</p>
1056
+ </div>
1057
+ </div>
1058
+ </div>
420
1059
  </div>
421
1060
  </section>
422
1061
  </div>
423
- <div id="status"></div>
1062
+ <div id="activity-modal">
1063
+ <div id="activity-overlay"></div>
1064
+ <div id="activity-dialog">
1065
+ <div id="activity-dialog-head"><span><i class="ph ph-pulse"></i> Activity</span><button id="activity-close"
1066
+ aria-label="Close"><i class="ph ph-x"></i></button></div>
1067
+ <div id="activity-dialog-body"></div>
1068
+ </div>
1069
+ </div>
1070
+ <footer id="status">
1071
+ <span id="status-text"></span>
1072
+ <button id="activity-btn" aria-label="Activity log"><i class="ph ph-clock-counter-clockwise"></i>
1073
+ Activity</button>
1074
+ </footer>
424
1075
  <script type="module">
425
1076
  const { marked } = await import("https://cdn.jsdelivr.net/npm/marked/lib/marked.esm.js");
426
1077
  const matter = (await import("https://cdn.jsdelivr.net/npm/gray-matter@4.0.3/+esm")).default;
427
1078
  const DOMPurify = (await import("https://cdn.jsdelivr.net/npm/dompurify@3.0.9/+esm")).default;
428
1079
  const morphdom = (await import("https://cdn.jsdelivr.net/npm/morphdom@2.7.4/+esm")).default;
429
1080
 
1081
+ const emptyStateHTML = '<div class="empty-state"><i class="ph ph-plant"></i><p>Select a file to view</p></div>';
1082
+
430
1083
  function renderMarkdown(raw) {
431
- if (typeof raw !== "string") return "";
1084
+ if (typeof raw !== "string") return { html: "", meta: {} };
432
1085
  try {
433
- const { content } = matter(raw);
1086
+ const { content, data } = matter(raw);
434
1087
  const html = marked.parse(content);
435
- return DOMPurify.sanitize(html, { USE_PROFILES: { html: true } });
1088
+ return {
1089
+ html: DOMPurify.sanitize(html, { USE_PROFILES: { html: true } }),
1090
+ meta: data || {},
1091
+ };
436
1092
  } catch {
437
1093
  const escaped = raw
438
1094
  .replace(/&/g, "&amp;")
439
1095
  .replace(/</g, "&lt;")
440
1096
  .replace(/>/g, "&gt;")
441
1097
  .replace(/"/g, "&quot;");
442
- return "<pre>" + escaped + "</pre>";
1098
+ return { html: "<pre>" + escaped + "</pre>", meta: {} };
443
1099
  }
444
1100
  }
445
1101
 
446
1102
  const $ = (id) => document.getElementById(id);
447
1103
  const tokenEl = $("token");
448
1104
  const filesEl = $("files");
449
- const contentEl = $("content");
450
- const statusEl = $("status");
1105
+ const contentEl = $("content-body");
1106
+ const statusTextEl = $("status-text");
451
1107
  const backdropEl = $("backdrop");
452
1108
  const menuToggleEl = $("menu-toggle");
453
1109
 
@@ -468,7 +1124,7 @@
468
1124
  let token = localStorage.getItem("sv-token") || "";
469
1125
  tokenEl.value = token;
470
1126
 
471
- function status(msg) { statusEl.textContent = msg; }
1127
+ function status(msg) { statusTextEl.textContent = msg; }
472
1128
 
473
1129
  async function api(url, opts = {}) {
474
1130
  const res = await fetch(url, {
@@ -485,8 +1141,7 @@
485
1141
  return res;
486
1142
  }
487
1143
 
488
- const savedContributor = localStorage.getItem("sv-contributor") || "";
489
- const savedFile = localStorage.getItem("sv-file") || "";
1144
+ const selectEl = $("contributor-select");
490
1145
 
491
1146
  function getExpandedKeys() {
492
1147
  try {
@@ -559,7 +1214,7 @@
559
1214
  li.dataset.key = nodePath;
560
1215
  const row = document.createElement("div");
561
1216
  row.className = "tree-row";
562
- row.style.paddingLeft = (depth * 12 + 8) + "px";
1217
+ row.style.paddingLeft = (depth * 14 + 12) + "px";
563
1218
 
564
1219
  if (isFile) {
565
1220
  const ctime = child.__file.createdAt || "";
@@ -573,7 +1228,7 @@
573
1228
  const expanded = getExpandedKeys().has(username + ":" + nodePath);
574
1229
  const arrow = expanded ? "&#9660;" : "&#9654;";
575
1230
  if (!expanded) sub.classList.add("collapsed");
576
- 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>';
1231
+ row.innerHTML = '<span class="name"><span class="arrow">' + arrow + '</span><i class="ph ph-folder"></i> ' + key + ' <span style="opacity:0.45;font-size:11px">' + fileCount + '</span></span>';
577
1232
  row.dataset.action = "toggle";
578
1233
  row.dataset.toggleKey = username + ":" + nodePath;
579
1234
  renderTree(child, sub, username, depth + 1, nodePath);
@@ -631,12 +1286,6 @@
631
1286
  rows[idx].click();
632
1287
  }
633
1288
 
634
- function getLoadedContributorList(username) {
635
- return filesEl.querySelector(
636
- 'ul[data-contributor="' + CSS.escape(username) + '"][data-loaded="true"]'
637
- );
638
- }
639
-
640
1289
  filesEl.addEventListener("click", (e) => {
641
1290
  const row = e.target.closest(".tree-row");
642
1291
  if (!row) return;
@@ -656,26 +1305,13 @@
656
1305
  else keys.add(toggleKey);
657
1306
  saveExpandedKeys(keys);
658
1307
  }
659
- } else if (action === "contributor") {
660
- const sub = row.nextElementSibling;
661
- if (!sub) return;
662
- const collapsed = sub.classList.contains("collapsed");
663
- const keys = getExpandedKeys();
664
- const toggleKey = "contributor:" + row.dataset.contributor;
665
- if (collapsed) {
666
- sub.classList.remove("collapsed");
667
- row.querySelector(".arrow").innerHTML = "&#9660;";
668
- keys.add(toggleKey);
669
- } else {
670
- sub.classList.add("collapsed");
671
- row.querySelector(".arrow").innerHTML = "&#9654;";
672
- keys.delete(toggleKey);
673
- }
674
- saveExpandedKeys(keys);
675
1308
  }
676
1309
  });
677
1310
 
678
- async function loadContributorFiles(username, sub, opts = {}) {
1311
+ async function loadSelectedContributor(opts = {}) {
1312
+ const username = selectEl.value;
1313
+ if (!username) return;
1314
+ localStorage.setItem("sv-contributor", username);
679
1315
  const silent = !!opts.silent;
680
1316
  if (!silent) status("Loading files...");
681
1317
  const { files } = await (await api("/v1/files?prefix=" + encodeURIComponent(username + "/"))).json();
@@ -685,63 +1321,79 @@
685
1321
  path: f.path.startsWith(prefix) ? f.path.slice(prefix.length) : f.path,
686
1322
  })));
687
1323
  const tmp = document.createElement("ul");
688
- renderTree(tree, tmp, username, 1);
689
- if (sub.hasChildNodes()) {
690
- morphdom(sub, tmp, {
1324
+ renderTree(tree, tmp, username, 0);
1325
+ if (filesEl.hasChildNodes()) {
1326
+ morphdom(filesEl, tmp, {
691
1327
  childrenOnly: true,
692
1328
  getNodeKey(node) {
693
1329
  return node.dataset?.key || "";
694
1330
  },
695
1331
  });
696
1332
  } else {
697
- sub.append(...tmp.childNodes);
1333
+ filesEl.append(...tmp.childNodes);
698
1334
  }
699
1335
  markActiveRow();
700
- if (!silent) status(files.length + " file(s)");
701
- const row = sub.parentElement && sub.parentElement.querySelector(".tree-row");
702
- if (row) {
703
- const arrow = row.querySelector(".arrow").outerHTML;
704
- row.innerHTML = '<span class="name">' + arrow + '<i class="ph ph-user"></i> ' + username + ' <span style="opacity:0.5">(' + files.length + ')</span></span>';
705
- }
1336
+ status(files.length + " file(s)");
706
1337
  }
707
1338
 
708
1339
  async function loadContributors() {
709
1340
  filesEl.innerHTML = "";
710
- contentEl.innerHTML = "";
1341
+ contentEl.innerHTML = emptyStateHTML;
1342
+ $("item-header").hidden = true;
711
1343
  status("Loading contributors...");
712
1344
  const { contributors } = await (await api("/v1/contributors")).json();
713
- const expandedKeys = getExpandedKeys();
714
1345
 
1346
+ selectEl.innerHTML = "";
715
1347
  for (const b of contributors) {
716
- const li = document.createElement("li");
717
- const row = document.createElement("div");
718
- row.className = "tree-row";
719
- row.style.paddingLeft = "8px";
720
- const sub = document.createElement("ul");
721
- sub.dataset.contributor = b.username;
722
- sub.dataset.loaded = "true";
723
- const expanded = expandedKeys.has("contributor:" + b.username);
724
- if (!expanded) sub.classList.add("collapsed");
725
- const arrow = expanded ? "&#9660;" : "&#9654;";
726
- row.innerHTML = '<span class="name"><span class="arrow">' + arrow + '</span><i class="ph ph-user"></i> ' + b.username + '</span>';
727
- row.dataset.action = "contributor";
728
- row.dataset.contributor = b.username;
729
- li.appendChild(row);
730
- li.appendChild(sub);
731
- filesEl.appendChild(li);
1348
+ const opt = document.createElement("option");
1349
+ opt.value = b.username;
1350
+ opt.textContent = b.username;
1351
+ selectEl.appendChild(opt);
1352
+ }
1353
+ selectEl.disabled = contributors.length === 0;
1354
+
1355
+ const saved = localStorage.getItem("sv-contributor");
1356
+ if (saved && contributors.some((c) => c.username === saved)) {
1357
+ selectEl.value = saved;
1358
+ } else if (contributors.length > 0) {
1359
+ selectEl.value = contributors[0].username;
732
1360
  }
733
1361
 
734
- await Promise.all(contributors.map((b) => {
735
- const sub = getLoadedContributorList(b.username);
736
- return sub ? loadContributorFiles(b.username, sub, { silent: true }) : null;
737
- }));
1362
+ await loadSelectedContributor();
738
1363
 
739
- if (savedFile && savedContributor) {
1364
+ const savedFile = localStorage.getItem("sv-file");
1365
+ if (savedFile) {
740
1366
  const match = filesEl.querySelector('[data-path="' + CSS.escape(savedFile) + '"]');
741
- if (match) await loadContent(savedContributor, savedFile, match);
1367
+ if (match) await loadContent(selectEl.value, savedFile, match);
742
1368
  }
1369
+ }
1370
+
1371
+ selectEl.addEventListener("change", () => {
1372
+ contentEl.innerHTML = emptyStateHTML;
1373
+ $("item-header").hidden = true;
1374
+ localStorage.removeItem("sv-file");
1375
+ loadSelectedContributor().catch((e) => status(e.message));
1376
+ });
743
1377
 
744
- status(contributors.length + " contributor(s)");
1378
+ function escapeHtml(str) {
1379
+ return String(str).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
1380
+ }
1381
+
1382
+ function renderMeta(meta) {
1383
+ const skip = new Set(["title"]);
1384
+ const parts = [];
1385
+ for (const [key, val] of Object.entries(meta)) {
1386
+ if (skip.has(key) || val == null) continue;
1387
+ const label = '<span class="meta-label">' + escapeHtml(key) + '</span>';
1388
+ let value;
1389
+ if (Array.isArray(val)) {
1390
+ value = val.map((v) => '<span class="meta-tag">' + escapeHtml(v) + "</span>").join(" ");
1391
+ } else {
1392
+ value = '<span class="meta-value">' + escapeHtml(val) + "</span>";
1393
+ }
1394
+ parts.push('<span class="meta-field">' + label + " " + value + "</span>");
1395
+ }
1396
+ return parts.join("");
745
1397
  }
746
1398
 
747
1399
  async function loadContent(username, path, row) {
@@ -755,8 +1407,18 @@
755
1407
  const encodedPath = path.split("/").map(encodeURIComponent).join("/");
756
1408
  const res = await api("/v1/files/" + encodeURIComponent(username) + "/" + encodedPath);
757
1409
  const text = await res.text();
758
- contentEl.innerHTML = renderMarkdown(text);
759
- contentEl.parentElement.scrollTop = 0;
1410
+ const { html, meta } = renderMarkdown(text);
1411
+ const headerEl = $("item-header");
1412
+ const titleEl = $("item-title");
1413
+ const pathEl = $("item-path");
1414
+ const metaEl = $("item-meta");
1415
+ const fileName = path.split("/").pop().replace(/\.md$/, "");
1416
+ titleEl.textContent = meta.title || fileName;
1417
+ pathEl.textContent = username + ":" + path;
1418
+ metaEl.innerHTML = renderMeta(meta);
1419
+ headerEl.hidden = false;
1420
+ contentEl.innerHTML = html;
1421
+ $("content").scrollTop = 0;
760
1422
  status(path);
761
1423
  }
762
1424
 
@@ -771,16 +1433,15 @@
771
1433
  const contributorReloadTimers = new Map();
772
1434
 
773
1435
  function scheduleContributorReload(username) {
774
- if (!getLoadedContributorList(username)) return;
1436
+ if (username !== selectEl.value) return;
775
1437
 
776
1438
  const existing = contributorReloadTimers.get(username);
777
1439
  if (existing) clearTimeout(existing);
778
1440
 
779
1441
  const timer = setTimeout(() => {
780
1442
  contributorReloadTimers.delete(username);
781
- const targetUl = getLoadedContributorList(username);
782
- if (!targetUl) return;
783
- loadContributorFiles(username, targetUl, { silent: true }).catch((e) => status(e.message));
1443
+ if (username !== selectEl.value) return;
1444
+ loadSelectedContributor({ silent: true }).catch((e) => status(e.message));
784
1445
  }, 100);
785
1446
 
786
1447
  contributorReloadTimers.set(username, timer);
@@ -795,25 +1456,36 @@
795
1456
  evtSource.addEventListener("file_updated", (e) => {
796
1457
  const { contributor, path } = JSON.parse(e.data);
797
1458
  scheduleContributorReload(contributor);
798
- // If this file is currently open, reload its content
799
1459
  if (localStorage.getItem("sv-file") === path && localStorage.getItem("sv-contributor") === contributor) {
800
1460
  const encodedPath = path.split("/").map(encodeURIComponent).join("/");
801
1461
  api("/v1/files/" + encodeURIComponent(contributor) + "/" + encodedPath)
802
1462
  .then((res) => res.text())
803
- .then((text) => { contentEl.innerHTML = renderMarkdown(text); });
1463
+ .then((text) => {
1464
+ const { html, meta } = renderMarkdown(text);
1465
+ const fileName = path.split("/").pop().replace(/\.md$/, "");
1466
+ $("item-title").textContent = meta.title || fileName;
1467
+ $("item-path").textContent = contributor + ":" + path;
1468
+ $("item-meta").innerHTML = renderMeta(meta);
1469
+ $("item-header").hidden = false;
1470
+ contentEl.innerHTML = html;
1471
+ });
804
1472
  }
805
1473
  });
806
1474
 
807
1475
  evtSource.addEventListener("file_deleted", (e) => {
808
1476
  const { contributor, path } = JSON.parse(e.data);
809
1477
  scheduleContributorReload(contributor);
810
- // If this file was being viewed, clear the content
811
1478
  if (localStorage.getItem("sv-file") === path && localStorage.getItem("sv-contributor") === contributor) {
812
- contentEl.innerHTML = "";
1479
+ $("item-header").hidden = true;
1480
+ contentEl.innerHTML = emptyStateHTML;
813
1481
  status("File deleted: " + path);
814
1482
  }
815
1483
  });
816
1484
 
1485
+ evtSource.addEventListener("activity", (e) => {
1486
+ handleActivitySSE(e);
1487
+ });
1488
+
817
1489
  evtSource.onerror = () => {
818
1490
  // EventSource auto-reconnects
819
1491
  };
@@ -855,7 +1527,177 @@
855
1527
  document.addEventListener("mousemove", onMove);
856
1528
  document.addEventListener("mouseup", onUp);
857
1529
  });
1530
+
1531
+ // --- Activity modal ---
1532
+
1533
+ const activityModal = $("activity-modal");
1534
+ const activityBody = $("activity-dialog-body");
1535
+ const ACTIVITY_PAGE_SIZE = 1000;
1536
+ let activitySeenIds = new Set();
1537
+ let activityOffset = 0;
1538
+ let activityHasMore = false;
1539
+
1540
+ function openActivityModal() {
1541
+ activitySeenIds = new Set();
1542
+ activityOffset = 0;
1543
+ activityBody.innerHTML = "";
1544
+ activityModal.classList.add("open");
1545
+ loadActivityPage();
1546
+ }
1547
+
1548
+ function closeActivityModal() {
1549
+ activityModal.classList.remove("open");
1550
+ }
1551
+
1552
+ function handleActivitySSE(ev) {
1553
+ if (!activityModal.classList.contains("open")) return;
1554
+ const event = JSON.parse(ev.detail || ev.data);
1555
+ if (activitySeenIds.has(event.id)) return;
1556
+ activitySeenIds.add(event.id);
1557
+ activityOffset++;
1558
+ let list = activityBody.querySelector(".activity-list");
1559
+ if (!list) {
1560
+ const empty = activityBody.querySelector("p");
1561
+ if (empty) empty.remove();
1562
+ list = document.createElement("ul");
1563
+ list.className = "activity-list";
1564
+ activityBody.prepend(list);
1565
+ }
1566
+ list.insertAdjacentHTML("afterbegin", renderActivityItems([event]));
1567
+ }
1568
+
1569
+ $("activity-btn").addEventListener("click", openActivityModal);
1570
+ $("activity-close").addEventListener("click", closeActivityModal);
1571
+ $("activity-overlay").addEventListener("click", closeActivityModal);
1572
+
1573
+ document.addEventListener("keydown", (e) => {
1574
+ if (e.key === "Escape" && activityModal.classList.contains("open")) {
1575
+ closeActivityModal();
1576
+ }
1577
+ });
1578
+
1579
+ function formatActivityTime(iso) {
1580
+ if (!iso) return { time: "", date: "" };
1581
+ const d = new Date(iso);
1582
+ const hh = String(d.getHours()).padStart(2, "0");
1583
+ const mm = String(d.getMinutes()).padStart(2, "0");
1584
+ const ss = String(d.getSeconds()).padStart(2, "0");
1585
+ const ms = String(d.getMilliseconds()).padStart(3, "0");
1586
+ const mon = d.toLocaleString(undefined, { month: "short" });
1587
+ const day = d.getDate();
1588
+ const yr = d.getFullYear();
1589
+ const now = new Date();
1590
+ const dateStr = yr === now.getFullYear()
1591
+ ? mon + " " + day
1592
+ : mon + " " + day + ", " + yr;
1593
+ return {
1594
+ time: hh + ":" + mm + ":" + ss + "." + ms,
1595
+ date: dateStr,
1596
+ };
1597
+ }
1598
+
1599
+ function parseDetail(raw) {
1600
+ if (!raw) return null;
1601
+ try { return JSON.parse(raw); } catch { return null; }
1602
+ }
1603
+
1604
+ function esc(str) {
1605
+ return String(str)
1606
+ .replace(/&/g, "&amp;")
1607
+ .replace(/</g, "&lt;")
1608
+ .replace(/>/g, "&gt;")
1609
+ .replace(/"/g, "&quot;");
1610
+ }
1611
+
1612
+ function formatDetail(detail) {
1613
+ if (!detail || typeof detail !== "object") return "";
1614
+ return Object.entries(detail)
1615
+ .filter(([k]) => k !== "diff" && k !== "diff_truncated")
1616
+ .map(([k, v]) => k + "=" + v)
1617
+ .join(" ");
1618
+ }
1619
+
1620
+ function renderDiff(raw) {
1621
+ if (!raw) return "";
1622
+ const lines = raw.split("\n");
1623
+ const htmlLines = lines.map((line) => {
1624
+ const escaped = esc(line);
1625
+ if (line.startsWith("@@")) return '<span class="diff-hunk">' + escaped + '</span>';
1626
+ if (line.startsWith("+")) return '<span class="diff-add">' + escaped + '</span>';
1627
+ if (line.startsWith("-")) return '<span class="diff-del">' + escaped + '</span>';
1628
+ return escaped;
1629
+ });
1630
+ return htmlLines.join("\n");
1631
+ }
1632
+
1633
+ function renderActivityItems(events) {
1634
+ return events.map((ev) => {
1635
+ const { time, date } = formatActivityTime(ev.created_at);
1636
+ const parsed = parseDetail(ev.detail);
1637
+ const detail = formatDetail(parsed);
1638
+ const diff = parsed?.diff || null;
1639
+ const truncated = parsed?.diff_truncated || false;
1640
+
1641
+ let content = '<span class="activity-action">' + esc(ev.action) + '</span>' +
1642
+ ' <span class="activity-by">by</span>' +
1643
+ ' <span class="activity-contributor">' + esc(ev.contributor) + '</span>';
1644
+ if (detail) content += '<div class="activity-detail">' + esc(detail) + '</div>';
1645
+ if (diff) {
1646
+ content += '<div class="activity-diff">' + renderDiff(diff) + '</div>';
1647
+ if (truncated) content += '<span class="activity-diff-truncated">diff truncated</span>';
1648
+ }
1649
+
1650
+ return '<li class="activity-item">' +
1651
+ '<div class="activity-time-col">' +
1652
+ '<div class="activity-time">' + time + '</div>' +
1653
+ '<div class="activity-date">' + date + '</div>' +
1654
+ '</div>' +
1655
+ '<div class="activity-dot-col">' +
1656
+ '<div class="activity-dot"></div>' +
1657
+ '<div class="activity-line"></div>' +
1658
+ '</div>' +
1659
+ '<div class="activity-content">' + content + '</div>' +
1660
+ '</li>';
1661
+ }).join("");
1662
+ }
1663
+
1664
+ async function loadActivityPage() {
1665
+ const existingBtn = activityBody.querySelector("#activity-load-more");
1666
+ if (existingBtn) existingBtn.remove();
1667
+
1668
+ const limit = ACTIVITY_PAGE_SIZE;
1669
+ const offset = activityOffset;
1670
+ const url = "/v1/activity?limit=" + (limit + 1) + "&offset=" + offset;
1671
+ const { events } = await (await api(url)).json();
1672
+
1673
+ activityHasMore = events.length > limit;
1674
+ const page = activityHasMore ? events.slice(0, limit) : events;
1675
+ for (const ev of page) activitySeenIds.add(ev.id);
1676
+ activityOffset += page.length;
1677
+
1678
+ let list = activityBody.querySelector(".activity-list");
1679
+ if (!list) {
1680
+ list = document.createElement("ul");
1681
+ list.className = "activity-list";
1682
+ activityBody.appendChild(list);
1683
+ }
1684
+
1685
+ if (activitySeenIds.size === 0) {
1686
+ activityBody.innerHTML = '<p style="padding:16px;color:var(--text-tertiary);font-size:13px">No activity yet.</p>';
1687
+ return;
1688
+ }
1689
+
1690
+ list.insertAdjacentHTML("beforeend", renderActivityItems(page));
1691
+
1692
+ if (activityHasMore) {
1693
+ const btn = document.createElement("button");
1694
+ btn.id = "activity-load-more";
1695
+ btn.textContent = "Load more";
1696
+ btn.addEventListener("click", () => loadActivityPage());
1697
+ activityBody.appendChild(btn);
1698
+ }
1699
+ }
858
1700
  </script>
859
1701
  </body>
860
1702
 
861
- </html>
1703
+ </html>