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}