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