forked from
nekomimi.pet/wisp.place-monorepo
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)