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