forked from
nekomimi.pet/wisp.place-monorepo
Monorepo for Wisp.place. A static site hosting service built on top of the AT Protocol.
1import { useState, useEffect } from 'react'
2import { createRoot } from 'react-dom/client'
3import { Button } from '@public/components/ui/button'
4import {
5 Card,
6 CardContent,
7 CardDescription,
8 CardHeader,
9 CardTitle
10} from '@public/components/ui/card'
11import { Input } from '@public/components/ui/input'
12import { Label } from '@public/components/ui/label'
13import {
14 Tabs,
15 TabsContent,
16 TabsList,
17 TabsTrigger
18} from '@public/components/ui/tabs'
19import { Badge } from '@public/components/ui/badge'
20import {
21 Dialog,
22 DialogContent,
23 DialogDescription,
24 DialogHeader,
25 DialogTitle,
26 DialogFooter
27} from '@public/components/ui/dialog'
28import {
29 Globe,
30 Upload,
31 ExternalLink,
32 CheckCircle2,
33 XCircle,
34 AlertCircle,
35 Loader2,
36 Trash2,
37 RefreshCw,
38 Settings
39} from 'lucide-react'
40import { RadioGroup, RadioGroupItem } from '@public/components/ui/radio-group'
41
42import Layout from '@public/layouts'
43
44interface UserInfo {
45 did: string
46 handle: string
47}
48
49interface Site {
50 did: string
51 rkey: string
52 display_name: string | null
53 created_at: number
54 updated_at: number
55}
56
57interface CustomDomain {
58 id: string
59 domain: string
60 did: string
61 rkey: string
62 verified: boolean
63 last_verified_at: number | null
64 created_at: number
65}
66
67interface WispDomain {
68 domain: string
69 rkey: string | null
70}
71
72function Dashboard() {
73 // User state
74 const [userInfo, setUserInfo] = useState<UserInfo | null>(null)
75 const [loading, setLoading] = useState(true)
76
77 // Sites state
78 const [sites, setSites] = useState<Site[]>([])
79 const [sitesLoading, setSitesLoading] = useState(true)
80 const [isSyncing, setIsSyncing] = useState(false)
81
82 // Domains state
83 const [wispDomain, setWispDomain] = useState<WispDomain | null>(null)
84 const [customDomains, setCustomDomains] = useState<CustomDomain[]>([])
85 const [domainsLoading, setDomainsLoading] = useState(true)
86
87 // Site configuration state
88 const [configuringSite, setConfiguringSite] = useState<Site | null>(null)
89 const [selectedDomain, setSelectedDomain] = useState<string>('')
90 const [isSavingConfig, setIsSavingConfig] = useState(false)
91
92 // Upload state
93 const [siteName, setSiteName] = useState('')
94 const [selectedFiles, setSelectedFiles] = useState<FileList | null>(null)
95 const [isUploading, setIsUploading] = useState(false)
96 const [uploadProgress, setUploadProgress] = useState('')
97
98 // Custom domain modal state
99 const [addDomainModalOpen, setAddDomainModalOpen] = useState(false)
100 const [customDomain, setCustomDomain] = useState('')
101 const [isAddingDomain, setIsAddingDomain] = useState(false)
102 const [verificationStatus, setVerificationStatus] = useState<{
103 [id: string]: 'idle' | 'verifying' | 'success' | 'error'
104 }>({})
105 const [viewDomainDNS, setViewDomainDNS] = useState<string | null>(null)
106
107 // Fetch user info on mount
108 useEffect(() => {
109 fetchUserInfo()
110 fetchSites()
111 fetchDomains()
112 }, [])
113
114 const fetchUserInfo = async () => {
115 try {
116 const response = await fetch('/api/user/info')
117 const data = await response.json()
118 setUserInfo(data)
119 } catch (err) {
120 console.error('Failed to fetch user info:', err)
121 } finally {
122 setLoading(false)
123 }
124 }
125
126 const fetchSites = async () => {
127 try {
128 const response = await fetch('/api/user/sites')
129 const data = await response.json()
130 setSites(data.sites || [])
131 } catch (err) {
132 console.error('Failed to fetch sites:', err)
133 } finally {
134 setSitesLoading(false)
135 }
136 }
137
138 const syncSites = async () => {
139 setIsSyncing(true)
140 try {
141 const response = await fetch('/api/user/sync', {
142 method: 'POST'
143 })
144 const data = await response.json()
145 if (data.success) {
146 console.log(`Synced ${data.synced} sites from PDS`)
147 // Refresh sites list
148 await fetchSites()
149 }
150 } catch (err) {
151 console.error('Failed to sync sites:', err)
152 alert('Failed to sync sites from PDS')
153 } finally {
154 setIsSyncing(false)
155 }
156 }
157
158 const fetchDomains = async () => {
159 try {
160 const response = await fetch('/api/user/domains')
161 const data = await response.json()
162 setWispDomain(data.wispDomain)
163 setCustomDomains(data.customDomains || [])
164 } catch (err) {
165 console.error('Failed to fetch domains:', err)
166 } finally {
167 setDomainsLoading(false)
168 }
169 }
170
171 const getSiteUrl = (site: Site) => {
172 // Check if this site is mapped to the wisp.place domain
173 if (wispDomain && wispDomain.rkey === site.rkey) {
174 return `https://${wispDomain.domain}`
175 }
176
177 // Check if this site is mapped to any custom domain
178 const customDomain = customDomains.find((d) => d.rkey === site.rkey)
179 if (customDomain) {
180 return `https://${customDomain.domain}`
181 }
182
183 // Default fallback URL
184 if (!userInfo) return '#'
185 return `https://sites.wisp.place/${site.did}/${site.rkey}`
186 }
187
188 const getSiteDomainName = (site: Site) => {
189 if (wispDomain && wispDomain.rkey === site.rkey) {
190 return wispDomain.domain
191 }
192
193 const customDomain = customDomains.find((d) => d.rkey === site.rkey)
194 if (customDomain) {
195 return customDomain.domain
196 }
197
198 return `sites.wisp.place/${site.did}/${site.rkey}`
199 }
200
201 const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
202 if (e.target.files && e.target.files.length > 0) {
203 setSelectedFiles(e.target.files)
204 }
205 }
206
207 const handleUpload = async () => {
208 if (!siteName) {
209 alert('Please enter a site name')
210 return
211 }
212
213 setIsUploading(true)
214 setUploadProgress('Preparing files...')
215
216 try {
217 const formData = new FormData()
218 formData.append('siteName', siteName)
219
220 if (selectedFiles) {
221 for (let i = 0; i < selectedFiles.length; i++) {
222 formData.append('files', selectedFiles[i])
223 }
224 }
225
226 setUploadProgress('Uploading to AT Protocol...')
227 const response = await fetch('/wisp/upload-files', {
228 method: 'POST',
229 body: formData
230 })
231
232 const data = await response.json()
233 if (data.success) {
234 setUploadProgress('Upload complete!')
235 setSiteName('')
236 setSelectedFiles(null)
237
238 // Refresh sites list
239 await fetchSites()
240
241 // Reset form
242 setTimeout(() => {
243 setUploadProgress('')
244 setIsUploading(false)
245 }, 1500)
246 } else {
247 throw new Error(data.error || 'Upload failed')
248 }
249 } catch (err) {
250 console.error('Upload error:', err)
251 alert(
252 `Upload failed: ${err instanceof Error ? err.message : 'Unknown error'}`
253 )
254 setIsUploading(false)
255 setUploadProgress('')
256 }
257 }
258
259 const handleAddCustomDomain = async () => {
260 if (!customDomain) {
261 alert('Please enter a domain')
262 return
263 }
264
265 setIsAddingDomain(true)
266 try {
267 const response = await fetch('/api/domain/custom/add', {
268 method: 'POST',
269 headers: { 'Content-Type': 'application/json' },
270 body: JSON.stringify({ domain: customDomain })
271 })
272
273 const data = await response.json()
274 if (data.success) {
275 setCustomDomain('')
276 setAddDomainModalOpen(false)
277 await fetchDomains()
278
279 // Automatically show DNS configuration for the newly added domain
280 setViewDomainDNS(data.id)
281 } else {
282 throw new Error(data.error || 'Failed to add domain')
283 }
284 } catch (err) {
285 console.error('Add domain error:', err)
286 alert(
287 `Failed to add domain: ${err instanceof Error ? err.message : 'Unknown error'}`
288 )
289 } finally {
290 setIsAddingDomain(false)
291 }
292 }
293
294 const handleVerifyDomain = async (id: string) => {
295 setVerificationStatus({ ...verificationStatus, [id]: 'verifying' })
296
297 try {
298 const response = await fetch('/api/domain/custom/verify', {
299 method: 'POST',
300 headers: { 'Content-Type': 'application/json' },
301 body: JSON.stringify({ id })
302 })
303
304 const data = await response.json()
305 if (data.success && data.verified) {
306 setVerificationStatus({ ...verificationStatus, [id]: 'success' })
307 await fetchDomains()
308 } else {
309 setVerificationStatus({ ...verificationStatus, [id]: 'error' })
310 if (data.error) {
311 alert(`Verification failed: ${data.error}`)
312 }
313 }
314 } catch (err) {
315 console.error('Verify domain error:', err)
316 setVerificationStatus({ ...verificationStatus, [id]: 'error' })
317 alert(
318 `Verification failed: ${err instanceof Error ? err.message : 'Unknown error'}`
319 )
320 }
321 }
322
323 const handleDeleteCustomDomain = async (id: string) => {
324 if (!confirm('Are you sure you want to remove this custom domain?')) {
325 return
326 }
327
328 try {
329 const response = await fetch(`/api/domain/custom/${id}`, {
330 method: 'DELETE'
331 })
332
333 const data = await response.json()
334 if (data.success) {
335 await fetchDomains()
336 } else {
337 throw new Error('Failed to delete domain')
338 }
339 } catch (err) {
340 console.error('Delete domain error:', err)
341 alert(
342 `Failed to delete domain: ${err instanceof Error ? err.message : 'Unknown error'}`
343 )
344 }
345 }
346
347 const handleConfigureSite = (site: Site) => {
348 setConfiguringSite(site)
349
350 // Determine current domain mapping
351 if (wispDomain && wispDomain.rkey === site.rkey) {
352 setSelectedDomain('wisp')
353 } else {
354 const customDomain = customDomains.find((d) => d.rkey === site.rkey)
355 if (customDomain) {
356 setSelectedDomain(customDomain.id)
357 } else {
358 setSelectedDomain('none')
359 }
360 }
361 }
362
363 const handleSaveSiteConfig = async () => {
364 if (!configuringSite) return
365
366 setIsSavingConfig(true)
367 try {
368 if (selectedDomain === 'wisp') {
369 // Map to wisp.place domain
370 const response = await fetch('/api/domain/wisp/map-site', {
371 method: 'POST',
372 headers: { 'Content-Type': 'application/json' },
373 body: JSON.stringify({ siteRkey: configuringSite.rkey })
374 })
375 const data = await response.json()
376 if (!data.success) throw new Error('Failed to map site')
377 } else if (selectedDomain === 'none') {
378 // Unmap from all domains
379 // Unmap wisp domain if this site was mapped to it
380 if (wispDomain && wispDomain.rkey === configuringSite.rkey) {
381 await fetch('/api/domain/wisp/map-site', {
382 method: 'POST',
383 headers: { 'Content-Type': 'application/json' },
384 body: JSON.stringify({ siteRkey: null })
385 })
386 }
387
388 // Unmap from custom domains
389 const mappedCustom = customDomains.find(
390 (d) => d.rkey === configuringSite.rkey
391 )
392 if (mappedCustom) {
393 await fetch(`/api/domain/custom/${mappedCustom.id}/map-site`, {
394 method: 'POST',
395 headers: { 'Content-Type': 'application/json' },
396 body: JSON.stringify({ siteRkey: null })
397 })
398 }
399 } else {
400 // Map to a custom domain
401 const response = await fetch(
402 `/api/domain/custom/${selectedDomain}/map-site`,
403 {
404 method: 'POST',
405 headers: { 'Content-Type': 'application/json' },
406 body: JSON.stringify({ siteRkey: configuringSite.rkey })
407 }
408 )
409 const data = await response.json()
410 if (!data.success) throw new Error('Failed to map site')
411 }
412
413 // Refresh domains to get updated mappings
414 await fetchDomains()
415 setConfiguringSite(null)
416 } catch (err) {
417 console.error('Save config error:', err)
418 alert(
419 `Failed to save configuration: ${err instanceof Error ? err.message : 'Unknown error'}`
420 )
421 } finally {
422 setIsSavingConfig(false)
423 }
424 }
425
426 if (loading) {
427 return (
428 <div className="w-full min-h-screen bg-background flex items-center justify-center">
429 <Loader2 className="w-8 h-8 animate-spin text-primary" />
430 </div>
431 )
432 }
433
434 return (
435 <div className="w-full min-h-screen bg-background">
436 {/* Header */}
437 <header className="border-b border-border/40 bg-background/80 backdrop-blur-sm sticky top-0 z-50">
438 <div className="container mx-auto px-4 py-4 flex items-center justify-between">
439 <div className="flex items-center gap-2">
440 <div className="w-8 h-8 bg-primary rounded-lg flex items-center justify-center">
441 <Globe className="w-5 h-5 text-primary-foreground" />
442 </div>
443 <span className="text-xl font-semibold text-foreground">
444 wisp.place
445 </span>
446 </div>
447 <div className="flex items-center gap-3">
448 <span className="text-sm text-muted-foreground">
449 {userInfo?.handle || 'Loading...'}
450 </span>
451 </div>
452 </div>
453 </header>
454
455 <div className="container mx-auto px-4 py-8 max-w-6xl w-full">
456 <div className="mb-8">
457 <h1 className="text-3xl font-bold mb-2">Dashboard</h1>
458 <p className="text-muted-foreground">
459 Manage your sites and domains
460 </p>
461 </div>
462
463 <Tabs defaultValue="sites" className="space-y-6 w-full">
464 <TabsList className="grid w-full grid-cols-3 max-w-md">
465 <TabsTrigger value="sites">Sites</TabsTrigger>
466 <TabsTrigger value="domains">Domains</TabsTrigger>
467 <TabsTrigger value="upload">Upload</TabsTrigger>
468 </TabsList>
469
470 {/* Sites Tab */}
471 <TabsContent value="sites" className="space-y-4 min-h-[400px]">
472 <Card>
473 <CardHeader>
474 <div className="flex items-center justify-between">
475 <div>
476 <CardTitle>Your Sites</CardTitle>
477 <CardDescription>
478 View and manage all your deployed sites
479 </CardDescription>
480 </div>
481 <Button
482 variant="outline"
483 size="sm"
484 onClick={syncSites}
485 disabled={isSyncing || sitesLoading}
486 >
487 <RefreshCw
488 className={`w-4 h-4 mr-2 ${isSyncing ? 'animate-spin' : ''}`}
489 />
490 Sync from PDS
491 </Button>
492 </div>
493 </CardHeader>
494 <CardContent className="space-y-4">
495 {sitesLoading ? (
496 <div className="flex items-center justify-center py-8">
497 <Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
498 </div>
499 ) : sites.length === 0 ? (
500 <div className="text-center py-8 text-muted-foreground">
501 <p>No sites yet. Upload your first site!</p>
502 </div>
503 ) : (
504 sites.map((site) => (
505 <div
506 key={`${site.did}-${site.rkey}`}
507 className="flex items-center justify-between p-4 border border-border rounded-lg hover:bg-muted/50 transition-colors"
508 >
509 <div className="flex-1">
510 <div className="flex items-center gap-3 mb-2">
511 <h3 className="font-semibold text-lg">
512 {site.display_name || site.rkey}
513 </h3>
514 <Badge
515 variant="secondary"
516 className="text-xs"
517 >
518 active
519 </Badge>
520 </div>
521 <a
522 href={getSiteUrl(site)}
523 target="_blank"
524 rel="noopener noreferrer"
525 className="text-sm text-accent hover:text-accent/80 flex items-center gap-1"
526 >
527 {getSiteDomainName(site)}
528 <ExternalLink className="w-3 h-3" />
529 </a>
530 </div>
531 <Button
532 variant="outline"
533 size="sm"
534 onClick={() => handleConfigureSite(site)}
535 >
536 <Settings className="w-4 h-4 mr-2" />
537 Configure
538 </Button>
539 </div>
540 ))
541 )}
542 </CardContent>
543 </Card>
544 </TabsContent>
545
546 {/* Domains Tab */}
547 <TabsContent value="domains" className="space-y-4 min-h-[400px]">
548 <Card>
549 <CardHeader>
550 <CardTitle>wisp.place Subdomain</CardTitle>
551 <CardDescription>
552 Your free subdomain on the wisp.place network
553 </CardDescription>
554 </CardHeader>
555 <CardContent>
556 {domainsLoading ? (
557 <div className="flex items-center justify-center py-4">
558 <Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
559 </div>
560 ) : wispDomain ? (
561 <>
562 <div className="flex flex-col gap-2 p-4 bg-muted/50 rounded-lg">
563 <div className="flex items-center gap-2">
564 <CheckCircle2 className="w-5 h-5 text-green-500" />
565 <span className="font-mono text-lg">
566 {wispDomain.domain}
567 </span>
568 </div>
569 {wispDomain.rkey && (
570 <p className="text-xs text-muted-foreground ml-7">
571 → Mapped to site: {wispDomain.rkey}
572 </p>
573 )}
574 </div>
575 <p className="text-sm text-muted-foreground mt-3">
576 {wispDomain.rkey
577 ? 'This domain is mapped to a specific site'
578 : 'This domain is not mapped to any site yet. Configure it from the Sites tab.'}
579 </p>
580 </>
581 ) : (
582 <div className="text-center py-4 text-muted-foreground">
583 <p>No wisp.place subdomain claimed yet.</p>
584 <p className="text-sm mt-1">
585 You should have claimed one during onboarding!
586 </p>
587 </div>
588 )}
589 </CardContent>
590 </Card>
591
592 <Card>
593 <CardHeader>
594 <CardTitle>Custom Domains</CardTitle>
595 <CardDescription>
596 Bring your own domain with DNS verification
597 </CardDescription>
598 </CardHeader>
599 <CardContent className="space-y-4">
600 <Button
601 onClick={() => setAddDomainModalOpen(true)}
602 className="w-full"
603 >
604 Add Custom Domain
605 </Button>
606
607 {domainsLoading ? (
608 <div className="flex items-center justify-center py-4">
609 <Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
610 </div>
611 ) : customDomains.length === 0 ? (
612 <div className="text-center py-4 text-muted-foreground text-sm">
613 No custom domains added yet
614 </div>
615 ) : (
616 <div className="space-y-2">
617 {customDomains.map((domain) => (
618 <div
619 key={domain.id}
620 className="flex items-center justify-between p-3 border border-border rounded-lg"
621 >
622 <div className="flex flex-col gap-1 flex-1">
623 <div className="flex items-center gap-2">
624 {domain.verified ? (
625 <CheckCircle2 className="w-4 h-4 text-green-500" />
626 ) : (
627 <XCircle className="w-4 h-4 text-red-500" />
628 )}
629 <span className="font-mono">
630 {domain.domain}
631 </span>
632 </div>
633 {domain.rkey && domain.rkey !== 'self' && (
634 <p className="text-xs text-muted-foreground ml-6">
635 → Mapped to site: {domain.rkey}
636 </p>
637 )}
638 </div>
639 <div className="flex items-center gap-2">
640 <Button
641 variant="outline"
642 size="sm"
643 onClick={() =>
644 setViewDomainDNS(domain.id)
645 }
646 >
647 View DNS
648 </Button>
649 {domain.verified ? (
650 <Badge variant="secondary">
651 Verified
652 </Badge>
653 ) : (
654 <Button
655 variant="outline"
656 size="sm"
657 onClick={() =>
658 handleVerifyDomain(domain.id)
659 }
660 disabled={
661 verificationStatus[
662 domain.id
663 ] === 'verifying'
664 }
665 >
666 {verificationStatus[
667 domain.id
668 ] === 'verifying' ? (
669 <>
670 <Loader2 className="w-3 h-3 mr-1 animate-spin" />
671 Verifying...
672 </>
673 ) : (
674 'Verify DNS'
675 )}
676 </Button>
677 )}
678 <Button
679 variant="ghost"
680 size="sm"
681 onClick={() =>
682 handleDeleteCustomDomain(
683 domain.id
684 )
685 }
686 >
687 <Trash2 className="w-4 h-4" />
688 </Button>
689 </div>
690 </div>
691 ))}
692 </div>
693 )}
694 </CardContent>
695 </Card>
696 </TabsContent>
697
698 {/* Upload Tab */}
699 <TabsContent value="upload" className="space-y-4 min-h-[400px]">
700 <Card>
701 <CardHeader>
702 <CardTitle>Upload Site</CardTitle>
703 <CardDescription>
704 Deploy a new site from a folder or Git repository
705 </CardDescription>
706 </CardHeader>
707 <CardContent className="space-y-6">
708 <div className="space-y-2">
709 <Label htmlFor="site-name">Site Name</Label>
710 <Input
711 id="site-name"
712 placeholder="my-awesome-site"
713 value={siteName}
714 onChange={(e) => setSiteName(e.target.value)}
715 disabled={isUploading}
716 />
717 </div>
718
719 <div className="grid md:grid-cols-2 gap-4">
720 <Card className="border-2 border-dashed hover:border-accent transition-colors cursor-pointer">
721 <CardContent className="flex flex-col items-center justify-center p-8 text-center">
722 <Upload className="w-12 h-12 text-muted-foreground mb-4" />
723 <h3 className="font-semibold mb-2">
724 Upload Folder
725 </h3>
726 <p className="text-sm text-muted-foreground mb-4">
727 Drag and drop or click to upload your
728 static site files
729 </p>
730 <input
731 type="file"
732 id="file-upload"
733 multiple
734 onChange={handleFileSelect}
735 className="hidden"
736 {...(({ webkitdirectory: '', directory: '' } as any))}
737 disabled={isUploading}
738 />
739 <label htmlFor="file-upload">
740 <Button
741 variant="outline"
742 type="button"
743 onClick={() =>
744 document
745 .getElementById('file-upload')
746 ?.click()
747 }
748 disabled={isUploading}
749 >
750 Choose Folder
751 </Button>
752 </label>
753 {selectedFiles && selectedFiles.length > 0 && (
754 <p className="text-sm text-muted-foreground mt-3">
755 {selectedFiles.length} files selected
756 </p>
757 )}
758 </CardContent>
759 </Card>
760
761 <Card className="border-2 border-dashed opacity-50">
762 <CardContent className="flex flex-col items-center justify-center p-8 text-center">
763 <Globe className="w-12 h-12 text-muted-foreground mb-4" />
764 <h3 className="font-semibold mb-2">
765 Connect Git Repository
766 </h3>
767 <p className="text-sm text-muted-foreground mb-4">
768 Link your GitHub, GitLab, or any Git
769 repository
770 </p>
771 <Badge variant="secondary">Coming soon!</Badge>
772 </CardContent>
773 </Card>
774 </div>
775
776 {uploadProgress && (
777 <div className="p-4 bg-muted rounded-lg">
778 <div className="flex items-center gap-2">
779 <Loader2 className="w-4 h-4 animate-spin" />
780 <span className="text-sm">{uploadProgress}</span>
781 </div>
782 </div>
783 )}
784
785 <Button
786 onClick={handleUpload}
787 className="w-full"
788 disabled={!siteName || isUploading}
789 >
790 {isUploading ? (
791 <>
792 <Loader2 className="w-4 h-4 mr-2 animate-spin" />
793 Uploading...
794 </>
795 ) : (
796 <>
797 {selectedFiles && selectedFiles.length > 0
798 ? 'Upload & Deploy'
799 : 'Create Empty Site'}
800 </>
801 )}
802 </Button>
803 </CardContent>
804 </Card>
805 </TabsContent>
806 </Tabs>
807 </div>
808
809 {/* Add Custom Domain Modal */}
810 <Dialog open={addDomainModalOpen} onOpenChange={setAddDomainModalOpen}>
811 <DialogContent className="sm:max-w-lg">
812 <DialogHeader>
813 <DialogTitle>Add Custom Domain</DialogTitle>
814 <DialogDescription>
815 Enter your domain name. After adding, you'll see the DNS
816 records to configure.
817 </DialogDescription>
818 </DialogHeader>
819 <div className="space-y-4 py-4">
820 <div className="space-y-2">
821 <Label htmlFor="new-domain">Domain Name</Label>
822 <Input
823 id="new-domain"
824 placeholder="example.com"
825 value={customDomain}
826 onChange={(e) => setCustomDomain(e.target.value)}
827 />
828 <p className="text-xs text-muted-foreground">
829 After adding, click "View DNS" to see the records you
830 need to configure.
831 </p>
832 </div>
833 </div>
834 <DialogFooter className="flex-col sm:flex-row gap-2">
835 <Button
836 variant="outline"
837 onClick={() => {
838 setAddDomainModalOpen(false)
839 setCustomDomain('')
840 }}
841 className="w-full sm:w-auto"
842 disabled={isAddingDomain}
843 >
844 Cancel
845 </Button>
846 <Button
847 onClick={handleAddCustomDomain}
848 disabled={!customDomain || isAddingDomain}
849 className="w-full sm:w-auto"
850 >
851 {isAddingDomain ? (
852 <>
853 <Loader2 className="w-4 h-4 mr-2 animate-spin" />
854 Adding...
855 </>
856 ) : (
857 'Add Domain'
858 )}
859 </Button>
860 </DialogFooter>
861 </DialogContent>
862 </Dialog>
863
864 {/* Site Configuration Modal */}
865 <Dialog
866 open={configuringSite !== null}
867 onOpenChange={(open) => !open && setConfiguringSite(null)}
868 >
869 <DialogContent className="sm:max-w-lg">
870 <DialogHeader>
871 <DialogTitle>Configure Site Domain</DialogTitle>
872 <DialogDescription>
873 Choose which domain this site should use
874 </DialogDescription>
875 </DialogHeader>
876 {configuringSite && (
877 <div className="space-y-4 py-4">
878 <div className="p-3 bg-muted/30 rounded-lg">
879 <p className="text-sm font-medium mb-1">Site:</p>
880 <p className="font-mono text-sm">
881 {configuringSite.display_name ||
882 configuringSite.rkey}
883 </p>
884 </div>
885
886 <RadioGroup
887 value={selectedDomain}
888 onValueChange={setSelectedDomain}
889 >
890 {wispDomain && (
891 <div className="flex items-center space-x-2">
892 <RadioGroupItem value="wisp" id="wisp" />
893 <Label
894 htmlFor="wisp"
895 className="flex-1 cursor-pointer"
896 >
897 <div className="flex items-center justify-between">
898 <span className="font-mono text-sm">
899 {wispDomain.domain}
900 </span>
901 <Badge variant="secondary" className="text-xs ml-2">
902 Free
903 </Badge>
904 </div>
905 </Label>
906 </div>
907 )}
908
909 {customDomains
910 .filter((d) => d.verified)
911 .map((domain) => (
912 <div
913 key={domain.id}
914 className="flex items-center space-x-2"
915 >
916 <RadioGroupItem
917 value={domain.id}
918 id={domain.id}
919 />
920 <Label
921 htmlFor={domain.id}
922 className="flex-1 cursor-pointer"
923 >
924 <div className="flex items-center justify-between">
925 <span className="font-mono text-sm">
926 {domain.domain}
927 </span>
928 <Badge
929 variant="outline"
930 className="text-xs ml-2"
931 >
932 Custom
933 </Badge>
934 </div>
935 </Label>
936 </div>
937 ))}
938
939 <div className="flex items-center space-x-2">
940 <RadioGroupItem value="none" id="none" />
941 <Label htmlFor="none" className="flex-1 cursor-pointer">
942 <div className="flex flex-col">
943 <span className="text-sm">Default URL</span>
944 <span className="text-xs text-muted-foreground font-mono break-all">
945 sites.wisp.place/{configuringSite.did}/
946 {configuringSite.rkey}
947 </span>
948 </div>
949 </Label>
950 </div>
951 </RadioGroup>
952 </div>
953 )}
954 <DialogFooter>
955 <Button
956 variant="outline"
957 onClick={() => setConfiguringSite(null)}
958 disabled={isSavingConfig}
959 >
960 Cancel
961 </Button>
962 <Button
963 onClick={handleSaveSiteConfig}
964 disabled={isSavingConfig}
965 >
966 {isSavingConfig ? (
967 <>
968 <Loader2 className="w-4 h-4 mr-2 animate-spin" />
969 Saving...
970 </>
971 ) : (
972 'Save'
973 )}
974 </Button>
975 </DialogFooter>
976 </DialogContent>
977 </Dialog>
978
979 {/* View DNS Records Modal */}
980 <Dialog
981 open={viewDomainDNS !== null}
982 onOpenChange={(open) => !open && setViewDomainDNS(null)}
983 >
984 <DialogContent className="sm:max-w-lg">
985 <DialogHeader>
986 <DialogTitle>DNS Configuration</DialogTitle>
987 <DialogDescription>
988 Add these DNS records to your domain provider
989 </DialogDescription>
990 </DialogHeader>
991 {viewDomainDNS && userInfo && (
992 <>
993 {(() => {
994 const domain = customDomains.find(
995 (d) => d.id === viewDomainDNS
996 )
997 if (!domain) return null
998
999 return (
1000 <div className="space-y-4 py-4">
1001 <div className="p-3 bg-muted/30 rounded-lg">
1002 <p className="text-sm font-medium mb-1">
1003 Domain:
1004 </p>
1005 <p className="font-mono text-sm">
1006 {domain.domain}
1007 </p>
1008 </div>
1009
1010 <div className="space-y-3">
1011 <div className="p-3 bg-background rounded border border-border">
1012 <div className="flex justify-between items-start mb-2">
1013 <span className="text-xs font-semibold text-muted-foreground">
1014 TXT Record (Verification)
1015 </span>
1016 </div>
1017 <div className="font-mono text-xs space-y-2">
1018 <div>
1019 <span className="text-muted-foreground">
1020 Name:
1021 </span>{' '}
1022 <span className="select-all">
1023 _wisp.{domain.domain}
1024 </span>
1025 </div>
1026 <div>
1027 <span className="text-muted-foreground">
1028 Value:
1029 </span>{' '}
1030 <span className="select-all break-all">
1031 {userInfo.did}
1032 </span>
1033 </div>
1034 </div>
1035 </div>
1036
1037 <div className="p-3 bg-background rounded border border-border">
1038 <div className="flex justify-between items-start mb-2">
1039 <span className="text-xs font-semibold text-muted-foreground">
1040 CNAME Record (Pointing)
1041 </span>
1042 </div>
1043 <div className="font-mono text-xs space-y-2">
1044 <div>
1045 <span className="text-muted-foreground">
1046 Name:
1047 </span>{' '}
1048 <span className="select-all">
1049 {domain.domain}
1050 </span>
1051 </div>
1052 <div>
1053 <span className="text-muted-foreground">
1054 Value:
1055 </span>{' '}
1056 <span className="select-all">
1057 {domain.id}.dns.wisp.place
1058 </span>
1059 </div>
1060 </div>
1061 <p className="text-xs text-muted-foreground mt-2">
1062 Some DNS providers may require you to use @ or leave it blank for the root domain
1063 </p>
1064 </div>
1065 </div>
1066
1067 <div className="p-3 bg-muted/30 rounded-lg">
1068 <p className="text-xs text-muted-foreground">
1069 💡 After configuring DNS, click "Verify DNS"
1070 to check if everything is set up correctly.
1071 DNS changes can take a few minutes to
1072 propagate.
1073 </p>
1074 </div>
1075 </div>
1076 )
1077 })()}
1078 </>
1079 )}
1080 <DialogFooter>
1081 <Button
1082 variant="outline"
1083 onClick={() => setViewDomainDNS(null)}
1084 className="w-full sm:w-auto"
1085 >
1086 Close
1087 </Button>
1088 </DialogFooter>
1089 </DialogContent>
1090 </Dialog>
1091 </div>
1092 )
1093}
1094
1095const root = createRoot(document.getElementById('elysia')!)
1096root.render(
1097 <Layout className="gap-6">
1098 <Dashboard />
1099 </Layout>
1100)