Monorepo for wisp.place. A static site hosting service built on top of the AT Protocol. wisp.place

supporters dashboard

+386 -2
+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 + 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 + 140 + // Supporter management 141 + const [newSupporterIdentifier, setNewSupporterIdentifier] = useState('') 142 + const [supporterLoading, setSupporterLoading] = useState(false) 143 + const [supporterError, setSupporterError] = useState('') 144 + const [supporterSuccess, setSupporterSuccess] = useState('') 145 + const [actorSearchResults, setActorSearchResults] = useState<any[]>([]) 146 + const [showActorDropdown, setShowActorDropdown] = useState(false) 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 + const fetchSupporters = async () => { 213 + const res = await fetch('/api/admin/supporters', { credentials: 'include' }) 214 + if (res.ok) { 215 + const data = await res.json() 216 + const supportersWithHandles = await Promise.all( 217 + data.supporters.map(async (supporter: any) => { 218 + try { 219 + const profileRes = await fetch( 220 + `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${supporter.did}` 221 + ) 222 + if (profileRes.ok) { 223 + const profile = await profileRes.json() 224 + return { ...supporter, handle: profile.handle } 225 + } 226 + } catch (err) { 227 + // Failed to fetch handle, just use DID 228 + } 229 + return { ...supporter, handle: null } 230 + }) 231 + ) 232 + setSupporters(supportersWithHandles) 233 + } 234 + } 235 + 236 + const searchActors = async (query: string) => { 237 + if (query.trim().length < 2) { 238 + setActorSearchResults([]) 239 + setShowActorDropdown(false) 240 + return 241 + } 242 + 243 + setSearchLoading(true) 244 + try { 245 + const url = new URL('https://public.api.bsky.app/xrpc/app.bsky.actor.searchActorsTypeahead') 246 + url.searchParams.set('q', query.trim()) 247 + url.searchParams.set('limit', '10') 248 + 249 + const response = await fetch(url.toString(), { 250 + headers: { 251 + 'Accept': 'application/json', 252 + }, 253 + }) 254 + 255 + if (response.ok) { 256 + const data = await response.json() 257 + setActorSearchResults(data.actors || []) 258 + setShowActorDropdown(true) 259 + } 260 + } catch (err) { 261 + console.error('Failed to search actors:', err) 262 + } finally { 263 + setSearchLoading(false) 264 + } 265 + } 266 + 267 + // Debounced search effect 268 + useEffect(() => { 269 + if (!newSupporterIdentifier.trim()) { 270 + setActorSearchResults([]) 271 + setShowActorDropdown(false) 272 + return 273 + } 274 + 275 + if (newSupporterIdentifier.startsWith('did:')) { 276 + setShowActorDropdown(false) 277 + return 278 + } 279 + 280 + const timeoutId = setTimeout(() => { 281 + searchActors(newSupporterIdentifier) 282 + }, 300) 283 + 284 + return () => clearTimeout(timeoutId) 285 + }, [newSupporterIdentifier]) 286 + 287 + const selectActor = (actor: any) => { 288 + setNewSupporterIdentifier(actor.handle) 289 + setShowActorDropdown(false) 290 + setActorSearchResults([]) 291 + } 292 + 293 + const addNewSupporter = async (e: React.FormEvent) => { 294 + e.preventDefault() 295 + setSupporterError('') 296 + setSupporterSuccess('') 297 + setSupporterLoading(true) 298 + 299 + try { 300 + const res = await fetch('/api/admin/supporters', { 301 + method: 'POST', 302 + headers: { 'Content-Type': 'application/json' }, 303 + body: JSON.stringify({ identifier: newSupporterIdentifier }), 304 + credentials: 'include' 305 + }) 306 + 307 + if (res.ok) { 308 + const data = await res.json() 309 + setSupporterSuccess(`Added supporter: ${data.did}`) 310 + setNewSupporterIdentifier('') 311 + await fetchSupporters() 312 + } else { 313 + const error = await res.json() 314 + setSupporterError(error.message || 'Failed to add supporter') 315 + } 316 + } catch (err) { 317 + setSupporterError('Failed to add supporter') 318 + } finally { 319 + setSupporterLoading(false) 320 + } 321 + } 322 + 323 + const removeSupporter = async (did: string) => { 324 + if (!confirm(`Remove supporter ${did}?`)) return 325 + 326 + try { 327 + const res = await fetch(`/api/admin/supporters/${encodeURIComponent(did)}`, { 328 + method: 'DELETE', 329 + credentials: 'include' 330 + }) 331 + 332 + if (res.ok) { 333 + await fetchSupporters() 334 + } 335 + } catch (err) { 336 + alert('Failed to remove supporter') 337 + } 338 + } 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 + fetchSupporters() 215 354 }, []) 216 355 217 356 useEffect(() => { ··· 234 373 fetchDatabase() 235 374 } else if (tab === 'sites') { 236 375 fetchSites() 376 + } else if (tab === 'supporters') { 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 - {['overview', 'logs', 'errors', 'database', 'sites'].map((t) => ( 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 + </div> 1025 + </div> 1026 + </div> 1027 + )} 1028 + 1029 + {tab === 'supporters' && ( 1030 + <div className="space-y-6"> 1031 + {/* Add Supporter Form */} 1032 + <div className="bg-gray-900 border border-gray-800 rounded-lg p-6"> 1033 + <h3 className="text-lg font-semibold mb-4">Add Supporter</h3> 1034 + <form onSubmit={addNewSupporter} className="space-y-4"> 1035 + <div className="relative"> 1036 + <label className="block text-sm font-medium text-gray-300 mb-2"> 1037 + Bluesky Handle or DID 1038 + </label> 1039 + <input 1040 + type="text" 1041 + value={newSupporterIdentifier} 1042 + onChange={(e) => { 1043 + setNewSupporterIdentifier(e.target.value) 1044 + setSupporterError('') 1045 + setSupporterSuccess('') 1046 + }} 1047 + onFocus={() => { 1048 + if (actorSearchResults.length > 0) { 1049 + setShowActorDropdown(true) 1050 + } 1051 + }} 1052 + onBlur={() => { 1053 + // Delay to allow clicking on results 1054 + setTimeout(() => setShowActorDropdown(false), 200) 1055 + }} 1056 + placeholder="Search for a user or enter did:plc:..." 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 + required 1059 + autoComplete="off" 1060 + /> 1061 + {searchLoading && ( 1062 + <div className="absolute right-3 top-9 text-gray-500"> 1063 + <svg className="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"> 1064 + <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle> 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 + </svg> 1067 + </div> 1068 + )} 1069 + {showActorDropdown && actorSearchResults.length > 0 && ( 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 + {actorSearchResults.map((actor) => ( 1072 + <button 1073 + key={actor.did} 1074 + type="button" 1075 + onClick={() => selectActor(actor)} 1076 + className="w-full px-4 py-3 hover:bg-gray-700 flex items-start gap-3 text-left transition-colors" 1077 + > 1078 + {actor.avatar && ( 1079 + <img 1080 + src={actor.avatar} 1081 + alt={actor.displayName || actor.handle} 1082 + className="w-10 h-10 rounded-full flex-shrink-0" 1083 + /> 1084 + )} 1085 + <div className="flex-1 min-w-0"> 1086 + <div className="font-medium text-white truncate"> 1087 + {actor.displayName || actor.handle} 1088 + </div> 1089 + <div className="text-sm text-gray-400 truncate"> 1090 + @{actor.handle} 1091 + </div> 1092 + {actor.description && ( 1093 + <div className="text-xs text-gray-500 truncate mt-1"> 1094 + {actor.description} 1095 + </div> 1096 + )} 1097 + </div> 1098 + </button> 1099 + ))} 1100 + </div> 1101 + )} 1102 + <p className="text-xs text-gray-500 mt-1"> 1103 + Start typing to search for users, or enter a DID directly 1104 + </p> 1105 + </div> 1106 + {supporterError && ( 1107 + <div className="text-red-400 text-sm">{supporterError}</div> 1108 + )} 1109 + {supporterSuccess && ( 1110 + <div className="text-green-400 text-sm">{supporterSuccess}</div> 1111 + )} 1112 + <button 1113 + type="submit" 1114 + disabled={supporterLoading} 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 + > 1117 + {supporterLoading ? 'Adding...' : 'Add Supporter'} 1118 + </button> 1119 + </form> 1120 + </div> 1121 + 1122 + {/* Supporters List */} 1123 + <div> 1124 + <h3 className="text-lg font-semibold mb-3">Current Supporters ({supporters.length})</h3> 1125 + <div className="bg-gray-900 border border-gray-800 rounded-lg overflow-hidden"> 1126 + <table className="w-full text-sm"> 1127 + <thead className="bg-gray-800"> 1128 + <tr> 1129 + <th className="px-4 py-2 text-left">Handle</th> 1130 + <th className="px-4 py-2 text-left">DID</th> 1131 + <th className="px-4 py-2 text-left">Added</th> 1132 + <th className="px-4 py-2 text-left">Actions</th> 1133 + </tr> 1134 + </thead> 1135 + <tbody> 1136 + {supporters.map((supporter: any) => ( 1137 + <tr key={supporter.did} className="border-t border-gray-800 hover:bg-gray-800"> 1138 + <td className="px-4 py-2"> 1139 + {supporter.handle ? ( 1140 + <a 1141 + href={`https://bsky.app/profile/${supporter.handle}`} 1142 + target="_blank" 1143 + rel="noopener noreferrer" 1144 + className="text-blue-400 hover:underline" 1145 + > 1146 + @{supporter.handle} 1147 + </a> 1148 + ) : ( 1149 + <span className="text-gray-500 italic">Loading...</span> 1150 + )} 1151 + </td> 1152 + <td className="px-4 py-2 font-mono text-xs text-gray-400"> 1153 + {supporter.did} 1154 + </td> 1155 + <td className="px-4 py-2 text-gray-400"> 1156 + {supporter.created_at ? formatDbDate(supporter.created_at).toLocaleString() : 'N/A'} 1157 + </td> 1158 + <td className="px-4 py-2"> 1159 + <button 1160 + onClick={() => removeSupporter(supporter.did)} 1161 + className="px-3 py-1 bg-red-900 hover:bg-red-800 text-red-200 rounded text-xs font-medium transition-colors" 1162 + > 1163 + Remove 1164 + </button> 1165 + </td> 1166 + </tr> 1167 + ))} 1168 + </tbody> 1169 + </table> 1170 + {supporters.length === 0 && ( 1171 + <div className="text-center text-gray-500 py-8">No supporters yet</div> 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 - import { db } from '../lib/db' 5 + import { db, getAllSupporters, addSupporter, removeSupporter } from '../lib/db' 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 + 507 + // Get all supporters (protected) 508 + /** 509 + * GET /api/admin/supporters 510 + * Success: { supporters } 511 + * Unauthorized (401): { error: 'Unauthorized' } 512 + */ 513 + .get('/supporters', async ({ cookie, set }) => { 514 + const check = requireAdmin({ cookie, set }) 515 + if (check) return check 516 + 517 + const supporters = await getAllSupporters() 518 + return { supporters } 519 + }, { 520 + cookie: t.Cookie({ 521 + admin_session: t.Optional(t.String()) 522 + }, { 523 + secrets: cookieSecret, 524 + sign: ['admin_session'] 525 + }) 526 + }) 527 + 528 + // Add supporter (protected) 529 + /** 530 + * POST /api/admin/supporters 531 + * Body: { identifier } - can be a handle or DID 532 + * Success: { success: true, did } 533 + * Failure (400): { error, message } 534 + */ 535 + .post('/supporters', async ({ body, cookie, set }) => { 536 + const check = requireAdmin({ cookie, set }) 537 + if (check) return check 538 + 539 + const { identifier } = body 540 + let did = identifier.trim() 541 + 542 + // If it's not a DID, treat it as a handle and resolve it 543 + if (!did.startsWith('did:')) { 544 + const handleResolver = new SlingshotHandleResolver() 545 + const resolvedDid = await handleResolver.resolve(did) 546 + 547 + if (!resolvedDid) { 548 + set.status = 400 549 + return { 550 + error: 'Invalid handle', 551 + message: `Could not resolve handle: ${did}` 552 + } 553 + } 554 + 555 + did = resolvedDid 556 + } 557 + 558 + // Add to supporters table 559 + await addSupporter(did) 560 + 561 + return { 562 + success: true, 563 + did 564 + } 565 + }, { 566 + body: t.Object({ 567 + identifier: t.String() 568 + }), 569 + cookie: t.Cookie({ 570 + admin_session: t.Optional(t.String()) 571 + }, { 572 + secrets: cookieSecret, 573 + sign: ['admin_session'] 574 + }) 575 + }) 576 + 577 + // Remove supporter (protected) 578 + /** 579 + * DELETE /api/admin/supporters/:did 580 + * Success: { success: true } 581 + * Unauthorized (401): { error: 'Unauthorized' } 582 + */ 583 + .delete('/supporters/:did', async ({ params, cookie, set }) => { 584 + const check = requireAdmin({ cookie, set }) 585 + if (check) return check 586 + 587 + const { did } = params 588 + await removeSupporter(did) 589 + 590 + return { success: true } 591 + }, { 592 + cookie: t.Cookie({ 593 + admin_session: t.Optional(t.String()) 594 + }, { 595 + secrets: cookieSecret, 596 + sign: ['admin_session'] 597 + }) 598 + })