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)