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