Monorepo for Wisp.place. A static site hosting service built on top of the AT Protocol.
1import { StrictMode, useState, useEffect } from 'react' 2import { createRoot } from 'react-dom/client' 3import './styles.css' 4 5// Types 6interface LogEntry { 7 id: string 8 timestamp: string 9 level: 'info' | 'warn' | 'error' | 'debug' 10 message: string 11 service: string 12 context?: Record<string, any> 13 eventType?: string 14} 15 16interface ErrorEntry { 17 id: string 18 timestamp: string 19 message: string 20 stack?: string 21 service: string 22 count: number 23 lastSeen: string 24} 25 26interface MetricsStats { 27 totalRequests: number 28 avgDuration: number 29 p50Duration: number 30 p95Duration: number 31 p99Duration: number 32 errorRate: number 33 requestsPerMinute: number 34} 35 36// Helper function to format Unix timestamp from database 37function formatDbDate(timestamp: number | string): Date { 38 const num = typeof timestamp === 'string' ? parseFloat(timestamp) : timestamp 39 return new Date(num * 1000) // Convert seconds to milliseconds 40} 41 42// Login Component 43function Login({ onLogin }: { onLogin: () => void }) { 44 const [username, setUsername] = useState('') 45 const [password, setPassword] = useState('') 46 const [error, setError] = useState('') 47 const [loading, setLoading] = useState(false) 48 49 const handleSubmit = async (e: React.FormEvent) => { 50 e.preventDefault() 51 setError('') 52 setLoading(true) 53 54 try { 55 const res = await fetch('/api/admin/login', { 56 method: 'POST', 57 headers: { 'Content-Type': 'application/json' }, 58 body: JSON.stringify({ username, password }), 59 credentials: 'include' 60 }) 61 62 if (res.ok) { 63 onLogin() 64 } else { 65 setError('Invalid credentials') 66 } 67 } catch (err) { 68 setError('Failed to login') 69 } finally { 70 setLoading(false) 71 } 72 } 73 74 return ( 75 <div className="min-h-screen bg-gray-950 flex items-center justify-center p-4"> 76 <div className="w-full max-w-md"> 77 <div className="bg-gray-900 border border-gray-800 rounded-lg p-8 shadow-xl"> 78 <h1 className="text-2xl font-bold text-white mb-6">Admin Login</h1> 79 <form onSubmit={handleSubmit} className="space-y-4"> 80 <div> 81 <label className="block text-sm font-medium text-gray-300 mb-2"> 82 Username 83 </label> 84 <input 85 type="text" 86 value={username} 87 onChange={(e) => setUsername(e.target.value)} 88 className="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded text-white focus:outline-none focus:border-blue-500" 89 required 90 /> 91 </div> 92 <div> 93 <label className="block text-sm font-medium text-gray-300 mb-2"> 94 Password 95 </label> 96 <input 97 type="password" 98 value={password} 99 onChange={(e) => setPassword(e.target.value)} 100 className="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded text-white focus:outline-none focus:border-blue-500" 101 required 102 /> 103 </div> 104 {error && ( 105 <div className="text-red-400 text-sm">{error}</div> 106 )} 107 <button 108 type="submit" 109 disabled={loading} 110 className="w-full bg-blue-600 hover:bg-blue-700 disabled:bg-gray-700 text-white font-medium py-2 px-4 rounded transition-colors" 111 > 112 {loading ? 'Logging in...' : 'Login'} 113 </button> 114 </form> 115 </div> 116 </div> 117 </div> 118 ) 119} 120 121// Dashboard Component 122function Dashboard() { 123 const [tab, setTab] = useState('overview') 124 const [logs, setLogs] = useState<LogEntry[]>([]) 125 const [errors, setErrors] = useState<ErrorEntry[]>([]) 126 const [metrics, setMetrics] = useState<any>(null) 127 const [database, setDatabase] = useState<any>(null) 128 const [sites, setSites] = useState<any>(null) 129 const [health, setHealth] = useState<any>(null) 130 const [autoRefresh, setAutoRefresh] = useState(true) 131 132 // Filters 133 const [logLevel, setLogLevel] = useState('') 134 const [logService, setLogService] = useState('') 135 const [logSearch, setLogSearch] = useState('') 136 const [logEventType, setLogEventType] = useState('') 137 138 const fetchLogs = async () => { 139 const params = new URLSearchParams() 140 if (logLevel) params.append('level', logLevel) 141 if (logService) params.append('service', logService) 142 if (logSearch) params.append('search', logSearch) 143 if (logEventType) params.append('eventType', logEventType) 144 params.append('limit', '100') 145 146 const res = await fetch(`/api/admin/logs?${params}`, { credentials: 'include' }) 147 if (res.ok) { 148 const data = await res.json() 149 setLogs(data.logs) 150 } 151 } 152 153 const fetchErrors = async () => { 154 const res = await fetch('/api/admin/errors', { credentials: 'include' }) 155 if (res.ok) { 156 const data = await res.json() 157 setErrors(data.errors) 158 } 159 } 160 161 const fetchMetrics = async () => { 162 const res = await fetch('/api/admin/metrics', { credentials: 'include' }) 163 if (res.ok) { 164 const data = await res.json() 165 setMetrics(data) 166 } 167 } 168 169 const fetchDatabase = async () => { 170 const res = await fetch('/api/admin/database', { credentials: 'include' }) 171 if (res.ok) { 172 const data = await res.json() 173 setDatabase(data) 174 } 175 } 176 177 const fetchSites = async () => { 178 const res = await fetch('/api/admin/sites', { credentials: 'include' }) 179 if (res.ok) { 180 const data = await res.json() 181 setSites(data) 182 } 183 } 184 185 const fetchHealth = async () => { 186 const res = await fetch('/api/admin/health', { credentials: 'include' }) 187 if (res.ok) { 188 const data = await res.json() 189 setHealth(data) 190 } 191 } 192 193 const logout = async () => { 194 await fetch('/api/admin/logout', { method: 'POST', credentials: 'include' }) 195 window.location.reload() 196 } 197 198 useEffect(() => { 199 fetchMetrics() 200 fetchDatabase() 201 fetchHealth() 202 fetchLogs() 203 fetchErrors() 204 fetchSites() 205 }, []) 206 207 useEffect(() => { 208 fetchLogs() 209 }, [logLevel, logService, logSearch]) 210 211 useEffect(() => { 212 if (!autoRefresh) return 213 214 const interval = setInterval(() => { 215 if (tab === 'overview') { 216 fetchMetrics() 217 fetchHealth() 218 } else if (tab === 'logs') { 219 fetchLogs() 220 } else if (tab === 'errors') { 221 fetchErrors() 222 } else if (tab === 'database') { 223 fetchDatabase() 224 } else if (tab === 'sites') { 225 fetchSites() 226 } 227 }, 5000) 228 229 return () => clearInterval(interval) 230 }, [tab, autoRefresh, logLevel, logService, logSearch]) 231 232 const formatDuration = (ms: number) => { 233 if (ms < 1000) return `${ms}ms` 234 return `${(ms / 1000).toFixed(2)}s` 235 } 236 237 const formatUptime = (seconds: number) => { 238 const hours = Math.floor(seconds / 3600) 239 const minutes = Math.floor((seconds % 3600) / 60) 240 return `${hours}h ${minutes}m` 241 } 242 243 return ( 244 <div className="min-h-screen bg-gray-950 text-white"> 245 {/* Header */} 246 <div className="bg-gray-900 border-b border-gray-800 px-6 py-4"> 247 <div className="flex items-center justify-between"> 248 <h1 className="text-2xl font-bold">Wisp.place Admin</h1> 249 <div className="flex items-center gap-4"> 250 <label className="flex items-center gap-2 text-sm text-gray-400"> 251 <input 252 type="checkbox" 253 checked={autoRefresh} 254 onChange={(e) => setAutoRefresh(e.target.checked)} 255 className="rounded" 256 /> 257 Auto-refresh 258 </label> 259 <button 260 onClick={logout} 261 className="px-4 py-2 bg-gray-800 hover:bg-gray-700 rounded text-sm" 262 > 263 Logout 264 </button> 265 </div> 266 </div> 267 </div> 268 269 {/* Tabs */} 270 <div className="bg-gray-900 border-b border-gray-800 px-6"> 271 <div className="flex gap-1"> 272 {['overview', 'logs', 'errors', 'database', 'sites'].map((t) => ( 273 <button 274 key={t} 275 onClick={() => setTab(t)} 276 className={`px-4 py-3 text-sm font-medium capitalize transition-colors ${ 277 tab === t 278 ? 'text-white border-b-2 border-blue-500' 279 : 'text-gray-400 hover:text-white' 280 }`} 281 > 282 {t} 283 </button> 284 ))} 285 </div> 286 </div> 287 288 {/* Content */} 289 <div className="p-6"> 290 {tab === 'overview' && ( 291 <div className="space-y-6"> 292 {/* Health */} 293 {health && ( 294 <div className="grid grid-cols-1 md:grid-cols-3 gap-4"> 295 <div className="bg-gray-900 border border-gray-800 rounded-lg p-4"> 296 <div className="text-sm text-gray-400 mb-1">Uptime</div> 297 <div className="text-2xl font-bold">{formatUptime(health.uptime)}</div> 298 </div> 299 <div className="bg-gray-900 border border-gray-800 rounded-lg p-4"> 300 <div className="text-sm text-gray-400 mb-1">Memory Used</div> 301 <div className="text-2xl font-bold">{health.memory.heapUsed} MB</div> 302 </div> 303 <div className="bg-gray-900 border border-gray-800 rounded-lg p-4"> 304 <div className="text-sm text-gray-400 mb-1">RSS</div> 305 <div className="text-2xl font-bold">{health.memory.rss} MB</div> 306 </div> 307 </div> 308 )} 309 310 {/* Metrics */} 311 {metrics && ( 312 <div> 313 <h2 className="text-xl font-bold mb-4">Performance Metrics</h2> 314 <div className="space-y-4"> 315 {/* Overall */} 316 <div className="bg-gray-900 border border-gray-800 rounded-lg p-4"> 317 <h3 className="text-lg font-semibold mb-3">Overall (Last Hour)</h3> 318 <div className="grid grid-cols-2 md:grid-cols-4 gap-4"> 319 <div> 320 <div className="text-sm text-gray-400">Total Requests</div> 321 <div className="text-xl font-bold">{metrics.overall.totalRequests}</div> 322 </div> 323 <div> 324 <div className="text-sm text-gray-400">Avg Duration</div> 325 <div className="text-xl font-bold">{metrics.overall.avgDuration}ms</div> 326 </div> 327 <div> 328 <div className="text-sm text-gray-400">P95 Duration</div> 329 <div className="text-xl font-bold">{metrics.overall.p95Duration}ms</div> 330 </div> 331 <div> 332 <div className="text-sm text-gray-400">Error Rate</div> 333 <div className="text-xl font-bold">{metrics.overall.errorRate.toFixed(2)}%</div> 334 </div> 335 </div> 336 </div> 337 338 {/* Main App */} 339 <div className="bg-gray-900 border border-gray-800 rounded-lg p-4"> 340 <h3 className="text-lg font-semibold mb-3">Main App</h3> 341 <div className="grid grid-cols-2 md:grid-cols-4 gap-4"> 342 <div> 343 <div className="text-sm text-gray-400">Requests</div> 344 <div className="text-xl font-bold">{metrics.mainApp.totalRequests}</div> 345 </div> 346 <div> 347 <div className="text-sm text-gray-400">Avg</div> 348 <div className="text-xl font-bold">{metrics.mainApp.avgDuration}ms</div> 349 </div> 350 <div> 351 <div className="text-sm text-gray-400">P95</div> 352 <div className="text-xl font-bold">{metrics.mainApp.p95Duration}ms</div> 353 </div> 354 <div> 355 <div className="text-sm text-gray-400">Req/min</div> 356 <div className="text-xl font-bold">{metrics.mainApp.requestsPerMinute}</div> 357 </div> 358 </div> 359 </div> 360 361 {/* Hosting Service */} 362 <div className="bg-gray-900 border border-gray-800 rounded-lg p-4"> 363 <h3 className="text-lg font-semibold mb-3">Hosting Service</h3> 364 <div className="grid grid-cols-2 md:grid-cols-4 gap-4"> 365 <div> 366 <div className="text-sm text-gray-400">Requests</div> 367 <div className="text-xl font-bold">{metrics.hostingService.totalRequests}</div> 368 </div> 369 <div> 370 <div className="text-sm text-gray-400">Avg</div> 371 <div className="text-xl font-bold">{metrics.hostingService.avgDuration}ms</div> 372 </div> 373 <div> 374 <div className="text-sm text-gray-400">P95</div> 375 <div className="text-xl font-bold">{metrics.hostingService.p95Duration}ms</div> 376 </div> 377 <div> 378 <div className="text-sm text-gray-400">Req/min</div> 379 <div className="text-xl font-bold">{metrics.hostingService.requestsPerMinute}</div> 380 </div> 381 </div> 382 </div> 383 </div> 384 </div> 385 )} 386 </div> 387 )} 388 389 {tab === 'logs' && ( 390 <div className="space-y-4"> 391 <div className="flex gap-4"> 392 <select 393 value={logLevel} 394 onChange={(e) => setLogLevel(e.target.value)} 395 className="px-3 py-2 bg-gray-900 border border-gray-800 rounded text-white" 396 > 397 <option value="">All Levels</option> 398 <option value="info">Info</option> 399 <option value="warn">Warn</option> 400 <option value="error">Error</option> 401 <option value="debug">Debug</option> 402 </select> 403 <select 404 value={logService} 405 onChange={(e) => setLogService(e.target.value)} 406 className="px-3 py-2 bg-gray-900 border border-gray-800 rounded text-white" 407 > 408 <option value="">All Services</option> 409 <option value="main-app">Main App</option> 410 <option value="hosting-service">Hosting Service</option> 411 </select> 412 <select 413 value={logEventType} 414 onChange={(e) => setLogEventType(e.target.value)} 415 className="px-3 py-2 bg-gray-900 border border-gray-800 rounded text-white" 416 > 417 <option value="">All Event Types</option> 418 <option value="DNS Verifier">DNS Verifier</option> 419 <option value="Auth">Auth</option> 420 <option value="User">User</option> 421 <option value="Domain">Domain</option> 422 <option value="Site">Site</option> 423 <option value="File Upload">File Upload</option> 424 <option value="Sync">Sync</option> 425 <option value="Maintenance">Maintenance</option> 426 <option value="KeyRotation">Key Rotation</option> 427 <option value="Cleanup">Cleanup</option> 428 <option value="Cache">Cache</option> 429 <option value="FirehoseWorker">Firehose Worker</option> 430 </select> 431 <input 432 type="text" 433 value={logSearch} 434 onChange={(e) => setLogSearch(e.target.value)} 435 placeholder="Search logs..." 436 className="flex-1 px-3 py-2 bg-gray-900 border border-gray-800 rounded text-white" 437 /> 438 </div> 439 440 <div className="bg-gray-900 border border-gray-800 rounded-lg overflow-hidden"> 441 <div className="max-h-[600px] overflow-y-auto"> 442 <table className="w-full text-sm"> 443 <thead className="bg-gray-800 sticky top-0"> 444 <tr> 445 <th className="px-4 py-2 text-left">Time</th> 446 <th className="px-4 py-2 text-left">Level</th> 447 <th className="px-4 py-2 text-left">Service</th> 448 <th className="px-4 py-2 text-left">Event Type</th> 449 <th className="px-4 py-2 text-left">Message</th> 450 </tr> 451 </thead> 452 <tbody> 453 {logs.map((log) => ( 454 <tr key={log.id} className="border-t border-gray-800 hover:bg-gray-800"> 455 <td className="px-4 py-2 text-gray-400 whitespace-nowrap"> 456 {new Date(log.timestamp).toLocaleTimeString()} 457 </td> 458 <td className="px-4 py-2"> 459 <span 460 className={`px-2 py-1 rounded text-xs font-medium ${ 461 log.level === 'error' 462 ? 'bg-red-900 text-red-200' 463 : log.level === 'warn' 464 ? 'bg-yellow-900 text-yellow-200' 465 : log.level === 'info' 466 ? 'bg-blue-900 text-blue-200' 467 : 'bg-gray-700 text-gray-300' 468 }`} 469 > 470 {log.level} 471 </span> 472 </td> 473 <td className="px-4 py-2 text-gray-400">{log.service}</td> 474 <td className="px-4 py-2"> 475 {log.eventType && ( 476 <span className="px-2 py-1 bg-purple-900 text-purple-200 rounded text-xs font-medium"> 477 {log.eventType} 478 </span> 479 )} 480 </td> 481 <td className="px-4 py-2"> 482 <div>{log.message}</div> 483 {log.context && Object.keys(log.context).length > 0 && ( 484 <div className="text-xs text-gray-500 mt-1"> 485 {JSON.stringify(log.context)} 486 </div> 487 )} 488 </td> 489 </tr> 490 ))} 491 </tbody> 492 </table> 493 </div> 494 </div> 495 </div> 496 )} 497 498 {tab === 'errors' && ( 499 <div className="space-y-4"> 500 <h2 className="text-xl font-bold">Recent Errors</h2> 501 <div className="space-y-3"> 502 {errors.map((error) => ( 503 <div key={error.id} className="bg-gray-900 border border-red-900 rounded-lg p-4"> 504 <div className="flex items-start justify-between mb-2"> 505 <div className="flex-1"> 506 <div className="font-semibold text-red-400">{error.message}</div> 507 <div className="text-sm text-gray-400 mt-1"> 508 Service: {error.service} Count: {error.count} Last seen:{' '} 509 {new Date(error.lastSeen).toLocaleString()} 510 </div> 511 </div> 512 </div> 513 {error.stack && ( 514 <pre className="text-xs text-gray-500 bg-gray-950 p-2 rounded mt-2 overflow-x-auto"> 515 {error.stack} 516 </pre> 517 )} 518 </div> 519 ))} 520 {errors.length === 0 && ( 521 <div className="text-center text-gray-500 py-8">No errors found</div> 522 )} 523 </div> 524 </div> 525 )} 526 527 {tab === 'database' && database && ( 528 <div className="space-y-6"> 529 {/* Stats */} 530 <div className="grid grid-cols-1 md:grid-cols-3 gap-4"> 531 <div className="bg-gray-900 border border-gray-800 rounded-lg p-4"> 532 <div className="text-sm text-gray-400 mb-1">Total Sites</div> 533 <div className="text-3xl font-bold">{database.stats.totalSites}</div> 534 </div> 535 <div className="bg-gray-900 border border-gray-800 rounded-lg p-4"> 536 <div className="text-sm text-gray-400 mb-1">Wisp Subdomains</div> 537 <div className="text-3xl font-bold">{database.stats.totalWispSubdomains}</div> 538 </div> 539 <div className="bg-gray-900 border border-gray-800 rounded-lg p-4"> 540 <div className="text-sm text-gray-400 mb-1">Custom Domains</div> 541 <div className="text-3xl font-bold">{database.stats.totalCustomDomains}</div> 542 </div> 543 </div> 544 545 {/* Recent Sites */} 546 <div> 547 <h3 className="text-lg font-semibold mb-3">Recent Sites</h3> 548 <div className="bg-gray-900 border border-gray-800 rounded-lg overflow-hidden"> 549 <table className="w-full text-sm"> 550 <thead className="bg-gray-800"> 551 <tr> 552 <th className="px-4 py-2 text-left">Site Name</th> 553 <th className="px-4 py-2 text-left">Subdomain</th> 554 <th className="px-4 py-2 text-left">DID</th> 555 <th className="px-4 py-2 text-left">RKey</th> 556 <th className="px-4 py-2 text-left">Created</th> 557 </tr> 558 </thead> 559 <tbody> 560 {database.recentSites.map((site: any, i: number) => ( 561 <tr key={i} className="border-t border-gray-800"> 562 <td className="px-4 py-2">{site.display_name || 'Untitled'}</td> 563 <td className="px-4 py-2"> 564 {site.subdomain ? ( 565 <a 566 href={`https://${site.subdomain}`} 567 target="_blank" 568 rel="noopener noreferrer" 569 className="text-blue-400 hover:underline" 570 > 571 {site.subdomain} 572 </a> 573 ) : ( 574 <span className="text-gray-500">No domain</span> 575 )} 576 </td> 577 <td className="px-4 py-2 text-gray-400 font-mono text-xs"> 578 {site.did.slice(0, 20)}... 579 </td> 580 <td className="px-4 py-2 text-gray-400">{site.rkey || 'self'}</td> 581 <td className="px-4 py-2 text-gray-400"> 582 {formatDbDate(site.created_at).toLocaleDateString()} 583 </td> 584 <td className="px-4 py-2"> 585 <a 586 href={`https://pdsls.dev/at://${site.did}/place.wisp.fs/${site.rkey || 'self'}`} 587 target="_blank" 588 rel="noopener noreferrer" 589 className="text-blue-400 hover:text-blue-300 transition-colors" 590 title="View on PDSls.dev" 591 > 592 <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> 593 <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" /> 594 </svg> 595 </a> 596 </td> 597 </tr> 598 ))} 599 </tbody> 600 </table> 601 </div> 602 </div> 603 604 {/* Recent Domains */} 605 <div> 606 <h3 className="text-lg font-semibold mb-3">Recent Custom Domains</h3> 607 <div className="bg-gray-900 border border-gray-800 rounded-lg overflow-hidden"> 608 <table className="w-full text-sm"> 609 <thead className="bg-gray-800"> 610 <tr> 611 <th className="px-4 py-2 text-left">Domain</th> 612 <th className="px-4 py-2 text-left">DID</th> 613 <th className="px-4 py-2 text-left">Verified</th> 614 <th className="px-4 py-2 text-left">Created</th> 615 </tr> 616 </thead> 617 <tbody> 618 {database.recentDomains.map((domain: any, i: number) => ( 619 <tr key={i} className="border-t border-gray-800"> 620 <td className="px-4 py-2">{domain.domain}</td> 621 <td className="px-4 py-2 text-gray-400 font-mono text-xs"> 622 {domain.did.slice(0, 20)}... 623 </td> 624 <td className="px-4 py-2"> 625 <span 626 className={`px-2 py-1 rounded text-xs ${ 627 domain.verified 628 ? 'bg-green-900 text-green-200' 629 : 'bg-yellow-900 text-yellow-200' 630 }`} 631 > 632 {domain.verified ? 'Yes' : 'No'} 633 </span> 634 </td> 635 <td className="px-4 py-2 text-gray-400"> 636 {formatDbDate(domain.created_at).toLocaleDateString()} 637 </td> 638 </tr> 639 ))} 640 </tbody> 641 </table> 642 </div> 643 </div> 644 </div> 645 )} 646 647 {tab === 'sites' && sites && ( 648 <div className="space-y-6"> 649 {/* All Sites */} 650 <div> 651 <h3 className="text-lg font-semibold mb-3">All Sites</h3> 652 <div className="bg-gray-900 border border-gray-800 rounded-lg overflow-hidden"> 653 <table className="w-full text-sm"> 654 <thead className="bg-gray-800"> 655 <tr> 656 <th className="px-4 py-2 text-left">Site Name</th> 657 <th className="px-4 py-2 text-left">Subdomain</th> 658 <th className="px-4 py-2 text-left">DID</th> 659 <th className="px-4 py-2 text-left">RKey</th> 660 <th className="px-4 py-2 text-left">Created</th> 661 </tr> 662 </thead> 663 <tbody> 664 {sites.sites.map((site: any, i: number) => ( 665 <tr key={i} className="border-t border-gray-800 hover:bg-gray-800"> 666 <td className="px-4 py-2">{site.display_name || 'Untitled'}</td> 667 <td className="px-4 py-2"> 668 {site.subdomain ? ( 669 <a 670 href={`https://${site.subdomain}`} 671 target="_blank" 672 rel="noopener noreferrer" 673 className="text-blue-400 hover:underline" 674 > 675 {site.subdomain} 676 </a> 677 ) : ( 678 <span className="text-gray-500">No domain</span> 679 )} 680 </td> 681 <td className="px-4 py-2 text-gray-400 font-mono text-xs"> 682 {site.did.slice(0, 30)}... 683 </td> 684 <td className="px-4 py-2 text-gray-400">{site.rkey || 'self'}</td> 685 <td className="px-4 py-2 text-gray-400"> 686 {formatDbDate(site.created_at).toLocaleString()} 687 </td> 688 <td className="px-4 py-2"> 689 <a 690 href={`https://pdsls.dev/at://${site.did}/place.wisp.fs/${site.rkey || 'self'}`} 691 target="_blank" 692 rel="noopener noreferrer" 693 className="text-blue-400 hover:text-blue-300 transition-colors" 694 title="View on PDSls.dev" 695 > 696 <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> 697 <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" /> 698 </svg> 699 </a> 700 </td> 701 </tr> 702 ))} 703 </tbody> 704 </table> 705 </div> 706 </div> 707 708 {/* Custom Domains */} 709 <div> 710 <h3 className="text-lg font-semibold mb-3">Custom Domains</h3> 711 <div className="bg-gray-900 border border-gray-800 rounded-lg overflow-hidden"> 712 <table className="w-full text-sm"> 713 <thead className="bg-gray-800"> 714 <tr> 715 <th className="px-4 py-2 text-left">Domain</th> 716 <th className="px-4 py-2 text-left">Verified</th> 717 <th className="px-4 py-2 text-left">DID</th> 718 <th className="px-4 py-2 text-left">RKey</th> 719 <th className="px-4 py-2 text-left">Created</th> 720 <th className="px-4 py-2 text-left">PDSls</th> 721 </tr> 722 </thead> 723 <tbody> 724 {sites.customDomains.map((domain: any, i: number) => ( 725 <tr key={i} className="border-t border-gray-800 hover:bg-gray-800"> 726 <td className="px-4 py-2"> 727 {domain.verified ? ( 728 <a 729 href={`https://${domain.domain}`} 730 target="_blank" 731 rel="noopener noreferrer" 732 className="text-blue-400 hover:underline" 733 > 734 {domain.domain} 735 </a> 736 ) : ( 737 <span className="text-gray-400">{domain.domain}</span> 738 )} 739 </td> 740 <td className="px-4 py-2"> 741 <span 742 className={`px-2 py-1 rounded text-xs ${ 743 domain.verified 744 ? 'bg-green-900 text-green-200' 745 : 'bg-yellow-900 text-yellow-200' 746 }`} 747 > 748 {domain.verified ? 'Yes' : 'Pending'} 749 </span> 750 </td> 751 <td className="px-4 py-2 text-gray-400 font-mono text-xs"> 752 {domain.did.slice(0, 30)}... 753 </td> 754 <td className="px-4 py-2 text-gray-400">{domain.rkey || 'self'}</td> 755 <td className="px-4 py-2 text-gray-400"> 756 {formatDbDate(domain.created_at).toLocaleString()} 757 </td> 758 <td className="px-4 py-2"> 759 <a 760 href={`https://pdsls.dev/at://${domain.did}/place.wisp.fs/${domain.rkey || 'self'}`} 761 target="_blank" 762 rel="noopener noreferrer" 763 className="text-blue-400 hover:text-blue-300 transition-colors" 764 title="View on PDSls.dev" 765 > 766 <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> 767 <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" /> 768 </svg> 769 </a> 770 </td> 771 </tr> 772 ))} 773 </tbody> 774 </table> 775 </div> 776 </div> 777 </div> 778 )} 779 </div> 780 </div> 781 ) 782} 783 784// Main App 785function App() { 786 const [authenticated, setAuthenticated] = useState(false) 787 const [checking, setChecking] = useState(true) 788 789 useEffect(() => { 790 fetch('/api/admin/status', { credentials: 'include' }) 791 .then((res) => res.json()) 792 .then((data) => { 793 setAuthenticated(data.authenticated) 794 setChecking(false) 795 }) 796 .catch(() => { 797 setChecking(false) 798 }) 799 }, []) 800 801 if (checking) { 802 return ( 803 <div className="min-h-screen bg-gray-950 flex items-center justify-center"> 804 <div className="text-white">Loading...</div> 805 </div> 806 ) 807 } 808 809 if (!authenticated) { 810 return <Login onLogin={() => setAuthenticated(true)} /> 811 } 812 813 return <Dashboard /> 814} 815 816createRoot(document.getElementById('root')!).render( 817 <StrictMode> 818 <App /> 819 </StrictMode> 820)