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}