tangled
alpha
login
or
join now
nekomimi.pet
/
wisp.place-monorepo
88
fork
atom
Monorepo for wisp.place. A static site hosting service built on top of the AT Protocol.
wisp.place
88
fork
atom
overview
issues
9
pulls
pipelines
supporters dashboard
nekomimi.pet
1 month ago
52757cbe
6e392755
1/2
deploy-wisp.yml
success
34s
test.yml
failed
27s
+386
-2
2 changed files
expand all
collapse all
unified
split
apps
main-app
public
admin
admin.tsx
src
routes
admin.ts
+291
-1
apps/main-app/public/admin/admin.tsx
···
128
128
const [sites, setSites] = useState<any>(null)
129
129
const [health, setHealth] = useState<any>(null)
130
130
const [firehose, setFirehose] = useState<any>(null)
131
131
+
const [supporters, setSupporters] = useState<any[]>([])
131
132
const [autoRefresh, setAutoRefresh] = useState(true)
132
133
133
134
// Filters
···
135
136
const [logService, setLogService] = useState('')
136
137
const [logSearch, setLogSearch] = useState('')
137
138
const [logEventType, setLogEventType] = useState('')
139
139
+
140
140
+
// Supporter management
141
141
+
const [newSupporterIdentifier, setNewSupporterIdentifier] = useState('')
142
142
+
const [supporterLoading, setSupporterLoading] = useState(false)
143
143
+
const [supporterError, setSupporterError] = useState('')
144
144
+
const [supporterSuccess, setSupporterSuccess] = useState('')
145
145
+
const [actorSearchResults, setActorSearchResults] = useState<any[]>([])
146
146
+
const [showActorDropdown, setShowActorDropdown] = useState(false)
147
147
+
const [searchLoading, setSearchLoading] = useState(false)
138
148
139
149
const fetchLogs = async () => {
140
150
const params = new URLSearchParams()
···
199
209
}
200
210
}
201
211
212
212
+
const fetchSupporters = async () => {
213
213
+
const res = await fetch('/api/admin/supporters', { credentials: 'include' })
214
214
+
if (res.ok) {
215
215
+
const data = await res.json()
216
216
+
const supportersWithHandles = await Promise.all(
217
217
+
data.supporters.map(async (supporter: any) => {
218
218
+
try {
219
219
+
const profileRes = await fetch(
220
220
+
`https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${supporter.did}`
221
221
+
)
222
222
+
if (profileRes.ok) {
223
223
+
const profile = await profileRes.json()
224
224
+
return { ...supporter, handle: profile.handle }
225
225
+
}
226
226
+
} catch (err) {
227
227
+
// Failed to fetch handle, just use DID
228
228
+
}
229
229
+
return { ...supporter, handle: null }
230
230
+
})
231
231
+
)
232
232
+
setSupporters(supportersWithHandles)
233
233
+
}
234
234
+
}
235
235
+
236
236
+
const searchActors = async (query: string) => {
237
237
+
if (query.trim().length < 2) {
238
238
+
setActorSearchResults([])
239
239
+
setShowActorDropdown(false)
240
240
+
return
241
241
+
}
242
242
+
243
243
+
setSearchLoading(true)
244
244
+
try {
245
245
+
const url = new URL('https://public.api.bsky.app/xrpc/app.bsky.actor.searchActorsTypeahead')
246
246
+
url.searchParams.set('q', query.trim())
247
247
+
url.searchParams.set('limit', '10')
248
248
+
249
249
+
const response = await fetch(url.toString(), {
250
250
+
headers: {
251
251
+
'Accept': 'application/json',
252
252
+
},
253
253
+
})
254
254
+
255
255
+
if (response.ok) {
256
256
+
const data = await response.json()
257
257
+
setActorSearchResults(data.actors || [])
258
258
+
setShowActorDropdown(true)
259
259
+
}
260
260
+
} catch (err) {
261
261
+
console.error('Failed to search actors:', err)
262
262
+
} finally {
263
263
+
setSearchLoading(false)
264
264
+
}
265
265
+
}
266
266
+
267
267
+
// Debounced search effect
268
268
+
useEffect(() => {
269
269
+
if (!newSupporterIdentifier.trim()) {
270
270
+
setActorSearchResults([])
271
271
+
setShowActorDropdown(false)
272
272
+
return
273
273
+
}
274
274
+
275
275
+
if (newSupporterIdentifier.startsWith('did:')) {
276
276
+
setShowActorDropdown(false)
277
277
+
return
278
278
+
}
279
279
+
280
280
+
const timeoutId = setTimeout(() => {
281
281
+
searchActors(newSupporterIdentifier)
282
282
+
}, 300)
283
283
+
284
284
+
return () => clearTimeout(timeoutId)
285
285
+
}, [newSupporterIdentifier])
286
286
+
287
287
+
const selectActor = (actor: any) => {
288
288
+
setNewSupporterIdentifier(actor.handle)
289
289
+
setShowActorDropdown(false)
290
290
+
setActorSearchResults([])
291
291
+
}
292
292
+
293
293
+
const addNewSupporter = async (e: React.FormEvent) => {
294
294
+
e.preventDefault()
295
295
+
setSupporterError('')
296
296
+
setSupporterSuccess('')
297
297
+
setSupporterLoading(true)
298
298
+
299
299
+
try {
300
300
+
const res = await fetch('/api/admin/supporters', {
301
301
+
method: 'POST',
302
302
+
headers: { 'Content-Type': 'application/json' },
303
303
+
body: JSON.stringify({ identifier: newSupporterIdentifier }),
304
304
+
credentials: 'include'
305
305
+
})
306
306
+
307
307
+
if (res.ok) {
308
308
+
const data = await res.json()
309
309
+
setSupporterSuccess(`Added supporter: ${data.did}`)
310
310
+
setNewSupporterIdentifier('')
311
311
+
await fetchSupporters()
312
312
+
} else {
313
313
+
const error = await res.json()
314
314
+
setSupporterError(error.message || 'Failed to add supporter')
315
315
+
}
316
316
+
} catch (err) {
317
317
+
setSupporterError('Failed to add supporter')
318
318
+
} finally {
319
319
+
setSupporterLoading(false)
320
320
+
}
321
321
+
}
322
322
+
323
323
+
const removeSupporter = async (did: string) => {
324
324
+
if (!confirm(`Remove supporter ${did}?`)) return
325
325
+
326
326
+
try {
327
327
+
const res = await fetch(`/api/admin/supporters/${encodeURIComponent(did)}`, {
328
328
+
method: 'DELETE',
329
329
+
credentials: 'include'
330
330
+
})
331
331
+
332
332
+
if (res.ok) {
333
333
+
await fetchSupporters()
334
334
+
}
335
335
+
} catch (err) {
336
336
+
alert('Failed to remove supporter')
337
337
+
}
338
338
+
}
339
339
+
202
340
const logout = async () => {
203
341
await fetch('/api/admin/logout', { method: 'POST', credentials: 'include' })
204
342
window.location.reload()
···
212
350
fetchLogs()
213
351
fetchErrors()
214
352
fetchSites()
353
353
+
fetchSupporters()
215
354
}, [])
216
355
217
356
useEffect(() => {
···
234
373
fetchDatabase()
235
374
} else if (tab === 'sites') {
236
375
fetchSites()
376
376
+
} else if (tab === 'supporters') {
377
377
+
fetchSupporters()
237
378
}
238
379
}, 5000)
239
380
···
280
421
{/* Tabs */}
281
422
<div className="bg-gray-900 border-b border-gray-800 px-6">
282
423
<div className="flex gap-1">
283
283
-
{['overview', 'logs', 'errors', 'database', 'sites'].map((t) => (
424
424
+
{['overview', 'logs', 'errors', 'database', 'sites', 'supporters'].map((t) => (
284
425
<button
285
426
key={t}
286
427
onClick={() => setTab(t)}
···
880
1021
))}
881
1022
</tbody>
882
1023
</table>
1024
1024
+
</div>
1025
1025
+
</div>
1026
1026
+
</div>
1027
1027
+
)}
1028
1028
+
1029
1029
+
{tab === 'supporters' && (
1030
1030
+
<div className="space-y-6">
1031
1031
+
{/* Add Supporter Form */}
1032
1032
+
<div className="bg-gray-900 border border-gray-800 rounded-lg p-6">
1033
1033
+
<h3 className="text-lg font-semibold mb-4">Add Supporter</h3>
1034
1034
+
<form onSubmit={addNewSupporter} className="space-y-4">
1035
1035
+
<div className="relative">
1036
1036
+
<label className="block text-sm font-medium text-gray-300 mb-2">
1037
1037
+
Bluesky Handle or DID
1038
1038
+
</label>
1039
1039
+
<input
1040
1040
+
type="text"
1041
1041
+
value={newSupporterIdentifier}
1042
1042
+
onChange={(e) => {
1043
1043
+
setNewSupporterIdentifier(e.target.value)
1044
1044
+
setSupporterError('')
1045
1045
+
setSupporterSuccess('')
1046
1046
+
}}
1047
1047
+
onFocus={() => {
1048
1048
+
if (actorSearchResults.length > 0) {
1049
1049
+
setShowActorDropdown(true)
1050
1050
+
}
1051
1051
+
}}
1052
1052
+
onBlur={() => {
1053
1053
+
// Delay to allow clicking on results
1054
1054
+
setTimeout(() => setShowActorDropdown(false), 200)
1055
1055
+
}}
1056
1056
+
placeholder="Search for a user or enter did:plc:..."
1057
1057
+
className="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded text-white focus:outline-none focus:border-blue-500"
1058
1058
+
required
1059
1059
+
autoComplete="off"
1060
1060
+
/>
1061
1061
+
{searchLoading && (
1062
1062
+
<div className="absolute right-3 top-9 text-gray-500">
1063
1063
+
<svg className="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
1064
1064
+
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
1065
1065
+
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
1066
1066
+
</svg>
1067
1067
+
</div>
1068
1068
+
)}
1069
1069
+
{showActorDropdown && actorSearchResults.length > 0 && (
1070
1070
+
<div className="absolute z-10 w-full mt-1 bg-gray-800 border border-gray-700 rounded-lg shadow-xl max-h-64 overflow-y-auto">
1071
1071
+
{actorSearchResults.map((actor) => (
1072
1072
+
<button
1073
1073
+
key={actor.did}
1074
1074
+
type="button"
1075
1075
+
onClick={() => selectActor(actor)}
1076
1076
+
className="w-full px-4 py-3 hover:bg-gray-700 flex items-start gap-3 text-left transition-colors"
1077
1077
+
>
1078
1078
+
{actor.avatar && (
1079
1079
+
<img
1080
1080
+
src={actor.avatar}
1081
1081
+
alt={actor.displayName || actor.handle}
1082
1082
+
className="w-10 h-10 rounded-full flex-shrink-0"
1083
1083
+
/>
1084
1084
+
)}
1085
1085
+
<div className="flex-1 min-w-0">
1086
1086
+
<div className="font-medium text-white truncate">
1087
1087
+
{actor.displayName || actor.handle}
1088
1088
+
</div>
1089
1089
+
<div className="text-sm text-gray-400 truncate">
1090
1090
+
@{actor.handle}
1091
1091
+
</div>
1092
1092
+
{actor.description && (
1093
1093
+
<div className="text-xs text-gray-500 truncate mt-1">
1094
1094
+
{actor.description}
1095
1095
+
</div>
1096
1096
+
)}
1097
1097
+
</div>
1098
1098
+
</button>
1099
1099
+
))}
1100
1100
+
</div>
1101
1101
+
)}
1102
1102
+
<p className="text-xs text-gray-500 mt-1">
1103
1103
+
Start typing to search for users, or enter a DID directly
1104
1104
+
</p>
1105
1105
+
</div>
1106
1106
+
{supporterError && (
1107
1107
+
<div className="text-red-400 text-sm">{supporterError}</div>
1108
1108
+
)}
1109
1109
+
{supporterSuccess && (
1110
1110
+
<div className="text-green-400 text-sm">{supporterSuccess}</div>
1111
1111
+
)}
1112
1112
+
<button
1113
1113
+
type="submit"
1114
1114
+
disabled={supporterLoading}
1115
1115
+
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-700 text-white font-medium rounded transition-colors"
1116
1116
+
>
1117
1117
+
{supporterLoading ? 'Adding...' : 'Add Supporter'}
1118
1118
+
</button>
1119
1119
+
</form>
1120
1120
+
</div>
1121
1121
+
1122
1122
+
{/* Supporters List */}
1123
1123
+
<div>
1124
1124
+
<h3 className="text-lg font-semibold mb-3">Current Supporters ({supporters.length})</h3>
1125
1125
+
<div className="bg-gray-900 border border-gray-800 rounded-lg overflow-hidden">
1126
1126
+
<table className="w-full text-sm">
1127
1127
+
<thead className="bg-gray-800">
1128
1128
+
<tr>
1129
1129
+
<th className="px-4 py-2 text-left">Handle</th>
1130
1130
+
<th className="px-4 py-2 text-left">DID</th>
1131
1131
+
<th className="px-4 py-2 text-left">Added</th>
1132
1132
+
<th className="px-4 py-2 text-left">Actions</th>
1133
1133
+
</tr>
1134
1134
+
</thead>
1135
1135
+
<tbody>
1136
1136
+
{supporters.map((supporter: any) => (
1137
1137
+
<tr key={supporter.did} className="border-t border-gray-800 hover:bg-gray-800">
1138
1138
+
<td className="px-4 py-2">
1139
1139
+
{supporter.handle ? (
1140
1140
+
<a
1141
1141
+
href={`https://bsky.app/profile/${supporter.handle}`}
1142
1142
+
target="_blank"
1143
1143
+
rel="noopener noreferrer"
1144
1144
+
className="text-blue-400 hover:underline"
1145
1145
+
>
1146
1146
+
@{supporter.handle}
1147
1147
+
</a>
1148
1148
+
) : (
1149
1149
+
<span className="text-gray-500 italic">Loading...</span>
1150
1150
+
)}
1151
1151
+
</td>
1152
1152
+
<td className="px-4 py-2 font-mono text-xs text-gray-400">
1153
1153
+
{supporter.did}
1154
1154
+
</td>
1155
1155
+
<td className="px-4 py-2 text-gray-400">
1156
1156
+
{supporter.created_at ? formatDbDate(supporter.created_at).toLocaleString() : 'N/A'}
1157
1157
+
</td>
1158
1158
+
<td className="px-4 py-2">
1159
1159
+
<button
1160
1160
+
onClick={() => removeSupporter(supporter.did)}
1161
1161
+
className="px-3 py-1 bg-red-900 hover:bg-red-800 text-red-200 rounded text-xs font-medium transition-colors"
1162
1162
+
>
1163
1163
+
Remove
1164
1164
+
</button>
1165
1165
+
</td>
1166
1166
+
</tr>
1167
1167
+
))}
1168
1168
+
</tbody>
1169
1169
+
</table>
1170
1170
+
{supporters.length === 0 && (
1171
1171
+
<div className="text-center text-gray-500 py-8">No supporters yet</div>
1172
1172
+
)}
883
1173
</div>
884
1174
</div>
885
1175
</div>
+95
-1
apps/main-app/src/routes/admin.ts
···
2
2
import { Elysia, t } from 'elysia'
3
3
import { adminAuth, requireAdmin } from '../lib/admin-auth'
4
4
import { logCollector, errorTracker, metricsCollector } from '@wispplace/observability'
5
5
-
import { db } from '../lib/db'
5
5
+
import { db, getAllSupporters, addSupporter, removeSupporter } from '../lib/db'
6
6
+
import { SlingshotHandleResolver } from '../lib/slingshot-handle-resolver'
6
7
7
8
export const adminRoutes = (cookieSecret: string) =>
8
9
new Elysia({
···
502
503
sign: ['admin_session']
503
504
})
504
505
})
506
506
+
507
507
+
// Get all supporters (protected)
508
508
+
/**
509
509
+
* GET /api/admin/supporters
510
510
+
* Success: { supporters }
511
511
+
* Unauthorized (401): { error: 'Unauthorized' }
512
512
+
*/
513
513
+
.get('/supporters', async ({ cookie, set }) => {
514
514
+
const check = requireAdmin({ cookie, set })
515
515
+
if (check) return check
516
516
+
517
517
+
const supporters = await getAllSupporters()
518
518
+
return { supporters }
519
519
+
}, {
520
520
+
cookie: t.Cookie({
521
521
+
admin_session: t.Optional(t.String())
522
522
+
}, {
523
523
+
secrets: cookieSecret,
524
524
+
sign: ['admin_session']
525
525
+
})
526
526
+
})
527
527
+
528
528
+
// Add supporter (protected)
529
529
+
/**
530
530
+
* POST /api/admin/supporters
531
531
+
* Body: { identifier } - can be a handle or DID
532
532
+
* Success: { success: true, did }
533
533
+
* Failure (400): { error, message }
534
534
+
*/
535
535
+
.post('/supporters', async ({ body, cookie, set }) => {
536
536
+
const check = requireAdmin({ cookie, set })
537
537
+
if (check) return check
538
538
+
539
539
+
const { identifier } = body
540
540
+
let did = identifier.trim()
541
541
+
542
542
+
// If it's not a DID, treat it as a handle and resolve it
543
543
+
if (!did.startsWith('did:')) {
544
544
+
const handleResolver = new SlingshotHandleResolver()
545
545
+
const resolvedDid = await handleResolver.resolve(did)
546
546
+
547
547
+
if (!resolvedDid) {
548
548
+
set.status = 400
549
549
+
return {
550
550
+
error: 'Invalid handle',
551
551
+
message: `Could not resolve handle: ${did}`
552
552
+
}
553
553
+
}
554
554
+
555
555
+
did = resolvedDid
556
556
+
}
557
557
+
558
558
+
// Add to supporters table
559
559
+
await addSupporter(did)
560
560
+
561
561
+
return {
562
562
+
success: true,
563
563
+
did
564
564
+
}
565
565
+
}, {
566
566
+
body: t.Object({
567
567
+
identifier: t.String()
568
568
+
}),
569
569
+
cookie: t.Cookie({
570
570
+
admin_session: t.Optional(t.String())
571
571
+
}, {
572
572
+
secrets: cookieSecret,
573
573
+
sign: ['admin_session']
574
574
+
})
575
575
+
})
576
576
+
577
577
+
// Remove supporter (protected)
578
578
+
/**
579
579
+
* DELETE /api/admin/supporters/:did
580
580
+
* Success: { success: true }
581
581
+
* Unauthorized (401): { error: 'Unauthorized' }
582
582
+
*/
583
583
+
.delete('/supporters/:did', async ({ params, cookie, set }) => {
584
584
+
const check = requireAdmin({ cookie, set })
585
585
+
if (check) return check
586
586
+
587
587
+
const { did } = params
588
588
+
await removeSupporter(did)
589
589
+
590
590
+
return { success: true }
591
591
+
}, {
592
592
+
cookie: t.Cookie({
593
593
+
admin_session: t.Optional(t.String())
594
594
+
}, {
595
595
+
secrets: cookieSecret,
596
596
+
sign: ['admin_session']
597
597
+
})
598
598
+
})