Monorepo for wisp.place. A static site hosting service built on top of the AT Protocol. wisp.place
1import { useState } from 'react' 2import { 3 Card, 4 CardContent, 5 CardDescription, 6 CardHeader, 7 CardTitle 8} from '@public/components/ui/card' 9import { Button } from '@public/components/ui/button' 10import { Input } from '@public/components/ui/input' 11import { Label } from '@public/components/ui/label' 12import { Badge } from '@public/components/ui/badge' 13import { SkeletonShimmer } from '@public/components/ui/skeleton' 14import { 15 Dialog, 16 DialogContent, 17 DialogDescription, 18 DialogHeader, 19 DialogTitle, 20 DialogFooter 21} from '@public/components/ui/dialog' 22import { 23 CheckCircle2, 24 XCircle, 25 Loader2, 26 Trash2 27} from 'lucide-react' 28import type { WispDomain, CustomDomain } from '../hooks/useDomainData' 29import type { UserInfo } from '../hooks/useUserInfo' 30 31interface DomainsTabProps { 32 wispDomains: WispDomain[] 33 customDomains: CustomDomain[] 34 domainsLoading: boolean 35 verificationStatus: { [id: string]: 'idle' | 'verifying' | 'success' | 'error' } 36 userInfo: UserInfo | null 37 onAddCustomDomain: (domain: string) => Promise<{ success: boolean; id?: string }> 38 onVerifyDomain: (id: string) => Promise<void> 39 onDeleteCustomDomain: (id: string) => Promise<boolean> 40 onDeleteWispDomain: (domain: string) => Promise<boolean> 41 onClaimWispDomain: (handle: string) => Promise<{ success: boolean; error?: string }> 42 onCheckWispAvailability: (handle: string) => Promise<{ available: boolean | null }> 43} 44 45export function DomainsTab({ 46 wispDomains, 47 customDomains, 48 domainsLoading, 49 verificationStatus, 50 userInfo, 51 onAddCustomDomain, 52 onVerifyDomain, 53 onDeleteCustomDomain, 54 onDeleteWispDomain, 55 onClaimWispDomain, 56 onCheckWispAvailability 57}: DomainsTabProps) { 58 // Wisp domain claim state 59 const [wispHandle, setWispHandle] = useState('') 60 const [isClaimingWisp, setIsClaimingWisp] = useState(false) 61 const [wispAvailability, setWispAvailability] = useState<{ 62 available: boolean | null 63 checking: boolean 64 }>({ available: null, checking: false }) 65 66 // Custom domain modal state 67 const [addDomainModalOpen, setAddDomainModalOpen] = useState(false) 68 const [customDomain, setCustomDomain] = useState('') 69 const [isAddingDomain, setIsAddingDomain] = useState(false) 70 const [viewDomainDNS, setViewDomainDNS] = useState<string | null>(null) 71 72 const checkWispAvailability = async (handle: string) => { 73 const trimmedHandle = handle.trim().toLowerCase() 74 if (!trimmedHandle) { 75 setWispAvailability({ available: null, checking: false }) 76 return 77 } 78 79 setWispAvailability({ available: null, checking: true }) 80 const result = await onCheckWispAvailability(trimmedHandle) 81 setWispAvailability({ available: result.available, checking: false }) 82 } 83 84 const handleClaimWispDomain = async () => { 85 const trimmedHandle = wispHandle.trim().toLowerCase() 86 if (!trimmedHandle) { 87 alert('Please enter a handle') 88 return 89 } 90 91 setIsClaimingWisp(true) 92 const result = await onClaimWispDomain(trimmedHandle) 93 if (result.success) { 94 setWispHandle('') 95 setWispAvailability({ available: null, checking: false }) 96 } 97 setIsClaimingWisp(false) 98 } 99 100 const handleAddCustomDomain = async () => { 101 if (!customDomain) { 102 alert('Please enter a domain') 103 return 104 } 105 106 setIsAddingDomain(true) 107 const result = await onAddCustomDomain(customDomain) 108 setIsAddingDomain(false) 109 110 if (result.success) { 111 setCustomDomain('') 112 setAddDomainModalOpen(false) 113 // Automatically show DNS configuration for the newly added domain 114 if (result.id) { 115 setViewDomainDNS(result.id) 116 } 117 } 118 } 119 120 return ( 121 <> 122 <div className="space-y-4 min-h-[400px]"> 123 <Card> 124 <CardHeader> 125 <CardTitle>wisp.place Subdomains</CardTitle> 126 <CardDescription> 127 Your free subdomains on the wisp.place network (up to 3) 128 </CardDescription> 129 </CardHeader> 130 <CardContent> 131 {domainsLoading ? ( 132 <div className="space-y-4"> 133 <div className="space-y-2"> 134 {[...Array(2)].map((_, i) => ( 135 <div 136 key={i} 137 className="flex items-center justify-between p-3 border border-border rounded-lg" 138 > 139 <div className="flex flex-col gap-2 flex-1"> 140 <div className="flex items-center gap-2"> 141 <SkeletonShimmer className="h-4 w-4 rounded-full" /> 142 <SkeletonShimmer className="h-4 w-40" /> 143 </div> 144 <SkeletonShimmer className="h-3 w-32 ml-6" /> 145 </div> 146 <SkeletonShimmer className="h-8 w-8" /> 147 </div> 148 ))} 149 </div> 150 <div className="p-4 bg-muted/30 rounded-lg space-y-3"> 151 <SkeletonShimmer className="h-4 w-full" /> 152 <div className="space-y-2"> 153 <SkeletonShimmer className="h-4 w-24" /> 154 <SkeletonShimmer className="h-10 w-full" /> 155 </div> 156 <SkeletonShimmer className="h-10 w-full" /> 157 </div> 158 </div> 159 ) : ( 160 <div className="space-y-4"> 161 {wispDomains.length > 0 && ( 162 <div className="space-y-2"> 163 {wispDomains.map((domain) => ( 164 <div 165 key={domain.domain} 166 className="flex items-center justify-between p-3 border border-border rounded-lg" 167 > 168 <div className="flex flex-col gap-1 flex-1"> 169 <div className="flex items-center gap-2"> 170 <CheckCircle2 className="w-4 h-4 text-green-500" /> 171 <span className="font-mono"> 172 {domain.domain} 173 </span> 174 </div> 175 {domain.rkey && ( 176 <p className="text-xs text-muted-foreground ml-6"> 177 Mapped to site: {domain.rkey} 178 </p> 179 )} 180 </div> 181 <Button 182 variant="ghost" 183 size="sm" 184 onClick={() => onDeleteWispDomain(domain.domain)} 185 > 186 <Trash2 className="w-4 h-4" /> 187 </Button> 188 </div> 189 ))} 190 </div> 191 )} 192 193 {wispDomains.length < 3 && ( 194 <div className="p-4 bg-muted/30 rounded-lg"> 195 <p className="text-sm text-muted-foreground mb-4"> 196 {wispDomains.length === 0 197 ? 'Claim your free wisp.place subdomain' 198 : `Claim another wisp.place subdomain (${wispDomains.length}/3)`} 199 </p> 200 <div className="space-y-3"> 201 <div className="space-y-2"> 202 <Label htmlFor="wisp-handle">Choose your handle</Label> 203 <div className="flex gap-2"> 204 <div className="flex-1 relative"> 205 <Input 206 id="wisp-handle" 207 placeholder="mysite" 208 value={wispHandle} 209 onChange={(e) => { 210 setWispHandle(e.target.value) 211 if (e.target.value.trim()) { 212 checkWispAvailability(e.target.value) 213 } else { 214 setWispAvailability({ available: null, checking: false }) 215 } 216 }} 217 disabled={isClaimingWisp} 218 className="pr-24" 219 /> 220 <span className="absolute right-3 top-1/2 -translate-y-1/2 text-sm text-muted-foreground"> 221 .wisp.place 222 </span> 223 </div> 224 </div> 225 {wispAvailability.checking && ( 226 <p className="text-xs text-muted-foreground flex items-center gap-1"> 227 <Loader2 className="w-3 h-3 animate-spin" /> 228 Checking availability... 229 </p> 230 )} 231 {!wispAvailability.checking && wispAvailability.available === true && ( 232 <p className="text-xs text-green-600 flex items-center gap-1"> 233 <CheckCircle2 className="w-3 h-3" /> 234 Available 235 </p> 236 )} 237 {!wispAvailability.checking && wispAvailability.available === false && ( 238 <p className="text-xs text-red-600 flex items-center gap-1"> 239 <XCircle className="w-3 h-3" /> 240 Not available 241 </p> 242 )} 243 </div> 244 <Button 245 onClick={handleClaimWispDomain} 246 disabled={!wispHandle.trim() || isClaimingWisp || wispAvailability.available !== true} 247 className="w-full" 248 > 249 {isClaimingWisp ? ( 250 <> 251 <Loader2 className="w-4 h-4 mr-2 animate-spin" /> 252 Claiming... 253 </> 254 ) : ( 255 'Claim Subdomain' 256 )} 257 </Button> 258 </div> 259 </div> 260 )} 261 262 {wispDomains.length === 3 && ( 263 <div className="p-3 bg-muted/30 rounded-lg text-center"> 264 <p className="text-sm text-muted-foreground"> 265 You have claimed the maximum of 3 wisp.place subdomains 266 </p> 267 </div> 268 )} 269 </div> 270 )} 271 </CardContent> 272 </Card> 273 274 <Card> 275 <CardHeader> 276 <CardTitle>Custom Domains</CardTitle> 277 <CardDescription> 278 Bring your own domain with DNS verification 279 </CardDescription> 280 </CardHeader> 281 <CardContent className="space-y-4"> 282 <Button 283 onClick={() => setAddDomainModalOpen(true)} 284 className="w-full" 285 > 286 Add Custom Domain 287 </Button> 288 289 {domainsLoading ? ( 290 <div className="space-y-2"> 291 {[...Array(2)].map((_, i) => ( 292 <div 293 key={i} 294 className="flex items-center justify-between p-3 border border-border rounded-lg" 295 > 296 <div className="flex flex-col gap-2 flex-1"> 297 <div className="flex items-center gap-2"> 298 <SkeletonShimmer className="h-4 w-4 rounded-full" /> 299 <SkeletonShimmer className="h-4 w-48" /> 300 </div> 301 <SkeletonShimmer className="h-3 w-36 ml-6" /> 302 </div> 303 <div className="flex items-center gap-2"> 304 <SkeletonShimmer className="h-8 w-20" /> 305 <SkeletonShimmer className="h-8 w-20" /> 306 <SkeletonShimmer className="h-8 w-8" /> 307 </div> 308 </div> 309 ))} 310 </div> 311 ) : customDomains.length === 0 ? ( 312 <div className="text-center py-4 text-muted-foreground text-sm"> 313 No custom domains added yet 314 </div> 315 ) : ( 316 <div className="space-y-2"> 317 {customDomains.map((domain) => ( 318 <div 319 key={domain.id} 320 className="flex items-center justify-between p-3 border border-border rounded-lg" 321 > 322 <div className="flex flex-col gap-1 flex-1"> 323 <div className="flex items-center gap-2"> 324 {domain.verified ? ( 325 <CheckCircle2 className="w-4 h-4 text-green-500" /> 326 ) : ( 327 <XCircle className="w-4 h-4 text-red-500" /> 328 )} 329 <span className="font-mono"> 330 {domain.domain} 331 </span> 332 </div> 333 {domain.rkey && domain.rkey !== 'self' && ( 334 <p className="text-xs text-muted-foreground ml-6"> 335 Mapped to site: {domain.rkey} 336 </p> 337 )} 338 </div> 339 <div className="flex items-center gap-2"> 340 <Button 341 variant="outline" 342 size="sm" 343 onClick={() => 344 setViewDomainDNS(domain.id) 345 } 346 > 347 View DNS 348 </Button> 349 {domain.verified ? ( 350 <Badge variant="secondary"> 351 Verified 352 </Badge> 353 ) : ( 354 <Button 355 variant="outline" 356 size="sm" 357 onClick={() => 358 onVerifyDomain(domain.id) 359 } 360 disabled={ 361 verificationStatus[ 362 domain.id 363 ] === 'verifying' 364 } 365 > 366 {verificationStatus[ 367 domain.id 368 ] === 'verifying' ? ( 369 <> 370 <Loader2 className="w-3 h-3 mr-1 animate-spin" /> 371 Verifying... 372 </> 373 ) : ( 374 'Verify DNS' 375 )} 376 </Button> 377 )} 378 <Button 379 variant="ghost" 380 size="sm" 381 onClick={() => 382 onDeleteCustomDomain( 383 domain.id 384 ) 385 } 386 > 387 <Trash2 className="w-4 h-4" /> 388 </Button> 389 </div> 390 </div> 391 ))} 392 </div> 393 )} 394 </CardContent> 395 </Card> 396 </div> 397 398 {/* Add Custom Domain Modal */} 399 <Dialog open={addDomainModalOpen} onOpenChange={setAddDomainModalOpen}> 400 <DialogContent className="sm:max-w-lg"> 401 <DialogHeader> 402 <DialogTitle>Add Custom Domain</DialogTitle> 403 <DialogDescription> 404 Enter your domain name. After adding, you'll see the DNS 405 records to configure. 406 </DialogDescription> 407 </DialogHeader> 408 <div className="space-y-4 py-4"> 409 <div className="space-y-2"> 410 <Label htmlFor="new-domain">Domain Name</Label> 411 <Input 412 id="new-domain" 413 placeholder="example.com" 414 value={customDomain} 415 onChange={(e) => setCustomDomain(e.target.value)} 416 /> 417 <p className="text-xs text-muted-foreground"> 418 After adding, click "View DNS" to see the records you 419 need to configure. 420 </p> 421 </div> 422 </div> 423 <DialogFooter className="flex-col sm:flex-row gap-2"> 424 <Button 425 variant="outline" 426 onClick={() => { 427 setAddDomainModalOpen(false) 428 setCustomDomain('') 429 }} 430 className="w-full sm:w-auto" 431 disabled={isAddingDomain} 432 > 433 Cancel 434 </Button> 435 <Button 436 onClick={handleAddCustomDomain} 437 disabled={!customDomain || isAddingDomain} 438 className="w-full sm:w-auto" 439 > 440 {isAddingDomain ? ( 441 <> 442 <Loader2 className="w-4 h-4 mr-2 animate-spin" /> 443 Adding... 444 </> 445 ) : ( 446 'Add Domain' 447 )} 448 </Button> 449 </DialogFooter> 450 </DialogContent> 451 </Dialog> 452 453 {/* View DNS Records Modal */} 454 <Dialog 455 open={viewDomainDNS !== null} 456 onOpenChange={(open) => !open && setViewDomainDNS(null)} 457 > 458 <DialogContent className="sm:max-w-lg"> 459 <DialogHeader> 460 <DialogTitle>DNS Configuration</DialogTitle> 461 <DialogDescription> 462 Add these DNS records to your domain provider 463 </DialogDescription> 464 </DialogHeader> 465 {viewDomainDNS && userInfo && ( 466 <> 467 {(() => { 468 const domain = customDomains.find( 469 (d) => d.id === viewDomainDNS 470 ) 471 if (!domain) return null 472 473 return ( 474 <div className="space-y-4 py-4"> 475 <div className="p-3 bg-muted/30 rounded-lg"> 476 <p className="text-sm font-medium mb-1"> 477 Domain: 478 </p> 479 <p className="font-mono text-sm"> 480 {domain.domain} 481 </p> 482 </div> 483 484 <div className="space-y-3"> 485 <div className="p-3 bg-background rounded border border-border"> 486 <div className="flex justify-between items-start mb-2"> 487 <span className="text-xs font-semibold text-muted-foreground"> 488 TXT Record (Verification) 489 </span> 490 </div> 491 <div className="font-mono text-xs space-y-2"> 492 <div> 493 <span className="text-muted-foreground"> 494 Name: 495 </span>{' '} 496 <span className="select-all"> 497 _wisp.{domain.domain} 498 </span> 499 </div> 500 <div> 501 <span className="text-muted-foreground"> 502 Value: 503 </span>{' '} 504 <span className="select-all break-all"> 505 {userInfo.did} 506 </span> 507 </div> 508 </div> 509 </div> 510 511 <div className="p-3 bg-background rounded border border-border"> 512 <div className="flex justify-between items-start mb-2"> 513 <span className="text-xs font-semibold text-muted-foreground"> 514 CNAME Record (Pointing) 515 </span> 516 </div> 517 <div className="font-mono text-xs space-y-2"> 518 <div> 519 <span className="text-muted-foreground"> 520 Name: 521 </span>{' '} 522 <span className="select-all"> 523 {domain.domain} 524 </span> 525 </div> 526 <div> 527 <span className="text-muted-foreground"> 528 Value: 529 </span>{' '} 530 <span className="select-all"> 531 {domain.id}.dns.wisp.place 532 </span> 533 </div> 534 </div> 535 <p className="text-xs text-muted-foreground mt-2"> 536 Note: Some DNS providers (like Cloudflare) flatten CNAMEs to A records - this is fine and won't affect verification. 537 </p> 538 </div> 539 </div> 540 541 <div className="p-3 bg-muted/30 rounded-lg"> 542 <p className="text-xs text-muted-foreground"> 543 💡 After configuring DNS, click "Verify DNS" 544 to check if everything is set up correctly. 545 DNS changes can take a few minutes to 546 propagate. 547 </p> 548 </div> 549 </div> 550 ) 551 })()} 552 </> 553 )} 554 <DialogFooter> 555 <Button 556 variant="outline" 557 onClick={() => setViewDomainDNS(null)} 558 className="w-full sm:w-auto" 559 > 560 Close 561 </Button> 562 </DialogFooter> 563 </DialogContent> 564 </Dialog> 565 </> 566 ) 567}