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