@hatk/hatk 0.0.1-alpha.94 → 0.0.1-alpha.96
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.
- package/dist/classifiers.d.ts +168 -0
- package/dist/classifiers.d.ts.map +1 -0
- package/dist/classifiers.js +370 -0
- package/dist/cli.js +1 -0
- package/dist/config.d.ts +14 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +11 -0
- package/dist/database/db.d.ts +78 -1
- package/dist/database/db.d.ts.map +1 -1
- package/dist/database/db.js +253 -1
- package/dist/database/index.d.ts +1 -1
- package/dist/database/index.d.ts.map +1 -1
- package/dist/database/index.js +1 -1
- package/dist/database/schema.d.ts +27 -0
- package/dist/database/schema.d.ts.map +1 -1
- package/dist/database/schema.js +38 -21
- package/dist/dev-entry.d.ts.map +1 -1
- package/dist/dev-entry.js +3 -4
- package/dist/hydrate.d.ts +8 -2
- package/dist/hydrate.d.ts.map +1 -1
- package/dist/main.js +8 -5
- package/dist/oauth/server.d.ts +36 -0
- package/dist/oauth/server.d.ts.map +1 -1
- package/dist/oauth/server.js +78 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +95 -6
- package/dist/spaces/blob.d.ts +67 -6
- package/dist/spaces/blob.d.ts.map +1 -1
- package/dist/spaces/blob.js +371 -22
- package/dist/spaces/index.d.ts +1 -1
- package/dist/spaces/index.d.ts.map +1 -1
- package/dist/spaces/index.js +1 -1
- package/dist/xrpc.d.ts +21 -0
- package/dist/xrpc.d.ts.map +1 -1
- package/dist/xrpc.js +40 -25
- package/package.json +10 -2
- package/public/admin.html +467 -0
package/public/admin.html
CHANGED
|
@@ -935,6 +935,130 @@
|
|
|
935
935
|
border-radius: 10px;
|
|
936
936
|
background: var(--bg);
|
|
937
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: background 0.1s, border-color 0.1s;
|
|
944
|
+
}
|
|
945
|
+
.review-card:last-child {
|
|
946
|
+
border-bottom: none;
|
|
947
|
+
}
|
|
948
|
+
.review-card.selected {
|
|
949
|
+
background: #faf8f6;
|
|
950
|
+
border-left-color: var(--accent);
|
|
951
|
+
}
|
|
952
|
+
.review-top {
|
|
953
|
+
display: flex;
|
|
954
|
+
align-items: flex-start;
|
|
955
|
+
justify-content: space-between;
|
|
956
|
+
gap: 0.75rem;
|
|
957
|
+
}
|
|
958
|
+
.review-handle {
|
|
959
|
+
font-weight: 650;
|
|
960
|
+
font-size: 0.95rem;
|
|
961
|
+
color: var(--text);
|
|
962
|
+
}
|
|
963
|
+
.review-score {
|
|
964
|
+
font-family: var(--mono);
|
|
965
|
+
font-size: 0.8rem;
|
|
966
|
+
font-weight: 700;
|
|
967
|
+
padding: 0.15rem 0.45rem;
|
|
968
|
+
border-radius: 6px;
|
|
969
|
+
flex-shrink: 0;
|
|
970
|
+
background: var(--bg);
|
|
971
|
+
color: var(--text-2);
|
|
972
|
+
}
|
|
973
|
+
.review-score.high {
|
|
974
|
+
background: #fdeaea;
|
|
975
|
+
color: #b4242a;
|
|
976
|
+
}
|
|
977
|
+
.review-score.mid {
|
|
978
|
+
background: #fdf3e3;
|
|
979
|
+
color: #9a6412;
|
|
980
|
+
}
|
|
981
|
+
.signal-row {
|
|
982
|
+
display: flex;
|
|
983
|
+
gap: 0.3rem;
|
|
984
|
+
flex-wrap: wrap;
|
|
985
|
+
margin-top: 0.45rem;
|
|
986
|
+
}
|
|
987
|
+
.signal-chip {
|
|
988
|
+
font-family: var(--mono);
|
|
989
|
+
font-size: 0.7rem;
|
|
990
|
+
padding: 0.1rem 0.4rem;
|
|
991
|
+
border-radius: 5px;
|
|
992
|
+
background: var(--bg);
|
|
993
|
+
color: var(--text-3);
|
|
994
|
+
border: 1px solid var(--border);
|
|
995
|
+
}
|
|
996
|
+
.signal-chip.crossed {
|
|
997
|
+
background: #fdeaea;
|
|
998
|
+
color: #b4242a;
|
|
999
|
+
border-color: #f3c9c9;
|
|
1000
|
+
}
|
|
1001
|
+
.review-state {
|
|
1002
|
+
font-size: 0.85rem;
|
|
1003
|
+
color: var(--text-2);
|
|
1004
|
+
margin-top: 0.45rem;
|
|
1005
|
+
white-space: pre-wrap;
|
|
1006
|
+
word-break: break-word;
|
|
1007
|
+
}
|
|
1008
|
+
.review-state .k {
|
|
1009
|
+
font-family: var(--mono);
|
|
1010
|
+
font-size: 0.72rem;
|
|
1011
|
+
color: var(--text-3);
|
|
1012
|
+
}
|
|
1013
|
+
.review-thumbs {
|
|
1014
|
+
display: flex;
|
|
1015
|
+
gap: 0.35rem;
|
|
1016
|
+
flex-wrap: wrap;
|
|
1017
|
+
margin-top: 0.55rem;
|
|
1018
|
+
}
|
|
1019
|
+
.review-thumbs img {
|
|
1020
|
+
width: 72px;
|
|
1021
|
+
height: 72px;
|
|
1022
|
+
object-fit: cover;
|
|
1023
|
+
border-radius: 8px;
|
|
1024
|
+
background: var(--bg);
|
|
1025
|
+
}
|
|
1026
|
+
.review-actions {
|
|
1027
|
+
display: flex;
|
|
1028
|
+
gap: 0.3rem;
|
|
1029
|
+
align-items: center;
|
|
1030
|
+
margin-top: 0.6rem;
|
|
1031
|
+
flex-wrap: wrap;
|
|
1032
|
+
}
|
|
1033
|
+
.scan-bar {
|
|
1034
|
+
display: flex;
|
|
1035
|
+
align-items: center;
|
|
1036
|
+
gap: 0.6rem;
|
|
1037
|
+
flex-wrap: wrap;
|
|
1038
|
+
padding: 0.6rem 1rem;
|
|
1039
|
+
border-bottom: 1px solid var(--border);
|
|
1040
|
+
font-size: 0.82rem;
|
|
1041
|
+
color: var(--text-2);
|
|
1042
|
+
}
|
|
1043
|
+
.kbd {
|
|
1044
|
+
font-family: var(--mono);
|
|
1045
|
+
font-size: 0.68rem;
|
|
1046
|
+
padding: 0.05rem 0.3rem;
|
|
1047
|
+
border: 1px solid var(--border);
|
|
1048
|
+
border-bottom-width: 2px;
|
|
1049
|
+
border-radius: 4px;
|
|
1050
|
+
color: var(--text-3);
|
|
1051
|
+
background: #fff;
|
|
1052
|
+
}
|
|
1053
|
+
.review-hint {
|
|
1054
|
+
font-size: 0.75rem;
|
|
1055
|
+
color: var(--text-3);
|
|
1056
|
+
padding: 0.5rem 1rem;
|
|
1057
|
+
display: flex;
|
|
1058
|
+
gap: 0.7rem;
|
|
1059
|
+
flex-wrap: wrap;
|
|
1060
|
+
align-items: center;
|
|
1061
|
+
}
|
|
938
1062
|
.blob-link {
|
|
939
1063
|
font-family: var(--mono);
|
|
940
1064
|
font-size: 0.72rem;
|
|
@@ -1213,6 +1337,7 @@
|
|
|
1213
1337
|
<button class="tab active" data-tab="overview">Overview</button>
|
|
1214
1338
|
<button class="tab" data-tab="repos">Repos</button>
|
|
1215
1339
|
<button class="tab" data-tab="content">Content</button>
|
|
1340
|
+
<button class="tab" data-tab="review">Review</button>
|
|
1216
1341
|
<button class="tab" data-tab="reports">Reports</button>
|
|
1217
1342
|
</nav>
|
|
1218
1343
|
|
|
@@ -1289,6 +1414,28 @@
|
|
|
1289
1414
|
</div>
|
|
1290
1415
|
|
|
1291
1416
|
<!-- Reports -->
|
|
1417
|
+
<div class="tab-panel" id="panel-review">
|
|
1418
|
+
<div class="search-bar">
|
|
1419
|
+
<select class="search-input" id="review-classifier" style="max-width: 200px">
|
|
1420
|
+
<option value="">All classifiers</option>
|
|
1421
|
+
</select>
|
|
1422
|
+
<select class="search-input" id="review-state" style="max-width: 180px">
|
|
1423
|
+
<option value="pending">Needs review</option>
|
|
1424
|
+
<option value="actioned">Actioned</option>
|
|
1425
|
+
<option value="all">All scored</option>
|
|
1426
|
+
</select>
|
|
1427
|
+
<select class="search-input" id="review-min" style="max-width: 160px">
|
|
1428
|
+
<option value="0.5">Score ≥ 0.50</option>
|
|
1429
|
+
<option value="0.6">Score ≥ 0.60</option>
|
|
1430
|
+
<option value="0.7">Score ≥ 0.70</option>
|
|
1431
|
+
<option value="0.8">Score ≥ 0.80</option>
|
|
1432
|
+
<option value="0">Any score</option>
|
|
1433
|
+
</select>
|
|
1434
|
+
<button class="btn btn-sm" id="review-scan-btn">Scan</button>
|
|
1435
|
+
</div>
|
|
1436
|
+
<div id="review-results"></div>
|
|
1437
|
+
</div>
|
|
1438
|
+
|
|
1292
1439
|
<div class="tab-panel" id="panel-reports">
|
|
1293
1440
|
<div class="search-bar">
|
|
1294
1441
|
<select class="search-input" id="reports-status" style="max-width: 200px">
|
|
@@ -1310,6 +1457,7 @@
|
|
|
1310
1457
|
<button class="bnav-btn active" data-tab="overview">Overview</button>
|
|
1311
1458
|
<button class="bnav-btn" data-tab="repos">Repos</button>
|
|
1312
1459
|
<button class="bnav-btn" data-tab="content">Content</button>
|
|
1460
|
+
<button class="bnav-btn" data-tab="review">Review</button>
|
|
1313
1461
|
<button class="bnav-btn" data-tab="reports">Reports</button>
|
|
1314
1462
|
</div>
|
|
1315
1463
|
</div>
|
|
@@ -1502,6 +1650,7 @@
|
|
|
1502
1650
|
if (tab === 'overview') loadOverview()
|
|
1503
1651
|
if (tab === 'repos') loadRepos()
|
|
1504
1652
|
if (tab === 'content') loadContent()
|
|
1653
|
+
if (tab === 'review') loadReview()
|
|
1505
1654
|
if (tab === 'reports') loadReports()
|
|
1506
1655
|
if (push) pushURL({ tab, status: '', q: '', offset: 0, cq: '' })
|
|
1507
1656
|
}
|
|
@@ -2168,6 +2317,324 @@
|
|
|
2168
2317
|
loadReports()
|
|
2169
2318
|
})
|
|
2170
2319
|
|
|
2320
|
+
// ── Review queue ──
|
|
2321
|
+
//
|
|
2322
|
+
// The queue exists so nobody has to look at every account. Each card
|
|
2323
|
+
// carries the state the model was shown and the score it returned, so a
|
|
2324
|
+
// decision can be made from the list without opening anything.
|
|
2325
|
+
|
|
2326
|
+
const review = { items: [], index: 0, total: 0, offset: 0, limit: 25, thumbs: new Map() }
|
|
2327
|
+
let reviewClassifiersLoaded = false
|
|
2328
|
+
let scanPoll = null
|
|
2329
|
+
|
|
2330
|
+
function scoreClass(v) {
|
|
2331
|
+
return v >= 0.8 ? 'high' : v >= 0.6 ? 'mid' : ''
|
|
2332
|
+
}
|
|
2333
|
+
|
|
2334
|
+
async function loadReview() {
|
|
2335
|
+
const container = document.getElementById('review-results')
|
|
2336
|
+
if (!reviewClassifiersLoaded) {
|
|
2337
|
+
try {
|
|
2338
|
+
const stats = await api('/admin/review/stats')
|
|
2339
|
+
const sel = document.getElementById('review-classifier')
|
|
2340
|
+
for (const name of stats.classifiers || []) {
|
|
2341
|
+
const o = document.createElement('option')
|
|
2342
|
+
o.value = o.textContent = name
|
|
2343
|
+
sel.appendChild(o)
|
|
2344
|
+
}
|
|
2345
|
+
reviewClassifiersLoaded = true
|
|
2346
|
+
} catch (e) {
|
|
2347
|
+
container.innerHTML = `<div class="empty-state">${escapeHtml(e.message)}</div>`
|
|
2348
|
+
return
|
|
2349
|
+
}
|
|
2350
|
+
}
|
|
2351
|
+
const classifier = document.getElementById('review-classifier').value
|
|
2352
|
+
const state = document.getElementById('review-state').value
|
|
2353
|
+
const minScore = document.getElementById('review-min').value
|
|
2354
|
+
container.innerHTML = '<div class="loading">Loading</div>'
|
|
2355
|
+
try {
|
|
2356
|
+
let url = `/admin/review?state=${state}&minScore=${minScore}&limit=${review.limit}&offset=${review.offset}`
|
|
2357
|
+
if (classifier) url += `&classifier=${encodeURIComponent(classifier)}`
|
|
2358
|
+
const result = await api(url)
|
|
2359
|
+
review.items = result.rows || []
|
|
2360
|
+
review.total = result.total
|
|
2361
|
+
review.index = 0
|
|
2362
|
+
renderReview()
|
|
2363
|
+
} catch (e) {
|
|
2364
|
+
container.innerHTML = `<div class="empty-state">${escapeHtml(e.message)}</div>`
|
|
2365
|
+
}
|
|
2366
|
+
}
|
|
2367
|
+
|
|
2368
|
+
function renderScanBar(stats) {
|
|
2369
|
+
const el = document.getElementById('review-scan-status')
|
|
2370
|
+
if (!el) return
|
|
2371
|
+
if (!stats.configured) {
|
|
2372
|
+
el.textContent = 'System One not configured — set `typesafe` in hatk.config.ts'
|
|
2373
|
+
return
|
|
2374
|
+
}
|
|
2375
|
+
const scan = stats.scan
|
|
2376
|
+
if (scan?.running) {
|
|
2377
|
+
el.textContent = `Scanning… ${scan.scored} scored, ${scan.skipped} skipped, ${scan.filed} filed`
|
|
2378
|
+
} else if (scan) {
|
|
2379
|
+
const cost = ((scan.inputTokens / 1e6) * 0.042).toFixed(3)
|
|
2380
|
+
el.textContent = `Last scan: ${scan.scored} scored, ${scan.skipped} unchanged, ${scan.filed} filed, $${cost}`
|
|
2381
|
+
} else {
|
|
2382
|
+
el.textContent = `${stats.total} scored · ${stats.pending} awaiting review`
|
|
2383
|
+
}
|
|
2384
|
+
}
|
|
2385
|
+
|
|
2386
|
+
// Render the model's state as short "key: value" lines. Long values are
|
|
2387
|
+
// clipped rather than dropped: the reason an account looks wrong is often
|
|
2388
|
+
// the first few words of its bio.
|
|
2389
|
+
function renderState(state) {
|
|
2390
|
+
if (!state || typeof state !== 'object') return ''
|
|
2391
|
+
const parts = []
|
|
2392
|
+
for (const [k, v] of Object.entries(state)) {
|
|
2393
|
+
if (v == null || v === '') continue
|
|
2394
|
+
let text
|
|
2395
|
+
if (typeof v === 'string') text = v
|
|
2396
|
+
else if (Array.isArray(v)) {
|
|
2397
|
+
text = v
|
|
2398
|
+
.map((x) => (typeof x === 'string' ? x : x?.title || x?.text || JSON.stringify(x)))
|
|
2399
|
+
.filter(Boolean)
|
|
2400
|
+
.slice(0, 3)
|
|
2401
|
+
.join(' · ')
|
|
2402
|
+
} else if (typeof v === 'object') {
|
|
2403
|
+
text = Object.entries(v)
|
|
2404
|
+
.filter(([, n]) => n)
|
|
2405
|
+
.map(([n, val]) => `${n} ${val}`)
|
|
2406
|
+
.join(', ')
|
|
2407
|
+
} else text = String(v)
|
|
2408
|
+
if (!text) continue
|
|
2409
|
+
if (text.length > 180) text = text.slice(0, 180) + '…'
|
|
2410
|
+
parts.push(`<span class="k">${escapeHtml(k)}</span> ${escapeHtml(text)}`)
|
|
2411
|
+
}
|
|
2412
|
+
return parts.join('<br>')
|
|
2413
|
+
}
|
|
2414
|
+
|
|
2415
|
+
function renderReview() {
|
|
2416
|
+
const container = document.getElementById('review-results')
|
|
2417
|
+
const labelOptions = labelDefinitions.map((d) => `<option value="${d.identifier}">${d.identifier}</option>`).join('')
|
|
2418
|
+
|
|
2419
|
+
if (!review.items.length) {
|
|
2420
|
+
container.innerHTML = `
|
|
2421
|
+
<div class="scan-bar"><span id="review-scan-status"></span></div>
|
|
2422
|
+
<div class="empty-state">Nothing awaiting review</div>`
|
|
2423
|
+
api('/admin/review/stats').then(renderScanBar).catch(() => {})
|
|
2424
|
+
return
|
|
2425
|
+
}
|
|
2426
|
+
|
|
2427
|
+
const showPagination = review.total > review.limit
|
|
2428
|
+
container.innerHTML = `
|
|
2429
|
+
<div class="card">
|
|
2430
|
+
<div class="scan-bar"><span id="review-scan-status"></span></div>
|
|
2431
|
+
<div class="review-hint">
|
|
2432
|
+
<span><span class="kbd">j</span><span class="kbd">k</span> move</span>
|
|
2433
|
+
<span><span class="kbd">t</span> takedown</span>
|
|
2434
|
+
<span><span class="kbd">l</span> label</span>
|
|
2435
|
+
<span><span class="kbd">d</span> dismiss</span>
|
|
2436
|
+
</div>
|
|
2437
|
+
${review.items
|
|
2438
|
+
.map((r, i) => {
|
|
2439
|
+
const signals = Object.entries(r.signals || {}).sort((a, b) => b[1].score - a[1].score)
|
|
2440
|
+
const top = Number(r.top_score || 0)
|
|
2441
|
+
const handle = r.handle ? '@' + escapeHtml(r.handle) : escapeHtml(r.subject_did)
|
|
2442
|
+
const takendown = r.repo_status === 'takendown'
|
|
2443
|
+
return `<div class="review-card${i === review.index ? ' selected' : ''}" data-review-index="${i}">
|
|
2444
|
+
<div class="review-top">
|
|
2445
|
+
<div style="min-width:0">
|
|
2446
|
+
<div class="review-handle">${handle}${takendown ? ' <span class="label-tag">taken down</span>' : ''}</div>
|
|
2447
|
+
<div class="record-uri" title="${escapeHtml(r.subject_uri)}">${escapeHtml(r.subject_uri)}</div>
|
|
2448
|
+
</div>
|
|
2449
|
+
<div class="review-score ${scoreClass(top)}">${top.toFixed(2)}</div>
|
|
2450
|
+
</div>
|
|
2451
|
+
<div class="signal-row">
|
|
2452
|
+
${signals
|
|
2453
|
+
.map(
|
|
2454
|
+
([name, sig]) =>
|
|
2455
|
+
`<span class="signal-chip${sig.score >= 0.5 ? ' crossed' : ''}" title="${escapeHtml(sig.classifier || '')}">${escapeHtml(name)} ${sig.score.toFixed(2)}${
|
|
2456
|
+
sig.choice ? ' · ' + escapeHtml(String(sig.choice)) : ''
|
|
2457
|
+
}</span>`,
|
|
2458
|
+
)
|
|
2459
|
+
.join('')}
|
|
2460
|
+
</div>
|
|
2461
|
+
<div class="review-state">${renderState(r.state)}</div>
|
|
2462
|
+
<div class="review-thumbs" data-thumbs-for="${escapeHtml(r.subject_did)}"></div>
|
|
2463
|
+
<div class="review-actions">
|
|
2464
|
+
<button class="btn btn-sm" data-review-act="takedown" data-i="${i}" style="background:#b4242a;color:white">Takedown</button>
|
|
2465
|
+
<select class="search-input" data-review-label="${i}" style="max-width:150px;font-size:0.8rem;padding:0.25rem 0.5rem">
|
|
2466
|
+
${labelOptions}
|
|
2467
|
+
</select>
|
|
2468
|
+
<button class="btn btn-sm" data-review-act="label" data-i="${i}">Apply label</button>
|
|
2469
|
+
<button class="btn btn-sm" data-review-act="dismiss" data-i="${i}">Dismiss</button>
|
|
2470
|
+
<a class="btn btn-sm" href="/admin?tab=repos&q=${encodeURIComponent(r.subject_did)}">Open in Repos</a>
|
|
2471
|
+
</div>
|
|
2472
|
+
</div>`
|
|
2473
|
+
})
|
|
2474
|
+
.join('')}
|
|
2475
|
+
${
|
|
2476
|
+
showPagination
|
|
2477
|
+
? `<div class="pagination">
|
|
2478
|
+
<span>${review.offset + 1}–${Math.min(review.offset + review.limit, review.total)} of ${review.total.toLocaleString()}</span>
|
|
2479
|
+
<div class="pagination-buttons">
|
|
2480
|
+
<button class="btn btn-sm" data-review-page="prev" ${review.offset === 0 ? 'disabled' : ''}>Prev</button>
|
|
2481
|
+
<button class="btn btn-sm" data-review-page="next" ${review.offset + review.limit >= review.total ? 'disabled' : ''}>Next</button>
|
|
2482
|
+
</div>
|
|
2483
|
+
</div>`
|
|
2484
|
+
: ''
|
|
2485
|
+
}
|
|
2486
|
+
</div>`
|
|
2487
|
+
|
|
2488
|
+
api('/admin/review/stats').then(renderScanBar).catch(() => {})
|
|
2489
|
+
wireReview()
|
|
2490
|
+
loadReviewThumbs()
|
|
2491
|
+
}
|
|
2492
|
+
|
|
2493
|
+
// Thumbnails come from a second request per subject, so they are fetched
|
|
2494
|
+
// after the cards are on screen and cached — the queue stays useful
|
|
2495
|
+
// whether or not they arrive.
|
|
2496
|
+
async function loadReviewThumbs() {
|
|
2497
|
+
for (const r of review.items) {
|
|
2498
|
+
const holder = document.querySelector(`[data-thumbs-for="${CSS.escape(r.subject_did)}"]`)
|
|
2499
|
+
if (!holder || holder.dataset.done) continue
|
|
2500
|
+
holder.dataset.done = '1'
|
|
2501
|
+
try {
|
|
2502
|
+
let ctx = review.thumbs.get(r.subject_did)
|
|
2503
|
+
if (!ctx) {
|
|
2504
|
+
ctx = await api(`/admin/review/subject/${encodeURIComponent(r.subject_did)}`)
|
|
2505
|
+
review.thumbs.set(r.subject_did, ctx)
|
|
2506
|
+
}
|
|
2507
|
+
const blobs = []
|
|
2508
|
+
for (const records of Object.values(ctx.records || {})) {
|
|
2509
|
+
for (const rec of records) {
|
|
2510
|
+
for (const b of findBlobs(rec.value || rec)) {
|
|
2511
|
+
if (b.cid && (b.mimeType || '').startsWith('image/')) blobs.push(b)
|
|
2512
|
+
}
|
|
2513
|
+
}
|
|
2514
|
+
}
|
|
2515
|
+
holder.innerHTML = blobs
|
|
2516
|
+
.slice(0, 6)
|
|
2517
|
+
.map((b) => `<img src="${blobUrl(r.subject_did, b.cid)}" loading="lazy" alt="">`)
|
|
2518
|
+
.join('')
|
|
2519
|
+
} catch {
|
|
2520
|
+
/* context is a nicety; the score and state already carry the decision */
|
|
2521
|
+
}
|
|
2522
|
+
}
|
|
2523
|
+
}
|
|
2524
|
+
|
|
2525
|
+
function selectReview(i) {
|
|
2526
|
+
if (!review.items.length) return
|
|
2527
|
+
review.index = Math.max(0, Math.min(review.items.length - 1, i))
|
|
2528
|
+
document.querySelectorAll('.review-card').forEach((el, n) => {
|
|
2529
|
+
el.classList.toggle('selected', n === review.index)
|
|
2530
|
+
if (n === review.index) el.scrollIntoView({ block: 'nearest' })
|
|
2531
|
+
})
|
|
2532
|
+
}
|
|
2533
|
+
|
|
2534
|
+
async function actOnReview(i, action) {
|
|
2535
|
+
const item = review.items[i]
|
|
2536
|
+
if (!item) return
|
|
2537
|
+
const body = { did: item.subject_did, uri: item.subject_uri, action }
|
|
2538
|
+
if (action === 'label') {
|
|
2539
|
+
const sel = document.querySelector(`[data-review-label="${i}"]`)
|
|
2540
|
+
body.label = sel?.value
|
|
2541
|
+
if (!body.label) return toast('Pick a label first', 'error')
|
|
2542
|
+
}
|
|
2543
|
+
try {
|
|
2544
|
+
await api('/admin/review/act', { method: 'POST', body: JSON.stringify(body) })
|
|
2545
|
+
toast(action === 'dismiss' ? 'Dismissed' : action === 'takedown' ? 'Taken down' : 'Label applied')
|
|
2546
|
+
review.items.splice(i, 1)
|
|
2547
|
+
review.total = Math.max(0, review.total - 1)
|
|
2548
|
+
if (review.index >= review.items.length) review.index = Math.max(0, review.items.length - 1)
|
|
2549
|
+
renderReview()
|
|
2550
|
+
} catch (e) {
|
|
2551
|
+
toast(e.message, 'error')
|
|
2552
|
+
}
|
|
2553
|
+
}
|
|
2554
|
+
|
|
2555
|
+
function wireReview() {
|
|
2556
|
+
document.querySelectorAll('[data-review-index]').forEach((el) => {
|
|
2557
|
+
el.addEventListener('click', (e) => {
|
|
2558
|
+
if (e.target.closest('button,select,a')) return
|
|
2559
|
+
selectReview(Number(el.dataset.reviewIndex))
|
|
2560
|
+
})
|
|
2561
|
+
})
|
|
2562
|
+
document.querySelectorAll('[data-review-act]').forEach((b) => {
|
|
2563
|
+
b.addEventListener('click', () => actOnReview(Number(b.dataset.i), b.dataset.reviewAct))
|
|
2564
|
+
})
|
|
2565
|
+
document.querySelectorAll('[data-review-page]').forEach((b) => {
|
|
2566
|
+
b.addEventListener('click', () => {
|
|
2567
|
+
review.offset += b.dataset.reviewPage === 'next' ? review.limit : -review.limit
|
|
2568
|
+
review.offset = Math.max(0, review.offset)
|
|
2569
|
+
loadReview()
|
|
2570
|
+
})
|
|
2571
|
+
})
|
|
2572
|
+
}
|
|
2573
|
+
|
|
2574
|
+
document.getElementById('review-classifier').addEventListener('change', () => {
|
|
2575
|
+
review.offset = 0
|
|
2576
|
+
loadReview()
|
|
2577
|
+
})
|
|
2578
|
+
document.getElementById('review-state').addEventListener('change', () => {
|
|
2579
|
+
review.offset = 0
|
|
2580
|
+
loadReview()
|
|
2581
|
+
})
|
|
2582
|
+
document.getElementById('review-min').addEventListener('change', () => {
|
|
2583
|
+
review.offset = 0
|
|
2584
|
+
loadReview()
|
|
2585
|
+
})
|
|
2586
|
+
|
|
2587
|
+
document.getElementById('review-scan-btn').addEventListener('click', async () => {
|
|
2588
|
+
const classifier = document.getElementById('review-classifier').value
|
|
2589
|
+
try {
|
|
2590
|
+
await api('/admin/review/scan', {
|
|
2591
|
+
method: 'POST',
|
|
2592
|
+
body: JSON.stringify(classifier ? { classifier } : {}),
|
|
2593
|
+
})
|
|
2594
|
+
toast('Scan started')
|
|
2595
|
+
if (scanPoll) clearInterval(scanPoll)
|
|
2596
|
+
scanPoll = setInterval(async () => {
|
|
2597
|
+
try {
|
|
2598
|
+
const stats = await api('/admin/review/stats')
|
|
2599
|
+
renderScanBar(stats)
|
|
2600
|
+
if (!stats.scan?.running) {
|
|
2601
|
+
clearInterval(scanPoll)
|
|
2602
|
+
scanPoll = null
|
|
2603
|
+
loadReview()
|
|
2604
|
+
}
|
|
2605
|
+
} catch {
|
|
2606
|
+
clearInterval(scanPoll)
|
|
2607
|
+
scanPoll = null
|
|
2608
|
+
}
|
|
2609
|
+
}, 2000)
|
|
2610
|
+
} catch (e) {
|
|
2611
|
+
toast(e.message, 'error')
|
|
2612
|
+
}
|
|
2613
|
+
})
|
|
2614
|
+
|
|
2615
|
+
document.addEventListener('keydown', (e) => {
|
|
2616
|
+
const panel = document.getElementById('panel-review')
|
|
2617
|
+
if (!panel?.classList.contains('active')) return
|
|
2618
|
+
if (e.target.matches('input,select,textarea')) return
|
|
2619
|
+
const key = e.key.toLowerCase()
|
|
2620
|
+
if (key === 'j' || e.key === 'ArrowDown') {
|
|
2621
|
+
e.preventDefault()
|
|
2622
|
+
selectReview(review.index + 1)
|
|
2623
|
+
} else if (key === 'k' || e.key === 'ArrowUp') {
|
|
2624
|
+
e.preventDefault()
|
|
2625
|
+
selectReview(review.index - 1)
|
|
2626
|
+
} else if (key === 't') {
|
|
2627
|
+
e.preventDefault()
|
|
2628
|
+
actOnReview(review.index, 'takedown')
|
|
2629
|
+
} else if (key === 'l') {
|
|
2630
|
+
e.preventDefault()
|
|
2631
|
+
actOnReview(review.index, 'label')
|
|
2632
|
+
} else if (key === 'd' || key === 'x') {
|
|
2633
|
+
e.preventDefault()
|
|
2634
|
+
actOnReview(review.index, 'dismiss')
|
|
2635
|
+
}
|
|
2636
|
+
})
|
|
2637
|
+
|
|
2171
2638
|
async function loadReports() {
|
|
2172
2639
|
populateReportsLabelFilter()
|
|
2173
2640
|
const status = document.getElementById('reports-status').value
|