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