Monorepo for Wisp.place. A static site hosting service built on top of the AT Protocol.
at main 51 kB view raw
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' 41import { CodeBlock } from '@public/components/ui/code-block' 42 43import Layout from '@public/layouts' 44 45interface UserInfo { 46 did: string 47 handle: string 48} 49 50interface Site { 51 did: string 52 rkey: string 53 display_name: string | null 54 created_at: number 55 updated_at: number 56} 57 58interface CustomDomain { 59 id: string 60 domain: string 61 did: string 62 rkey: string 63 verified: boolean 64 last_verified_at: number | null 65 created_at: number 66} 67 68interface WispDomain { 69 domain: string 70 rkey: string | null 71} 72 73function Dashboard() { 74 // User state 75 const [userInfo, setUserInfo] = useState<UserInfo | null>(null) 76 const [loading, setLoading] = useState(true) 77 78 // Sites state 79 const [sites, setSites] = useState<Site[]>([]) 80 const [sitesLoading, setSitesLoading] = useState(true) 81 const [isSyncing, setIsSyncing] = useState(false) 82 83 // Domains state 84 const [wispDomain, setWispDomain] = useState<WispDomain | null>(null) 85 const [customDomains, setCustomDomains] = useState<CustomDomain[]>([]) 86 const [domainsLoading, setDomainsLoading] = useState(true) 87 88 // Site configuration state 89 const [configuringSite, setConfiguringSite] = useState<Site | null>(null) 90 const [selectedDomain, setSelectedDomain] = useState<string>('') 91 const [isSavingConfig, setIsSavingConfig] = useState(false) 92 const [isDeletingSite, setIsDeletingSite] = useState(false) 93 94 // Upload state 95 const [siteMode, setSiteMode] = useState<'existing' | 'new'>('existing') 96 const [selectedSiteRkey, setSelectedSiteRkey] = useState<string>('') 97 const [newSiteName, setNewSiteName] = useState('') 98 const [selectedFiles, setSelectedFiles] = useState<FileList | null>(null) 99 const [isUploading, setIsUploading] = useState(false) 100 const [uploadProgress, setUploadProgress] = useState('') 101 const [skippedFiles, setSkippedFiles] = useState<Array<{ name: string; reason: string }>>([]) 102 const [uploadedCount, setUploadedCount] = useState(0) 103 104 // Custom domain modal state 105 const [addDomainModalOpen, setAddDomainModalOpen] = useState(false) 106 const [customDomain, setCustomDomain] = useState('') 107 const [isAddingDomain, setIsAddingDomain] = useState(false) 108 const [verificationStatus, setVerificationStatus] = useState<{ 109 [id: string]: 'idle' | 'verifying' | 'success' | 'error' 110 }>({}) 111 const [viewDomainDNS, setViewDomainDNS] = useState<string | null>(null) 112 113 // Wisp domain claim state 114 const [wispHandle, setWispHandle] = useState('') 115 const [isClaimingWisp, setIsClaimingWisp] = useState(false) 116 const [wispAvailability, setWispAvailability] = useState<{ 117 available: boolean | null 118 checking: boolean 119 }>({ available: null, checking: false }) 120 121 // Fetch user info on mount 122 useEffect(() => { 123 fetchUserInfo() 124 fetchSites() 125 fetchDomains() 126 }, []) 127 128 // Auto-switch to 'new' mode if no sites exist 129 useEffect(() => { 130 if (!sitesLoading && sites.length === 0 && siteMode === 'existing') { 131 setSiteMode('new') 132 } 133 }, [sites, sitesLoading, siteMode]) 134 135 const fetchUserInfo = async () => { 136 try { 137 const response = await fetch('/api/user/info') 138 const data = await response.json() 139 setUserInfo(data) 140 } catch (err) { 141 console.error('Failed to fetch user info:', err) 142 } finally { 143 setLoading(false) 144 } 145 } 146 147 const fetchSites = async () => { 148 try { 149 const response = await fetch('/api/user/sites') 150 const data = await response.json() 151 setSites(data.sites || []) 152 } catch (err) { 153 console.error('Failed to fetch sites:', err) 154 } finally { 155 setSitesLoading(false) 156 } 157 } 158 159 const syncSites = async () => { 160 setIsSyncing(true) 161 try { 162 const response = await fetch('/api/user/sync', { 163 method: 'POST' 164 }) 165 const data = await response.json() 166 if (data.success) { 167 console.log(`Synced ${data.synced} sites from PDS`) 168 // Refresh sites list 169 await fetchSites() 170 } 171 } catch (err) { 172 console.error('Failed to sync sites:', err) 173 alert('Failed to sync sites from PDS') 174 } finally { 175 setIsSyncing(false) 176 } 177 } 178 179 const fetchDomains = async () => { 180 try { 181 const response = await fetch('/api/user/domains') 182 const data = await response.json() 183 setWispDomain(data.wispDomain) 184 setCustomDomains(data.customDomains || []) 185 } catch (err) { 186 console.error('Failed to fetch domains:', err) 187 } finally { 188 setDomainsLoading(false) 189 } 190 } 191 192 const getSiteUrl = (site: Site) => { 193 // Check if this site is mapped to the wisp.place domain 194 if (wispDomain && wispDomain.rkey === site.rkey) { 195 return `https://${wispDomain.domain}` 196 } 197 198 // Check if this site is mapped to any custom domain 199 const customDomain = customDomains.find((d) => d.rkey === site.rkey) 200 if (customDomain) { 201 return `https://${customDomain.domain}` 202 } 203 204 // Default fallback URL 205 if (!userInfo) return '#' 206 return `https://sites.wisp.place/${site.did}/${site.rkey}` 207 } 208 209 const getSiteDomainName = (site: Site) => { 210 if (wispDomain && wispDomain.rkey === site.rkey) { 211 return wispDomain.domain 212 } 213 214 const customDomain = customDomains.find((d) => d.rkey === site.rkey) 215 if (customDomain) { 216 return customDomain.domain 217 } 218 219 return `sites.wisp.place/${site.did}/${site.rkey}` 220 } 221 222 const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => { 223 if (e.target.files && e.target.files.length > 0) { 224 setSelectedFiles(e.target.files) 225 } 226 } 227 228 const handleUpload = async () => { 229 const siteName = siteMode === 'existing' ? selectedSiteRkey : newSiteName 230 231 if (!siteName) { 232 alert(siteMode === 'existing' ? 'Please select a site' : 'Please enter a site name') 233 return 234 } 235 236 setIsUploading(true) 237 setUploadProgress('Preparing files...') 238 239 try { 240 const formData = new FormData() 241 formData.append('siteName', siteName) 242 243 if (selectedFiles) { 244 for (let i = 0; i < selectedFiles.length; i++) { 245 formData.append('files', selectedFiles[i]) 246 } 247 } 248 249 setUploadProgress('Uploading to AT Protocol...') 250 const response = await fetch('/wisp/upload-files', { 251 method: 'POST', 252 body: formData 253 }) 254 255 const data = await response.json() 256 if (data.success) { 257 setUploadProgress('Upload complete!') 258 setSkippedFiles(data.skippedFiles || []) 259 setUploadedCount(data.uploadedCount || data.fileCount || 0) 260 setSelectedSiteRkey('') 261 setNewSiteName('') 262 setSelectedFiles(null) 263 264 // Refresh sites list 265 await fetchSites() 266 267 // Reset form - give more time if there are skipped files 268 const resetDelay = data.skippedFiles && data.skippedFiles.length > 0 ? 4000 : 1500 269 setTimeout(() => { 270 setUploadProgress('') 271 setSkippedFiles([]) 272 setUploadedCount(0) 273 setIsUploading(false) 274 }, resetDelay) 275 } else { 276 throw new Error(data.error || 'Upload failed') 277 } 278 } catch (err) { 279 console.error('Upload error:', err) 280 alert( 281 `Upload failed: ${err instanceof Error ? err.message : 'Unknown error'}` 282 ) 283 setIsUploading(false) 284 setUploadProgress('') 285 } 286 } 287 288 const handleAddCustomDomain = async () => { 289 if (!customDomain) { 290 alert('Please enter a domain') 291 return 292 } 293 294 setIsAddingDomain(true) 295 try { 296 const response = await fetch('/api/domain/custom/add', { 297 method: 'POST', 298 headers: { 'Content-Type': 'application/json' }, 299 body: JSON.stringify({ domain: customDomain }) 300 }) 301 302 const data = await response.json() 303 if (data.success) { 304 setCustomDomain('') 305 setAddDomainModalOpen(false) 306 await fetchDomains() 307 308 // Automatically show DNS configuration for the newly added domain 309 setViewDomainDNS(data.id) 310 } else { 311 throw new Error(data.error || 'Failed to add domain') 312 } 313 } catch (err) { 314 console.error('Add domain error:', err) 315 alert( 316 `Failed to add domain: ${err instanceof Error ? err.message : 'Unknown error'}` 317 ) 318 } finally { 319 setIsAddingDomain(false) 320 } 321 } 322 323 const handleVerifyDomain = async (id: string) => { 324 setVerificationStatus({ ...verificationStatus, [id]: 'verifying' }) 325 326 try { 327 const response = await fetch('/api/domain/custom/verify', { 328 method: 'POST', 329 headers: { 'Content-Type': 'application/json' }, 330 body: JSON.stringify({ id }) 331 }) 332 333 const data = await response.json() 334 if (data.success && data.verified) { 335 setVerificationStatus({ ...verificationStatus, [id]: 'success' }) 336 await fetchDomains() 337 } else { 338 setVerificationStatus({ ...verificationStatus, [id]: 'error' }) 339 if (data.error) { 340 alert(`Verification failed: ${data.error}`) 341 } 342 } 343 } catch (err) { 344 console.error('Verify domain error:', err) 345 setVerificationStatus({ ...verificationStatus, [id]: 'error' }) 346 alert( 347 `Verification failed: ${err instanceof Error ? err.message : 'Unknown error'}` 348 ) 349 } 350 } 351 352 const handleDeleteCustomDomain = async (id: string) => { 353 if (!confirm('Are you sure you want to remove this custom domain?')) { 354 return 355 } 356 357 try { 358 const response = await fetch(`/api/domain/custom/${id}`, { 359 method: 'DELETE' 360 }) 361 362 const data = await response.json() 363 if (data.success) { 364 await fetchDomains() 365 } else { 366 throw new Error('Failed to delete domain') 367 } 368 } catch (err) { 369 console.error('Delete domain error:', err) 370 alert( 371 `Failed to delete domain: ${err instanceof Error ? err.message : 'Unknown error'}` 372 ) 373 } 374 } 375 376 const handleConfigureSite = (site: Site) => { 377 setConfiguringSite(site) 378 379 // Determine current domain mapping 380 if (wispDomain && wispDomain.rkey === site.rkey) { 381 setSelectedDomain('wisp') 382 } else { 383 const customDomain = customDomains.find((d) => d.rkey === site.rkey) 384 if (customDomain) { 385 setSelectedDomain(customDomain.id) 386 } else { 387 setSelectedDomain('none') 388 } 389 } 390 } 391 392 const handleSaveSiteConfig = async () => { 393 if (!configuringSite) return 394 395 setIsSavingConfig(true) 396 try { 397 if (selectedDomain === 'wisp') { 398 // Map to wisp.place domain 399 const response = await fetch('/api/domain/wisp/map-site', { 400 method: 'POST', 401 headers: { 'Content-Type': 'application/json' }, 402 body: JSON.stringify({ siteRkey: configuringSite.rkey }) 403 }) 404 const data = await response.json() 405 if (!data.success) throw new Error('Failed to map site') 406 } else if (selectedDomain === 'none') { 407 // Unmap from all domains 408 // Unmap wisp domain if this site was mapped to it 409 if (wispDomain && wispDomain.rkey === configuringSite.rkey) { 410 await fetch('/api/domain/wisp/map-site', { 411 method: 'POST', 412 headers: { 'Content-Type': 'application/json' }, 413 body: JSON.stringify({ siteRkey: null }) 414 }) 415 } 416 417 // Unmap from custom domains 418 const mappedCustom = customDomains.find( 419 (d) => d.rkey === configuringSite.rkey 420 ) 421 if (mappedCustom) { 422 await fetch(`/api/domain/custom/${mappedCustom.id}/map-site`, { 423 method: 'POST', 424 headers: { 'Content-Type': 'application/json' }, 425 body: JSON.stringify({ siteRkey: null }) 426 }) 427 } 428 } else { 429 // Map to a custom domain 430 const response = await fetch( 431 `/api/domain/custom/${selectedDomain}/map-site`, 432 { 433 method: 'POST', 434 headers: { 'Content-Type': 'application/json' }, 435 body: JSON.stringify({ siteRkey: configuringSite.rkey }) 436 } 437 ) 438 const data = await response.json() 439 if (!data.success) throw new Error('Failed to map site') 440 } 441 442 // Refresh domains to get updated mappings 443 await fetchDomains() 444 setConfiguringSite(null) 445 } catch (err) { 446 console.error('Save config error:', err) 447 alert( 448 `Failed to save configuration: ${err instanceof Error ? err.message : 'Unknown error'}` 449 ) 450 } finally { 451 setIsSavingConfig(false) 452 } 453 } 454 455 const handleDeleteSite = async () => { 456 if (!configuringSite) return 457 458 if (!confirm(`Are you sure you want to delete "${configuringSite.display_name || configuringSite.rkey}"? This action cannot be undone.`)) { 459 return 460 } 461 462 setIsDeletingSite(true) 463 try { 464 const response = await fetch(`/api/site/${configuringSite.rkey}`, { 465 method: 'DELETE' 466 }) 467 468 const data = await response.json() 469 if (data.success) { 470 // Refresh sites list 471 await fetchSites() 472 // Refresh domains in case this site was mapped 473 await fetchDomains() 474 setConfiguringSite(null) 475 } else { 476 throw new Error(data.error || 'Failed to delete site') 477 } 478 } catch (err) { 479 console.error('Delete site error:', err) 480 alert( 481 `Failed to delete site: ${err instanceof Error ? err.message : 'Unknown error'}` 482 ) 483 } finally { 484 setIsDeletingSite(false) 485 } 486 } 487 488 const checkWispAvailability = async (handle: string) => { 489 const trimmedHandle = handle.trim().toLowerCase() 490 if (!trimmedHandle) { 491 setWispAvailability({ available: null, checking: false }) 492 return 493 } 494 495 setWispAvailability({ available: null, checking: true }) 496 try { 497 const response = await fetch(`/api/domain/check?handle=${encodeURIComponent(trimmedHandle)}`) 498 const data = await response.json() 499 setWispAvailability({ available: data.available, checking: false }) 500 } catch (err) { 501 console.error('Check availability error:', err) 502 setWispAvailability({ available: false, checking: false }) 503 } 504 } 505 506 const handleClaimWispDomain = async () => { 507 const trimmedHandle = wispHandle.trim().toLowerCase() 508 if (!trimmedHandle) { 509 alert('Please enter a handle') 510 return 511 } 512 513 setIsClaimingWisp(true) 514 try { 515 const response = await fetch('/api/domain/claim', { 516 method: 'POST', 517 headers: { 'Content-Type': 'application/json' }, 518 body: JSON.stringify({ handle: trimmedHandle }) 519 }) 520 521 const data = await response.json() 522 if (data.success) { 523 setWispHandle('') 524 setWispAvailability({ available: null, checking: false }) 525 await fetchDomains() 526 } else { 527 throw new Error(data.error || 'Failed to claim domain') 528 } 529 } catch (err) { 530 console.error('Claim domain error:', err) 531 const errorMessage = err instanceof Error ? err.message : 'Unknown error' 532 533 // Handle "Already claimed" error more gracefully 534 if (errorMessage.includes('Already claimed')) { 535 alert('You have already claimed a wisp.place subdomain. Please refresh the page.') 536 await fetchDomains() 537 } else { 538 alert(`Failed to claim domain: ${errorMessage}`) 539 } 540 } finally { 541 setIsClaimingWisp(false) 542 } 543 } 544 545 if (loading) { 546 return ( 547 <div className="w-full min-h-screen bg-background flex items-center justify-center"> 548 <Loader2 className="w-8 h-8 animate-spin text-primary" /> 549 </div> 550 ) 551 } 552 553 return ( 554 <div className="w-full min-h-screen bg-background"> 555 {/* Header */} 556 <header className="border-b border-border/40 bg-background/80 backdrop-blur-sm sticky top-0 z-50"> 557 <div className="container mx-auto px-4 py-4 flex items-center justify-between"> 558 <div className="flex items-center gap-2"> 559 <div className="w-8 h-8 bg-primary rounded-lg flex items-center justify-center"> 560 <Globe className="w-5 h-5 text-primary-foreground" /> 561 </div> 562 <span className="text-xl font-semibold text-foreground"> 563 wisp.place 564 </span> 565 </div> 566 <div className="flex items-center gap-3"> 567 <span className="text-sm text-muted-foreground"> 568 {userInfo?.handle || 'Loading...'} 569 </span> 570 </div> 571 </div> 572 </header> 573 574 <div className="container mx-auto px-4 py-8 max-w-6xl w-full"> 575 <div className="mb-8"> 576 <h1 className="text-3xl font-bold mb-2">Dashboard</h1> 577 <p className="text-muted-foreground"> 578 Manage your sites and domains 579 </p> 580 </div> 581 582 <Tabs defaultValue="sites" className="space-y-6 w-full"> 583 <TabsList className="grid w-full grid-cols-4"> 584 <TabsTrigger value="sites">Sites</TabsTrigger> 585 <TabsTrigger value="domains">Domains</TabsTrigger> 586 <TabsTrigger value="upload">Upload</TabsTrigger> 587 <TabsTrigger value="cli">CLI</TabsTrigger> 588 </TabsList> 589 590 {/* Sites Tab */} 591 <TabsContent value="sites" className="space-y-4 min-h-[400px]"> 592 <Card> 593 <CardHeader> 594 <div className="flex items-center justify-between"> 595 <div> 596 <CardTitle>Your Sites</CardTitle> 597 <CardDescription> 598 View and manage all your deployed sites 599 </CardDescription> 600 </div> 601 <Button 602 variant="outline" 603 size="sm" 604 onClick={syncSites} 605 disabled={isSyncing || sitesLoading} 606 > 607 <RefreshCw 608 className={`w-4 h-4 mr-2 ${isSyncing ? 'animate-spin' : ''}`} 609 /> 610 Sync from PDS 611 </Button> 612 </div> 613 </CardHeader> 614 <CardContent className="space-y-4"> 615 {sitesLoading ? ( 616 <div className="flex items-center justify-center py-8"> 617 <Loader2 className="w-6 h-6 animate-spin text-muted-foreground" /> 618 </div> 619 ) : sites.length === 0 ? ( 620 <div className="text-center py-8 text-muted-foreground"> 621 <p>No sites yet. Upload your first site!</p> 622 </div> 623 ) : ( 624 sites.map((site) => ( 625 <div 626 key={`${site.did}-${site.rkey}`} 627 className="flex items-center justify-between p-4 border border-border rounded-lg hover:bg-muted/50 transition-colors" 628 > 629 <div className="flex-1"> 630 <div className="flex items-center gap-3 mb-2"> 631 <h3 className="font-semibold text-lg"> 632 {site.display_name || site.rkey} 633 </h3> 634 <Badge 635 variant="secondary" 636 className="text-xs" 637 > 638 active 639 </Badge> 640 </div> 641 <a 642 href={getSiteUrl(site)} 643 target="_blank" 644 rel="noopener noreferrer" 645 className="text-sm text-accent hover:text-accent/80 flex items-center gap-1" 646 > 647 {getSiteDomainName(site)} 648 <ExternalLink className="w-3 h-3" /> 649 </a> 650 </div> 651 <Button 652 variant="outline" 653 size="sm" 654 onClick={() => handleConfigureSite(site)} 655 > 656 <Settings className="w-4 h-4 mr-2" /> 657 Configure 658 </Button> 659 </div> 660 )) 661 )} 662 </CardContent> 663 </Card> 664 </TabsContent> 665 666 {/* Domains Tab */} 667 <TabsContent value="domains" className="space-y-4 min-h-[400px]"> 668 <Card> 669 <CardHeader> 670 <CardTitle>wisp.place Subdomain</CardTitle> 671 <CardDescription> 672 Your free subdomain on the wisp.place network 673 </CardDescription> 674 </CardHeader> 675 <CardContent> 676 {domainsLoading ? ( 677 <div className="flex items-center justify-center py-4"> 678 <Loader2 className="w-6 h-6 animate-spin text-muted-foreground" /> 679 </div> 680 ) : wispDomain ? ( 681 <> 682 <div className="flex flex-col gap-2 p-4 bg-muted/50 rounded-lg"> 683 <div className="flex items-center gap-2"> 684 <CheckCircle2 className="w-5 h-5 text-green-500" /> 685 <span className="font-mono text-lg"> 686 {wispDomain.domain} 687 </span> 688 </div> 689 {wispDomain.rkey && ( 690 <p className="text-xs text-muted-foreground ml-7"> 691 Mapped to site: {wispDomain.rkey} 692 </p> 693 )} 694 </div> 695 <p className="text-sm text-muted-foreground mt-3"> 696 {wispDomain.rkey 697 ? 'This domain is mapped to a specific site' 698 : 'This domain is not mapped to any site yet. Configure it from the Sites tab.'} 699 </p> 700 </> 701 ) : ( 702 <div className="space-y-4"> 703 <div className="p-4 bg-muted/30 rounded-lg"> 704 <p className="text-sm text-muted-foreground mb-4"> 705 Claim your free wisp.place subdomain 706 </p> 707 <div className="space-y-3"> 708 <div className="space-y-2"> 709 <Label htmlFor="wisp-handle">Choose your handle</Label> 710 <div className="flex gap-2"> 711 <div className="flex-1 relative"> 712 <Input 713 id="wisp-handle" 714 placeholder="mysite" 715 value={wispHandle} 716 onChange={(e) => { 717 setWispHandle(e.target.value) 718 if (e.target.value.trim()) { 719 checkWispAvailability(e.target.value) 720 } else { 721 setWispAvailability({ available: null, checking: false }) 722 } 723 }} 724 disabled={isClaimingWisp} 725 className="pr-24" 726 /> 727 <span className="absolute right-3 top-1/2 -translate-y-1/2 text-sm text-muted-foreground"> 728 .wisp.place 729 </span> 730 </div> 731 </div> 732 {wispAvailability.checking && ( 733 <p className="text-xs text-muted-foreground flex items-center gap-1"> 734 <Loader2 className="w-3 h-3 animate-spin" /> 735 Checking availability... 736 </p> 737 )} 738 {!wispAvailability.checking && wispAvailability.available === true && ( 739 <p className="text-xs text-green-600 flex items-center gap-1"> 740 <CheckCircle2 className="w-3 h-3" /> 741 Available 742 </p> 743 )} 744 {!wispAvailability.checking && wispAvailability.available === false && ( 745 <p className="text-xs text-red-600 flex items-center gap-1"> 746 <XCircle className="w-3 h-3" /> 747 Not available 748 </p> 749 )} 750 </div> 751 <Button 752 onClick={handleClaimWispDomain} 753 disabled={!wispHandle.trim() || isClaimingWisp || wispAvailability.available !== true} 754 className="w-full" 755 > 756 {isClaimingWisp ? ( 757 <> 758 <Loader2 className="w-4 h-4 mr-2 animate-spin" /> 759 Claiming... 760 </> 761 ) : ( 762 'Claim Subdomain' 763 )} 764 </Button> 765 </div> 766 </div> 767 </div> 768 )} 769 </CardContent> 770 </Card> 771 772 <Card> 773 <CardHeader> 774 <CardTitle>Custom Domains</CardTitle> 775 <CardDescription> 776 Bring your own domain with DNS verification 777 </CardDescription> 778 </CardHeader> 779 <CardContent className="space-y-4"> 780 <Button 781 onClick={() => setAddDomainModalOpen(true)} 782 className="w-full" 783 > 784 Add Custom Domain 785 </Button> 786 787 {domainsLoading ? ( 788 <div className="flex items-center justify-center py-4"> 789 <Loader2 className="w-6 h-6 animate-spin text-muted-foreground" /> 790 </div> 791 ) : customDomains.length === 0 ? ( 792 <div className="text-center py-4 text-muted-foreground text-sm"> 793 No custom domains added yet 794 </div> 795 ) : ( 796 <div className="space-y-2"> 797 {customDomains.map((domain) => ( 798 <div 799 key={domain.id} 800 className="flex items-center justify-between p-3 border border-border rounded-lg" 801 > 802 <div className="flex flex-col gap-1 flex-1"> 803 <div className="flex items-center gap-2"> 804 {domain.verified ? ( 805 <CheckCircle2 className="w-4 h-4 text-green-500" /> 806 ) : ( 807 <XCircle className="w-4 h-4 text-red-500" /> 808 )} 809 <span className="font-mono"> 810 {domain.domain} 811 </span> 812 </div> 813 {domain.rkey && domain.rkey !== 'self' && ( 814 <p className="text-xs text-muted-foreground ml-6"> 815 Mapped to site: {domain.rkey} 816 </p> 817 )} 818 </div> 819 <div className="flex items-center gap-2"> 820 <Button 821 variant="outline" 822 size="sm" 823 onClick={() => 824 setViewDomainDNS(domain.id) 825 } 826 > 827 View DNS 828 </Button> 829 {domain.verified ? ( 830 <Badge variant="secondary"> 831 Verified 832 </Badge> 833 ) : ( 834 <Button 835 variant="outline" 836 size="sm" 837 onClick={() => 838 handleVerifyDomain(domain.id) 839 } 840 disabled={ 841 verificationStatus[ 842 domain.id 843 ] === 'verifying' 844 } 845 > 846 {verificationStatus[ 847 domain.id 848 ] === 'verifying' ? ( 849 <> 850 <Loader2 className="w-3 h-3 mr-1 animate-spin" /> 851 Verifying... 852 </> 853 ) : ( 854 'Verify DNS' 855 )} 856 </Button> 857 )} 858 <Button 859 variant="ghost" 860 size="sm" 861 onClick={() => 862 handleDeleteCustomDomain( 863 domain.id 864 ) 865 } 866 > 867 <Trash2 className="w-4 h-4" /> 868 </Button> 869 </div> 870 </div> 871 ))} 872 </div> 873 )} 874 </CardContent> 875 </Card> 876 </TabsContent> 877 878 {/* Upload Tab */} 879 <TabsContent value="upload" className="space-y-4 min-h-[400px]"> 880 <Card> 881 <CardHeader> 882 <CardTitle>Upload Site</CardTitle> 883 <CardDescription> 884 Deploy a new site from a folder or Git repository 885 </CardDescription> 886 </CardHeader> 887 <CardContent className="space-y-6"> 888 <div className="space-y-4"> 889 <div className="p-4 bg-muted/50 rounded-lg"> 890 <RadioGroup 891 value={siteMode} 892 onValueChange={(value) => setSiteMode(value as 'existing' | 'new')} 893 disabled={isUploading} 894 > 895 <div className="flex items-center space-x-2"> 896 <RadioGroupItem value="existing" id="existing" /> 897 <Label htmlFor="existing" className="cursor-pointer"> 898 Update existing site 899 </Label> 900 </div> 901 <div className="flex items-center space-x-2"> 902 <RadioGroupItem value="new" id="new" /> 903 <Label htmlFor="new" className="cursor-pointer"> 904 Create new site 905 </Label> 906 </div> 907 </RadioGroup> 908 </div> 909 910 {siteMode === 'existing' ? ( 911 <div className="space-y-2"> 912 <Label htmlFor="site-select">Select Site</Label> 913 {sitesLoading ? ( 914 <div className="flex items-center justify-center py-4"> 915 <Loader2 className="w-5 h-5 animate-spin text-muted-foreground" /> 916 </div> 917 ) : sites.length === 0 ? ( 918 <div className="p-4 border border-dashed rounded-lg text-center text-sm text-muted-foreground"> 919 No sites available. Create a new site instead. 920 </div> 921 ) : ( 922 <select 923 id="site-select" 924 className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50" 925 value={selectedSiteRkey} 926 onChange={(e) => setSelectedSiteRkey(e.target.value)} 927 disabled={isUploading} 928 > 929 <option value="">Select a site...</option> 930 {sites.map((site) => ( 931 <option key={site.rkey} value={site.rkey}> 932 {site.display_name || site.rkey} 933 </option> 934 ))} 935 </select> 936 )} 937 </div> 938 ) : ( 939 <div className="space-y-2"> 940 <Label htmlFor="new-site-name">New Site Name</Label> 941 <Input 942 id="new-site-name" 943 placeholder="my-awesome-site" 944 value={newSiteName} 945 onChange={(e) => setNewSiteName(e.target.value)} 946 disabled={isUploading} 947 /> 948 </div> 949 )} 950 951 <p className="text-xs text-muted-foreground"> 952 File limits: 100MB per file, 300MB total 953 </p> 954 </div> 955 956 <div className="grid md:grid-cols-2 gap-4"> 957 <Card className="border-2 border-dashed hover:border-accent transition-colors cursor-pointer"> 958 <CardContent className="flex flex-col items-center justify-center p-8 text-center"> 959 <Upload className="w-12 h-12 text-muted-foreground mb-4" /> 960 <h3 className="font-semibold mb-2"> 961 Upload Folder 962 </h3> 963 <p className="text-sm text-muted-foreground mb-4"> 964 Drag and drop or click to upload your 965 static site files 966 </p> 967 <input 968 type="file" 969 id="file-upload" 970 multiple 971 onChange={handleFileSelect} 972 className="hidden" 973 {...(({ webkitdirectory: '', directory: '' } as any))} 974 disabled={isUploading} 975 /> 976 <label htmlFor="file-upload"> 977 <Button 978 variant="outline" 979 type="button" 980 onClick={() => 981 document 982 .getElementById('file-upload') 983 ?.click() 984 } 985 disabled={isUploading} 986 > 987 Choose Folder 988 </Button> 989 </label> 990 {selectedFiles && selectedFiles.length > 0 && ( 991 <p className="text-sm text-muted-foreground mt-3"> 992 {selectedFiles.length} files selected 993 </p> 994 )} 995 </CardContent> 996 </Card> 997 998 <Card className="border-2 border-dashed opacity-50"> 999 <CardContent className="flex flex-col items-center justify-center p-8 text-center"> 1000 <Globe className="w-12 h-12 text-muted-foreground mb-4" /> 1001 <h3 className="font-semibold mb-2"> 1002 Connect Git Repository 1003 </h3> 1004 <p className="text-sm text-muted-foreground mb-4"> 1005 Link your GitHub, GitLab, or any Git 1006 repository 1007 </p> 1008 <Badge variant="secondary">Coming soon!</Badge> 1009 </CardContent> 1010 </Card> 1011 </div> 1012 1013 {uploadProgress && ( 1014 <div className="space-y-3"> 1015 <div className="p-4 bg-muted rounded-lg"> 1016 <div className="flex items-center gap-2"> 1017 <Loader2 className="w-4 h-4 animate-spin" /> 1018 <span className="text-sm">{uploadProgress}</span> 1019 </div> 1020 </div> 1021 1022 {skippedFiles.length > 0 && ( 1023 <div className="p-4 bg-yellow-500/10 border border-yellow-500/20 rounded-lg"> 1024 <div className="flex items-start gap-2 text-yellow-600 dark:text-yellow-400 mb-2"> 1025 <AlertCircle className="w-4 h-4 mt-0.5 flex-shrink-0" /> 1026 <div className="flex-1"> 1027 <span className="font-medium"> 1028 {skippedFiles.length} file{skippedFiles.length > 1 ? 's' : ''} skipped 1029 </span> 1030 {uploadedCount > 0 && ( 1031 <span className="text-sm ml-2"> 1032 ({uploadedCount} uploaded successfully) 1033 </span> 1034 )} 1035 </div> 1036 </div> 1037 <div className="ml-6 space-y-1 max-h-32 overflow-y-auto"> 1038 {skippedFiles.slice(0, 5).map((file, idx) => ( 1039 <div key={idx} className="text-xs"> 1040 <span className="font-mono">{file.name}</span> 1041 <span className="text-muted-foreground"> - {file.reason}</span> 1042 </div> 1043 ))} 1044 {skippedFiles.length > 5 && ( 1045 <div className="text-xs text-muted-foreground"> 1046 ...and {skippedFiles.length - 5} more 1047 </div> 1048 )} 1049 </div> 1050 </div> 1051 )} 1052 </div> 1053 )} 1054 1055 <Button 1056 onClick={handleUpload} 1057 className="w-full" 1058 disabled={ 1059 (siteMode === 'existing' ? !selectedSiteRkey : !newSiteName) || 1060 isUploading || 1061 (siteMode === 'existing' && (!selectedFiles || selectedFiles.length === 0)) 1062 } 1063 > 1064 {isUploading ? ( 1065 <> 1066 <Loader2 className="w-4 h-4 mr-2 animate-spin" /> 1067 Uploading... 1068 </> 1069 ) : ( 1070 <> 1071 {siteMode === 'existing' ? ( 1072 'Update Site' 1073 ) : ( 1074 selectedFiles && selectedFiles.length > 0 1075 ? 'Upload & Deploy' 1076 : 'Create Empty Site' 1077 )} 1078 </> 1079 )} 1080 </Button> 1081 </CardContent> 1082 </Card> 1083 </TabsContent> 1084 1085 {/* CLI Tab */} 1086 <TabsContent value="cli" className="space-y-4 min-h-[400px]"> 1087 <Card> 1088 <CardHeader> 1089 <div className="flex items-center gap-2 mb-2"> 1090 <CardTitle>Wisp CLI Tool</CardTitle> 1091 <Badge variant="secondary" className="text-xs">v0.1.0</Badge> 1092 <Badge variant="outline" className="text-xs">Alpha</Badge> 1093 </div> 1094 <CardDescription> 1095 Deploy static sites directly from your terminal 1096 </CardDescription> 1097 </CardHeader> 1098 <CardContent className="space-y-6"> 1099 <div className="prose prose-sm max-w-none dark:prose-invert"> 1100 <p className="text-sm text-muted-foreground"> 1101 The Wisp CLI is a command-line tool for deploying static websites directly to your AT Protocol account. 1102 Authenticate with app password or OAuth and deploy from CI/CD pipelines. 1103 </p> 1104 </div> 1105 1106 <div className="space-y-3"> 1107 <h3 className="text-sm font-semibold">Download CLI</h3> 1108 <div className="grid gap-2"> 1109 <div className="p-3 bg-muted/50 hover:bg-muted rounded-lg transition-colors border border-border"> 1110 <a 1111 href="https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-macos-arm64" 1112 target="_blank" 1113 rel="noopener noreferrer" 1114 className="flex items-center justify-between mb-2" 1115 > 1116 <span className="font-mono text-sm">macOS (Apple Silicon)</span> 1117 <ExternalLink className="w-4 h-4 text-muted-foreground" /> 1118 </a> 1119 <div className="text-xs text-muted-foreground"> 1120 <span className="font-mono">SHA256: 637e325d9668ca745e01493d80dfc72447ef0a889b313e28913ca65c94c7aaae</span> 1121 </div> 1122 </div> 1123 <div className="p-3 bg-muted/50 hover:bg-muted rounded-lg transition-colors border border-border"> 1124 <a 1125 href="https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-aarch64-linux" 1126 target="_blank" 1127 rel="noopener noreferrer" 1128 className="flex items-center justify-between mb-2" 1129 > 1130 <span className="font-mono text-sm">Linux (ARM64)</span> 1131 <ExternalLink className="w-4 h-4 text-muted-foreground" /> 1132 </a> 1133 <div className="text-xs text-muted-foreground"> 1134 <span className="font-mono">SHA256: 01561656b64826f95b39f13c65c97da8bcc63ecd9f4d7e4e369c8ba8c903c22a</span> 1135 </div> 1136 </div> 1137 <div className="p-3 bg-muted/50 hover:bg-muted rounded-lg transition-colors border border-border"> 1138 <a 1139 href="https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-x86_64-linux" 1140 target="_blank" 1141 rel="noopener noreferrer" 1142 className="flex items-center justify-between mb-2" 1143 > 1144 <span className="font-mono text-sm">Linux (x86_64)</span> 1145 <ExternalLink className="w-4 h-4 text-muted-foreground" /> 1146 </a> 1147 <div className="text-xs text-muted-foreground"> 1148 <span className="font-mono">SHA256: 1ff485b9bcf89bc5721a862863c4843cf4530cbcd2489cf200cb24a44f7865a2</span> 1149 </div> 1150 </div> 1151 </div> 1152 </div> 1153 1154 <div className="space-y-3"> 1155 <h3 className="text-sm font-semibold">Basic Usage</h3> 1156 <CodeBlock 1157 code={`# Download and make executable 1158curl -O https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-macos-arm64 1159chmod +x wisp-cli-macos-arm64 1160 1161# Deploy your site (will use OAuth) 1162./wisp-cli-macos-arm64 your-handle.bsky.social \\ 1163 --path ./dist \\ 1164 --site my-site 1165 1166# Your site will be available at: 1167# https://sites.wisp.place/your-handle/my-site`} 1168 language="bash" 1169 /> 1170 </div> 1171 1172 <div className="space-y-3"> 1173 <h3 className="text-sm font-semibold">CI/CD with Tangled Spindle</h3> 1174 <p className="text-xs text-muted-foreground"> 1175 Deploy automatically on every push using{' '} 1176 <a 1177 href="https://blog.tangled.org/ci" 1178 target="_blank" 1179 rel="noopener noreferrer" 1180 className="text-accent hover:underline" 1181 > 1182 Tangled Spindle 1183 </a> 1184 </p> 1185 1186 <div className="space-y-4"> 1187 <div> 1188 <h4 className="text-xs font-semibold mb-2 flex items-center gap-2"> 1189 <span>Example 1: Simple Asset Publishing</span> 1190 <Badge variant="secondary" className="text-xs">Copy Files</Badge> 1191 </h4> 1192 <CodeBlock 1193 code={`when: 1194 - event: ['push'] 1195 branch: ['main'] 1196 - event: ['manual'] 1197 1198engine: 'nixery' 1199 1200clone: 1201 skip: false 1202 depth: 1 1203 1204dependencies: 1205 nixpkgs: 1206 - coreutils 1207 - curl 1208 1209environment: 1210 SITE_PATH: '.' # Copy entire repo 1211 SITE_NAME: 'myWebbedSite' 1212 WISP_HANDLE: 'your-handle.bsky.social' 1213 1214steps: 1215 - name: deploy assets to wisp 1216 command: | 1217 # Download Wisp CLI 1218 curl https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-x86_64-linux -o wisp-cli 1219 chmod +x wisp-cli 1220 1221 # Deploy to Wisp 1222 ./wisp-cli \\ 1223 "$WISP_HANDLE" \\ 1224 --path "$SITE_PATH" \\ 1225 --site "$SITE_NAME" \\ 1226 --password "$WISP_APP_PASSWORD" 1227 1228 # Output 1229 #Deployed site 'myWebbedSite': at://did:plc:ttdrpj45ibqunmfhdsb4zdwq/place.wisp.fs/myWebbedSite 1230 #Available at: https://sites.wisp.place/did:plc:ttdrpj45ibqunmfhdsb4zdwq/myWebbedSite 1231 `} 1232 language="yaml" 1233 /> 1234 </div> 1235 1236 <div> 1237 <h4 className="text-xs font-semibold mb-2 flex items-center gap-2"> 1238 <span>Example 2: React/Vite Build & Deploy</span> 1239 <Badge variant="secondary" className="text-xs">Full Build</Badge> 1240 </h4> 1241 <CodeBlock 1242 code={`when: 1243 - event: ['push'] 1244 branch: ['main'] 1245 - event: ['manual'] 1246 1247engine: 'nixery' 1248 1249clone: 1250 skip: false 1251 depth: 1 1252 submodules: false 1253 1254dependencies: 1255 nixpkgs: 1256 - nodejs 1257 - coreutils 1258 - curl 1259 github:NixOS/nixpkgs/nixpkgs-unstable: 1260 - bun 1261 1262environment: 1263 SITE_PATH: 'dist' 1264 SITE_NAME: 'my-react-site' 1265 WISP_HANDLE: 'your-handle.bsky.social' 1266 1267steps: 1268 - name: build site 1269 command: | 1270 # necessary to ensure bun is in PATH 1271 export PATH="$HOME/.nix-profile/bin:$PATH" 1272 1273 bun install --frozen-lockfile 1274 1275 # build with vite, run directly to get around env issues 1276 bun node_modules/.bin/vite build 1277 1278 - name: deploy to wisp 1279 command: | 1280 # Download Wisp CLI 1281 curl https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-x86_64-linux -o wisp-cli 1282 chmod +x wisp-cli 1283 1284 # Deploy to Wisp 1285 ./wisp-cli \\ 1286 "$WISP_HANDLE" \\ 1287 --path "$SITE_PATH" \\ 1288 --site "$SITE_NAME" \\ 1289 --password "$WISP_APP_PASSWORD"`} 1290 language="yaml" 1291 /> 1292 </div> 1293 </div> 1294 1295 <div className="p-3 bg-muted/30 rounded-lg border-l-4 border-accent"> 1296 <p className="text-xs text-muted-foreground"> 1297 <strong className="text-foreground">Note:</strong> Set <code className="px-1.5 py-0.5 bg-background rounded text-xs">WISP_APP_PASSWORD</code> as a secret in your Tangled Spindle repository settings. 1298 Generate an app password from your AT Protocol account settings. 1299 </p> 1300 </div> 1301 </div> 1302 1303 <div className="space-y-3"> 1304 <h3 className="text-sm font-semibold">Learn More</h3> 1305 <div className="grid gap-2"> 1306 <a 1307 href="https://tangled.org/@nekomimi.pet/wisp.place-monorepo/tree/main/cli" 1308 target="_blank" 1309 rel="noopener noreferrer" 1310 className="flex items-center justify-between p-3 bg-muted/50 hover:bg-muted rounded-lg transition-colors border border-border" 1311 > 1312 <span className="text-sm">Source Code</span> 1313 <ExternalLink className="w-4 h-4 text-muted-foreground" /> 1314 </a> 1315 <a 1316 href="https://blog.tangled.org/ci" 1317 target="_blank" 1318 rel="noopener noreferrer" 1319 className="flex items-center justify-between p-3 bg-muted/50 hover:bg-muted rounded-lg transition-colors border border-border" 1320 > 1321 <span className="text-sm">Tangled Spindle CI/CD</span> 1322 <ExternalLink className="w-4 h-4 text-muted-foreground" /> 1323 </a> 1324 </div> 1325 </div> 1326 </CardContent> 1327 </Card> 1328 </TabsContent> 1329 </Tabs> 1330 </div> 1331 1332 {/* Add Custom Domain Modal */} 1333 <Dialog open={addDomainModalOpen} onOpenChange={setAddDomainModalOpen}> 1334 <DialogContent className="sm:max-w-lg"> 1335 <DialogHeader> 1336 <DialogTitle>Add Custom Domain</DialogTitle> 1337 <DialogDescription> 1338 Enter your domain name. After adding, you'll see the DNS 1339 records to configure. 1340 </DialogDescription> 1341 </DialogHeader> 1342 <div className="space-y-4 py-4"> 1343 <div className="space-y-2"> 1344 <Label htmlFor="new-domain">Domain Name</Label> 1345 <Input 1346 id="new-domain" 1347 placeholder="example.com" 1348 value={customDomain} 1349 onChange={(e) => setCustomDomain(e.target.value)} 1350 /> 1351 <p className="text-xs text-muted-foreground"> 1352 After adding, click "View DNS" to see the records you 1353 need to configure. 1354 </p> 1355 </div> 1356 </div> 1357 <DialogFooter className="flex-col sm:flex-row gap-2"> 1358 <Button 1359 variant="outline" 1360 onClick={() => { 1361 setAddDomainModalOpen(false) 1362 setCustomDomain('') 1363 }} 1364 className="w-full sm:w-auto" 1365 disabled={isAddingDomain} 1366 > 1367 Cancel 1368 </Button> 1369 <Button 1370 onClick={handleAddCustomDomain} 1371 disabled={!customDomain || isAddingDomain} 1372 className="w-full sm:w-auto" 1373 > 1374 {isAddingDomain ? ( 1375 <> 1376 <Loader2 className="w-4 h-4 mr-2 animate-spin" /> 1377 Adding... 1378 </> 1379 ) : ( 1380 'Add Domain' 1381 )} 1382 </Button> 1383 </DialogFooter> 1384 </DialogContent> 1385 </Dialog> 1386 1387 {/* Site Configuration Modal */} 1388 <Dialog 1389 open={configuringSite !== null} 1390 onOpenChange={(open) => !open && setConfiguringSite(null)} 1391 > 1392 <DialogContent className="sm:max-w-lg"> 1393 <DialogHeader> 1394 <DialogTitle>Configure Site Domain</DialogTitle> 1395 <DialogDescription> 1396 Choose which domain this site should use 1397 </DialogDescription> 1398 </DialogHeader> 1399 {configuringSite && ( 1400 <div className="space-y-4 py-4"> 1401 <div className="p-3 bg-muted/30 rounded-lg"> 1402 <p className="text-sm font-medium mb-1">Site:</p> 1403 <p className="font-mono text-sm"> 1404 {configuringSite.display_name || 1405 configuringSite.rkey} 1406 </p> 1407 </div> 1408 1409 <RadioGroup 1410 value={selectedDomain} 1411 onValueChange={setSelectedDomain} 1412 > 1413 {wispDomain && ( 1414 <div className="flex items-center space-x-2"> 1415 <RadioGroupItem value="wisp" id="wisp" /> 1416 <Label 1417 htmlFor="wisp" 1418 className="flex-1 cursor-pointer" 1419 > 1420 <div className="flex items-center justify-between"> 1421 <span className="font-mono text-sm"> 1422 {wispDomain.domain} 1423 </span> 1424 <Badge variant="secondary" className="text-xs ml-2"> 1425 Free 1426 </Badge> 1427 </div> 1428 </Label> 1429 </div> 1430 )} 1431 1432 {customDomains 1433 .filter((d) => d.verified) 1434 .map((domain) => ( 1435 <div 1436 key={domain.id} 1437 className="flex items-center space-x-2" 1438 > 1439 <RadioGroupItem 1440 value={domain.id} 1441 id={domain.id} 1442 /> 1443 <Label 1444 htmlFor={domain.id} 1445 className="flex-1 cursor-pointer" 1446 > 1447 <div className="flex items-center justify-between"> 1448 <span className="font-mono text-sm"> 1449 {domain.domain} 1450 </span> 1451 <Badge 1452 variant="outline" 1453 className="text-xs ml-2" 1454 > 1455 Custom 1456 </Badge> 1457 </div> 1458 </Label> 1459 </div> 1460 ))} 1461 1462 <div className="flex items-center space-x-2"> 1463 <RadioGroupItem value="none" id="none" /> 1464 <Label htmlFor="none" className="flex-1 cursor-pointer"> 1465 <div className="flex flex-col"> 1466 <span className="text-sm">Default URL</span> 1467 <span className="text-xs text-muted-foreground font-mono break-all"> 1468 sites.wisp.place/{configuringSite.did}/ 1469 {configuringSite.rkey} 1470 </span> 1471 </div> 1472 </Label> 1473 </div> 1474 </RadioGroup> 1475 </div> 1476 )} 1477 <DialogFooter className="flex flex-col sm:flex-row sm:justify-between gap-2"> 1478 <Button 1479 variant="destructive" 1480 onClick={handleDeleteSite} 1481 disabled={isSavingConfig || isDeletingSite} 1482 className="sm:mr-auto" 1483 > 1484 {isDeletingSite ? ( 1485 <> 1486 <Loader2 className="w-4 h-4 mr-2 animate-spin" /> 1487 Deleting... 1488 </> 1489 ) : ( 1490 <> 1491 <Trash2 className="w-4 h-4 mr-2" /> 1492 Delete Site 1493 </> 1494 )} 1495 </Button> 1496 <div className="flex flex-col sm:flex-row gap-2 w-full sm:w-auto"> 1497 <Button 1498 variant="outline" 1499 onClick={() => setConfiguringSite(null)} 1500 disabled={isSavingConfig || isDeletingSite} 1501 className="w-full sm:w-auto" 1502 > 1503 Cancel 1504 </Button> 1505 <Button 1506 onClick={handleSaveSiteConfig} 1507 disabled={isSavingConfig || isDeletingSite} 1508 className="w-full sm:w-auto" 1509 > 1510 {isSavingConfig ? ( 1511 <> 1512 <Loader2 className="w-4 h-4 mr-2 animate-spin" /> 1513 Saving... 1514 </> 1515 ) : ( 1516 'Save' 1517 )} 1518 </Button> 1519 </div> 1520 </DialogFooter> 1521 </DialogContent> 1522 </Dialog> 1523 1524 {/* View DNS Records Modal */} 1525 <Dialog 1526 open={viewDomainDNS !== null} 1527 onOpenChange={(open) => !open && setViewDomainDNS(null)} 1528 > 1529 <DialogContent className="sm:max-w-lg"> 1530 <DialogHeader> 1531 <DialogTitle>DNS Configuration</DialogTitle> 1532 <DialogDescription> 1533 Add these DNS records to your domain provider 1534 </DialogDescription> 1535 </DialogHeader> 1536 {viewDomainDNS && userInfo && ( 1537 <> 1538 {(() => { 1539 const domain = customDomains.find( 1540 (d) => d.id === viewDomainDNS 1541 ) 1542 if (!domain) return null 1543 1544 return ( 1545 <div className="space-y-4 py-4"> 1546 <div className="p-3 bg-muted/30 rounded-lg"> 1547 <p className="text-sm font-medium mb-1"> 1548 Domain: 1549 </p> 1550 <p className="font-mono text-sm"> 1551 {domain.domain} 1552 </p> 1553 </div> 1554 1555 <div className="space-y-3"> 1556 <div className="p-3 bg-background rounded border border-border"> 1557 <div className="flex justify-between items-start mb-2"> 1558 <span className="text-xs font-semibold text-muted-foreground"> 1559 TXT Record (Verification) 1560 </span> 1561 </div> 1562 <div className="font-mono text-xs space-y-2"> 1563 <div> 1564 <span className="text-muted-foreground"> 1565 Name: 1566 </span>{' '} 1567 <span className="select-all"> 1568 _wisp.{domain.domain} 1569 </span> 1570 </div> 1571 <div> 1572 <span className="text-muted-foreground"> 1573 Value: 1574 </span>{' '} 1575 <span className="select-all break-all"> 1576 {userInfo.did} 1577 </span> 1578 </div> 1579 </div> 1580 </div> 1581 1582 <div className="p-3 bg-background rounded border border-border"> 1583 <div className="flex justify-between items-start mb-2"> 1584 <span className="text-xs font-semibold text-muted-foreground"> 1585 CNAME Record (Pointing) 1586 </span> 1587 </div> 1588 <div className="font-mono text-xs space-y-2"> 1589 <div> 1590 <span className="text-muted-foreground"> 1591 Name: 1592 </span>{' '} 1593 <span className="select-all"> 1594 {domain.domain} 1595 </span> 1596 </div> 1597 <div> 1598 <span className="text-muted-foreground"> 1599 Value: 1600 </span>{' '} 1601 <span className="select-all"> 1602 {domain.id}.dns.wisp.place 1603 </span> 1604 </div> 1605 </div> 1606 <p className="text-xs text-muted-foreground mt-2"> 1607 Some DNS providers may require you to use @ or leave it blank for the root domain 1608 </p> 1609 </div> 1610 </div> 1611 1612 <div className="p-3 bg-muted/30 rounded-lg"> 1613 <p className="text-xs text-muted-foreground"> 1614 💡 After configuring DNS, click "Verify DNS" 1615 to check if everything is set up correctly. 1616 DNS changes can take a few minutes to 1617 propagate. 1618 </p> 1619 </div> 1620 </div> 1621 ) 1622 })()} 1623 </> 1624 )} 1625 <DialogFooter> 1626 <Button 1627 variant="outline" 1628 onClick={() => setViewDomainDNS(null)} 1629 className="w-full sm:w-auto" 1630 > 1631 Close 1632 </Button> 1633 </DialogFooter> 1634 </DialogContent> 1635 </Dialog> 1636 </div> 1637 ) 1638} 1639 1640const root = createRoot(document.getElementById('elysia')!) 1641root.render( 1642 <Layout className="gap-6"> 1643 <Dashboard /> 1644 </Layout> 1645)