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