@hatk/hatk 0.0.1-alpha.40 → 0.0.1-alpha.42
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/cli.js +16 -553
- package/dist/database/adapters/sqlite.d.ts.map +1 -1
- package/dist/database/adapters/sqlite.js +2 -1
- package/dist/database/db.d.ts +23 -0
- package/dist/database/db.d.ts.map +1 -1
- package/dist/database/db.js +81 -4
- package/dist/dev-entry.d.ts.map +1 -1
- package/dist/dev-entry.js +2 -1
- package/dist/hooks.d.ts +24 -3
- package/dist/hooks.d.ts.map +1 -1
- package/dist/hooks.js +34 -7
- package/dist/indexer.d.ts +2 -0
- package/dist/indexer.d.ts.map +1 -1
- package/dist/indexer.js +17 -0
- package/dist/labels.d.ts +2 -0
- package/dist/labels.d.ts.map +1 -1
- package/dist/labels.js +5 -0
- package/dist/lexicon-resolve.d.ts.map +1 -1
- package/dist/lexicon-resolve.js +27 -112
- package/dist/lexicons/com/atproto/label/defs.json +75 -0
- package/dist/lexicons/com/atproto/moderation/defs.json +30 -0
- package/dist/lexicons/com/atproto/repo/strongRef.json +24 -0
- package/dist/lexicons/dev/hatk/createRecord.json +40 -0
- package/dist/lexicons/dev/hatk/createReport.json +48 -0
- package/dist/lexicons/dev/hatk/deleteRecord.json +25 -0
- package/dist/lexicons/dev/hatk/describeCollections.json +41 -0
- package/dist/lexicons/dev/hatk/describeFeeds.json +29 -0
- package/dist/lexicons/dev/hatk/describeLabels.json +31 -0
- package/dist/lexicons/dev/hatk/getFeed.json +30 -0
- package/dist/lexicons/dev/hatk/getPreferences.json +19 -0
- package/dist/lexicons/dev/hatk/getRecord.json +26 -0
- package/dist/lexicons/dev/hatk/getRecords.json +32 -0
- package/dist/lexicons/dev/hatk/putPreference.json +28 -0
- package/dist/lexicons/dev/hatk/putRecord.json +41 -0
- package/dist/lexicons/dev/hatk/searchRecords.json +32 -0
- package/dist/lexicons/dev/hatk/uploadBlob.json +23 -0
- package/dist/main.js +2 -1
- package/dist/oauth/server.d.ts.map +1 -1
- package/dist/oauth/server.js +3 -2
- package/dist/pds-proxy.d.ts.map +1 -1
- package/dist/pds-proxy.js +15 -0
- package/dist/server-init.d.ts.map +1 -1
- package/dist/server-init.js +3 -2
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +91 -13
- package/dist/templates/feed.tpl +14 -0
- package/dist/templates/hook.tpl +5 -0
- package/dist/templates/label.tpl +15 -0
- package/dist/templates/og.tpl +17 -0
- package/dist/templates/seed.tpl +11 -0
- package/dist/templates/setup.tpl +5 -0
- package/dist/templates/test-feed.tpl +19 -0
- package/dist/templates/test-xrpc.tpl +19 -0
- package/dist/templates/xrpc.tpl +41 -0
- package/dist/xrpc.d.ts +14 -0
- package/dist/xrpc.d.ts.map +1 -1
- package/dist/xrpc.js +27 -0
- package/package.json +3 -2
- package/public/admin.html +133 -0
- package/dist/cloudflare/container.d.ts +0 -73
- package/dist/cloudflare/container.d.ts.map +0 -1
- package/dist/cloudflare/container.js +0 -232
- package/dist/cloudflare/hooks.d.ts +0 -33
- package/dist/cloudflare/hooks.d.ts.map +0 -1
- package/dist/cloudflare/hooks.js +0 -40
- package/dist/cloudflare/init.d.ts +0 -27
- package/dist/cloudflare/init.d.ts.map +0 -1
- package/dist/cloudflare/init.js +0 -103
- package/dist/cloudflare/worker.d.ts +0 -27
- package/dist/cloudflare/worker.d.ts.map +0 -1
- package/dist/cloudflare/worker.js +0 -54
- package/dist/database/adapters/d1.d.ts +0 -56
- package/dist/database/adapters/d1.d.ts.map +0 -1
- package/dist/database/adapters/d1.js +0 -108
- package/dist/db.d.ts +0 -134
- package/dist/db.d.ts.map +0 -1
- package/dist/db.js +0 -1327
- package/dist/fts.d.ts +0 -20
- package/dist/fts.d.ts.map +0 -1
- package/dist/fts.js +0 -767
- package/dist/oauth/hooks.d.ts +0 -10
- package/dist/oauth/hooks.d.ts.map +0 -1
- package/dist/oauth/hooks.js +0 -40
- package/dist/schema.d.ts +0 -59
- package/dist/schema.d.ts.map +0 -1
- package/dist/schema.js +0 -387
- package/dist/test-browser.d.ts +0 -14
- package/dist/test-browser.d.ts.map +0 -1
- package/dist/test-browser.js +0 -26
package/dist/server.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs';
|
|
2
2
|
import { readFile } from 'node:fs/promises';
|
|
3
3
|
import { join, extname } from 'node:path';
|
|
4
|
-
import { queryRecords, getRecordByUri, searchRecords, getSchema, reshapeRow, setRepoStatus, getRepoStatus, getRepoRetryInfo, queryLabelsForUris, insertLabels, searchAccounts, listReposPaginated, getCollectionCounts, getRepoStatusCounts, getDatabaseSize, deleteLabels, getRecentRecords, listActiveRepoDids, removeRepo, getRepoHandle, getPreferences, putPreference, } from "./database/db.js";
|
|
4
|
+
import { queryRecords, getRecordByUri, searchRecords, getSchema, reshapeRow, setRepoStatus, getRepoStatus, getRepoRetryInfo, queryLabelsForUris, insertLabels, searchAccounts, listReposPaginated, getCollectionCounts, getRepoStatusCounts, getDatabaseSize, deleteLabels, getRecentRecords, listActiveRepoDids, removeRepo, getRepoHandle, getPreferences, putPreference, insertReport, queryReports, resolveReport, getOpenReportCount, } from "./database/db.js";
|
|
5
5
|
import { executeFeed, listFeeds } from "./feeds.js";
|
|
6
6
|
import { executeXrpc, InvalidRequestError, NotFoundError, registerCoreXrpcHandler } from "./xrpc.js";
|
|
7
7
|
import { resolveRecords } from "./hydrate.js";
|
|
@@ -16,8 +16,8 @@ import { pdsCreateRecord, pdsDeleteRecord, pdsPutRecord, pdsUploadBlob, ProxyErr
|
|
|
16
16
|
import { json, jsonError, cors, withCors, file, notFound } from "./response.js";
|
|
17
17
|
import { serve } from "./adapter.js";
|
|
18
18
|
import { renderPage } from "./renderer.js";
|
|
19
|
-
function scopeMissingResponse(acceptEncoding) {
|
|
20
|
-
const res = withCors(
|
|
19
|
+
function scopeMissingResponse(acceptEncoding, handle) {
|
|
20
|
+
const res = withCors(json({ error: 'ScopeMissingError', ...(handle ? { handle } : {}) }, 401, acceptEncoding));
|
|
21
21
|
res.headers.append('Set-Cookie', clearSessionCookieHeader());
|
|
22
22
|
return res;
|
|
23
23
|
}
|
|
@@ -154,6 +154,53 @@ export function registerCoreHandlers(collections, oauth) {
|
|
|
154
154
|
throw new InvalidRequestError('Authentication required');
|
|
155
155
|
return pdsUploadBlob(oauth, viewer, input, 'application/octet-stream');
|
|
156
156
|
});
|
|
157
|
+
registerCoreXrpcHandler('dev.hatk.createReport', async (_params, _cursor, _limit, viewer, input) => {
|
|
158
|
+
if (!viewer)
|
|
159
|
+
throw new InvalidRequestError('Authentication required');
|
|
160
|
+
const body = input;
|
|
161
|
+
if (!body.subject)
|
|
162
|
+
throw new InvalidRequestError('Missing subject');
|
|
163
|
+
if (!body.label || typeof body.label !== 'string')
|
|
164
|
+
throw new InvalidRequestError('Missing or invalid label');
|
|
165
|
+
const defs = getLabelDefinitions();
|
|
166
|
+
if (!defs.some((d) => d.identifier === body.label)) {
|
|
167
|
+
throw new InvalidRequestError(`Unknown label: ${body.label}`);
|
|
168
|
+
}
|
|
169
|
+
if (body.reason && body.reason.length > 2000) {
|
|
170
|
+
throw new InvalidRequestError('Reason must be 2000 characters or less');
|
|
171
|
+
}
|
|
172
|
+
let subjectUri;
|
|
173
|
+
let subjectDid;
|
|
174
|
+
if (body.subject.uri) {
|
|
175
|
+
subjectUri = body.subject.uri;
|
|
176
|
+
const match = body.subject.uri.match(/^at:\/\/(did:[^/]+)/);
|
|
177
|
+
if (!match)
|
|
178
|
+
throw new InvalidRequestError('Invalid subject URI');
|
|
179
|
+
subjectDid = match[1];
|
|
180
|
+
}
|
|
181
|
+
else if (body.subject.did) {
|
|
182
|
+
subjectUri = `at://${body.subject.did}`;
|
|
183
|
+
subjectDid = body.subject.did;
|
|
184
|
+
}
|
|
185
|
+
else {
|
|
186
|
+
throw new InvalidRequestError('Subject must have uri or did');
|
|
187
|
+
}
|
|
188
|
+
const result = await insertReport({
|
|
189
|
+
subjectUri,
|
|
190
|
+
subjectDid,
|
|
191
|
+
label: body.label,
|
|
192
|
+
reason: body.reason,
|
|
193
|
+
reportedBy: viewer.did,
|
|
194
|
+
});
|
|
195
|
+
return {
|
|
196
|
+
id: result.id,
|
|
197
|
+
subject: body.subject,
|
|
198
|
+
label: body.label,
|
|
199
|
+
reason: body.reason || null,
|
|
200
|
+
reportedBy: viewer.did,
|
|
201
|
+
createdAt: new Date().toISOString(),
|
|
202
|
+
};
|
|
203
|
+
});
|
|
157
204
|
}
|
|
158
205
|
}
|
|
159
206
|
/**
|
|
@@ -561,7 +608,38 @@ export function createHandler(config) {
|
|
|
561
608
|
heapTotal: `${(mem.heapTotal / 1024 / 1024).toFixed(1)} MiB`,
|
|
562
609
|
external: `${(mem.external / 1024 / 1024).toFixed(1)} MiB`,
|
|
563
610
|
};
|
|
564
|
-
|
|
611
|
+
const openReports = await getOpenReportCount();
|
|
612
|
+
return withCors(json({ repos: counts, duckdb: dbInfo, node, collections: collectionCounts, openReports }, 200, acceptEncoding));
|
|
613
|
+
}
|
|
614
|
+
// GET /admin/reports — list reports
|
|
615
|
+
if (url.pathname === '/admin/reports' && request.method === 'GET') {
|
|
616
|
+
const denied = requireAdmin(viewer, acceptEncoding);
|
|
617
|
+
if (denied)
|
|
618
|
+
return denied;
|
|
619
|
+
const status = url.searchParams.get('status') || 'open';
|
|
620
|
+
const label = url.searchParams.get('label') || undefined;
|
|
621
|
+
const limit = parseInt(url.searchParams.get('limit') || '50');
|
|
622
|
+
const offset = parseInt(url.searchParams.get('offset') || '0');
|
|
623
|
+
const result = await queryReports({ status, label, limit, offset });
|
|
624
|
+
return withCors(json(result, 200, acceptEncoding));
|
|
625
|
+
}
|
|
626
|
+
// POST /admin/reports/resolve — resolve or dismiss a report
|
|
627
|
+
if (url.pathname === '/admin/reports/resolve' && request.method === 'POST') {
|
|
628
|
+
const denied = requireAdmin(viewer, acceptEncoding);
|
|
629
|
+
if (denied)
|
|
630
|
+
return denied;
|
|
631
|
+
const { id, action } = JSON.parse(await request.text());
|
|
632
|
+
if (!id || !action)
|
|
633
|
+
return withCors(jsonError(400, 'Missing id or action', acceptEncoding));
|
|
634
|
+
if (action !== 'resolve' && action !== 'dismiss')
|
|
635
|
+
return withCors(jsonError(400, 'Action must be resolve or dismiss', acceptEncoding));
|
|
636
|
+
const report = await resolveReport(id, action === 'resolve' ? 'resolved' : 'dismissed', viewer.did);
|
|
637
|
+
if (!report)
|
|
638
|
+
return withCors(jsonError(404, 'Report not found or already resolved', acceptEncoding));
|
|
639
|
+
if (action === 'resolve') {
|
|
640
|
+
await insertLabels([{ src: 'admin', uri: report.subjectUri, val: report.label }]);
|
|
641
|
+
}
|
|
642
|
+
return withCors(json({ ok: true }, 200, acceptEncoding));
|
|
565
643
|
}
|
|
566
644
|
// GET /admin/info/:did — repo status info
|
|
567
645
|
if (url.pathname.startsWith('/admin/info/did:')) {
|
|
@@ -757,9 +835,9 @@ export function createHandler(config) {
|
|
|
757
835
|
}
|
|
758
836
|
catch (err) {
|
|
759
837
|
if (err instanceof ScopeMissingProxyError)
|
|
760
|
-
return scopeMissingResponse(acceptEncoding);
|
|
838
|
+
return scopeMissingResponse(acceptEncoding, viewer?.handle);
|
|
761
839
|
if (err instanceof ProxyError)
|
|
762
|
-
return withCors(
|
|
840
|
+
return withCors(json({ error: err.message, ...(viewer?.handle ? { handle: viewer.handle } : {}) }, err.status, acceptEncoding));
|
|
763
841
|
throw err;
|
|
764
842
|
}
|
|
765
843
|
}
|
|
@@ -774,9 +852,9 @@ export function createHandler(config) {
|
|
|
774
852
|
}
|
|
775
853
|
catch (err) {
|
|
776
854
|
if (err instanceof ScopeMissingProxyError)
|
|
777
|
-
return scopeMissingResponse(acceptEncoding);
|
|
855
|
+
return scopeMissingResponse(acceptEncoding, viewer?.handle);
|
|
778
856
|
if (err instanceof ProxyError)
|
|
779
|
-
return withCors(
|
|
857
|
+
return withCors(json({ error: err.message, ...(viewer?.handle ? { handle: viewer.handle } : {}) }, err.status, acceptEncoding));
|
|
780
858
|
throw err;
|
|
781
859
|
}
|
|
782
860
|
}
|
|
@@ -791,9 +869,9 @@ export function createHandler(config) {
|
|
|
791
869
|
}
|
|
792
870
|
catch (err) {
|
|
793
871
|
if (err instanceof ScopeMissingProxyError)
|
|
794
|
-
return scopeMissingResponse(acceptEncoding);
|
|
872
|
+
return scopeMissingResponse(acceptEncoding, viewer?.handle);
|
|
795
873
|
if (err instanceof ProxyError)
|
|
796
|
-
return withCors(
|
|
874
|
+
return withCors(json({ error: err.message, ...(viewer?.handle ? { handle: viewer.handle } : {}) }, err.status, acceptEncoding));
|
|
797
875
|
throw err;
|
|
798
876
|
}
|
|
799
877
|
}
|
|
@@ -809,9 +887,9 @@ export function createHandler(config) {
|
|
|
809
887
|
}
|
|
810
888
|
catch (err) {
|
|
811
889
|
if (err instanceof ScopeMissingProxyError)
|
|
812
|
-
return scopeMissingResponse(acceptEncoding);
|
|
890
|
+
return scopeMissingResponse(acceptEncoding, viewer?.handle);
|
|
813
891
|
if (err instanceof ProxyError)
|
|
814
|
-
return withCors(
|
|
892
|
+
return withCors(json({ error: err.message, ...(viewer?.handle ? { handle: viewer.handle } : {}) }, err.status, acceptEncoding));
|
|
815
893
|
throw err;
|
|
816
894
|
}
|
|
817
895
|
}
|
|
@@ -873,7 +951,7 @@ export function createHandler(config) {
|
|
|
873
951
|
}
|
|
874
952
|
catch (err) {
|
|
875
953
|
if (err instanceof ScopeMissingProxyError)
|
|
876
|
-
return scopeMissingResponse(acceptEncoding);
|
|
954
|
+
return scopeMissingResponse(acceptEncoding, viewer?.handle);
|
|
877
955
|
if (err instanceof InvalidRequestError) {
|
|
878
956
|
return withCors(jsonError(err.status, err.errorName || err.message, acceptEncoding));
|
|
879
957
|
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { defineFeed } from '$hatk'
|
|
2
|
+
|
|
3
|
+
export default defineFeed({
|
|
4
|
+
collection: 'your.collection.here',
|
|
5
|
+
label: '{{Name}}',
|
|
6
|
+
|
|
7
|
+
async generate(ctx) {
|
|
8
|
+
const { rows, cursor } = await ctx.paginate<{ uri: string }>(
|
|
9
|
+
`SELECT uri, cid, indexed_at FROM "your.collection.here"`,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
return ctx.ok({ uris: rows.map((r) => r.uri), cursor })
|
|
13
|
+
},
|
|
14
|
+
})
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { defineLabel } from '$hatk'
|
|
2
|
+
|
|
3
|
+
export default defineLabel({
|
|
4
|
+
definition: {
|
|
5
|
+
identifier: '{{name}}',
|
|
6
|
+
severity: 'inform',
|
|
7
|
+
blurs: 'none',
|
|
8
|
+
defaultSetting: 'warn',
|
|
9
|
+
locales: [{ lang: 'en', name: '{{Name}}', description: 'Description here' }],
|
|
10
|
+
},
|
|
11
|
+
async evaluate(ctx) {
|
|
12
|
+
// Return array of label identifiers to apply, or empty array
|
|
13
|
+
return []
|
|
14
|
+
},
|
|
15
|
+
})
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { OpengraphContext, OpengraphResult } from '@hatk/hatk/opengraph'
|
|
2
|
+
|
|
3
|
+
export default {
|
|
4
|
+
path: '/og/{{name}}/:id',
|
|
5
|
+
async generate(ctx: OpengraphContext): Promise<OpengraphResult> {
|
|
6
|
+
const { db, params } = ctx
|
|
7
|
+
return {
|
|
8
|
+
element: {
|
|
9
|
+
type: 'div',
|
|
10
|
+
props: {
|
|
11
|
+
style: { display: 'flex', width: '100%', height: '100%', background: '#080b12', color: 'white', alignItems: 'center', justifyContent: 'center' },
|
|
12
|
+
children: params.id,
|
|
13
|
+
},
|
|
14
|
+
},
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { seed } from '$hatk'
|
|
2
|
+
|
|
3
|
+
const { createAccount, createRecord } = seed()
|
|
4
|
+
|
|
5
|
+
const alice = await createAccount('alice.test')
|
|
6
|
+
|
|
7
|
+
// await createRecord(alice, 'your.collection.here', {
|
|
8
|
+
// field: 'value',
|
|
9
|
+
// }, { rkey: 'my-record' })
|
|
10
|
+
|
|
11
|
+
console.log('\n[seed] Done!')
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { describe, test, expect, beforeAll, afterAll } from 'vitest'
|
|
2
|
+
import { createTestContext } from '@hatk/hatk/test'
|
|
3
|
+
|
|
4
|
+
let ctx: Awaited<ReturnType<typeof createTestContext>>
|
|
5
|
+
|
|
6
|
+
beforeAll(async () => {
|
|
7
|
+
ctx = await createTestContext()
|
|
8
|
+
await ctx.loadFixtures()
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
afterAll(async () => ctx?.close())
|
|
12
|
+
|
|
13
|
+
describe('{{name}} feed', () => {
|
|
14
|
+
test('returns results', async () => {
|
|
15
|
+
const feed = ctx.loadFeed('{{name}}')
|
|
16
|
+
const result = await feed.generate(ctx.feedContext({ limit: 10 }))
|
|
17
|
+
expect(result).toBeDefined()
|
|
18
|
+
})
|
|
19
|
+
})
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { describe, test, expect, beforeAll, afterAll } from 'vitest'
|
|
2
|
+
import { createTestContext } from '@hatk/hatk/test'
|
|
3
|
+
|
|
4
|
+
let ctx: Awaited<ReturnType<typeof createTestContext>>
|
|
5
|
+
|
|
6
|
+
beforeAll(async () => {
|
|
7
|
+
ctx = await createTestContext()
|
|
8
|
+
await ctx.loadFixtures()
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
afterAll(async () => ctx?.close())
|
|
12
|
+
|
|
13
|
+
describe('{{name}}', () => {
|
|
14
|
+
test('returns response', async () => {
|
|
15
|
+
const handler = ctx.loadXrpc('{{name}}')
|
|
16
|
+
const result = await handler.handler({ params: {} })
|
|
17
|
+
expect(result).toBeDefined()
|
|
18
|
+
})
|
|
19
|
+
})
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { defineQuery } from '$hatk'
|
|
2
|
+
|
|
3
|
+
export default defineQuery('{{name}}', async (ctx) => {
|
|
4
|
+
const { ok, db, params, packCursor, unpackCursor } = ctx
|
|
5
|
+
const limit = params.limit ?? 30
|
|
6
|
+
const cursor = params.cursor
|
|
7
|
+
|
|
8
|
+
const conditions: string[] = []
|
|
9
|
+
const sqlParams: (string | number)[] = []
|
|
10
|
+
let paramIdx = 1
|
|
11
|
+
|
|
12
|
+
if (cursor) {
|
|
13
|
+
const parsed = unpackCursor(cursor)
|
|
14
|
+
if (parsed) {
|
|
15
|
+
conditions.push(`(s.indexed_at < $${paramIdx} OR (s.indexed_at = $${paramIdx + 1} AND s.cid < $${paramIdx + 2}))`)
|
|
16
|
+
sqlParams.push(parsed.primary, parsed.primary, parsed.cid)
|
|
17
|
+
paramIdx += 3
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const where = conditions.length ? 'WHERE ' + conditions.join(' AND ') : ''
|
|
22
|
+
|
|
23
|
+
const rows = (await db.query(
|
|
24
|
+
`SELECT s.* FROM "your.collection.here" s ${where} ORDER BY s.indexed_at DESC, s.cid DESC LIMIT $${paramIdx}`,
|
|
25
|
+
sqlParams.concat([limit + 1]),
|
|
26
|
+
)) as {
|
|
27
|
+
uri: string
|
|
28
|
+
cid: string
|
|
29
|
+
did: string
|
|
30
|
+
indexed_at: string
|
|
31
|
+
}[]
|
|
32
|
+
|
|
33
|
+
const hasMore = rows.length > limit
|
|
34
|
+
if (hasMore) rows.pop()
|
|
35
|
+
const lastRow = rows[rows.length - 1]
|
|
36
|
+
|
|
37
|
+
return ok({
|
|
38
|
+
items: rows,
|
|
39
|
+
cursor: hasMore && lastRow ? packCursor(lastRow.indexed_at, lastRow.cid) : undefined,
|
|
40
|
+
})
|
|
41
|
+
})
|
package/dist/xrpc.d.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import type { BaseContext } from './hydrate.ts';
|
|
2
2
|
import type { Row, FlatRow } from './lex-types.ts';
|
|
3
|
+
import type { OAuthConfig } from './config.ts';
|
|
3
4
|
export type { Row, FlatRow };
|
|
5
|
+
/** Set the OAuth config used for record write helpers. Called once during boot. */
|
|
6
|
+
export declare function configureOAuth(config: OAuthConfig | null): void;
|
|
4
7
|
/** Thrown from XRPC handlers to return a 400 response with an error message. */
|
|
5
8
|
export declare class InvalidRequestError extends Error {
|
|
6
9
|
status: number;
|
|
@@ -47,6 +50,17 @@ export interface XrpcContext<P = Record<string, string>, Records extends Record<
|
|
|
47
50
|
}>;
|
|
48
51
|
resolve: <R = unknown>(uris: string[]) => Promise<Row<R>[]>;
|
|
49
52
|
exists: (collection: string, filters: Record<string, string>) => Promise<boolean>;
|
|
53
|
+
createRecord: (collection: string, record: Record<string, unknown>, opts?: {
|
|
54
|
+
rkey?: string;
|
|
55
|
+
}) => Promise<{
|
|
56
|
+
uri?: string;
|
|
57
|
+
cid?: string;
|
|
58
|
+
}>;
|
|
59
|
+
putRecord: (collection: string, rkey: string, record: Record<string, unknown>) => Promise<{
|
|
60
|
+
uri?: string;
|
|
61
|
+
cid?: string;
|
|
62
|
+
}>;
|
|
63
|
+
deleteRecord: (collection: string, rkey: string) => Promise<void>;
|
|
50
64
|
}
|
|
51
65
|
/** Set the relay URL used for blob URL generation. Called once during boot. */
|
|
52
66
|
export declare function configureRelay(relay: string): void;
|
package/dist/xrpc.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"xrpc.d.ts","sourceRoot":"","sources":["../src/xrpc.ts"],"names":[],"mappings":"AAsCA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,cAAc,CAAA;AAE/C,OAAO,KAAK,EAAE,GAAG,EAAE,OAAO,EAAE,MAAM,gBAAgB,CAAA;
|
|
1
|
+
{"version":3,"file":"xrpc.d.ts","sourceRoot":"","sources":["../src/xrpc.ts"],"names":[],"mappings":"AAsCA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,cAAc,CAAA;AAE/C,OAAO,KAAK,EAAE,GAAG,EAAE,OAAO,EAAE,MAAM,gBAAgB,CAAA;AAClD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,aAAa,CAAA;AAG9C,YAAY,EAAE,GAAG,EAAE,OAAO,EAAE,CAAA;AAI5B,mFAAmF;AACnF,wBAAgB,cAAc,CAAC,MAAM,EAAE,WAAW,GAAG,IAAI,QAExD;AAED,gFAAgF;AAChF,qBAAa,mBAAoB,SAAQ,KAAK;IAC5C,MAAM,SAAM;IACZ,SAAS,CAAC,EAAE,MAAM,CAAA;gBACN,OAAO,EAAE,MAAM,EAAE,SAAS,CAAC,EAAE,MAAM;CAIhD;AACD,0DAA0D;AAC1D,qBAAa,aAAc,SAAQ,mBAAmB;IACpD,MAAM,SAAM;gBACA,OAAO,SAAc;CAGlC;AAED;;;;;;;;GAQG;AACH,MAAM,WAAW,WAAW,CAC1B,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EAC1B,OAAO,SAAS,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EACzD,CAAC,GAAG,OAAO,CACX,SAAQ,WAAW;IACnB,EAAE,EAAE;QACF,KAAK,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,OAAO,EAAE,KAAK,OAAO,CAAC,OAAO,EAAE,CAAC,CAAA;QAC9D,GAAG,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,OAAO,EAAE,KAAK,OAAO,CAAC,IAAI,CAAC,CAAA;KACxD,CAAA;IACD,MAAM,EAAE,CAAC,CAAA;IACT,KAAK,EAAE,CAAC,CAAA;IACR,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,KAAK,EAAE,MAAM,CAAA;IACb,UAAU,EAAE,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,EAAE,GAAG,EAAE,MAAM,KAAK,MAAM,CAAA;IAC7D,YAAY,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK;QAAE,OAAO,EAAE,MAAM,CAAC;QAAC,GAAG,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,CAAA;IACzE,WAAW,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC,OAAO,CAAC,CAAA;IAC9C,mBAAmB,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,OAAO,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC,CAAA;IAC7D,MAAM,EAAE,CAAC,CAAC,SAAS,MAAM,GAAG,MAAM,OAAO,EACvC,UAAU,EAAE,CAAC,EACb,CAAC,EAAE,MAAM,EACT,IAAI,CAAC,EAAE;QAAE,KAAK,CAAC,EAAE,MAAM,CAAC;QAAC,MAAM,CAAC,EAAE,MAAM,CAAC;QAAC,KAAK,CAAC,EAAE,OAAO,CAAA;KAAE,KACxD,OAAO,CAAC;QAAE,OAAO,EAAE,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC;QAAC,MAAM,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC,CAAA;IAC7D,OAAO,EAAE,CAAC,CAAC,GAAG,OAAO,EAAE,IAAI,EAAE,MAAM,EAAE,KAAK,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,CAAA;IAC3D,MAAM,EAAE,CAAC,UAAU,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,KAAK,OAAO,CAAC,OAAO,CAAC,CAAA;IACjF,YAAY,EAAE,CACZ,UAAU,EAAE,MAAM,EAClB,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC/B,IAAI,CAAC,EAAE;QAAE,IAAI,CAAC,EAAE,MAAM,CAAA;KAAE,KACrB,OAAO,CAAC;QAAE,GAAG,CAAC,EAAE,MAAM,CAAC;QAAC,GAAG,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC,CAAA;IAC5C,SAAS,EAAE,CACT,UAAU,EAAE,MAAM,EAClB,IAAI,EAAE,MAAM,EACZ,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAC5B,OAAO,CAAC;QAAE,GAAG,CAAC,EAAE,MAAM,CAAC;QAAC,GAAG,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC,CAAA;IAC5C,YAAY,EAAE,CACZ,UAAU,EAAE,MAAM,EAClB,IAAI,EAAE,MAAM,KACT,OAAO,CAAC,IAAI,CAAC,CAAA;CACnB;AAgBD,+EAA+E;AAC/E,wBAAgB,cAAc,CAAC,KAAK,EAAE,MAAM,QAE3C;AAED;;;GAGG;AACH,wBAAgB,OAAO,CACrB,GAAG,EAAE,MAAM,EACX,GAAG,EAAE,OAAO,EACZ,MAAM,GAAE,QAAQ,GAAG,QAAQ,GAAG,gBAAgB,GAAG,eAA0B,GAC1E,MAAM,GAAG,SAAS,CAQpB;AAED,mGAAmG;AACnG,wBAAgB,gBAAgB,CAC9B,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EAC9B,MAAM,EAAE,MAAM,GAAG,SAAS,EAC1B,KAAK,EAAE,MAAM,EACb,MAAM,EAAE;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,MAAM,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,EAC/C,KAAK,CAAC,EAAE,OAAO,GACd,WAAW,CAoCb;AAoBD;;;;GAIG;AACH,wBAAsB,QAAQ,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAwC7D;AAED,oEAAoE;AACpE,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,MAAM,EAAE,aAAa,EAAE;IAAE,OAAO,EAAE,CAAC,GAAG,EAAE,GAAG,KAAK,OAAO,CAAC,GAAG,CAAC,CAAA;CAAE,GAAG,IAAI,CA2B9G;AAED,qFAAqF;AACrF,wBAAsB,WAAW,CAC/B,IAAI,EAAE,MAAM,EACZ,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EAC9B,MAAM,EAAE,MAAM,GAAG,SAAS,EAC1B,KAAK,EAAE,MAAM,EACb,MAAM,CAAC,EAAE;IAAE,GAAG,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,EAC/B,KAAK,CAAC,EAAE,OAAO,GACd,OAAO,CAAC,GAAG,GAAG,IAAI,CAAC,CAYrB;AAED,mFAAmF;AACnF,wBAAsB,QAAQ,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,GAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAM,EAAE,KAAK,CAAC,EAAE,OAAO,GAAG,OAAO,CAAC,GAAG,CAAC,CAgB5G;AAED;;;GAGG;AACH,wBAAgB,uBAAuB,CACrC,IAAI,EAAE,MAAM,EACZ,EAAE,EAAE,CACF,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EAC9B,MAAM,EAAE,MAAM,GAAG,SAAS,EAC1B,KAAK,EAAE,MAAM,EACb,MAAM,EAAE;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,MAAM,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,EAC/C,KAAK,CAAC,EAAE,OAAO,KACZ,OAAO,CAAC,GAAG,CAAC,GAChB,IAAI,CAEN;AAED,+CAA+C;AAC/C,wBAAgB,QAAQ,IAAI,MAAM,EAAE,CAEnC"}
|
package/dist/xrpc.js
CHANGED
|
@@ -32,6 +32,12 @@ import { log, emit, timer } from "./logger.js";
|
|
|
32
32
|
import { querySQL, runSQL, packCursor, unpackCursor, isTakendownDid, filterTakendownDids, searchRecords, findUriByFields, } from "./database/db.js";
|
|
33
33
|
import { resolveRecords, buildBaseContext } from "./hydrate.js";
|
|
34
34
|
import { getLexicon } from "./database/schema.js";
|
|
35
|
+
import { pdsCreateRecord, pdsPutRecord, pdsDeleteRecord } from "./pds-proxy.js";
|
|
36
|
+
let _oauthConfig = null;
|
|
37
|
+
/** Set the OAuth config used for record write helpers. Called once during boot. */
|
|
38
|
+
export function configureOAuth(config) {
|
|
39
|
+
_oauthConfig = config;
|
|
40
|
+
}
|
|
35
41
|
/** Thrown from XRPC handlers to return a 400 response with an error message. */
|
|
36
42
|
export class InvalidRequestError extends Error {
|
|
37
43
|
status = 400;
|
|
@@ -89,6 +95,27 @@ export function buildXrpcContext(params, cursor, limit, viewer, input) {
|
|
|
89
95
|
const uri = await findUriByFields(collection, conditions);
|
|
90
96
|
return uri !== null;
|
|
91
97
|
},
|
|
98
|
+
createRecord: async (collection, record, opts) => {
|
|
99
|
+
if (!_oauthConfig)
|
|
100
|
+
throw new Error('No OAuth config — cannot write to PDS');
|
|
101
|
+
if (!viewer)
|
|
102
|
+
throw new Error('Authentication required to write records');
|
|
103
|
+
return pdsCreateRecord(_oauthConfig, viewer, { collection, record, rkey: opts?.rkey });
|
|
104
|
+
},
|
|
105
|
+
putRecord: async (collection, rkey, record) => {
|
|
106
|
+
if (!_oauthConfig)
|
|
107
|
+
throw new Error('No OAuth config — cannot write to PDS');
|
|
108
|
+
if (!viewer)
|
|
109
|
+
throw new Error('Authentication required to write records');
|
|
110
|
+
return pdsPutRecord(_oauthConfig, viewer, { collection, rkey, record });
|
|
111
|
+
},
|
|
112
|
+
deleteRecord: async (collection, rkey) => {
|
|
113
|
+
if (!_oauthConfig)
|
|
114
|
+
throw new Error('No OAuth config — cannot write to PDS');
|
|
115
|
+
if (!viewer)
|
|
116
|
+
throw new Error('Authentication required to write records');
|
|
117
|
+
await pdsDeleteRecord(_oauthConfig, viewer, { collection, rkey });
|
|
118
|
+
},
|
|
92
119
|
};
|
|
93
120
|
}
|
|
94
121
|
const handlers = new Map();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hatk/hatk",
|
|
3
|
-
"version": "0.0.1-alpha.
|
|
3
|
+
"version": "0.0.1-alpha.42",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"bin": {
|
|
6
6
|
"hatk": "dist/cli.js"
|
|
@@ -28,7 +28,8 @@
|
|
|
28
28
|
"./renderer": "./dist/renderer.js"
|
|
29
29
|
},
|
|
30
30
|
"scripts": {
|
|
31
|
-
"
|
|
31
|
+
"clean": "rm -rf dist",
|
|
32
|
+
"build": "npm run clean && tsc -p tsconfig.build.json && cp -r src/templates dist/templates && cp -r src/lexicons dist/lexicons",
|
|
32
33
|
"prepublishOnly": "npm run build"
|
|
33
34
|
},
|
|
34
35
|
"dependencies": {
|
package/public/admin.html
CHANGED
|
@@ -1203,6 +1203,7 @@
|
|
|
1203
1203
|
<button class="tab active" data-tab="overview">Overview</button>
|
|
1204
1204
|
<button class="tab" data-tab="repos">Repos</button>
|
|
1205
1205
|
<button class="tab" data-tab="content">Content</button>
|
|
1206
|
+
<button class="tab" data-tab="reports">Reports</button>
|
|
1206
1207
|
</nav>
|
|
1207
1208
|
|
|
1208
1209
|
<!-- Overview -->
|
|
@@ -1277,6 +1278,21 @@
|
|
|
1277
1278
|
<div class="loading">Loading</div>
|
|
1278
1279
|
</div>
|
|
1279
1280
|
</div>
|
|
1281
|
+
|
|
1282
|
+
<!-- Reports -->
|
|
1283
|
+
<div class="tab-panel" id="panel-reports">
|
|
1284
|
+
<div class="search-bar">
|
|
1285
|
+
<select class="search-input" id="reports-status" style="max-width: 200px">
|
|
1286
|
+
<option value="open">Open</option>
|
|
1287
|
+
<option value="resolved">Resolved</option>
|
|
1288
|
+
<option value="dismissed">Dismissed</option>
|
|
1289
|
+
</select>
|
|
1290
|
+
<select class="search-input" id="reports-label-filter" style="max-width: 200px">
|
|
1291
|
+
<option value="">All labels</option>
|
|
1292
|
+
</select>
|
|
1293
|
+
</div>
|
|
1294
|
+
<div id="reports-results"></div>
|
|
1295
|
+
</div>
|
|
1280
1296
|
</div>
|
|
1281
1297
|
|
|
1282
1298
|
<!-- Bottom nav (mobile) -->
|
|
@@ -1285,6 +1301,7 @@
|
|
|
1285
1301
|
<button class="bnav-btn active" data-tab="overview">Overview</button>
|
|
1286
1302
|
<button class="bnav-btn" data-tab="repos">Repos</button>
|
|
1287
1303
|
<button class="bnav-btn" data-tab="content">Content</button>
|
|
1304
|
+
<button class="bnav-btn" data-tab="reports">Reports</button>
|
|
1288
1305
|
</div>
|
|
1289
1306
|
</div>
|
|
1290
1307
|
</div>
|
|
@@ -1468,6 +1485,7 @@
|
|
|
1468
1485
|
if (tab === 'overview') loadOverview()
|
|
1469
1486
|
if (tab === 'repos') loadRepos()
|
|
1470
1487
|
if (tab === 'content') loadContent()
|
|
1488
|
+
if (tab === 'reports') loadReports()
|
|
1471
1489
|
if (push) pushURL({ tab, status: '', q: '', offset: 0, cq: '' })
|
|
1472
1490
|
}
|
|
1473
1491
|
|
|
@@ -1494,6 +1512,7 @@
|
|
|
1494
1512
|
<div class="stat-card"><div class="stat-label">Pending</div><div class="stat-value yellow">${fmt(repoStatuses.pending)}</div></div>
|
|
1495
1513
|
<div class="stat-card"><div class="stat-label">Failed</div><div class="stat-value red">${fmt(repoStatuses.failed)}</div></div>
|
|
1496
1514
|
<div class="stat-card"><div class="stat-label">Taken Down</div><div class="stat-value red">${fmt(repoStatuses.takendown)}</div></div>
|
|
1515
|
+
${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>` : ''}
|
|
1497
1516
|
`
|
|
1498
1517
|
|
|
1499
1518
|
const collectionCards = document.getElementById('collection-cards')
|
|
@@ -2107,6 +2126,120 @@
|
|
|
2107
2126
|
})
|
|
2108
2127
|
})
|
|
2109
2128
|
}
|
|
2129
|
+
|
|
2130
|
+
// ── Reports ──
|
|
2131
|
+
|
|
2132
|
+
const reportsPage = { limit: 50, offset: 0 }
|
|
2133
|
+
|
|
2134
|
+
function populateReportsLabelFilter() {
|
|
2135
|
+
const select = document.getElementById('reports-label-filter')
|
|
2136
|
+
const current = select.value
|
|
2137
|
+
select.innerHTML = '<option value="">All labels</option>' +
|
|
2138
|
+
labelDefinitions.map(d => `<option value="${d.identifier}">${d.identifier}</option>`).join('')
|
|
2139
|
+
select.value = current
|
|
2140
|
+
}
|
|
2141
|
+
|
|
2142
|
+
document.getElementById('reports-status').addEventListener('change', () => {
|
|
2143
|
+
reportsPage.offset = 0
|
|
2144
|
+
loadReports()
|
|
2145
|
+
})
|
|
2146
|
+
document.getElementById('reports-label-filter').addEventListener('change', () => {
|
|
2147
|
+
reportsPage.offset = 0
|
|
2148
|
+
loadReports()
|
|
2149
|
+
})
|
|
2150
|
+
|
|
2151
|
+
async function loadReports() {
|
|
2152
|
+
populateReportsLabelFilter()
|
|
2153
|
+
const status = document.getElementById('reports-status').value
|
|
2154
|
+
const label = document.getElementById('reports-label-filter').value
|
|
2155
|
+
const container = document.getElementById('reports-results')
|
|
2156
|
+
container.innerHTML = '<div class="loading">Loading</div>'
|
|
2157
|
+
try {
|
|
2158
|
+
let url = `/admin/reports?status=${status}&limit=${reportsPage.limit}&offset=${reportsPage.offset}`
|
|
2159
|
+
if (label) url += `&label=${encodeURIComponent(label)}`
|
|
2160
|
+
const result = await api(url)
|
|
2161
|
+
renderReports(result.reports || [], result.total)
|
|
2162
|
+
} catch (e) {
|
|
2163
|
+
container.innerHTML = `<div class="empty-state">${escapeHtml(e.message)}</div>`
|
|
2164
|
+
}
|
|
2165
|
+
}
|
|
2166
|
+
|
|
2167
|
+
function renderReports(reports, total) {
|
|
2168
|
+
const container = document.getElementById('reports-results')
|
|
2169
|
+
if (!reports.length) {
|
|
2170
|
+
container.innerHTML = '<div class="empty-state">No reports found</div>'
|
|
2171
|
+
return
|
|
2172
|
+
}
|
|
2173
|
+
|
|
2174
|
+
const showPagination = total != null && total > reportsPage.limit
|
|
2175
|
+
const paginationHtml = showPagination ? `
|
|
2176
|
+
<div class="pagination">
|
|
2177
|
+
<span>${reportsPage.offset + 1}\u2013${Math.min(reportsPage.offset + reportsPage.limit, total)} of ${total.toLocaleString()}</span>
|
|
2178
|
+
<div class="pagination-buttons">
|
|
2179
|
+
<button class="btn btn-sm" data-reports-page="prev" ${reportsPage.offset === 0 ? 'disabled' : ''}>Prev</button>
|
|
2180
|
+
<button class="btn btn-sm" data-reports-page="next" ${reportsPage.offset + reportsPage.limit >= total ? 'disabled' : ''}>Next</button>
|
|
2181
|
+
</div>
|
|
2182
|
+
</div>
|
|
2183
|
+
` : ''
|
|
2184
|
+
|
|
2185
|
+
const countLabel = total != null
|
|
2186
|
+
? `${total.toLocaleString()} report${total !== 1 ? 's' : ''}`
|
|
2187
|
+
: `${reports.length} result${reports.length !== 1 ? 's' : ''}`
|
|
2188
|
+
|
|
2189
|
+
const isOpen = document.getElementById('reports-status').value === 'open'
|
|
2190
|
+
|
|
2191
|
+
container.innerHTML = `
|
|
2192
|
+
<div class="card">
|
|
2193
|
+
<div class="result-count">${countLabel}</div>
|
|
2194
|
+
${reports.map(r => {
|
|
2195
|
+
const reporterDisplay = r.reported_by_handle ? `@${escapeHtml(r.reported_by_handle)}` : escapeHtml(r.reported_by)
|
|
2196
|
+
const date = new Date(r.created_at).toLocaleString()
|
|
2197
|
+
return `<div class="record-card">
|
|
2198
|
+
<div class="record-header">
|
|
2199
|
+
<div class="record-meta">
|
|
2200
|
+
<div class="record-uri" title="${escapeHtml(r.subject_uri)}">${escapeHtml(r.subject_uri)}</div>
|
|
2201
|
+
<div class="record-summary">
|
|
2202
|
+
<span class="label-tag">${escapeHtml(r.label)}</span>
|
|
2203
|
+
reported by ${reporterDisplay} · ${date}
|
|
2204
|
+
</div>
|
|
2205
|
+
${r.reason ? `<div class="record-summary" style="margin-top:0.25rem">${escapeHtml(r.reason)}</div>` : ''}
|
|
2206
|
+
${!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>` : ''}
|
|
2207
|
+
</div>
|
|
2208
|
+
${isOpen ? `<div class="record-actions">
|
|
2209
|
+
<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>
|
|
2210
|
+
<button class="btn btn-sm" data-action="resolve-report" data-id="${r.id}" data-resolve="dismiss">Dismiss</button>
|
|
2211
|
+
</div>` : ''}
|
|
2212
|
+
</div>
|
|
2213
|
+
</div>`
|
|
2214
|
+
}).join('')}
|
|
2215
|
+
${paginationHtml}
|
|
2216
|
+
</div>
|
|
2217
|
+
`
|
|
2218
|
+
|
|
2219
|
+
container.querySelectorAll('[data-reports-page="prev"]').forEach(b => {
|
|
2220
|
+
b.addEventListener('click', () => { reportsPage.offset = Math.max(0, reportsPage.offset - reportsPage.limit); loadReports() })
|
|
2221
|
+
})
|
|
2222
|
+
container.querySelectorAll('[data-reports-page="next"]').forEach(b => {
|
|
2223
|
+
b.addEventListener('click', () => { reportsPage.offset += reportsPage.limit; loadReports() })
|
|
2224
|
+
})
|
|
2225
|
+
|
|
2226
|
+
container.querySelectorAll('[data-action="resolve-report"]').forEach(btn => {
|
|
2227
|
+
btn.addEventListener('click', async () => {
|
|
2228
|
+
const action = btn.dataset.resolve
|
|
2229
|
+
try {
|
|
2230
|
+
await api('/admin/reports/resolve', {
|
|
2231
|
+
method: 'POST',
|
|
2232
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2233
|
+
body: JSON.stringify({ id: parseInt(btn.dataset.id), action }),
|
|
2234
|
+
})
|
|
2235
|
+
toast(action === 'resolve' ? 'Label applied & report resolved' : 'Report dismissed', 'success')
|
|
2236
|
+
loadReports()
|
|
2237
|
+
} catch (e) {
|
|
2238
|
+
toast(e.message, 'error')
|
|
2239
|
+
}
|
|
2240
|
+
})
|
|
2241
|
+
})
|
|
2242
|
+
}
|
|
2110
2243
|
</script>
|
|
2111
2244
|
</body>
|
|
2112
2245
|
</html>
|