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