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