@hatk/hatk 0.0.1-alpha.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 (109) hide show
  1. package/dist/backfill.d.ts +11 -0
  2. package/dist/backfill.d.ts.map +1 -0
  3. package/dist/backfill.js +328 -0
  4. package/dist/car.d.ts +5 -0
  5. package/dist/car.d.ts.map +1 -0
  6. package/dist/car.js +52 -0
  7. package/dist/cbor.d.ts +7 -0
  8. package/dist/cbor.d.ts.map +1 -0
  9. package/dist/cbor.js +89 -0
  10. package/dist/cid.d.ts +4 -0
  11. package/dist/cid.d.ts.map +1 -0
  12. package/dist/cid.js +39 -0
  13. package/dist/cli.d.ts +3 -0
  14. package/dist/cli.d.ts.map +1 -0
  15. package/dist/cli.js +1663 -0
  16. package/dist/config.d.ts +47 -0
  17. package/dist/config.d.ts.map +1 -0
  18. package/dist/config.js +43 -0
  19. package/dist/db.d.ts +134 -0
  20. package/dist/db.d.ts.map +1 -0
  21. package/dist/db.js +1361 -0
  22. package/dist/feeds.d.ts +95 -0
  23. package/dist/feeds.d.ts.map +1 -0
  24. package/dist/feeds.js +144 -0
  25. package/dist/fts.d.ts +20 -0
  26. package/dist/fts.d.ts.map +1 -0
  27. package/dist/fts.js +762 -0
  28. package/dist/hydrate.d.ts +23 -0
  29. package/dist/hydrate.d.ts.map +1 -0
  30. package/dist/hydrate.js +75 -0
  31. package/dist/indexer.d.ts +14 -0
  32. package/dist/indexer.d.ts.map +1 -0
  33. package/dist/indexer.js +316 -0
  34. package/dist/labels.d.ts +29 -0
  35. package/dist/labels.d.ts.map +1 -0
  36. package/dist/labels.js +111 -0
  37. package/dist/lex-types.d.ts +401 -0
  38. package/dist/lex-types.d.ts.map +1 -0
  39. package/dist/lex-types.js +4 -0
  40. package/dist/lexicon-resolve.d.ts +14 -0
  41. package/dist/lexicon-resolve.d.ts.map +1 -0
  42. package/dist/lexicon-resolve.js +280 -0
  43. package/dist/logger.d.ts +4 -0
  44. package/dist/logger.d.ts.map +1 -0
  45. package/dist/logger.js +23 -0
  46. package/dist/main.d.ts +3 -0
  47. package/dist/main.d.ts.map +1 -0
  48. package/dist/main.js +148 -0
  49. package/dist/mst.d.ts +6 -0
  50. package/dist/mst.d.ts.map +1 -0
  51. package/dist/mst.js +30 -0
  52. package/dist/oauth/client.d.ts +16 -0
  53. package/dist/oauth/client.d.ts.map +1 -0
  54. package/dist/oauth/client.js +54 -0
  55. package/dist/oauth/crypto.d.ts +28 -0
  56. package/dist/oauth/crypto.d.ts.map +1 -0
  57. package/dist/oauth/crypto.js +101 -0
  58. package/dist/oauth/db.d.ts +47 -0
  59. package/dist/oauth/db.d.ts.map +1 -0
  60. package/dist/oauth/db.js +139 -0
  61. package/dist/oauth/discovery.d.ts +22 -0
  62. package/dist/oauth/discovery.d.ts.map +1 -0
  63. package/dist/oauth/discovery.js +50 -0
  64. package/dist/oauth/dpop.d.ts +11 -0
  65. package/dist/oauth/dpop.d.ts.map +1 -0
  66. package/dist/oauth/dpop.js +56 -0
  67. package/dist/oauth/hooks.d.ts +10 -0
  68. package/dist/oauth/hooks.d.ts.map +1 -0
  69. package/dist/oauth/hooks.js +40 -0
  70. package/dist/oauth/server.d.ts +86 -0
  71. package/dist/oauth/server.d.ts.map +1 -0
  72. package/dist/oauth/server.js +572 -0
  73. package/dist/opengraph.d.ts +34 -0
  74. package/dist/opengraph.d.ts.map +1 -0
  75. package/dist/opengraph.js +198 -0
  76. package/dist/schema.d.ts +51 -0
  77. package/dist/schema.d.ts.map +1 -0
  78. package/dist/schema.js +358 -0
  79. package/dist/seed.d.ts +29 -0
  80. package/dist/seed.d.ts.map +1 -0
  81. package/dist/seed.js +86 -0
  82. package/dist/server.d.ts +6 -0
  83. package/dist/server.d.ts.map +1 -0
  84. package/dist/server.js +1024 -0
  85. package/dist/setup.d.ts +8 -0
  86. package/dist/setup.d.ts.map +1 -0
  87. package/dist/setup.js +48 -0
  88. package/dist/test-browser.d.ts +14 -0
  89. package/dist/test-browser.d.ts.map +1 -0
  90. package/dist/test-browser.js +26 -0
  91. package/dist/test.d.ts +47 -0
  92. package/dist/test.d.ts.map +1 -0
  93. package/dist/test.js +256 -0
  94. package/dist/views.d.ts +40 -0
  95. package/dist/views.d.ts.map +1 -0
  96. package/dist/views.js +178 -0
  97. package/dist/vite-plugin.d.ts +5 -0
  98. package/dist/vite-plugin.d.ts.map +1 -0
  99. package/dist/vite-plugin.js +86 -0
  100. package/dist/xrpc-client.d.ts +18 -0
  101. package/dist/xrpc-client.d.ts.map +1 -0
  102. package/dist/xrpc-client.js +54 -0
  103. package/dist/xrpc.d.ts +53 -0
  104. package/dist/xrpc.d.ts.map +1 -0
  105. package/dist/xrpc.js +139 -0
  106. package/fonts/Inter-Regular.woff +0 -0
  107. package/package.json +41 -0
  108. package/public/admin-auth.js +320 -0
  109. package/public/admin.html +2166 -0
@@ -0,0 +1,2166 @@
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.0, viewport-fit=cover" />
6
+ <title>Admin</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
8
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
9
+ <link
10
+ href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=Figtree:wght@400;500;600;700&display=swap"
11
+ rel="stylesheet"
12
+ />
13
+ <style>
14
+ *,
15
+ *::before,
16
+ *::after {
17
+ box-sizing: border-box;
18
+ margin: 0;
19
+ padding: 0;
20
+ }
21
+
22
+ :root {
23
+ --bg: #f5f2ee;
24
+ --bg-recessed: #eae6e1;
25
+ --surface: #ffffff;
26
+ --border: #e2ddd7;
27
+ --border-hover: #cdc6be;
28
+ --text: #1c1917;
29
+ --text-2: #78716c;
30
+ --text-3: #a8a29e;
31
+ --accent: #f06449;
32
+ --accent-hover: #de5338;
33
+ --green: #16a34a;
34
+ --green-bg: #f0fdf4;
35
+ --yellow: #ca8a04;
36
+ --yellow-bg: #fefce8;
37
+ --red: #dc2626;
38
+ --red-bg: #fef2f2;
39
+ --label-color: #4f46e5;
40
+ --label-bg: #eef2ff;
41
+ --label-border: #c7d2fe;
42
+ --font: 'Figtree', system-ui, -apple-system, sans-serif;
43
+ --mono: 'DM Mono', 'Menlo', monospace;
44
+ --shadow-sm: 0 1px 2px rgba(28, 25, 23, 0.04), 0 1px 3px rgba(28, 25, 23, 0.06);
45
+ --shadow-md: 0 2px 8px rgba(28, 25, 23, 0.07), 0 1px 3px rgba(28, 25, 23, 0.05);
46
+ --shadow-lg: 0 4px 16px rgba(28, 25, 23, 0.1);
47
+ }
48
+
49
+ html {
50
+ font-size: 16px;
51
+ }
52
+
53
+ body {
54
+ font-family: var(--font);
55
+ background: var(--bg);
56
+ color: var(--text);
57
+ min-height: 100vh;
58
+ min-height: 100dvh;
59
+ line-height: 1.6;
60
+ font-weight: 400;
61
+ -webkit-font-smoothing: antialiased;
62
+ }
63
+
64
+ /* ── Login ── */
65
+ #login-screen {
66
+ display: flex;
67
+ align-items: center;
68
+ justify-content: center;
69
+ min-height: 100vh;
70
+ min-height: 100dvh;
71
+ padding: 1.5rem;
72
+ }
73
+
74
+ .login-card {
75
+ background: var(--surface);
76
+ border-radius: 16px;
77
+ padding: 2.5rem 2rem;
78
+ width: 380px;
79
+ max-width: 100%;
80
+ display: flex;
81
+ flex-direction: column;
82
+ gap: 1.25rem;
83
+ box-shadow: var(--shadow-lg);
84
+ }
85
+
86
+ .login-header {
87
+ display: flex;
88
+ align-items: center;
89
+ gap: 0.6rem;
90
+ }
91
+ .login-header .logo-mark {
92
+ width: 28px;
93
+ height: 28px;
94
+ background: var(--accent);
95
+ border-radius: 8px;
96
+ flex-shrink: 0;
97
+ }
98
+ .login-card h1 {
99
+ font-size: 1.5rem;
100
+ font-weight: 700;
101
+ }
102
+ .login-card p {
103
+ font-size: 0.9375rem;
104
+ color: var(--text-2);
105
+ margin-top: -0.5rem;
106
+ }
107
+
108
+ .field {
109
+ display: flex;
110
+ flex-direction: column;
111
+ gap: 0.4rem;
112
+ }
113
+ .field label {
114
+ font-size: 0.8rem;
115
+ font-weight: 600;
116
+ color: var(--text-2);
117
+ }
118
+
119
+ input[type='text'],
120
+ .search-input {
121
+ font-family: var(--font);
122
+ font-size: 0.9375rem;
123
+ font-weight: 400;
124
+ background: var(--surface);
125
+ border: 1.5px solid var(--border);
126
+ color: var(--text);
127
+ padding: 0.6rem 0.85rem;
128
+ border-radius: 10px;
129
+ outline: none;
130
+ transition:
131
+ border-color 0.2s,
132
+ box-shadow 0.2s;
133
+ }
134
+ input:focus,
135
+ .search-input:focus {
136
+ border-color: var(--accent);
137
+ box-shadow: 0 0 0 3px rgba(240, 100, 73, 0.12);
138
+ }
139
+ input::placeholder,
140
+ .search-input::placeholder {
141
+ color: var(--text-3);
142
+ }
143
+
144
+ /* ── Buttons ── */
145
+ .btn {
146
+ font-family: var(--font);
147
+ font-size: 0.875rem;
148
+ font-weight: 600;
149
+ padding: 0.5rem 1rem;
150
+ border: 1.5px solid var(--border);
151
+ border-radius: 10px;
152
+ background: var(--surface);
153
+ color: var(--text);
154
+ cursor: pointer;
155
+ transition: all 0.15s;
156
+ white-space: nowrap;
157
+ line-height: 1.4;
158
+ }
159
+ .btn:hover {
160
+ border-color: var(--border-hover);
161
+ transform: translateY(-1px);
162
+ box-shadow: var(--shadow-sm);
163
+ }
164
+ .btn:active {
165
+ transform: translateY(0);
166
+ box-shadow: none;
167
+ }
168
+ .btn:disabled {
169
+ opacity: 0.4;
170
+ cursor: not-allowed;
171
+ transform: none;
172
+ box-shadow: none;
173
+ }
174
+
175
+ .btn-primary {
176
+ background: var(--accent);
177
+ color: #fff;
178
+ border-color: var(--accent);
179
+ }
180
+ .btn-primary:hover {
181
+ background: var(--accent-hover);
182
+ border-color: var(--accent-hover);
183
+ }
184
+
185
+ .btn-danger {
186
+ color: var(--red);
187
+ border-color: #fecaca;
188
+ background: var(--red-bg);
189
+ }
190
+ .btn-danger:hover {
191
+ background: var(--red);
192
+ color: #fff;
193
+ border-color: var(--red);
194
+ }
195
+
196
+ .btn-success {
197
+ color: var(--green);
198
+ border-color: #bbf7d0;
199
+ background: var(--green-bg);
200
+ }
201
+ .btn-success:hover {
202
+ background: var(--green);
203
+ color: #fff;
204
+ border-color: var(--green);
205
+ }
206
+
207
+ .btn-sm {
208
+ padding: 0.25rem 0.6rem;
209
+ font-size: 0.8rem;
210
+ border-radius: 8px;
211
+ }
212
+
213
+ .btn-link {
214
+ font-family: var(--font);
215
+ font-size: 0.875rem;
216
+ font-weight: 500;
217
+ color: var(--text-2);
218
+ background: none;
219
+ border: none;
220
+ cursor: pointer;
221
+ padding: 0;
222
+ transition: color 0.15s;
223
+ }
224
+ .btn-link:hover {
225
+ color: var(--text);
226
+ }
227
+
228
+ /* ── Topbar ── */
229
+ .topbar {
230
+ position: sticky;
231
+ top: 0;
232
+ z-index: 100;
233
+ background: rgba(245, 242, 238, 0.8);
234
+ backdrop-filter: blur(16px);
235
+ -webkit-backdrop-filter: blur(16px);
236
+ border-bottom: 1px solid rgba(0, 0, 0, 0.06);
237
+ }
238
+
239
+ .topbar-inner {
240
+ max-width: 800px;
241
+ margin: 0 auto;
242
+ padding: 0 1.5rem;
243
+ height: 56px;
244
+ display: flex;
245
+ align-items: center;
246
+ justify-content: space-between;
247
+ }
248
+
249
+ .logo {
250
+ font-size: 1.125rem;
251
+ font-weight: 700;
252
+ display: flex;
253
+ align-items: center;
254
+ gap: 0.5rem;
255
+ color: var(--text);
256
+ }
257
+ .logo .logo-mark {
258
+ width: 24px;
259
+ height: 24px;
260
+ background: var(--accent);
261
+ border-radius: 7px;
262
+ flex-shrink: 0;
263
+ }
264
+
265
+ .topbar-right {
266
+ display: flex;
267
+ align-items: center;
268
+ gap: 1rem;
269
+ }
270
+
271
+ .viewer-did {
272
+ font-family: var(--mono);
273
+ font-size: 0.75rem;
274
+ color: var(--text-3);
275
+ }
276
+
277
+ /* ── App shell ── */
278
+ #app {
279
+ display: none;
280
+ }
281
+ #app.visible {
282
+ display: flex;
283
+ flex-direction: column;
284
+ min-height: 100vh;
285
+ min-height: 100dvh;
286
+ }
287
+
288
+ .container {
289
+ max-width: 800px;
290
+ margin: 0 auto;
291
+ padding: 1.5rem;
292
+ padding-bottom: 3rem;
293
+ flex: 1;
294
+ width: 100%;
295
+ }
296
+
297
+ /* ── Tabs (desktop pill track) ── */
298
+ .tabs {
299
+ display: inline-flex;
300
+ gap: 0.2rem;
301
+ background: var(--bg-recessed);
302
+ padding: 0.3rem;
303
+ border-radius: 12px;
304
+ margin-bottom: 2rem;
305
+ }
306
+ .tab {
307
+ padding: 0.5rem 1.1rem;
308
+ border-radius: 10px;
309
+ font-family: var(--font);
310
+ font-size: 0.9375rem;
311
+ font-weight: 600;
312
+ color: var(--text-2);
313
+ background: transparent;
314
+ border: none;
315
+ cursor: pointer;
316
+ transition: all 0.2s;
317
+ }
318
+ .tab:hover {
319
+ color: var(--text);
320
+ }
321
+ .tab.active {
322
+ background: var(--surface);
323
+ color: var(--text);
324
+ box-shadow: var(--shadow-sm);
325
+ }
326
+
327
+ .tab-panel {
328
+ display: none;
329
+ animation: fadeIn 0.2s ease;
330
+ }
331
+ .tab-panel.active {
332
+ display: block;
333
+ }
334
+
335
+ /* ── Bottom nav (mobile) ── */
336
+ .bottom-nav {
337
+ display: none;
338
+ position: fixed;
339
+ bottom: 0;
340
+ left: 0;
341
+ right: 0;
342
+ z-index: 100;
343
+ background: rgba(245, 242, 238, 0.85);
344
+ backdrop-filter: blur(16px);
345
+ -webkit-backdrop-filter: blur(16px);
346
+ border-top: 1px solid rgba(0, 0, 0, 0.06);
347
+ padding: 0.5rem 1rem;
348
+ padding-bottom: calc(0.5rem + env(safe-area-inset-bottom, 0px));
349
+ }
350
+ .bottom-nav-track {
351
+ display: flex;
352
+ gap: 0.2rem;
353
+ background: var(--bg-recessed);
354
+ border-radius: 12px;
355
+ padding: 0.3rem;
356
+ max-width: 360px;
357
+ margin: 0 auto;
358
+ }
359
+ .bnav-btn {
360
+ flex: 1;
361
+ padding: 0.55rem;
362
+ border-radius: 10px;
363
+ font-family: var(--font);
364
+ font-size: 0.8rem;
365
+ font-weight: 600;
366
+ color: var(--text-2);
367
+ background: transparent;
368
+ border: none;
369
+ cursor: pointer;
370
+ transition: all 0.2s;
371
+ text-align: center;
372
+ }
373
+ .bnav-btn.active {
374
+ background: var(--surface);
375
+ color: var(--text);
376
+ box-shadow: var(--shadow-sm);
377
+ }
378
+
379
+ /* ── Section label ── */
380
+ .section-label {
381
+ font-size: 0.75rem;
382
+ font-weight: 700;
383
+ text-transform: uppercase;
384
+ letter-spacing: 0.08em;
385
+ color: var(--text-3);
386
+ margin-bottom: 0.75rem;
387
+ }
388
+
389
+ /* ── Overview cards ── */
390
+ .cards-grid {
391
+ display: grid;
392
+ grid-template-columns: repeat(auto-fill, minmax(155px, 1fr));
393
+ gap: 0.6rem;
394
+ margin-bottom: 2rem;
395
+ }
396
+
397
+ .stat-card {
398
+ background: var(--surface);
399
+ border-radius: 14px;
400
+ padding: 1rem 1.1rem;
401
+ box-shadow: var(--shadow-sm);
402
+ transition:
403
+ transform 0.2s,
404
+ box-shadow 0.2s;
405
+ }
406
+ .stat-card:hover {
407
+ transform: translateY(-2px);
408
+ box-shadow: var(--shadow-md);
409
+ }
410
+
411
+ .stat-card .stat-label {
412
+ font-size: 0.7rem;
413
+ font-weight: 600;
414
+ text-transform: uppercase;
415
+ letter-spacing: 0.06em;
416
+ color: var(--text-3);
417
+ margin-bottom: 0.3rem;
418
+ word-break: break-word;
419
+ line-height: 1.4;
420
+ }
421
+ .stat-card .stat-value {
422
+ font-size: 1.75rem;
423
+ font-weight: 700;
424
+ color: var(--text);
425
+ letter-spacing: -0.02em;
426
+ white-space: nowrap;
427
+ overflow: hidden;
428
+ text-overflow: ellipsis;
429
+ }
430
+ .stat-card .stat-value.green {
431
+ color: var(--green);
432
+ }
433
+ .stat-card .stat-value.yellow {
434
+ color: var(--yellow);
435
+ }
436
+ .stat-card .stat-value.red {
437
+ color: var(--red);
438
+ }
439
+
440
+ /* ── Collection list ── */
441
+ .collection-list {
442
+ background: var(--surface);
443
+ border-radius: 14px;
444
+ box-shadow: var(--shadow-sm);
445
+ overflow: hidden;
446
+ margin-bottom: 2rem;
447
+ }
448
+ .collection-row {
449
+ display: flex;
450
+ justify-content: space-between;
451
+ align-items: center;
452
+ padding: 0.6rem 1rem;
453
+ border-bottom: 1px solid var(--border);
454
+ }
455
+ .collection-row:last-child {
456
+ border-bottom: none;
457
+ }
458
+ .collection-row:hover {
459
+ background: #faf8f6;
460
+ }
461
+ .collection-name {
462
+ font-family: var(--mono);
463
+ font-size: 0.8rem;
464
+ color: var(--text-2);
465
+ }
466
+ .collection-count {
467
+ font-weight: 700;
468
+ font-size: 0.9375rem;
469
+ color: var(--text);
470
+ font-variant-numeric: tabular-nums;
471
+ }
472
+
473
+ .quick-actions {
474
+ display: flex;
475
+ gap: 0.5rem;
476
+ flex-wrap: wrap;
477
+ }
478
+
479
+ /* ── Search ── */
480
+ .search-bar {
481
+ display: flex;
482
+ gap: 0.5rem;
483
+ margin-bottom: 1.25rem;
484
+ }
485
+ .search-bar .search-input {
486
+ flex: 1;
487
+ }
488
+
489
+ /* ── Card wrapper ── */
490
+ .card {
491
+ background: var(--surface);
492
+ border-radius: 14px;
493
+ box-shadow: var(--shadow-sm);
494
+ overflow: hidden;
495
+ }
496
+
497
+ /* ── Table ── */
498
+ .data-table {
499
+ width: 100%;
500
+ border-collapse: collapse;
501
+ }
502
+
503
+ .data-table th {
504
+ font-size: 0.7rem;
505
+ font-weight: 700;
506
+ letter-spacing: 0.08em;
507
+ text-transform: uppercase;
508
+ color: var(--text-3);
509
+ text-align: left;
510
+ padding: 0.75rem 1rem;
511
+ border-bottom: 1px solid var(--border);
512
+ }
513
+ .data-table td {
514
+ padding: 0.7rem 1rem;
515
+ border-bottom: 1px solid var(--border);
516
+ vertical-align: middle;
517
+ font-size: 0.9375rem;
518
+ }
519
+ .data-table tbody tr:last-child td {
520
+ border-bottom: none;
521
+ }
522
+ .data-table tbody tr:hover td {
523
+ background: #faf8f6;
524
+ }
525
+
526
+ .data-table .did-cell {
527
+ font-family: var(--mono);
528
+ font-size: 0.75rem;
529
+ color: var(--text-2);
530
+ max-width: 180px;
531
+ overflow: hidden;
532
+ text-overflow: ellipsis;
533
+ white-space: nowrap;
534
+ cursor: pointer;
535
+ }
536
+ .data-table .did-cell:hover {
537
+ color: var(--accent);
538
+ }
539
+ .rcm-did {
540
+ cursor: pointer;
541
+ }
542
+ .rcm-did:hover {
543
+ color: var(--accent) !important;
544
+ }
545
+ .data-table .handle-cell {
546
+ font-weight: 600;
547
+ }
548
+
549
+ /* ── Actions dropdown ── */
550
+ .actions-wrap {
551
+ position: relative;
552
+ }
553
+
554
+ .actions-trigger {
555
+ font-family: var(--font);
556
+ font-size: 1.1rem;
557
+ font-weight: 700;
558
+ width: 32px;
559
+ height: 32px;
560
+ display: flex;
561
+ align-items: center;
562
+ justify-content: center;
563
+ background: none;
564
+ border: 1.5px solid var(--border);
565
+ border-radius: 8px;
566
+ color: var(--text-2);
567
+ cursor: pointer;
568
+ transition: all 0.15s;
569
+ line-height: 1;
570
+ letter-spacing: 2px;
571
+ }
572
+ .actions-trigger:hover {
573
+ border-color: var(--border-hover);
574
+ color: var(--text);
575
+ background: var(--bg);
576
+ }
577
+
578
+ .actions-menu {
579
+ display: none;
580
+ position: fixed;
581
+ background: var(--surface);
582
+ border-radius: 12px;
583
+ box-shadow: var(--shadow-lg);
584
+ min-width: 160px;
585
+ z-index: 200;
586
+ overflow: hidden;
587
+ border: 1px solid var(--border);
588
+ animation: fadeIn 0.12s ease;
589
+ }
590
+ .actions-menu.open {
591
+ display: block;
592
+ }
593
+
594
+ .actions-menu button {
595
+ font-family: var(--font);
596
+ font-size: 0.875rem;
597
+ font-weight: 500;
598
+ width: 100%;
599
+ text-align: left;
600
+ padding: 0.6rem 0.9rem;
601
+ background: none;
602
+ border: none;
603
+ color: var(--text);
604
+ cursor: pointer;
605
+ transition: background 0.1s;
606
+ }
607
+ .actions-menu button:hover {
608
+ background: var(--bg);
609
+ }
610
+ .actions-menu button.danger {
611
+ color: var(--red);
612
+ }
613
+ .actions-menu button.danger:hover {
614
+ background: var(--red-bg);
615
+ }
616
+ .actions-menu button.success {
617
+ color: var(--green);
618
+ }
619
+ .actions-menu button.success:hover {
620
+ background: var(--green-bg);
621
+ }
622
+ .actions-menu .menu-sep {
623
+ height: 1px;
624
+ background: var(--border);
625
+ margin: 0.2rem 0;
626
+ }
627
+
628
+ /* ── Status pills ── */
629
+ .status {
630
+ display: inline-flex;
631
+ align-items: center;
632
+ gap: 0.3rem;
633
+ font-size: 0.8rem;
634
+ font-weight: 600;
635
+ padding: 0.2rem 0.65rem;
636
+ border-radius: 20px;
637
+ }
638
+ .status::before {
639
+ content: '';
640
+ width: 6px;
641
+ height: 6px;
642
+ border-radius: 50%;
643
+ flex-shrink: 0;
644
+ }
645
+ .status-active {
646
+ color: #15803d;
647
+ background: var(--green-bg);
648
+ }
649
+ .status-active::before {
650
+ background: var(--green);
651
+ }
652
+ .status-pending {
653
+ color: #a16207;
654
+ background: var(--yellow-bg);
655
+ }
656
+ .status-pending::before {
657
+ background: var(--yellow);
658
+ animation: pulse 2s ease-in-out infinite;
659
+ }
660
+ .status-failed {
661
+ color: var(--red);
662
+ background: var(--red-bg);
663
+ }
664
+ .status-failed::before {
665
+ background: var(--red);
666
+ }
667
+ .status-takendown {
668
+ color: var(--red);
669
+ background: var(--red-bg);
670
+ }
671
+ .status-takendown::before {
672
+ background: var(--red);
673
+ }
674
+
675
+ /* ── Labels ── */
676
+ .label-tag {
677
+ display: inline-flex;
678
+ align-items: center;
679
+ gap: 0.2rem;
680
+ font-family: var(--mono);
681
+ font-size: 0.72rem;
682
+ font-weight: 500;
683
+ padding: 0.15rem 0.5rem;
684
+ background: var(--label-bg);
685
+ border: 1px solid var(--label-border);
686
+ color: var(--label-color);
687
+ border-radius: 6px;
688
+ line-height: 1.4;
689
+ }
690
+ .label-tag .remove-x {
691
+ cursor: pointer;
692
+ color: var(--text-3);
693
+ font-size: 0.85rem;
694
+ line-height: 1;
695
+ opacity: 0;
696
+ transition:
697
+ opacity 0.1s,
698
+ color 0.1s;
699
+ }
700
+ .label-tag:hover .remove-x {
701
+ opacity: 1;
702
+ }
703
+ .label-tag .remove-x:hover {
704
+ color: var(--red);
705
+ }
706
+
707
+ .label-select {
708
+ font-family: var(--font);
709
+ font-size: 0.8rem;
710
+ font-weight: 500;
711
+ background: var(--surface);
712
+ border: 1.5px solid var(--border);
713
+ color: var(--text-2);
714
+ padding: 0.25rem 0.5rem;
715
+ border-radius: 8px;
716
+ outline: none;
717
+ cursor: pointer;
718
+ }
719
+ .label-select:focus {
720
+ border-color: var(--accent);
721
+ box-shadow: 0 0 0 3px rgba(240, 100, 73, 0.12);
722
+ }
723
+
724
+ /* ── Toolbar ── */
725
+ .toolbar {
726
+ display: flex;
727
+ gap: 0.5rem;
728
+ margin-bottom: 1.25rem;
729
+ flex-wrap: wrap;
730
+ align-items: center;
731
+ }
732
+ .toolbar .search-input {
733
+ flex: 1;
734
+ min-width: 200px;
735
+ }
736
+
737
+ .filter-select {
738
+ font-family: var(--font);
739
+ font-size: 0.9375rem;
740
+ font-weight: 500;
741
+ background: var(--surface);
742
+ border: 1.5px solid var(--border);
743
+ color: var(--text);
744
+ padding: 0.55rem 0.85rem;
745
+ border-radius: 10px;
746
+ outline: none;
747
+ cursor: pointer;
748
+ }
749
+ .filter-select:focus {
750
+ border-color: var(--accent);
751
+ box-shadow: 0 0 0 3px rgba(240, 100, 73, 0.12);
752
+ }
753
+
754
+ .add-repos-bar {
755
+ display: flex;
756
+ gap: 0.5rem;
757
+ margin-bottom: 1.25rem;
758
+ }
759
+ .add-repos-bar .search-input {
760
+ flex: 1;
761
+ }
762
+
763
+ /* ── Pagination ── */
764
+ .pagination {
765
+ display: flex;
766
+ align-items: center;
767
+ justify-content: space-between;
768
+ padding: 0.75rem 1rem;
769
+ border-top: 1px solid var(--border);
770
+ font-size: 0.8rem;
771
+ color: var(--text-2);
772
+ }
773
+ .pagination-buttons {
774
+ display: flex;
775
+ gap: 0.3rem;
776
+ }
777
+
778
+ /* ── Empty / loading ── */
779
+ .empty-state {
780
+ text-align: center;
781
+ padding: 3rem 2rem;
782
+ color: var(--text-3);
783
+ font-size: 1rem;
784
+ }
785
+
786
+ /* ── Schema ── */
787
+ .schema-pre {
788
+ font-family: var(--mono);
789
+ font-size: 0.8rem;
790
+ line-height: 1.6;
791
+ padding: 1rem;
792
+ margin: 0;
793
+ background: var(--bg-recessed);
794
+ border-radius: 0 0 6px 6px;
795
+ white-space: pre-wrap;
796
+ word-break: break-word;
797
+ color: var(--text);
798
+ overflow-x: auto;
799
+ }
800
+ .schema-section {
801
+ margin-bottom: 1.5rem;
802
+ }
803
+ .loading {
804
+ color: var(--text-3);
805
+ font-size: 0.9375rem;
806
+ padding: 2rem;
807
+ text-align: center;
808
+ }
809
+ .loading::after {
810
+ content: '';
811
+ animation: dots 1.2s infinite steps(4);
812
+ }
813
+
814
+ .result-count {
815
+ font-size: 0.75rem;
816
+ font-weight: 600;
817
+ color: var(--text-3);
818
+ padding: 0.6rem 1rem;
819
+ border-bottom: 1px solid var(--border);
820
+ }
821
+
822
+ /* ── Record detail ── */
823
+ .record-card {
824
+ border-bottom: 1px solid var(--border);
825
+ padding: 0.85rem 1rem;
826
+ transition: background 0.1s;
827
+ }
828
+ .record-card:last-child {
829
+ border-bottom: none;
830
+ }
831
+ .record-card:hover {
832
+ background: #faf8f6;
833
+ }
834
+
835
+ .record-header {
836
+ display: flex;
837
+ align-items: flex-start;
838
+ justify-content: space-between;
839
+ gap: 0.75rem;
840
+ }
841
+ .record-meta {
842
+ flex: 1;
843
+ min-width: 0;
844
+ }
845
+
846
+ .record-uri {
847
+ font-family: var(--mono);
848
+ font-size: 0.72rem;
849
+ color: var(--text-2);
850
+ overflow: hidden;
851
+ text-overflow: ellipsis;
852
+ white-space: nowrap;
853
+ }
854
+ .record-summary {
855
+ font-size: 0.9375rem;
856
+ color: var(--text);
857
+ margin-top: 0.15rem;
858
+ overflow: hidden;
859
+ text-overflow: ellipsis;
860
+ white-space: nowrap;
861
+ }
862
+ .record-handle {
863
+ font-family: var(--mono);
864
+ font-size: 0.8rem;
865
+ color: var(--text-3);
866
+ margin-top: 0.1rem;
867
+ }
868
+ .record-actions {
869
+ display: flex;
870
+ gap: 0.3rem;
871
+ align-items: center;
872
+ flex-shrink: 0;
873
+ }
874
+ .record-labels {
875
+ display: flex;
876
+ gap: 0.3rem;
877
+ flex-wrap: wrap;
878
+ align-items: center;
879
+ margin-top: 0.4rem;
880
+ }
881
+
882
+ .toggle-detail {
883
+ font-family: var(--font);
884
+ font-size: 0.8rem;
885
+ font-weight: 600;
886
+ color: var(--accent);
887
+ background: none;
888
+ border: none;
889
+ cursor: pointer;
890
+ padding: 0;
891
+ margin-top: 0.4rem;
892
+ transition: opacity 0.15s;
893
+ }
894
+ .toggle-detail:hover {
895
+ opacity: 0.7;
896
+ }
897
+
898
+ .record-detail {
899
+ display: none;
900
+ margin-top: 0.6rem;
901
+ border-top: 1px solid var(--border);
902
+ padding-top: 0.6rem;
903
+ }
904
+ .record-detail.open {
905
+ display: block;
906
+ }
907
+
908
+ .record-detail pre {
909
+ font-family: var(--mono);
910
+ font-size: 0.8rem;
911
+ line-height: 1.6;
912
+ color: var(--text-2);
913
+ background: var(--bg);
914
+ border-radius: 10px;
915
+ padding: 1rem;
916
+ overflow-x: auto;
917
+ max-height: 400px;
918
+ white-space: pre-wrap;
919
+ word-break: break-all;
920
+ }
921
+
922
+ .blob-section {
923
+ margin-top: 0.6rem;
924
+ }
925
+ .blob-section h4 {
926
+ font-size: 0.7rem;
927
+ font-weight: 700;
928
+ color: var(--text-3);
929
+ text-transform: uppercase;
930
+ letter-spacing: 0.06em;
931
+ margin-bottom: 0.4rem;
932
+ }
933
+ .blob-grid {
934
+ display: flex;
935
+ gap: 0.5rem;
936
+ flex-wrap: wrap;
937
+ }
938
+ .blob-thumb {
939
+ width: 100px;
940
+ height: 100px;
941
+ object-fit: cover;
942
+ border-radius: 10px;
943
+ background: var(--bg);
944
+ }
945
+ .blob-link {
946
+ font-family: var(--mono);
947
+ font-size: 0.72rem;
948
+ color: var(--accent);
949
+ text-decoration: none;
950
+ display: inline-flex;
951
+ align-items: center;
952
+ gap: 0.3rem;
953
+ padding: 0.3rem 0.6rem;
954
+ border: 1.5px solid var(--border);
955
+ border-radius: 8px;
956
+ background: var(--surface);
957
+ }
958
+ .blob-link:hover {
959
+ border-color: var(--accent);
960
+ }
961
+
962
+ /* ── Toast ── */
963
+ #toast-container {
964
+ position: fixed;
965
+ bottom: 1.5rem;
966
+ right: 1.5rem;
967
+ display: flex;
968
+ flex-direction: column;
969
+ gap: 0.4rem;
970
+ z-index: 1000;
971
+ pointer-events: none;
972
+ }
973
+ .toast {
974
+ font-size: 0.875rem;
975
+ font-weight: 500;
976
+ padding: 0.65rem 1rem;
977
+ background: var(--surface);
978
+ border-radius: 12px;
979
+ box-shadow: var(--shadow-lg);
980
+ color: var(--text);
981
+ animation: slideUp 0.25s ease;
982
+ pointer-events: auto;
983
+ max-width: 380px;
984
+ }
985
+ .toast.error {
986
+ border-left: 3px solid var(--red);
987
+ }
988
+ .toast.success {
989
+ border-left: 3px solid var(--green);
990
+ }
991
+
992
+ /* ── Error screen ── */
993
+ #error-screen {
994
+ display: none;
995
+ flex-direction: column;
996
+ align-items: center;
997
+ justify-content: center;
998
+ min-height: 100vh;
999
+ gap: 0.75rem;
1000
+ text-align: center;
1001
+ padding: 2rem;
1002
+ }
1003
+ #error-screen.visible {
1004
+ display: flex;
1005
+ }
1006
+ #error-screen h2 {
1007
+ font-size: 1.25rem;
1008
+ font-weight: 700;
1009
+ }
1010
+ #error-screen .error-msg {
1011
+ font-size: 1rem;
1012
+ color: var(--text-2);
1013
+ }
1014
+
1015
+ /* ── Mobile repo cards ── */
1016
+ .repo-cards-mobile {
1017
+ display: none;
1018
+ }
1019
+
1020
+ .repo-card-m {
1021
+ background: var(--surface);
1022
+ border-radius: 14px;
1023
+ padding: 1rem;
1024
+ box-shadow: var(--shadow-sm);
1025
+ margin-bottom: 0.5rem;
1026
+ }
1027
+ .repo-card-m .rcm-top {
1028
+ display: flex;
1029
+ justify-content: space-between;
1030
+ align-items: flex-start;
1031
+ margin-bottom: 0.2rem;
1032
+ }
1033
+ .repo-card-m .rcm-handle {
1034
+ font-weight: 600;
1035
+ font-size: 1rem;
1036
+ }
1037
+ .repo-card-m .rcm-did {
1038
+ font-family: var(--mono);
1039
+ font-size: 0.7rem;
1040
+ color: var(--text-2);
1041
+ overflow: hidden;
1042
+ text-overflow: ellipsis;
1043
+ white-space: nowrap;
1044
+ margin-bottom: 0.35rem;
1045
+ }
1046
+ .repo-card-m .rcm-meta {
1047
+ font-size: 0.8rem;
1048
+ color: var(--text-3);
1049
+ margin-bottom: 0.6rem;
1050
+ }
1051
+ .repo-card-m .rcm-actions {
1052
+ display: flex;
1053
+ gap: 0.3rem;
1054
+ flex-wrap: wrap;
1055
+ }
1056
+
1057
+ /* ── Animations ── */
1058
+ @keyframes fadeIn {
1059
+ from {
1060
+ opacity: 0;
1061
+ }
1062
+ to {
1063
+ opacity: 1;
1064
+ }
1065
+ }
1066
+ @keyframes slideUp {
1067
+ from {
1068
+ opacity: 0;
1069
+ transform: translateY(8px);
1070
+ }
1071
+ to {
1072
+ opacity: 1;
1073
+ transform: translateY(0);
1074
+ }
1075
+ }
1076
+ @keyframes dots {
1077
+ 0%,
1078
+ 20% {
1079
+ content: '';
1080
+ }
1081
+ 40% {
1082
+ content: '.';
1083
+ }
1084
+ 60% {
1085
+ content: '..';
1086
+ }
1087
+ 80%,
1088
+ 100% {
1089
+ content: '...';
1090
+ }
1091
+ }
1092
+ @keyframes pulse {
1093
+ 0%,
1094
+ 100% {
1095
+ opacity: 1;
1096
+ }
1097
+ 50% {
1098
+ opacity: 0.35;
1099
+ }
1100
+ }
1101
+
1102
+ /* ── Responsive ── */
1103
+ @media (max-width: 640px) {
1104
+ .container {
1105
+ padding: 1rem;
1106
+ padding-bottom: 5.5rem;
1107
+ }
1108
+ .topbar-inner {
1109
+ padding: 0 1rem;
1110
+ }
1111
+ .tabs {
1112
+ display: none;
1113
+ }
1114
+ .bottom-nav {
1115
+ display: block;
1116
+ }
1117
+ .viewer-did {
1118
+ display: none;
1119
+ }
1120
+
1121
+ .cards-grid {
1122
+ grid-template-columns: repeat(2, 1fr);
1123
+ }
1124
+ .stat-card .stat-value {
1125
+ font-size: 1.4rem;
1126
+ }
1127
+
1128
+ .toolbar {
1129
+ flex-direction: column;
1130
+ }
1131
+ .toolbar .search-input {
1132
+ min-width: unset;
1133
+ width: 100%;
1134
+ }
1135
+ .add-repos-bar {
1136
+ flex-direction: column;
1137
+ }
1138
+ .search-bar {
1139
+ flex-direction: column;
1140
+ }
1141
+
1142
+ .record-header {
1143
+ flex-direction: column;
1144
+ gap: 0.5rem;
1145
+ }
1146
+ .record-actions {
1147
+ align-self: flex-start;
1148
+ }
1149
+
1150
+ .repos-table-wrap {
1151
+ display: none !important;
1152
+ }
1153
+ .repo-cards-mobile {
1154
+ display: block !important;
1155
+ }
1156
+
1157
+ .quick-actions {
1158
+ flex-direction: column;
1159
+ }
1160
+ .quick-actions .btn {
1161
+ width: 100%;
1162
+ text-align: center;
1163
+ }
1164
+
1165
+ #toast-container {
1166
+ bottom: 5rem;
1167
+ right: 1rem;
1168
+ left: 1rem;
1169
+ }
1170
+ .toast {
1171
+ max-width: none;
1172
+ }
1173
+ }
1174
+
1175
+ @media (max-width: 380px) {
1176
+ .login-card {
1177
+ padding: 2rem 1.5rem;
1178
+ }
1179
+ }
1180
+ </style>
1181
+ </head>
1182
+ <body>
1183
+ <!-- Login -->
1184
+ <div id="login-screen">
1185
+ <div class="login-card">
1186
+ <div class="login-header">
1187
+ <span class="logo-mark"></span>
1188
+ <h1>admin</h1>
1189
+ </div>
1190
+ <p>Sign in to manage your server.</p>
1191
+ <div class="field">
1192
+ <label for="handle-input">Handle</label>
1193
+ <input type="text" id="handle-input" placeholder="alice.bsky.social" autocomplete="off" spellcheck="false" />
1194
+ </div>
1195
+ <button class="btn btn-primary" id="login-btn" style="width: 100%">Sign In</button>
1196
+ </div>
1197
+ </div>
1198
+
1199
+ <!-- Error -->
1200
+ <div id="error-screen">
1201
+ <h2>Access Denied</h2>
1202
+ <div class="error-msg" id="error-msg">Your account does not have admin privileges.</div>
1203
+ <button class="btn" onclick="auth.logout().then(() => location.reload())">Sign Out</button>
1204
+ </div>
1205
+
1206
+ <!-- App -->
1207
+ <div id="app">
1208
+ <div class="topbar">
1209
+ <div class="topbar-inner">
1210
+ <span class="logo"><span class="logo-mark"></span> admin</span>
1211
+ <div class="topbar-right">
1212
+ <span class="viewer-did" id="viewer-did"></span>
1213
+ <button class="btn-link" id="logout-btn">Sign out</button>
1214
+ </div>
1215
+ </div>
1216
+ </div>
1217
+
1218
+ <div class="container">
1219
+ <nav class="tabs">
1220
+ <button class="tab active" data-tab="overview">Overview</button>
1221
+ <button class="tab" data-tab="repos">Repos</button>
1222
+ <button class="tab" data-tab="content">Content</button>
1223
+ <button class="tab" data-tab="schema">Schema</button>
1224
+ </nav>
1225
+
1226
+ <!-- Overview -->
1227
+ <div class="tab-panel active" id="panel-overview">
1228
+ <div id="overview-loading" class="loading">Loading</div>
1229
+ <div id="overview-content" style="display: none">
1230
+ <div class="section-label">Repos</div>
1231
+ <div class="cards-grid" id="status-cards"></div>
1232
+ <div class="section-label">Collections</div>
1233
+ <div id="collection-cards"></div>
1234
+ <div class="section-label">Database</div>
1235
+ <div class="cards-grid" id="db-cards"></div>
1236
+ <div class="quick-actions">
1237
+ <button class="btn" id="resync-all-btn">Resync All Repos</button>
1238
+ <button class="btn" id="rescan-labels-btn">Rescan Labels</button>
1239
+ <select class="filter-select" id="reset-label-select">
1240
+ <option value="">Reset labels...</option>
1241
+ </select>
1242
+ <button class="btn" id="reset-labels-btn" style="display: none">Delete All</button>
1243
+ </div>
1244
+ </div>
1245
+ </div>
1246
+
1247
+ <!-- Repos -->
1248
+ <div class="tab-panel" id="panel-repos">
1249
+ <div class="add-repos-bar">
1250
+ <input
1251
+ class="search-input"
1252
+ id="add-repos-input"
1253
+ type="text"
1254
+ placeholder="Add DIDs (comma-separated)..."
1255
+ autocomplete="off"
1256
+ spellcheck="false"
1257
+ />
1258
+ <button class="btn btn-primary" id="add-repos-btn">Add</button>
1259
+ </div>
1260
+ <div class="toolbar">
1261
+ <input
1262
+ class="search-input"
1263
+ id="repos-search"
1264
+ type="text"
1265
+ placeholder="Search by DID or handle..."
1266
+ autocomplete="off"
1267
+ spellcheck="false"
1268
+ />
1269
+ <select class="filter-select" id="repos-status-filter">
1270
+ <option value="">All statuses</option>
1271
+ <option value="active">Active</option>
1272
+ <option value="pending">Pending</option>
1273
+ <option value="failed">Failed</option>
1274
+ <option value="takendown">Taken down</option>
1275
+ </select>
1276
+ </div>
1277
+ <div id="repos-results"><div class="loading">Loading</div></div>
1278
+ </div>
1279
+
1280
+ <!-- Schema -->
1281
+ <div class="tab-panel" id="panel-schema">
1282
+ <div id="schema-results"><div class="loading">Loading</div></div>
1283
+ </div>
1284
+
1285
+ <!-- Content -->
1286
+ <div class="tab-panel" id="panel-content">
1287
+ <div class="search-bar">
1288
+ <input
1289
+ class="search-input"
1290
+ id="content-search"
1291
+ type="search"
1292
+ placeholder="Search records by text, DID, or AT URI..."
1293
+ autocomplete="off"
1294
+ spellcheck="false"
1295
+ />
1296
+ <button class="btn btn-primary" id="content-search-btn">Search</button>
1297
+ </div>
1298
+ <div id="content-results">
1299
+ <div class="loading">Loading</div>
1300
+ </div>
1301
+ </div>
1302
+ </div>
1303
+
1304
+ <!-- Bottom nav (mobile) -->
1305
+ <div class="bottom-nav">
1306
+ <div class="bottom-nav-track">
1307
+ <button class="bnav-btn active" data-tab="overview">Overview</button>
1308
+ <button class="bnav-btn" data-tab="repos">Repos</button>
1309
+ <button class="bnav-btn" data-tab="content">Content</button>
1310
+ <button class="bnav-btn" data-tab="schema">Schema</button>
1311
+ </div>
1312
+ </div>
1313
+ </div>
1314
+
1315
+ <div id="toast-container"></div>
1316
+
1317
+ <script type="module">
1318
+ import { OAuthClient } from '/admin/admin-auth.js'
1319
+
1320
+ const auth = new OAuthClient({
1321
+ server: location.origin,
1322
+ clientId: location.origin + '/oauth-client-metadata.json',
1323
+ redirectUri: location.origin + '/admin',
1324
+ })
1325
+
1326
+ window.auth = auth
1327
+ let labelDefinitions = []
1328
+
1329
+ function toast(msg, type = '') {
1330
+ const el = document.createElement('div')
1331
+ el.className = `toast ${type}`
1332
+ el.textContent = msg
1333
+ document.getElementById('toast-container').appendChild(el)
1334
+ setTimeout(() => el.remove(), 3000)
1335
+ }
1336
+
1337
+ async function api(path, opts = {}) {
1338
+ const res = await auth.fetch(path, opts)
1339
+ if (!res.ok) {
1340
+ const err = await res.json().catch(() => ({ error: res.statusText }))
1341
+ throw new Error(err.error || 'Request failed')
1342
+ }
1343
+ return res.json()
1344
+ }
1345
+
1346
+ function escapeHtml(str) {
1347
+ return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
1348
+ }
1349
+
1350
+ function summarizeValue(record) {
1351
+ if (!record.value) return ''
1352
+ const v = record.value
1353
+ const parts = []
1354
+ for (const [key, val] of Object.entries(v)) {
1355
+ if (typeof val === 'string' && val.length > 0 && val.length < 120) {
1356
+ parts.push(`${key}: ${val}`)
1357
+ }
1358
+ if (parts.length >= 3) break
1359
+ }
1360
+ return parts.join(' \u2014 ')
1361
+ }
1362
+
1363
+ function formatTimestamp(ts) {
1364
+ if (!ts) return '\u2014'
1365
+ try {
1366
+ const d = typeof ts === 'object' && 'micros' in ts ? new Date(Number(ts.micros) / 1000) : new Date(ts)
1367
+ return d.toLocaleDateString() + ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
1368
+ } catch {
1369
+ return '\u2014'
1370
+ }
1371
+ }
1372
+
1373
+ // ── Auth ──
1374
+
1375
+ const loginScreen = document.getElementById('login-screen')
1376
+ const errorScreen = document.getElementById('error-screen')
1377
+ const appEl = document.getElementById('app')
1378
+
1379
+ document.getElementById('login-btn').addEventListener('click', async () => {
1380
+ const handle = document.getElementById('handle-input').value.trim()
1381
+ if (!handle) return
1382
+ try {
1383
+ await auth.login(handle)
1384
+ } catch (e) {
1385
+ toast(e.message, 'error')
1386
+ }
1387
+ })
1388
+
1389
+ document.getElementById('handle-input').addEventListener('keydown', (e) => {
1390
+ if (e.key === 'Enter') document.getElementById('login-btn').click()
1391
+ })
1392
+
1393
+ async function boot() {
1394
+ try {
1395
+ const handled = await auth.handleCallback()
1396
+ if (handled) history.replaceState({}, '', '/admin')
1397
+ } catch (e) {
1398
+ console.error('OAuth callback error:', e)
1399
+ }
1400
+
1401
+ if (!auth.isLoggedIn) {
1402
+ loginScreen.style.display = 'flex'
1403
+ return
1404
+ }
1405
+ loginScreen.style.display = 'none'
1406
+
1407
+ try {
1408
+ const whoami = await api('/admin/whoami')
1409
+ document.getElementById('viewer-did').textContent = whoami.did
1410
+ } catch (e) {
1411
+ document.getElementById('error-msg').textContent =
1412
+ e.message === 'Admin access required'
1413
+ ? 'Your account does not have admin privileges on this server.'
1414
+ : e.message
1415
+ errorScreen.classList.add('visible')
1416
+ return
1417
+ }
1418
+
1419
+ try {
1420
+ const result = await api('/admin/labels/definitions')
1421
+ labelDefinitions = result.definitions || []
1422
+ } catch {}
1423
+
1424
+ appEl.classList.add('visible')
1425
+ restoreFromURL()
1426
+ }
1427
+
1428
+ boot()
1429
+
1430
+ document.getElementById('logout-btn').addEventListener('click', async () => {
1431
+ await auth.logout()
1432
+ location.reload()
1433
+ })
1434
+
1435
+ // ── URL State ──
1436
+
1437
+ function getURLState() {
1438
+ const p = new URLSearchParams(location.search)
1439
+ return {
1440
+ tab: p.get('tab') || 'overview',
1441
+ status: p.get('status') || '',
1442
+ q: p.get('q') || '',
1443
+ offset: parseInt(p.get('offset') || '0'),
1444
+ cq: p.get('cq') || '',
1445
+ }
1446
+ }
1447
+
1448
+ function pushURL(overrides = {}) {
1449
+ const cur = getURLState()
1450
+ const next = { ...cur, ...overrides }
1451
+ const p = new URLSearchParams()
1452
+ if (next.tab && next.tab !== 'overview') p.set('tab', next.tab)
1453
+ if (next.tab === 'repos') {
1454
+ if (next.status) p.set('status', next.status)
1455
+ if (next.q) p.set('q', next.q)
1456
+ if (next.offset) p.set('offset', next.offset)
1457
+ }
1458
+ if (next.tab === 'content' && next.cq) p.set('cq', next.cq)
1459
+ const qs = p.toString()
1460
+ const url = '/admin' + (qs ? '?' + qs : '')
1461
+ if (url !== location.pathname + location.search) {
1462
+ history.pushState(null, '', url)
1463
+ }
1464
+ }
1465
+
1466
+ function restoreFromURL() {
1467
+ const s = getURLState()
1468
+ // Set state before activateTab so tab loaders see the right values
1469
+ if (s.tab === 'repos') {
1470
+ reposPage.status = s.status
1471
+ reposPage.q = s.q
1472
+ reposPage.offset = s.offset
1473
+ document.getElementById('repos-search').value = s.q
1474
+ document.getElementById('repos-status-filter').value = s.status
1475
+ } else if (s.tab === 'content' && s.cq) {
1476
+ document.getElementById('content-search').value = s.cq
1477
+ }
1478
+ activateTab(s.tab, false)
1479
+ }
1480
+
1481
+ window.addEventListener('popstate', () => restoreFromURL())
1482
+
1483
+ // ── Tabs ──
1484
+
1485
+ function activateTab(tab, push = true) {
1486
+ document.querySelectorAll('.tab').forEach((b) => b.classList.remove('active'))
1487
+ document.querySelectorAll('.bnav-btn').forEach((b) => b.classList.remove('active'))
1488
+ document.querySelectorAll('.tab-panel').forEach((p) => p.classList.remove('active'))
1489
+ document.querySelectorAll(`[data-tab="${tab}"]`).forEach((b) => b.classList.add('active'))
1490
+ document.getElementById(`panel-${tab}`).classList.add('active')
1491
+ if (tab === 'overview') loadOverview()
1492
+ if (tab === 'repos') loadRepos()
1493
+ if (tab === 'schema') loadSchema()
1494
+ if (tab === 'content') loadContent()
1495
+ if (push) pushURL({ tab, status: '', q: '', offset: 0, cq: '' })
1496
+ }
1497
+
1498
+ document.querySelectorAll('.tab').forEach((btn) => {
1499
+ btn.addEventListener('click', () => activateTab(btn.dataset.tab))
1500
+ })
1501
+ document.querySelectorAll('.bnav-btn').forEach((btn) => {
1502
+ btn.addEventListener('click', () => activateTab(btn.dataset.tab))
1503
+ })
1504
+
1505
+ // ── Overview ──
1506
+
1507
+ async function loadOverview() {
1508
+ try {
1509
+ const info = await api('/admin/info')
1510
+ const statusCards = document.getElementById('status-cards')
1511
+ const repoStatuses = info.repos || {}
1512
+ const total = Object.values(repoStatuses).reduce((a, b) => a + b, 0)
1513
+
1514
+ const fmt = (n) => Number(n || 0).toLocaleString()
1515
+ statusCards.innerHTML = `
1516
+ <div class="stat-card"><div class="stat-label">Total</div><div class="stat-value">${fmt(total)}</div></div>
1517
+ <div class="stat-card"><div class="stat-label">Active</div><div class="stat-value green">${fmt(repoStatuses.active)}</div></div>
1518
+ <div class="stat-card"><div class="stat-label">Pending</div><div class="stat-value yellow">${fmt(repoStatuses.pending)}</div></div>
1519
+ <div class="stat-card"><div class="stat-label">Failed</div><div class="stat-value red">${fmt(repoStatuses.failed)}</div></div>
1520
+ <div class="stat-card"><div class="stat-label">Taken Down</div><div class="stat-value red">${fmt(repoStatuses.takendown)}</div></div>
1521
+ `
1522
+
1523
+ const collectionCards = document.getElementById('collection-cards')
1524
+ const collections = info.collections || {}
1525
+ const collectionRows = Object.entries(collections)
1526
+ .map(
1527
+ ([name, count]) =>
1528
+ `<div class="collection-row"><span class="collection-name">${escapeHtml(name)}</span><span class="collection-count">${Number(count).toLocaleString()}</span></div>`,
1529
+ )
1530
+ .join('')
1531
+ collectionCards.innerHTML = collectionRows
1532
+ ? `<div class="collection-list">${collectionRows}</div>`
1533
+ : '<div class="empty-state">No collections</div>'
1534
+
1535
+ const dbCards = document.getElementById('db-cards')
1536
+ const db = info.duckdb || {}
1537
+ dbCards.innerHTML = `
1538
+ <div class="stat-card"><div class="stat-label">DB Size</div><div class="stat-value" style="font-size:1.35rem">${db.database_size || '\u2014'}</div></div>
1539
+ <div class="stat-card"><div class="stat-label">Memory</div><div class="stat-value" style="font-size:1.35rem">${db.memory_usage || '\u2014'}</div></div>
1540
+ `
1541
+
1542
+ const resetSelect = document.getElementById('reset-label-select')
1543
+ resetSelect.innerHTML =
1544
+ '<option value="">Reset labels...</option>' +
1545
+ labelDefinitions
1546
+ .map((d) => `<option value="${escapeHtml(d.identifier)}">${escapeHtml(d.identifier)}</option>`)
1547
+ .join('')
1548
+
1549
+ document.getElementById('overview-loading').style.display = 'none'
1550
+ document.getElementById('overview-content').style.display = 'block'
1551
+ } catch (e) {
1552
+ document.getElementById('overview-loading').textContent = 'Failed to load: ' + e.message
1553
+ }
1554
+ }
1555
+
1556
+ document.getElementById('resync-all-btn').addEventListener('click', async () => {
1557
+ if (!confirm('Resync all active repos? This will re-download all data.')) return
1558
+ try {
1559
+ const result = await api('/admin/repos/resync', {
1560
+ method: 'POST',
1561
+ headers: { 'Content-Type': 'application/json' },
1562
+ body: JSON.stringify({}),
1563
+ })
1564
+ toast(`Resyncing ${result.resyncing} repos`, 'success')
1565
+ loadOverview()
1566
+ } catch (e) {
1567
+ toast(e.message, 'error')
1568
+ }
1569
+ })
1570
+
1571
+ document.getElementById('rescan-labels-btn').addEventListener('click', async () => {
1572
+ try {
1573
+ const result = await api('/admin/labels/rescan', { method: 'POST' })
1574
+ toast(`Label rescan complete: ${result.applied || 0} applied`, 'success')
1575
+ } catch (e) {
1576
+ toast(e.message, 'error')
1577
+ }
1578
+ })
1579
+
1580
+ document.getElementById('reset-label-select').addEventListener('change', (e) => {
1581
+ document.getElementById('reset-labels-btn').style.display = e.target.value ? '' : 'none'
1582
+ })
1583
+
1584
+ document.getElementById('reset-labels-btn').addEventListener('click', async () => {
1585
+ const val = document.getElementById('reset-label-select').value
1586
+ if (!val) return
1587
+ if (!confirm(`Delete ALL "${val}" labels? This cannot be undone.`)) return
1588
+ try {
1589
+ const result = await api('/admin/labels/reset', {
1590
+ method: 'POST',
1591
+ headers: { 'Content-Type': 'application/json' },
1592
+ body: JSON.stringify({ val }),
1593
+ })
1594
+ toast(`Deleted ${result.deleted} "${val}" labels`, 'success')
1595
+ document.getElementById('reset-label-select').value = ''
1596
+ document.getElementById('reset-labels-btn').style.display = 'none'
1597
+ } catch (e) {
1598
+ toast(e.message, 'error')
1599
+ }
1600
+ })
1601
+
1602
+ // ── Schema ──
1603
+
1604
+ async function loadSchema() {
1605
+ const container = document.getElementById('schema-results')
1606
+ try {
1607
+ const data = await api('/admin/schema')
1608
+ let html = ''
1609
+
1610
+ // Lexicons section
1611
+ if (data.lexicons && data.lexicons.length) {
1612
+ html += '<div class="schema-section"><div class="section-label">Lexicons</div>'
1613
+ for (const lex of data.lexicons) {
1614
+ html += `<div class="card" style="margin-bottom:0.5rem;"><div style="font-family:var(--mono);font-size:0.8rem;font-weight:600;padding:0.5rem 0.75rem;border-bottom:1px solid var(--border);">${escapeHtml(lex.nsid)}</div><pre class="schema-pre">${escapeHtml(JSON.stringify(lex.lexicon, null, 2))}</pre></div>`
1615
+ }
1616
+ html += '</div>'
1617
+ }
1618
+
1619
+ // DDL section
1620
+ if (data.ddl) {
1621
+ html += '<div class="schema-section"><div class="section-label">Tables (DuckDB DDL)</div>'
1622
+ html += `<div class="card"><pre class="schema-pre">${escapeHtml(data.ddl)}</pre></div>`
1623
+ html += '</div>'
1624
+ }
1625
+
1626
+ container.innerHTML = html || '<div class="empty-state">No schema found</div>'
1627
+ } catch (e) {
1628
+ container.innerHTML = `<div class="empty-state">${escapeHtml(e.message)}</div>`
1629
+ }
1630
+ }
1631
+
1632
+ // ── Repos ──
1633
+
1634
+ let reposLoaded = false
1635
+ let reposPage = { offset: 0, limit: 50, status: '', q: '' }
1636
+
1637
+ async function loadRepos() {
1638
+ reposLoaded = true
1639
+ const container = document.getElementById('repos-results')
1640
+ container.innerHTML = '<div class="loading">Loading</div>'
1641
+
1642
+ const params = new URLSearchParams({ limit: reposPage.limit, offset: reposPage.offset })
1643
+ if (reposPage.status) params.set('status', reposPage.status)
1644
+ if (reposPage.q) params.set('q', reposPage.q)
1645
+
1646
+ try {
1647
+ const result = await api(`/admin/repos?${params}`)
1648
+ renderRepos(result)
1649
+ } catch (e) {
1650
+ container.innerHTML = `<div class="empty-state">${escapeHtml(e.message)}</div>`
1651
+ }
1652
+ }
1653
+
1654
+ function repoActionMenu(repo) {
1655
+ const isTD = repo.status === 'takendown'
1656
+ const d = escapeHtml(repo.did)
1657
+ return `
1658
+ <div class="actions-wrap">
1659
+ <button class="actions-trigger" title="Actions">&middot;&middot;&middot;</button>
1660
+ <div class="actions-menu">
1661
+ <button data-action="resync" data-did="${d}">Resync</button>
1662
+ ${
1663
+ isTD
1664
+ ? `<button class="success" data-action="reverse-takedown" data-did="${d}">Reverse Takedown</button>`
1665
+ : `<button class="danger" data-action="takedown" data-did="${d}">Takedown</button>`
1666
+ }
1667
+ <div class="menu-sep"></div>
1668
+ <button class="danger" data-action="remove" data-did="${d}">Remove</button>
1669
+ </div>
1670
+ </div>
1671
+ `
1672
+ }
1673
+
1674
+ function renderRepos({ repos, total }) {
1675
+ const container = document.getElementById('repos-results')
1676
+ if (!repos.length) {
1677
+ container.innerHTML = '<div class="empty-state">No repos found</div>'
1678
+ return
1679
+ }
1680
+
1681
+ const totalPages = Math.ceil(total / reposPage.limit)
1682
+ const currentPage = Math.floor(reposPage.offset / reposPage.limit) + 1
1683
+
1684
+ const paginationHtml = `
1685
+ <div class="pagination">
1686
+ <span>${totalPages > 1 ? `Page ${currentPage} of ${totalPages} &middot; ` : ''}${total} repo${total !== 1 ? 's' : ''}</span>
1687
+ ${
1688
+ totalPages > 1
1689
+ ? `<div class="pagination-buttons">
1690
+ <button class="btn btn-sm" data-page="prev" ${currentPage <= 1 ? 'disabled' : ''}>&larr; Prev</button>
1691
+ <button class="btn btn-sm" data-page="next" ${currentPage >= totalPages ? 'disabled' : ''}>Next &rarr;</button>
1692
+ </div>`
1693
+ : '<span></span>'
1694
+ }
1695
+ </div>`
1696
+
1697
+ // Desktop table
1698
+ const tableHtml = `
1699
+ <div class="repos-table-wrap">
1700
+ <div class="card">
1701
+ <table class="data-table">
1702
+ <thead><tr>
1703
+ <th>Handle</th>
1704
+ <th>DID</th>
1705
+ <th>Status</th>
1706
+ <th>Backfilled</th>
1707
+ <th>Actions</th>
1708
+ </tr></thead>
1709
+ <tbody>
1710
+ ${repos
1711
+ .map((r) => {
1712
+ const sc = r.status || 'pending'
1713
+ return `<tr>
1714
+ <td class="handle-cell">${r.handle ? escapeHtml(r.handle) : '\u2014'}</td>
1715
+ <td class="did-cell" data-copy="${escapeHtml(r.did)}" title="Click to copy">${escapeHtml(r.did)}</td>
1716
+ <td><span class="status status-${sc}">${r.status || 'pending'}</span></td>
1717
+ <td style="font-size:0.8rem;color:var(--text-2)">${formatTimestamp(r.backfilled_at)}</td>
1718
+ <td>${repoActionMenu(r)}</td>
1719
+ </tr>`
1720
+ })
1721
+ .join('')}
1722
+ </tbody>
1723
+ </table>
1724
+ ${paginationHtml}
1725
+ </div>
1726
+ </div>`
1727
+
1728
+ // Mobile cards
1729
+ const cardsHtml = `
1730
+ <div class="repo-cards-mobile">
1731
+ ${repos
1732
+ .map((r) => {
1733
+ const sc = r.status || 'pending'
1734
+ return `<div class="repo-card-m">
1735
+ <div class="rcm-top">
1736
+ <span class="rcm-handle">${r.handle ? escapeHtml(r.handle) : '\u2014'}</span>
1737
+ <span class="status status-${sc}">${r.status || 'pending'}</span>
1738
+ </div>
1739
+ <div class="rcm-did" data-copy="${escapeHtml(r.did)}" title="Click to copy">${escapeHtml(r.did)}</div>
1740
+ <div class="rcm-meta">${formatTimestamp(r.backfilled_at)}</div>
1741
+ <div class="rcm-actions">${repoActionMenu(r)}</div>
1742
+ </div>`
1743
+ })
1744
+ .join('')}
1745
+ <div style="margin-top:0.5rem;">${paginationHtml}</div>
1746
+ </div>`
1747
+
1748
+ container.innerHTML = tableHtml + cardsHtml
1749
+ wireRepoActions(container)
1750
+ }
1751
+
1752
+ let activeMenuTrigger = null
1753
+
1754
+ function positionMenu(trigger, menu) {
1755
+ const rect = trigger.getBoundingClientRect()
1756
+ const menuH = menu.offsetHeight
1757
+ const spaceBelow = window.innerHeight - rect.bottom
1758
+ if (spaceBelow < menuH + 8) {
1759
+ menu.style.top = rect.top - menuH - 4 + 'px'
1760
+ } else {
1761
+ menu.style.top = rect.bottom + 4 + 'px'
1762
+ }
1763
+ menu.style.left = Math.max(8, rect.right - menu.offsetWidth) + 'px'
1764
+ }
1765
+
1766
+ function closeAllMenus() {
1767
+ document.querySelectorAll('.actions-menu.open').forEach((m) => m.classList.remove('open'))
1768
+ activeMenuTrigger = null
1769
+ }
1770
+
1771
+ document.addEventListener('click', (e) => {
1772
+ if (!e.target.closest('.actions-wrap')) closeAllMenus()
1773
+ })
1774
+
1775
+ window.addEventListener(
1776
+ 'scroll',
1777
+ () => {
1778
+ if (!activeMenuTrigger) return
1779
+ const menu = activeMenuTrigger.nextElementSibling
1780
+ if (!menu || !menu.classList.contains('open')) return
1781
+ positionMenu(activeMenuTrigger, menu)
1782
+ },
1783
+ true,
1784
+ )
1785
+
1786
+ function wireRepoActions(container) {
1787
+ container.querySelectorAll('[data-page="prev"]').forEach((b) => {
1788
+ b.addEventListener('click', () => {
1789
+ reposPage.offset = Math.max(0, reposPage.offset - reposPage.limit)
1790
+ pushURL({ offset: reposPage.offset })
1791
+ loadRepos()
1792
+ })
1793
+ })
1794
+ container.querySelectorAll('[data-page="next"]').forEach((b) => {
1795
+ b.addEventListener('click', () => {
1796
+ reposPage.offset += reposPage.limit
1797
+ pushURL({ offset: reposPage.offset })
1798
+ loadRepos()
1799
+ })
1800
+ })
1801
+
1802
+ // Click DID to copy
1803
+ container.querySelectorAll('[data-copy]').forEach((el) => {
1804
+ el.addEventListener('click', async () => {
1805
+ await navigator.clipboard.writeText(el.dataset.copy)
1806
+ const orig = el.textContent
1807
+ el.textContent = 'Copied!'
1808
+ setTimeout(() => {
1809
+ el.textContent = orig
1810
+ }, 1000)
1811
+ })
1812
+ })
1813
+
1814
+ // Toggle dropdown menus
1815
+ container.querySelectorAll('.actions-trigger').forEach((trigger) => {
1816
+ trigger.addEventListener('click', (e) => {
1817
+ e.stopPropagation()
1818
+ const menu = trigger.nextElementSibling
1819
+ const wasOpen = menu.classList.contains('open')
1820
+ closeAllMenus()
1821
+ if (!wasOpen) {
1822
+ menu.classList.add('open')
1823
+ activeMenuTrigger = trigger
1824
+ positionMenu(trigger, menu)
1825
+ }
1826
+ })
1827
+ })
1828
+
1829
+ // Action handlers
1830
+ async function doAction(b, action) {
1831
+ const did = b.dataset.did
1832
+ closeAllMenus()
1833
+ try {
1834
+ if (action === 'resync') {
1835
+ await api('/admin/repos/resync', {
1836
+ method: 'POST',
1837
+ headers: { 'Content-Type': 'application/json' },
1838
+ body: JSON.stringify({ dids: [did] }),
1839
+ })
1840
+ toast('Resync started', 'success')
1841
+ } else if (action === 'remove') {
1842
+ if (!confirm(`Remove ${did} from tracking?`)) return
1843
+ await api('/admin/repos/remove', {
1844
+ method: 'POST',
1845
+ headers: { 'Content-Type': 'application/json' },
1846
+ body: JSON.stringify({ dids: [did] }),
1847
+ })
1848
+ toast('Removed', 'success')
1849
+ } else if (action === 'takedown') {
1850
+ if (!confirm(`Takedown account ${did}?`)) return
1851
+ await api('/admin/takedown', {
1852
+ method: 'POST',
1853
+ headers: { 'Content-Type': 'application/json' },
1854
+ body: JSON.stringify({ did }),
1855
+ })
1856
+ toast('Taken down', 'success')
1857
+ } else if (action === 'reverse-takedown') {
1858
+ if (!confirm(`Reverse takedown for ${did}?`)) return
1859
+ await api('/admin/reverse-takedown', {
1860
+ method: 'POST',
1861
+ headers: { 'Content-Type': 'application/json' },
1862
+ body: JSON.stringify({ did }),
1863
+ })
1864
+ toast('Reversed', 'success')
1865
+ }
1866
+ loadRepos()
1867
+ } catch (e) {
1868
+ toast(e.message, 'error')
1869
+ }
1870
+ }
1871
+
1872
+ container.querySelectorAll('[data-action]').forEach((b) => {
1873
+ if (b.classList.contains('actions-trigger')) return
1874
+ b.addEventListener('click', () => doAction(b, b.dataset.action))
1875
+ })
1876
+ }
1877
+
1878
+ document.getElementById('repos-search').addEventListener('keydown', (e) => {
1879
+ if (e.key === 'Enter') {
1880
+ reposPage.offset = 0
1881
+ reposPage.q = e.target.value.trim()
1882
+ pushURL({ q: reposPage.q, offset: 0 })
1883
+ loadRepos()
1884
+ }
1885
+ })
1886
+
1887
+ document.getElementById('repos-status-filter').addEventListener('change', (e) => {
1888
+ reposPage.offset = 0
1889
+ reposPage.status = e.target.value
1890
+ pushURL({ status: reposPage.status, offset: 0 })
1891
+ loadRepos()
1892
+ })
1893
+
1894
+ document.getElementById('add-repos-btn').addEventListener('click', async () => {
1895
+ const input = document.getElementById('add-repos-input')
1896
+ const raw = input.value.trim()
1897
+ if (!raw) return
1898
+ const dids = raw
1899
+ .split(/[,\s]+/)
1900
+ .map((d) => d.trim())
1901
+ .filter((d) => d.startsWith('did:'))
1902
+ if (!dids.length) {
1903
+ toast('Enter valid DIDs (must start with did:)', 'error')
1904
+ return
1905
+ }
1906
+ try {
1907
+ const result = await api('/admin/repos/add', {
1908
+ method: 'POST',
1909
+ headers: { 'Content-Type': 'application/json' },
1910
+ body: JSON.stringify({ dids }),
1911
+ })
1912
+ toast(`Added ${result.added} repo${result.added !== 1 ? 's' : ''}`, 'success')
1913
+ input.value = ''
1914
+ loadRepos()
1915
+ loadOverview()
1916
+ } catch (e) {
1917
+ toast(e.message, 'error')
1918
+ }
1919
+ })
1920
+
1921
+ document.getElementById('add-repos-input').addEventListener('keydown', (e) => {
1922
+ if (e.key === 'Enter') document.getElementById('add-repos-btn').click()
1923
+ })
1924
+
1925
+ // ── Content ──
1926
+
1927
+ let contentPage = { offset: 0, limit: 50 }
1928
+
1929
+ document.getElementById('content-search-btn').addEventListener('click', () => {
1930
+ contentPage.offset = 0
1931
+ searchContent()
1932
+ })
1933
+ document.getElementById('content-search').addEventListener('keydown', (e) => {
1934
+ if (e.key === 'Enter') {
1935
+ contentPage.offset = 0
1936
+ searchContent()
1937
+ }
1938
+ })
1939
+ document.getElementById('content-search').addEventListener('search', () => {
1940
+ contentPage.offset = 0
1941
+ if (!document.getElementById('content-search').value) {
1942
+ pushURL({ cq: '' })
1943
+ loadContent()
1944
+ } else {
1945
+ searchContent()
1946
+ }
1947
+ })
1948
+
1949
+ async function loadContent() {
1950
+ const q = document.getElementById('content-search').value.trim()
1951
+ if (q) {
1952
+ searchContent()
1953
+ return
1954
+ }
1955
+ const container = document.getElementById('content-results')
1956
+ container.innerHTML = '<div class="loading">Loading</div>'
1957
+ try {
1958
+ const result = await api(`/admin/search?type=records&limit=${contentPage.limit}&offset=${contentPage.offset}`)
1959
+ renderContentResults(result.records || [], result.total)
1960
+ } catch (e) {
1961
+ container.innerHTML = `<div class="empty-state">${escapeHtml(e.message)}</div>`
1962
+ }
1963
+ }
1964
+
1965
+ async function searchContent() {
1966
+ const q = document.getElementById('content-search').value.trim()
1967
+ if (!q) {
1968
+ loadContent()
1969
+ return
1970
+ }
1971
+ pushURL({ tab: 'content', cq: q })
1972
+ const container = document.getElementById('content-results')
1973
+ container.innerHTML = '<div class="loading">Searching</div>'
1974
+ try {
1975
+ const result = await api(`/admin/search?q=${encodeURIComponent(q)}&type=records&limit=50`)
1976
+ renderContentResults(result.records || [])
1977
+ } catch (e) {
1978
+ container.innerHTML = `<div class="empty-state">${escapeHtml(e.message)}</div>`
1979
+ }
1980
+ }
1981
+
1982
+ function findBlobs(obj, path = '') {
1983
+ const blobs = []
1984
+ if (!obj || typeof obj !== 'object') return blobs
1985
+ if (obj.$type === 'blob' || (obj.ref && obj.ref.$link && obj.mimeType)) {
1986
+ blobs.push({ path, cid: obj.ref?.$link || obj.ref, mimeType: obj.mimeType || '', size: obj.size })
1987
+ return blobs
1988
+ }
1989
+ for (const [key, val] of Object.entries(obj)) {
1990
+ if (val && typeof val === 'object') {
1991
+ blobs.push(...findBlobs(val, path ? `${path}.${key}` : key))
1992
+ }
1993
+ }
1994
+ return blobs
1995
+ }
1996
+
1997
+ function blobUrl(did, cid) {
1998
+ return `https://cdn.bsky.app/img/feed_thumbnail/plain/${did}/${cid}@jpeg`
1999
+ }
2000
+
2001
+ function renderContentResults(records, total) {
2002
+ const container = document.getElementById('content-results')
2003
+ if (!records.length) {
2004
+ container.innerHTML = '<div class="empty-state">No records found</div>'
2005
+ return
2006
+ }
2007
+
2008
+ const labelOptions = labelDefinitions
2009
+ .map((d) => `<option value="${d.identifier}">${d.identifier}</option>`)
2010
+ .join('')
2011
+
2012
+ const showPagination = total != null && total > contentPage.limit
2013
+ const paginationHtml = showPagination
2014
+ ? `
2015
+ <div class="pagination">
2016
+ <span>${contentPage.offset + 1}\u2013${Math.min(contentPage.offset + contentPage.limit, total)} of ${total.toLocaleString()}</span>
2017
+ <div class="pagination-buttons">
2018
+ <button class="btn btn-sm" data-content-page="prev" ${contentPage.offset === 0 ? 'disabled' : ''}>Prev</button>
2019
+ <button class="btn btn-sm" data-content-page="next" ${contentPage.offset + contentPage.limit >= total ? 'disabled' : ''}>Next</button>
2020
+ </div>
2021
+ </div>
2022
+ `
2023
+ : ''
2024
+
2025
+ const countLabel =
2026
+ total != null
2027
+ ? `${total.toLocaleString()} record${total !== 1 ? 's' : ''}`
2028
+ : `${records.length} result${records.length !== 1 ? 's' : ''}`
2029
+
2030
+ container.innerHTML = `
2031
+ <div class="card">
2032
+ <div class="result-count">${countLabel}</div>
2033
+ ${records
2034
+ .map((rec, i) => {
2035
+ const uri = rec.uri || ''
2036
+ const did = rec.did || ''
2037
+ const handle = rec.handle || ''
2038
+ const activeLabels = (rec.labels || []).filter((l) => !l.neg)
2039
+ const summary = summarizeValue(rec)
2040
+ const value = rec.value || {}
2041
+ const blobs = findBlobs(value)
2042
+ const jsonStr = JSON.stringify(value, null, 2)
2043
+
2044
+ return `<div class="record-card">
2045
+ <div class="record-header">
2046
+ <div class="record-meta">
2047
+ <div class="record-uri" title="${escapeHtml(uri)}">${escapeHtml(uri)}</div>
2048
+ ${summary ? `<div class="record-summary">${escapeHtml(summary)}</div>` : ''}
2049
+ ${handle ? `<div class="record-handle">@${escapeHtml(handle)}</div>` : ''}
2050
+ ${
2051
+ activeLabels.length
2052
+ ? `<div class="record-labels">${activeLabels
2053
+ .map(
2054
+ (l) => `
2055
+ <span class="label-tag">
2056
+ ${escapeHtml(l.val)}<span class="remove-x" data-uri="${escapeHtml(uri)}" data-val="${escapeHtml(l.val)}" title="Remove">&times;</span>
2057
+ </span>
2058
+ `,
2059
+ )
2060
+ .join('')}</div>`
2061
+ : ''
2062
+ }
2063
+ </div>
2064
+ <div class="record-actions">
2065
+ <select class="label-select" id="lsel-${i}">
2066
+ <option value="">+ label</option>
2067
+ ${labelOptions}
2068
+ </select>
2069
+ <button class="btn btn-sm" data-action="add-label" data-uri="${escapeHtml(uri)}" data-sel="lsel-${i}">Apply</button>
2070
+ </div>
2071
+ </div>
2072
+ <button class="toggle-detail" data-target="detail-${i}">Show JSON${blobs.length ? ` + ${blobs.length} blob${blobs.length > 1 ? 's' : ''}` : ''}</button>
2073
+ <div class="record-detail" id="detail-${i}">
2074
+ ${
2075
+ blobs.length
2076
+ ? `
2077
+ <div class="blob-section">
2078
+ <h4>Blobs (${blobs.length})</h4>
2079
+ <div class="blob-grid">
2080
+ ${blobs
2081
+ .map((b) => {
2082
+ const isImage = b.mimeType.startsWith('image/')
2083
+ if (isImage && did) {
2084
+ return `<a href="${blobUrl(did, b.cid)}" target="_blank" rel="noopener">
2085
+ <img class="blob-thumb" src="${blobUrl(did, b.cid)}" alt="${escapeHtml(b.path)}" loading="lazy">
2086
+ </a>`
2087
+ }
2088
+ return `<a class="blob-link" href="${blobUrl(did, b.cid)}" target="_blank" rel="noopener">
2089
+ ${escapeHtml(b.mimeType)} (${b.size ? Math.round(b.size / 1024) + ' KB' : 'unknown size'})
2090
+ </a>`
2091
+ })
2092
+ .join('')}
2093
+ </div>
2094
+ </div>
2095
+ `
2096
+ : ''
2097
+ }
2098
+ <pre>${escapeHtml(jsonStr)}</pre>
2099
+ </div>
2100
+ </div>`
2101
+ })
2102
+ .join('')}
2103
+ ${paginationHtml}
2104
+ </div>
2105
+ `
2106
+
2107
+ container.querySelectorAll('[data-content-page="prev"]').forEach((b) => {
2108
+ b.addEventListener('click', () => {
2109
+ contentPage.offset = Math.max(0, contentPage.offset - contentPage.limit)
2110
+ loadContent()
2111
+ })
2112
+ })
2113
+ container.querySelectorAll('[data-content-page="next"]').forEach((b) => {
2114
+ b.addEventListener('click', () => {
2115
+ contentPage.offset += contentPage.limit
2116
+ loadContent()
2117
+ })
2118
+ })
2119
+
2120
+ container.querySelectorAll('.toggle-detail').forEach((btn) => {
2121
+ btn.addEventListener('click', () => {
2122
+ const detail = document.getElementById(btn.dataset.target)
2123
+ const isOpen = detail.classList.toggle('open')
2124
+ btn.textContent = btn.textContent.replace(/^(Show|Hide)/, isOpen ? 'Hide' : 'Show')
2125
+ })
2126
+ })
2127
+
2128
+ container.querySelectorAll('.remove-x').forEach((el) => {
2129
+ el.addEventListener('click', async () => {
2130
+ try {
2131
+ await api('/admin/labels/negate', {
2132
+ method: 'POST',
2133
+ headers: { 'Content-Type': 'application/json' },
2134
+ body: JSON.stringify({ uri: el.dataset.uri, val: el.dataset.val }),
2135
+ })
2136
+ toast(`Removed: ${el.dataset.val}`, 'success')
2137
+ loadContent()
2138
+ } catch (e) {
2139
+ toast(e.message, 'error')
2140
+ }
2141
+ })
2142
+ })
2143
+
2144
+ container.querySelectorAll('[data-action="add-label"]').forEach((btn) => {
2145
+ btn.addEventListener('click', async () => {
2146
+ const sel = document.getElementById(btn.dataset.sel)
2147
+ const val = sel.value
2148
+ if (!val) return
2149
+ try {
2150
+ await api('/admin/labels', {
2151
+ method: 'POST',
2152
+ headers: { 'Content-Type': 'application/json' },
2153
+ body: JSON.stringify({ uri: btn.dataset.uri, val }),
2154
+ })
2155
+ toast(`Applied: ${val}`, 'success')
2156
+ sel.value = ''
2157
+ loadContent()
2158
+ } catch (e) {
2159
+ toast(e.message, 'error')
2160
+ }
2161
+ })
2162
+ })
2163
+ }
2164
+ </script>
2165
+ </body>
2166
+ </html>