@hatk/hatk 0.0.1-alpha.11 → 0.0.1-alpha.111

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 (224) hide show
  1. package/dist/adapter.d.ts +19 -0
  2. package/dist/adapter.d.ts.map +1 -0
  3. package/dist/adapter.js +113 -0
  4. package/dist/backfill.d.ts +36 -2
  5. package/dist/backfill.d.ts.map +1 -1
  6. package/dist/backfill.js +153 -37
  7. package/dist/car.js +1 -1
  8. package/dist/classifiers.d.ts +228 -0
  9. package/dist/classifiers.d.ts.map +1 -0
  10. package/dist/classifiers.js +688 -0
  11. package/dist/cli.js +248 -1043
  12. package/dist/config.d.ts +179 -1
  13. package/dist/config.d.ts.map +1 -1
  14. package/dist/config.js +78 -8
  15. package/dist/database/adapter-factory.d.ts +6 -0
  16. package/dist/database/adapter-factory.d.ts.map +1 -0
  17. package/dist/database/adapter-factory.js +20 -0
  18. package/dist/database/adapters/duckdb-search.d.ts +12 -0
  19. package/dist/database/adapters/duckdb-search.d.ts.map +1 -0
  20. package/dist/database/adapters/duckdb-search.js +27 -0
  21. package/dist/database/adapters/duckdb.d.ts +25 -0
  22. package/dist/database/adapters/duckdb.d.ts.map +1 -0
  23. package/dist/database/adapters/duckdb.js +161 -0
  24. package/dist/database/adapters/sqlite-search.d.ts +16 -0
  25. package/dist/database/adapters/sqlite-search.d.ts.map +1 -0
  26. package/dist/database/adapters/sqlite-search.js +76 -0
  27. package/dist/database/adapters/sqlite.d.ts +18 -0
  28. package/dist/database/adapters/sqlite.d.ts.map +1 -0
  29. package/dist/database/adapters/sqlite.js +88 -0
  30. package/dist/database/db.d.ts +290 -0
  31. package/dist/database/db.d.ts.map +1 -0
  32. package/dist/database/db.js +2073 -0
  33. package/dist/database/dialect.d.ts +45 -0
  34. package/dist/database/dialect.d.ts.map +1 -0
  35. package/dist/database/dialect.js +72 -0
  36. package/dist/{fts.d.ts → database/fts.d.ts} +7 -0
  37. package/dist/database/fts.d.ts.map +1 -0
  38. package/dist/{fts.js → database/fts.js} +122 -34
  39. package/dist/database/index.d.ts +7 -0
  40. package/dist/database/index.d.ts.map +1 -0
  41. package/dist/database/index.js +6 -0
  42. package/dist/database/ports.d.ts +50 -0
  43. package/dist/database/ports.d.ts.map +1 -0
  44. package/dist/database/ports.js +1 -0
  45. package/dist/database/schema.d.ts +91 -0
  46. package/dist/database/schema.d.ts.map +1 -0
  47. package/dist/{schema.js → database/schema.js} +153 -54
  48. package/dist/dev-entry.d.ts +8 -0
  49. package/dist/dev-entry.d.ts.map +1 -0
  50. package/dist/dev-entry.js +131 -0
  51. package/dist/feeds.d.ts +26 -8
  52. package/dist/feeds.d.ts.map +1 -1
  53. package/dist/feeds.js +72 -16
  54. package/dist/hooks.d.ts +102 -0
  55. package/dist/hooks.d.ts.map +1 -0
  56. package/dist/hooks.js +186 -0
  57. package/dist/hydrate.d.ts +55 -6
  58. package/dist/hydrate.d.ts.map +1 -1
  59. package/dist/hydrate.js +21 -20
  60. package/dist/indexer.d.ts +194 -3
  61. package/dist/indexer.d.ts.map +1 -1
  62. package/dist/indexer.js +627 -92
  63. package/dist/jetstream.d.ts +118 -0
  64. package/dist/jetstream.d.ts.map +1 -0
  65. package/dist/jetstream.js +238 -0
  66. package/dist/labels.d.ts +36 -0
  67. package/dist/labels.d.ts.map +1 -1
  68. package/dist/labels.js +71 -6
  69. package/dist/lex-types.d.ts +2 -0
  70. package/dist/lex-types.d.ts.map +1 -1
  71. package/dist/lexicon-resolve.d.ts.map +1 -1
  72. package/dist/lexicon-resolve.js +27 -112
  73. package/dist/lexicons/com/atproto/label/defs.json +75 -0
  74. package/dist/lexicons/com/atproto/moderation/defs.json +30 -0
  75. package/dist/lexicons/com/atproto/repo/strongRef.json +21 -0
  76. package/dist/lexicons/dev/hatk/applyWrites.json +87 -0
  77. package/dist/lexicons/dev/hatk/createRecord.json +40 -0
  78. package/dist/lexicons/dev/hatk/createReport.json +48 -0
  79. package/dist/lexicons/dev/hatk/deleteRecord.json +25 -0
  80. package/dist/lexicons/dev/hatk/describeCollections.json +41 -0
  81. package/dist/lexicons/dev/hatk/describeFeeds.json +29 -0
  82. package/dist/lexicons/dev/hatk/describeLabels.json +45 -0
  83. package/dist/lexicons/dev/hatk/getFeed.json +30 -0
  84. package/dist/lexicons/dev/hatk/getPreferences.json +19 -0
  85. package/dist/lexicons/dev/hatk/getRecord.json +26 -0
  86. package/dist/lexicons/dev/hatk/getRecords.json +32 -0
  87. package/dist/lexicons/dev/hatk/putPreference.json +28 -0
  88. package/dist/lexicons/dev/hatk/putRecord.json +41 -0
  89. package/dist/lexicons/dev/hatk/searchRecords.json +32 -0
  90. package/dist/lexicons/dev/hatk/uploadBlob.json +23 -0
  91. package/dist/logger.d.ts +29 -0
  92. package/dist/logger.d.ts.map +1 -1
  93. package/dist/logger.js +29 -0
  94. package/dist/main.js +212 -75
  95. package/dist/mst.d.ts +15 -0
  96. package/dist/mst.d.ts.map +1 -1
  97. package/dist/mst.js +13 -0
  98. package/dist/oauth/db.d.ts +27 -1
  99. package/dist/oauth/db.d.ts.map +1 -1
  100. package/dist/oauth/db.js +80 -19
  101. package/dist/oauth/discovery.d.ts +10 -0
  102. package/dist/oauth/discovery.d.ts.map +1 -1
  103. package/dist/oauth/discovery.js +54 -2
  104. package/dist/oauth/server.d.ts +131 -1
  105. package/dist/oauth/server.d.ts.map +1 -1
  106. package/dist/oauth/server.js +504 -39
  107. package/dist/oauth/service-describe.d.ts +42 -0
  108. package/dist/oauth/service-describe.d.ts.map +1 -0
  109. package/dist/oauth/service-describe.js +108 -0
  110. package/dist/oauth/session.d.ts +32 -0
  111. package/dist/oauth/session.d.ts.map +1 -0
  112. package/dist/oauth/session.js +131 -0
  113. package/dist/opengraph.d.ts +10 -0
  114. package/dist/opengraph.d.ts.map +1 -1
  115. package/dist/opengraph.js +94 -42
  116. package/dist/pds-proxy.d.ts +92 -0
  117. package/dist/pds-proxy.d.ts.map +1 -0
  118. package/dist/pds-proxy.js +396 -0
  119. package/dist/private-collections.d.ts +13 -0
  120. package/dist/private-collections.d.ts.map +1 -0
  121. package/dist/private-collections.js +27 -0
  122. package/dist/push.d.ts +54 -0
  123. package/dist/push.d.ts.map +1 -0
  124. package/dist/push.js +367 -0
  125. package/dist/renderer.d.ts +27 -0
  126. package/dist/renderer.d.ts.map +1 -0
  127. package/dist/renderer.js +46 -0
  128. package/dist/resolve-hatk.d.ts +6 -0
  129. package/dist/resolve-hatk.d.ts.map +1 -0
  130. package/dist/resolve-hatk.js +20 -0
  131. package/dist/response.d.ts +16 -0
  132. package/dist/response.d.ts.map +1 -0
  133. package/dist/response.js +69 -0
  134. package/dist/scanner.d.ts +22 -0
  135. package/dist/scanner.d.ts.map +1 -0
  136. package/dist/scanner.js +92 -0
  137. package/dist/seed.d.ts +19 -0
  138. package/dist/seed.d.ts.map +1 -1
  139. package/dist/seed.js +43 -4
  140. package/dist/server-init.d.ts +8 -0
  141. package/dist/server-init.d.ts.map +1 -0
  142. package/dist/server-init.js +75 -0
  143. package/dist/server.d.ts +46 -3
  144. package/dist/server.d.ts.map +1 -1
  145. package/dist/server.js +1137 -649
  146. package/dist/setup.d.ts +28 -1
  147. package/dist/setup.d.ts.map +1 -1
  148. package/dist/setup.js +50 -3
  149. package/dist/spaces/blob.d.ts +112 -0
  150. package/dist/spaces/blob.d.ts.map +1 -0
  151. package/dist/spaces/blob.js +460 -0
  152. package/dist/spaces/credential.d.ts +82 -0
  153. package/dist/spaces/credential.d.ts.map +1 -0
  154. package/dist/spaces/credential.js +220 -0
  155. package/dist/spaces/engine.d.ts +97 -0
  156. package/dist/spaces/engine.d.ts.map +1 -0
  157. package/dist/spaces/engine.js +563 -0
  158. package/dist/spaces/guard.d.ts +41 -0
  159. package/dist/spaces/guard.d.ts.map +1 -0
  160. package/dist/spaces/guard.js +79 -0
  161. package/dist/spaces/identity.d.ts +37 -0
  162. package/dist/spaces/identity.d.ts.map +1 -0
  163. package/dist/spaces/identity.js +93 -0
  164. package/dist/spaces/index.d.ts +56 -0
  165. package/dist/spaces/index.d.ts.map +1 -0
  166. package/dist/spaces/index.js +134 -0
  167. package/dist/spaces/notify.d.ts +60 -0
  168. package/dist/spaces/notify.d.ts.map +1 -0
  169. package/dist/spaces/notify.js +128 -0
  170. package/dist/spaces/records.d.ts +37 -0
  171. package/dist/spaces/records.d.ts.map +1 -0
  172. package/dist/spaces/records.js +63 -0
  173. package/dist/spaces/signature.d.ts +36 -0
  174. package/dist/spaces/signature.d.ts.map +1 -0
  175. package/dist/spaces/signature.js +83 -0
  176. package/dist/spaces/store.d.ts +51 -0
  177. package/dist/spaces/store.d.ts.map +1 -0
  178. package/dist/spaces/store.js +99 -0
  179. package/dist/spaces/uri.d.ts +60 -0
  180. package/dist/spaces/uri.d.ts.map +1 -0
  181. package/dist/spaces/uri.js +97 -0
  182. package/dist/spaces/verify.d.ts +46 -0
  183. package/dist/spaces/verify.d.ts.map +1 -0
  184. package/dist/spaces/verify.js +94 -0
  185. package/dist/spaces/viewer.d.ts +51 -0
  186. package/dist/spaces/viewer.d.ts.map +1 -0
  187. package/dist/spaces/viewer.js +167 -0
  188. package/dist/spaces/visibility.d.ts +57 -0
  189. package/dist/spaces/visibility.d.ts.map +1 -0
  190. package/dist/spaces/visibility.js +81 -0
  191. package/dist/templates/feed.tpl +18 -0
  192. package/dist/templates/hook.tpl +5 -0
  193. package/dist/templates/label.tpl +15 -0
  194. package/dist/templates/og.tpl +17 -0
  195. package/dist/templates/seed.tpl +11 -0
  196. package/dist/templates/setup.tpl +5 -0
  197. package/dist/templates/test-feed.tpl +19 -0
  198. package/dist/templates/test-xrpc.tpl +19 -0
  199. package/dist/templates/xrpc.tpl +53 -0
  200. package/dist/test.d.ts +3 -1
  201. package/dist/test.d.ts.map +1 -1
  202. package/dist/test.js +60 -36
  203. package/dist/views.js +1 -1
  204. package/dist/vite-plugin.d.ts +1 -1
  205. package/dist/vite-plugin.d.ts.map +1 -1
  206. package/dist/vite-plugin.js +254 -66
  207. package/dist/xrpc.d.ts +143 -12
  208. package/dist/xrpc.d.ts.map +1 -1
  209. package/dist/xrpc.js +282 -55
  210. package/package.json +29 -9
  211. package/public/admin-auth.js +271 -306
  212. package/public/admin.html +652 -61
  213. package/dist/db.d.ts +0 -135
  214. package/dist/db.d.ts.map +0 -1
  215. package/dist/db.js +0 -1365
  216. package/dist/fts.d.ts.map +0 -1
  217. package/dist/oauth/hooks.d.ts +0 -10
  218. package/dist/oauth/hooks.d.ts.map +0 -1
  219. package/dist/oauth/hooks.js +0 -40
  220. package/dist/schema.d.ts +0 -51
  221. package/dist/schema.d.ts.map +0 -1
  222. package/dist/test-browser.d.ts +0 -14
  223. package/dist/test-browser.d.ts.map +0 -1
  224. package/dist/test-browser.js +0 -26
package/public/admin.html CHANGED
@@ -6,10 +6,20 @@
6
6
  <title>Admin</title>
7
7
  <link rel="preconnect" href="https://fonts.googleapis.com" />
8
8
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
9
+ <!-- Loaded off the critical path: media="print" keeps it from blocking the
10
+ first render, and the system-ui/Menlo fallbacks carry until it swaps in. -->
9
11
  <link
10
12
  href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=Figtree:wght@400;500;600;700&display=swap"
11
13
  rel="stylesheet"
14
+ media="print"
15
+ onload="this.media = 'all'"
12
16
  />
17
+ <noscript>
18
+ <link
19
+ href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=Figtree:wght@400;500;600;700&display=swap"
20
+ rel="stylesheet"
21
+ />
22
+ </noscript>
13
23
  <style>
14
24
  *,
15
25
  *::before,
@@ -783,23 +793,6 @@
783
793
  font-size: 1rem;
784
794
  }
785
795
 
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
796
  .loading {
804
797
  color: var(--text-3);
805
798
  font-size: 0.9375rem;
@@ -942,6 +935,132 @@
942
935
  border-radius: 10px;
943
936
  background: var(--bg);
944
937
  }
938
+ /* ── Review queue ── */
939
+ .review-card {
940
+ border-bottom: 1px solid var(--border);
941
+ padding: 0.9rem 1rem 0.9rem 0.75rem;
942
+ border-left: 3px solid transparent;
943
+ transition:
944
+ background 0.1s,
945
+ border-color 0.1s;
946
+ }
947
+ .review-card:last-child {
948
+ border-bottom: none;
949
+ }
950
+ .review-card.selected {
951
+ background: #faf8f6;
952
+ border-left-color: var(--accent);
953
+ }
954
+ .review-top {
955
+ display: flex;
956
+ align-items: flex-start;
957
+ justify-content: space-between;
958
+ gap: 0.75rem;
959
+ }
960
+ .review-handle {
961
+ font-weight: 650;
962
+ font-size: 0.95rem;
963
+ color: var(--text);
964
+ }
965
+ .review-score {
966
+ font-family: var(--mono);
967
+ font-size: 0.8rem;
968
+ font-weight: 700;
969
+ padding: 0.15rem 0.45rem;
970
+ border-radius: 6px;
971
+ flex-shrink: 0;
972
+ background: var(--bg);
973
+ color: var(--text-2);
974
+ }
975
+ .review-score.high {
976
+ background: #fdeaea;
977
+ color: #b4242a;
978
+ }
979
+ .review-score.mid {
980
+ background: #fdf3e3;
981
+ color: #9a6412;
982
+ }
983
+ .signal-row {
984
+ display: flex;
985
+ gap: 0.3rem;
986
+ flex-wrap: wrap;
987
+ margin-top: 0.45rem;
988
+ }
989
+ .signal-chip {
990
+ font-family: var(--mono);
991
+ font-size: 0.7rem;
992
+ padding: 0.1rem 0.4rem;
993
+ border-radius: 5px;
994
+ background: var(--bg);
995
+ color: var(--text-3);
996
+ border: 1px solid var(--border);
997
+ }
998
+ .signal-chip.crossed {
999
+ background: #fdeaea;
1000
+ color: #b4242a;
1001
+ border-color: #f3c9c9;
1002
+ }
1003
+ .review-state {
1004
+ font-size: 0.85rem;
1005
+ color: var(--text-2);
1006
+ margin-top: 0.45rem;
1007
+ white-space: pre-wrap;
1008
+ word-break: break-word;
1009
+ }
1010
+ .review-state .k {
1011
+ font-family: var(--mono);
1012
+ font-size: 0.72rem;
1013
+ color: var(--text-3);
1014
+ }
1015
+ .review-thumbs {
1016
+ display: flex;
1017
+ gap: 0.35rem;
1018
+ flex-wrap: wrap;
1019
+ margin-top: 0.55rem;
1020
+ }
1021
+ .review-thumbs img {
1022
+ width: 72px;
1023
+ height: 72px;
1024
+ object-fit: cover;
1025
+ border-radius: 8px;
1026
+ background: var(--bg);
1027
+ }
1028
+ .review-actions {
1029
+ display: flex;
1030
+ gap: 0.3rem;
1031
+ align-items: center;
1032
+ margin-top: 0.6rem;
1033
+ flex-wrap: wrap;
1034
+ }
1035
+ .scan-bar {
1036
+ display: flex;
1037
+ align-items: center;
1038
+ gap: 0.6rem;
1039
+ flex-wrap: wrap;
1040
+ padding: 0.6rem 1rem;
1041
+ border-bottom: 1px solid var(--border);
1042
+ font-size: 0.82rem;
1043
+ color: var(--text-2);
1044
+ }
1045
+ .kbd {
1046
+ font-family: var(--mono);
1047
+ font-size: 0.68rem;
1048
+ padding: 0.05rem 0.3rem;
1049
+ border: 1px solid var(--border);
1050
+ border-bottom-width: 2px;
1051
+ border-radius: 4px;
1052
+ color: var(--text-3);
1053
+ background: #fff;
1054
+ }
1055
+ .review-hint {
1056
+ font-size: 0.75rem;
1057
+ color: var(--text-3);
1058
+ padding: 0.5rem 1rem;
1059
+ display: flex;
1060
+ gap: 0.7rem;
1061
+ flex-wrap: wrap;
1062
+ align-items: center;
1063
+ }
945
1064
  .blob-link {
946
1065
  font-family: var(--mono);
947
1066
  font-size: 0.72rem;
@@ -1220,7 +1339,8 @@
1220
1339
  <button class="tab active" data-tab="overview">Overview</button>
1221
1340
  <button class="tab" data-tab="repos">Repos</button>
1222
1341
  <button class="tab" data-tab="content">Content</button>
1223
- <button class="tab" data-tab="schema">Schema</button>
1342
+ <button class="tab" data-tab="review">Review</button>
1343
+ <button class="tab" data-tab="reports">Reports</button>
1224
1344
  </nav>
1225
1345
 
1226
1346
  <!-- Overview -->
@@ -1277,11 +1397,6 @@
1277
1397
  <div id="repos-results"><div class="loading">Loading</div></div>
1278
1398
  </div>
1279
1399
 
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
1400
  <!-- Content -->
1286
1401
  <div class="tab-panel" id="panel-content">
1287
1402
  <div class="search-bar">
@@ -1299,6 +1414,43 @@
1299
1414
  <div class="loading">Loading</div>
1300
1415
  </div>
1301
1416
  </div>
1417
+
1418
+ <!-- Reports -->
1419
+ <div class="tab-panel" id="panel-review">
1420
+ <div class="search-bar">
1421
+ <select class="search-input" id="review-classifier" style="max-width: 200px">
1422
+ <option value="">All classifiers</option>
1423
+ </select>
1424
+ <select class="search-input" id="review-state" style="max-width: 180px">
1425
+ <option value="pending">Needs review</option>
1426
+ <option value="actioned">Actioned</option>
1427
+ <option value="all">All scored</option>
1428
+ </select>
1429
+ <select class="search-input" id="review-min" style="max-width: 160px">
1430
+ <option value="0.5">Score &ge; 0.50</option>
1431
+ <option value="0.6">Score &ge; 0.60</option>
1432
+ <option value="0.7">Score &ge; 0.70</option>
1433
+ <option value="0.8">Score &ge; 0.80</option>
1434
+ <option value="0">Any score</option>
1435
+ </select>
1436
+ <button class="btn btn-sm" id="review-scan-btn">Scan</button>
1437
+ </div>
1438
+ <div id="review-results"></div>
1439
+ </div>
1440
+
1441
+ <div class="tab-panel" id="panel-reports">
1442
+ <div class="search-bar">
1443
+ <select class="search-input" id="reports-status" style="max-width: 200px">
1444
+ <option value="open">Open</option>
1445
+ <option value="resolved">Resolved</option>
1446
+ <option value="dismissed">Dismissed</option>
1447
+ </select>
1448
+ <select class="search-input" id="reports-label-filter" style="max-width: 200px">
1449
+ <option value="">All labels</option>
1450
+ </select>
1451
+ </div>
1452
+ <div id="reports-results"></div>
1453
+ </div>
1302
1454
  </div>
1303
1455
 
1304
1456
  <!-- Bottom nav (mobile) -->
@@ -1307,7 +1459,8 @@
1307
1459
  <button class="bnav-btn active" data-tab="overview">Overview</button>
1308
1460
  <button class="bnav-btn" data-tab="repos">Repos</button>
1309
1461
  <button class="bnav-btn" data-tab="content">Content</button>
1310
- <button class="bnav-btn" data-tab="schema">Schema</button>
1462
+ <button class="bnav-btn" data-tab="review">Review</button>
1463
+ <button class="bnav-btn" data-tab="reports">Reports</button>
1311
1464
  </div>
1312
1465
  </div>
1313
1466
  </div>
@@ -1325,6 +1478,8 @@
1325
1478
 
1326
1479
  window.auth = auth
1327
1480
  let labelDefinitions = []
1481
+ // Started during boot so the overview's data is in flight while we auth
1482
+ let overviewPrefetch = null
1328
1483
 
1329
1484
  function toast(msg, type = '') {
1330
1485
  const el = document.createElement('div')
@@ -1404,8 +1559,17 @@
1404
1559
  }
1405
1560
  loginScreen.style.display = 'none'
1406
1561
 
1562
+ // Fire these together — awaiting them one at a time puts three round
1563
+ // trips in front of the first render.
1564
+ const whoamiPromise = api('/admin/whoami')
1565
+ const definitionsPromise = api('/admin/labels/definitions').catch(() => null)
1566
+ if (getURLState().tab === 'overview') {
1567
+ overviewPrefetch = api('/admin/info')
1568
+ overviewPrefetch.catch(() => {}) // loadOverview reports the failure
1569
+ }
1570
+
1407
1571
  try {
1408
- const whoami = await api('/admin/whoami')
1572
+ const whoami = await whoamiPromise
1409
1573
  document.getElementById('viewer-did').textContent = whoami.did
1410
1574
  } catch (e) {
1411
1575
  document.getElementById('error-msg').textContent =
@@ -1416,10 +1580,7 @@
1416
1580
  return
1417
1581
  }
1418
1582
 
1419
- try {
1420
- const result = await api('/admin/labels/definitions')
1421
- labelDefinitions = result.definitions || []
1422
- } catch {}
1583
+ labelDefinitions = (await definitionsPromise)?.definitions || []
1423
1584
 
1424
1585
  appEl.classList.add('visible')
1425
1586
  restoreFromURL()
@@ -1490,8 +1651,9 @@
1490
1651
  document.getElementById(`panel-${tab}`).classList.add('active')
1491
1652
  if (tab === 'overview') loadOverview()
1492
1653
  if (tab === 'repos') loadRepos()
1493
- if (tab === 'schema') loadSchema()
1494
1654
  if (tab === 'content') loadContent()
1655
+ if (tab === 'review') loadReview()
1656
+ if (tab === 'reports') loadReports()
1495
1657
  if (push) pushURL({ tab, status: '', q: '', offset: 0, cq: '' })
1496
1658
  }
1497
1659
 
@@ -1506,7 +1668,9 @@
1506
1668
 
1507
1669
  async function loadOverview() {
1508
1670
  try {
1509
- const info = await api('/admin/info')
1671
+ const pending = overviewPrefetch
1672
+ overviewPrefetch = null
1673
+ const info = await (pending || api('/admin/info'))
1510
1674
  const statusCards = document.getElementById('status-cards')
1511
1675
  const repoStatuses = info.repos || {}
1512
1676
  const total = Object.values(repoStatuses).reduce((a, b) => a + b, 0)
@@ -1518,6 +1682,7 @@
1518
1682
  <div class="stat-card"><div class="stat-label">Pending</div><div class="stat-value yellow">${fmt(repoStatuses.pending)}</div></div>
1519
1683
  <div class="stat-card"><div class="stat-label">Failed</div><div class="stat-value red">${fmt(repoStatuses.failed)}</div></div>
1520
1684
  <div class="stat-card"><div class="stat-label">Taken Down</div><div class="stat-value red">${fmt(repoStatuses.takendown)}</div></div>
1685
+ ${info.openReports > 0 ? `<div class="stat-card" style="cursor:pointer" onclick="activateTab('reports')"><div class="stat-label">Open Reports</div><div class="stat-value yellow">${fmt(info.openReports)}</div></div>` : ''}
1521
1686
  `
1522
1687
 
1523
1688
  const collectionCards = document.getElementById('collection-cards')
@@ -1599,36 +1764,6 @@
1599
1764
  }
1600
1765
  })
1601
1766
 
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
1767
  // ── Repos ──
1633
1768
 
1634
1769
  let reposLoaded = false
@@ -2161,6 +2296,462 @@
2161
2296
  })
2162
2297
  })
2163
2298
  }
2299
+
2300
+ // ── Reports ──
2301
+
2302
+ const reportsPage = { limit: 50, offset: 0 }
2303
+
2304
+ function populateReportsLabelFilter() {
2305
+ const select = document.getElementById('reports-label-filter')
2306
+ const current = select.value
2307
+ select.innerHTML =
2308
+ '<option value="">All labels</option>' +
2309
+ labelDefinitions.map((d) => `<option value="${d.identifier}">${d.identifier}</option>`).join('')
2310
+ select.value = current
2311
+ }
2312
+
2313
+ document.getElementById('reports-status').addEventListener('change', () => {
2314
+ reportsPage.offset = 0
2315
+ loadReports()
2316
+ })
2317
+ document.getElementById('reports-label-filter').addEventListener('change', () => {
2318
+ reportsPage.offset = 0
2319
+ loadReports()
2320
+ })
2321
+
2322
+ // ── Review queue ──
2323
+ //
2324
+ // The queue exists so nobody has to look at every account. Each card
2325
+ // carries the state the model was shown and the score it returned, so a
2326
+ // decision can be made from the list without opening anything.
2327
+
2328
+ const review = { items: [], index: 0, total: 0, offset: 0, limit: 25, thumbs: new Map() }
2329
+ let reviewClassifiersLoaded = false
2330
+ let scanPoll = null
2331
+
2332
+ function scoreClass(v) {
2333
+ return v >= 0.8 ? 'high' : v >= 0.6 ? 'mid' : ''
2334
+ }
2335
+
2336
+ async function loadReview() {
2337
+ const container = document.getElementById('review-results')
2338
+ if (!reviewClassifiersLoaded) {
2339
+ try {
2340
+ const stats = await api('/admin/review/stats')
2341
+ const sel = document.getElementById('review-classifier')
2342
+ for (const name of stats.classifiers || []) {
2343
+ const o = document.createElement('option')
2344
+ o.value = o.textContent = name
2345
+ sel.appendChild(o)
2346
+ }
2347
+ reviewClassifiersLoaded = true
2348
+ } catch (e) {
2349
+ container.innerHTML = `<div class="empty-state">${escapeHtml(e.message)}</div>`
2350
+ return
2351
+ }
2352
+ }
2353
+ const classifier = document.getElementById('review-classifier').value
2354
+ const state = document.getElementById('review-state').value
2355
+ const minScore = document.getElementById('review-min').value
2356
+ container.innerHTML = '<div class="loading">Loading</div>'
2357
+ try {
2358
+ let url = `/admin/review?state=${state}&minScore=${minScore}&limit=${review.limit}&offset=${review.offset}`
2359
+ if (classifier) url += `&classifier=${encodeURIComponent(classifier)}`
2360
+ const result = await api(url)
2361
+ review.items = result.rows || []
2362
+ review.total = result.total
2363
+ review.index = 0
2364
+ renderReview()
2365
+ } catch (e) {
2366
+ container.innerHTML = `<div class="empty-state">${escapeHtml(e.message)}</div>`
2367
+ }
2368
+ }
2369
+
2370
+ function renderScanBar(stats) {
2371
+ const el = document.getElementById('review-scan-status')
2372
+ if (!el) return
2373
+ if (!stats.configured) {
2374
+ el.textContent = 'Clef not configured — set `clef` in hatk.config.ts'
2375
+ return
2376
+ }
2377
+ const scan = stats.scan
2378
+ if (scan?.running) {
2379
+ el.textContent = `Scanning… ${scan.scored} scored, ${scan.skipped} skipped, ${scan.filed} filed`
2380
+ } else if (scan) {
2381
+ const cost = ((scan.inputTokens / 1e6) * 0.042).toFixed(3)
2382
+ el.textContent = `Last scan: ${scan.scored} scored, ${scan.skipped} unchanged, ${scan.filed} filed, $${cost}`
2383
+ } else {
2384
+ el.textContent = `${stats.total} scored · ${stats.pending} awaiting review`
2385
+ }
2386
+ }
2387
+
2388
+ // Render the model's state as short "key: value" lines. Long values are
2389
+ // clipped rather than dropped: the reason an account looks wrong is often
2390
+ // the first few words of its bio.
2391
+ function renderState(state) {
2392
+ if (!state || typeof state !== 'object') return ''
2393
+ const parts = []
2394
+ for (const [k, v] of Object.entries(state)) {
2395
+ if (v == null || v === '') continue
2396
+ let text
2397
+ if (typeof v === 'string') text = v
2398
+ else if (Array.isArray(v)) {
2399
+ text = v
2400
+ .map((x) => (typeof x === 'string' ? x : x?.title || x?.text || JSON.stringify(x)))
2401
+ .filter(Boolean)
2402
+ .slice(0, 3)
2403
+ .join(' · ')
2404
+ } else if (typeof v === 'object') {
2405
+ text = Object.entries(v)
2406
+ .filter(([, n]) => n)
2407
+ .map(([n, val]) => `${n} ${val}`)
2408
+ .join(', ')
2409
+ } else text = String(v)
2410
+ if (!text) continue
2411
+ if (text.length > 180) text = text.slice(0, 180) + '…'
2412
+ parts.push(`<span class="k">${escapeHtml(k)}</span> ${escapeHtml(text)}`)
2413
+ }
2414
+ return parts.join('<br>')
2415
+ }
2416
+
2417
+ function renderReview() {
2418
+ const container = document.getElementById('review-results')
2419
+ const labelOptions = labelDefinitions
2420
+ .map((d) => `<option value="${d.identifier}">${d.identifier}</option>`)
2421
+ .join('')
2422
+
2423
+ if (!review.items.length) {
2424
+ container.innerHTML = `
2425
+ <div class="scan-bar"><span id="review-scan-status"></span></div>
2426
+ <div class="empty-state">Nothing awaiting review</div>`
2427
+ api('/admin/review/stats')
2428
+ .then(renderScanBar)
2429
+ .catch(() => {})
2430
+ return
2431
+ }
2432
+
2433
+ const showPagination = review.total > review.limit
2434
+ container.innerHTML = `
2435
+ <div class="card">
2436
+ <div class="scan-bar"><span id="review-scan-status"></span></div>
2437
+ <div class="review-hint">
2438
+ <span><span class="kbd">j</span><span class="kbd">k</span> move</span>
2439
+ <span><span class="kbd">t</span> takedown</span>
2440
+ <span><span class="kbd">l</span> label</span>
2441
+ <span><span class="kbd">d</span> dismiss</span>
2442
+ </div>
2443
+ ${review.items
2444
+ .map((r, i) => {
2445
+ const signals = Object.entries(r.signals || {}).sort((a, b) => b[1].score - a[1].score)
2446
+ const top = Number(r.top_score || 0)
2447
+ const handle = r.handle ? '@' + escapeHtml(r.handle) : escapeHtml(r.subject_did)
2448
+ const takendown = r.repo_status === 'takendown'
2449
+ return `<div class="review-card${i === review.index ? ' selected' : ''}" data-review-index="${i}">
2450
+ <div class="review-top">
2451
+ <div style="min-width:0">
2452
+ <div class="review-handle">${handle}${takendown ? ' <span class="label-tag">taken down</span>' : ''}</div>
2453
+ <div class="record-uri" title="${escapeHtml(r.subject_uri)}">${escapeHtml(r.subject_uri)}</div>
2454
+ </div>
2455
+ <div class="review-score ${scoreClass(top)}">${top.toFixed(2)}</div>
2456
+ </div>
2457
+ <div class="signal-row">
2458
+ ${signals
2459
+ .map(
2460
+ ([name, sig]) =>
2461
+ `<span class="signal-chip${sig.score >= 0.5 ? ' crossed' : ''}" title="${escapeHtml(sig.classifier || '')}">${escapeHtml(name)} ${sig.score.toFixed(2)}${
2462
+ sig.choice ? ' · ' + escapeHtml(String(sig.choice)) : ''
2463
+ }</span>`,
2464
+ )
2465
+ .join('')}
2466
+ </div>
2467
+ <div class="review-state">${renderState(r.state)}</div>
2468
+ <div class="review-thumbs" data-thumbs-for="${escapeHtml(r.subject_did)}"></div>
2469
+ <div class="review-actions">
2470
+ <button class="btn btn-sm" data-review-act="takedown" data-i="${i}" style="background:#b4242a;color:white">Takedown</button>
2471
+ <select class="search-input" data-review-label="${i}" style="max-width:150px;font-size:0.8rem;padding:0.25rem 0.5rem">
2472
+ ${labelOptions}
2473
+ </select>
2474
+ <button class="btn btn-sm" data-review-act="label" data-i="${i}">Apply label</button>
2475
+ <button class="btn btn-sm" data-review-act="dismiss" data-i="${i}">Dismiss</button>
2476
+ <a class="btn btn-sm" href="/admin?tab=repos&q=${encodeURIComponent(r.subject_did)}">Open in Repos</a>
2477
+ </div>
2478
+ </div>`
2479
+ })
2480
+ .join('')}
2481
+ ${
2482
+ showPagination
2483
+ ? `<div class="pagination">
2484
+ <span>${review.offset + 1}–${Math.min(review.offset + review.limit, review.total)} of ${review.total.toLocaleString()}</span>
2485
+ <div class="pagination-buttons">
2486
+ <button class="btn btn-sm" data-review-page="prev" ${review.offset === 0 ? 'disabled' : ''}>Prev</button>
2487
+ <button class="btn btn-sm" data-review-page="next" ${review.offset + review.limit >= review.total ? 'disabled' : ''}>Next</button>
2488
+ </div>
2489
+ </div>`
2490
+ : ''
2491
+ }
2492
+ </div>`
2493
+
2494
+ api('/admin/review/stats')
2495
+ .then(renderScanBar)
2496
+ .catch(() => {})
2497
+ wireReview()
2498
+ loadReviewThumbs()
2499
+ }
2500
+
2501
+ // Thumbnails come from a second request per subject, so they are fetched
2502
+ // after the cards are on screen and cached — the queue stays useful
2503
+ // whether or not they arrive.
2504
+ async function loadReviewThumbs() {
2505
+ for (const r of review.items) {
2506
+ const holder = document.querySelector(`[data-thumbs-for="${CSS.escape(r.subject_did)}"]`)
2507
+ if (!holder || holder.dataset.done) continue
2508
+ holder.dataset.done = '1'
2509
+ try {
2510
+ let ctx = review.thumbs.get(r.subject_did)
2511
+ if (!ctx) {
2512
+ ctx = await api(`/admin/review/subject/${encodeURIComponent(r.subject_did)}`)
2513
+ review.thumbs.set(r.subject_did, ctx)
2514
+ }
2515
+ const blobs = []
2516
+ for (const records of Object.values(ctx.records || {})) {
2517
+ for (const rec of records) {
2518
+ for (const b of findBlobs(rec.value || rec)) {
2519
+ if (b.cid && (b.mimeType || '').startsWith('image/')) blobs.push(b)
2520
+ }
2521
+ }
2522
+ }
2523
+ holder.innerHTML = blobs
2524
+ .slice(0, 6)
2525
+ .map((b) => `<img src="${blobUrl(r.subject_did, b.cid)}" loading="lazy" alt="">`)
2526
+ .join('')
2527
+ } catch {
2528
+ /* context is a nicety; the score and state already carry the decision */
2529
+ }
2530
+ }
2531
+ }
2532
+
2533
+ function selectReview(i) {
2534
+ if (!review.items.length) return
2535
+ review.index = Math.max(0, Math.min(review.items.length - 1, i))
2536
+ document.querySelectorAll('.review-card').forEach((el, n) => {
2537
+ el.classList.toggle('selected', n === review.index)
2538
+ if (n === review.index) el.scrollIntoView({ block: 'nearest' })
2539
+ })
2540
+ }
2541
+
2542
+ async function actOnReview(i, action) {
2543
+ const item = review.items[i]
2544
+ if (!item) return
2545
+ const body = { did: item.subject_did, uri: item.subject_uri, action }
2546
+ if (action === 'label') {
2547
+ const sel = document.querySelector(`[data-review-label="${i}"]`)
2548
+ body.label = sel?.value
2549
+ if (!body.label) return toast('Pick a label first', 'error')
2550
+ }
2551
+ try {
2552
+ await api('/admin/review/act', { method: 'POST', body: JSON.stringify(body) })
2553
+ toast(action === 'dismiss' ? 'Dismissed' : action === 'takedown' ? 'Taken down' : 'Label applied')
2554
+ review.items.splice(i, 1)
2555
+ review.total = Math.max(0, review.total - 1)
2556
+ if (review.index >= review.items.length) review.index = Math.max(0, review.items.length - 1)
2557
+ renderReview()
2558
+ } catch (e) {
2559
+ toast(e.message, 'error')
2560
+ }
2561
+ }
2562
+
2563
+ function wireReview() {
2564
+ document.querySelectorAll('[data-review-index]').forEach((el) => {
2565
+ el.addEventListener('click', (e) => {
2566
+ if (e.target.closest('button,select,a')) return
2567
+ selectReview(Number(el.dataset.reviewIndex))
2568
+ })
2569
+ })
2570
+ document.querySelectorAll('[data-review-act]').forEach((b) => {
2571
+ b.addEventListener('click', () => actOnReview(Number(b.dataset.i), b.dataset.reviewAct))
2572
+ })
2573
+ document.querySelectorAll('[data-review-page]').forEach((b) => {
2574
+ b.addEventListener('click', () => {
2575
+ review.offset += b.dataset.reviewPage === 'next' ? review.limit : -review.limit
2576
+ review.offset = Math.max(0, review.offset)
2577
+ loadReview()
2578
+ })
2579
+ })
2580
+ }
2581
+
2582
+ document.getElementById('review-classifier').addEventListener('change', () => {
2583
+ review.offset = 0
2584
+ loadReview()
2585
+ })
2586
+ document.getElementById('review-state').addEventListener('change', () => {
2587
+ review.offset = 0
2588
+ loadReview()
2589
+ })
2590
+ document.getElementById('review-min').addEventListener('change', () => {
2591
+ review.offset = 0
2592
+ loadReview()
2593
+ })
2594
+
2595
+ document.getElementById('review-scan-btn').addEventListener('click', async () => {
2596
+ const classifier = document.getElementById('review-classifier').value
2597
+ try {
2598
+ await api('/admin/review/scan', {
2599
+ method: 'POST',
2600
+ body: JSON.stringify(classifier ? { classifier } : {}),
2601
+ })
2602
+ toast('Scan started')
2603
+ if (scanPoll) clearInterval(scanPoll)
2604
+ scanPoll = setInterval(async () => {
2605
+ try {
2606
+ const stats = await api('/admin/review/stats')
2607
+ renderScanBar(stats)
2608
+ if (!stats.scan?.running) {
2609
+ clearInterval(scanPoll)
2610
+ scanPoll = null
2611
+ loadReview()
2612
+ }
2613
+ } catch {
2614
+ clearInterval(scanPoll)
2615
+ scanPoll = null
2616
+ }
2617
+ }, 2000)
2618
+ } catch (e) {
2619
+ toast(e.message, 'error')
2620
+ }
2621
+ })
2622
+
2623
+ document.addEventListener('keydown', (e) => {
2624
+ const panel = document.getElementById('panel-review')
2625
+ if (!panel?.classList.contains('active')) return
2626
+ if (e.target.matches('input,select,textarea')) return
2627
+ const key = e.key.toLowerCase()
2628
+ if (key === 'j' || e.key === 'ArrowDown') {
2629
+ e.preventDefault()
2630
+ selectReview(review.index + 1)
2631
+ } else if (key === 'k' || e.key === 'ArrowUp') {
2632
+ e.preventDefault()
2633
+ selectReview(review.index - 1)
2634
+ } else if (key === 't') {
2635
+ e.preventDefault()
2636
+ actOnReview(review.index, 'takedown')
2637
+ } else if (key === 'l') {
2638
+ e.preventDefault()
2639
+ actOnReview(review.index, 'label')
2640
+ } else if (key === 'd' || key === 'x') {
2641
+ e.preventDefault()
2642
+ actOnReview(review.index, 'dismiss')
2643
+ }
2644
+ })
2645
+
2646
+ async function loadReports() {
2647
+ populateReportsLabelFilter()
2648
+ const status = document.getElementById('reports-status').value
2649
+ const label = document.getElementById('reports-label-filter').value
2650
+ const container = document.getElementById('reports-results')
2651
+ container.innerHTML = '<div class="loading">Loading</div>'
2652
+ try {
2653
+ let url = `/admin/reports?status=${status}&limit=${reportsPage.limit}&offset=${reportsPage.offset}`
2654
+ if (label) url += `&label=${encodeURIComponent(label)}`
2655
+ const result = await api(url)
2656
+ renderReports(result.reports || [], result.total)
2657
+ } catch (e) {
2658
+ container.innerHTML = `<div class="empty-state">${escapeHtml(e.message)}</div>`
2659
+ }
2660
+ }
2661
+
2662
+ function renderReports(reports, total) {
2663
+ const container = document.getElementById('reports-results')
2664
+ if (!reports.length) {
2665
+ container.innerHTML = '<div class="empty-state">No reports found</div>'
2666
+ return
2667
+ }
2668
+
2669
+ const showPagination = total != null && total > reportsPage.limit
2670
+ const paginationHtml = showPagination
2671
+ ? `
2672
+ <div class="pagination">
2673
+ <span>${reportsPage.offset + 1}\u2013${Math.min(reportsPage.offset + reportsPage.limit, total)} of ${total.toLocaleString()}</span>
2674
+ <div class="pagination-buttons">
2675
+ <button class="btn btn-sm" data-reports-page="prev" ${reportsPage.offset === 0 ? 'disabled' : ''}>Prev</button>
2676
+ <button class="btn btn-sm" data-reports-page="next" ${reportsPage.offset + reportsPage.limit >= total ? 'disabled' : ''}>Next</button>
2677
+ </div>
2678
+ </div>
2679
+ `
2680
+ : ''
2681
+
2682
+ const countLabel =
2683
+ total != null
2684
+ ? `${total.toLocaleString()} report${total !== 1 ? 's' : ''}`
2685
+ : `${reports.length} result${reports.length !== 1 ? 's' : ''}`
2686
+
2687
+ const isOpen = document.getElementById('reports-status').value === 'open'
2688
+
2689
+ container.innerHTML = `
2690
+ <div class="card">
2691
+ <div class="result-count">${countLabel}</div>
2692
+ ${reports
2693
+ .map((r) => {
2694
+ const reporterDisplay = r.reported_by_handle
2695
+ ? `@${escapeHtml(r.reported_by_handle)}`
2696
+ : escapeHtml(r.reported_by)
2697
+ const date = new Date(r.created_at).toLocaleString()
2698
+ return `<div class="record-card">
2699
+ <div class="record-header">
2700
+ <div class="record-meta">
2701
+ <div class="record-uri" title="${escapeHtml(r.subject_uri)}">${escapeHtml(r.subject_uri)}</div>
2702
+ <div class="record-summary">
2703
+ <span class="label-tag">${escapeHtml(r.label)}</span>
2704
+ reported by ${reporterDisplay} &middot; ${date}
2705
+ </div>
2706
+ ${r.reason ? `<div class="record-summary" style="margin-top:0.25rem">${escapeHtml(r.reason)}</div>` : ''}
2707
+ ${!isOpen ? `<div class="record-summary" style="margin-top:0.25rem;opacity:0.6">${escapeHtml(r.status)}${r.resolved_by ? ` by ${escapeHtml(r.resolved_by)}` : ''}</div>` : ''}
2708
+ </div>
2709
+ ${
2710
+ isOpen
2711
+ ? `<div class="record-actions">
2712
+ <button class="btn btn-sm" data-action="resolve-report" data-id="${r.id}" data-resolve="resolve" style="background:var(--accent);color:white">Apply Label</button>
2713
+ <button class="btn btn-sm" data-action="resolve-report" data-id="${r.id}" data-resolve="dismiss">Dismiss</button>
2714
+ </div>`
2715
+ : ''
2716
+ }
2717
+ </div>
2718
+ </div>`
2719
+ })
2720
+ .join('')}
2721
+ ${paginationHtml}
2722
+ </div>
2723
+ `
2724
+
2725
+ container.querySelectorAll('[data-reports-page="prev"]').forEach((b) => {
2726
+ b.addEventListener('click', () => {
2727
+ reportsPage.offset = Math.max(0, reportsPage.offset - reportsPage.limit)
2728
+ loadReports()
2729
+ })
2730
+ })
2731
+ container.querySelectorAll('[data-reports-page="next"]').forEach((b) => {
2732
+ b.addEventListener('click', () => {
2733
+ reportsPage.offset += reportsPage.limit
2734
+ loadReports()
2735
+ })
2736
+ })
2737
+
2738
+ container.querySelectorAll('[data-action="resolve-report"]').forEach((btn) => {
2739
+ btn.addEventListener('click', async () => {
2740
+ const action = btn.dataset.resolve
2741
+ try {
2742
+ await api('/admin/reports/resolve', {
2743
+ method: 'POST',
2744
+ headers: { 'Content-Type': 'application/json' },
2745
+ body: JSON.stringify({ id: parseInt(btn.dataset.id), action }),
2746
+ })
2747
+ toast(action === 'resolve' ? 'Label applied & report resolved' : 'Report dismissed', 'success')
2748
+ loadReports()
2749
+ } catch (e) {
2750
+ toast(e.message, 'error')
2751
+ }
2752
+ })
2753
+ })
2754
+ }
2164
2755
  </script>
2165
2756
  </body>
2166
2757
  </html>