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 Tabs,
6 TabsContent,
7 TabsList,
8 TabsTrigger
9} from '@public/components/ui/tabs'
10import {
11 Dialog,
12 DialogContent,
13 DialogDescription,
14 DialogHeader,
15 DialogTitle,
16 DialogFooter
17} from '@public/components/ui/dialog'
18import { Checkbox } from '@public/components/ui/checkbox'
19import { Label } from '@public/components/ui/label'
20import { Badge } from '@public/components/ui/badge'
21import {
22 Globe,
23 Loader2,
24 Trash2
25} from 'lucide-react'
26import Layout from '@public/layouts'
27import { useUserInfo } from './hooks/useUserInfo'
28import { useSiteData, type SiteWithDomains } from './hooks/useSiteData'
29import { useDomainData } from './hooks/useDomainData'
30import { SitesTab } from './tabs/SitesTab'
31import { DomainsTab } from './tabs/DomainsTab'
32import { UploadTab } from './tabs/UploadTab'
33import { CLITab } from './tabs/CLITab'
34
35function Dashboard() {
36 // Use custom hooks
37 const { userInfo, loading, fetchUserInfo } = useUserInfo()
38 const { sites, sitesLoading, isSyncing, fetchSites, syncSites, deleteSite } = useSiteData()
39 const {
40 wispDomain,
41 customDomains,
42 domainsLoading,
43 verificationStatus,
44 fetchDomains,
45 addCustomDomain,
46 verifyDomain,
47 deleteCustomDomain,
48 mapWispDomain,
49 mapCustomDomain,
50 claimWispDomain,
51 checkWispAvailability
52 } = useDomainData()
53
54 // Site configuration modal state (shared across components)
55 const [configuringSite, setConfiguringSite] = useState<SiteWithDomains | null>(null)
56 const [selectedDomains, setSelectedDomains] = useState<Set<string>>(new Set())
57 const [isSavingConfig, setIsSavingConfig] = useState(false)
58 const [isDeletingSite, setIsDeletingSite] = useState(false)
59
60 // Fetch initial data on mount
61 useEffect(() => {
62 fetchUserInfo()
63 fetchSites()
64 fetchDomains()
65 }, [])
66
67 // Handle site configuration modal
68 const handleConfigureSite = (site: SiteWithDomains) => {
69 setConfiguringSite(site)
70
71 // Build set of currently mapped domains
72 const mappedDomains = new Set<string>()
73
74 if (site.domains) {
75 site.domains.forEach(domainInfo => {
76 if (domainInfo.type === 'wisp') {
77 mappedDomains.add('wisp')
78 } else if (domainInfo.id) {
79 mappedDomains.add(domainInfo.id)
80 }
81 })
82 }
83
84 setSelectedDomains(mappedDomains)
85 }
86
87 const handleSaveSiteConfig = async () => {
88 if (!configuringSite) return
89
90 setIsSavingConfig(true)
91 try {
92 // Determine which domains should be mapped/unmapped
93 const shouldMapWisp = selectedDomains.has('wisp')
94 const isCurrentlyMappedToWisp = wispDomain && wispDomain.rkey === configuringSite.rkey
95
96 // Handle wisp domain mapping
97 if (shouldMapWisp && !isCurrentlyMappedToWisp) {
98 await mapWispDomain(configuringSite.rkey)
99 } else if (!shouldMapWisp && isCurrentlyMappedToWisp) {
100 await mapWispDomain(null)
101 }
102
103 // Handle custom domain mappings
104 const selectedCustomDomainIds = Array.from(selectedDomains).filter(id => id !== 'wisp')
105 const currentlyMappedCustomDomains = customDomains.filter(
106 d => d.rkey === configuringSite.rkey
107 )
108
109 // Unmap domains that are no longer selected
110 for (const domain of currentlyMappedCustomDomains) {
111 if (!selectedCustomDomainIds.includes(domain.id)) {
112 await mapCustomDomain(domain.id, null)
113 }
114 }
115
116 // Map newly selected domains
117 for (const domainId of selectedCustomDomainIds) {
118 const isAlreadyMapped = currentlyMappedCustomDomains.some(d => d.id === domainId)
119 if (!isAlreadyMapped) {
120 await mapCustomDomain(domainId, configuringSite.rkey)
121 }
122 }
123
124 // Refresh both domains and sites to get updated mappings
125 await fetchDomains()
126 await fetchSites()
127 setConfiguringSite(null)
128 } catch (err) {
129 console.error('Save config error:', err)
130 alert(
131 `Failed to save configuration: ${err instanceof Error ? err.message : 'Unknown error'}`
132 )
133 } finally {
134 setIsSavingConfig(false)
135 }
136 }
137
138 const handleDeleteSite = async () => {
139 if (!configuringSite) return
140
141 if (!confirm(`Are you sure you want to delete "${configuringSite.display_name || configuringSite.rkey}"? This action cannot be undone.`)) {
142 return
143 }
144
145 setIsDeletingSite(true)
146 const success = await deleteSite(configuringSite.rkey)
147 if (success) {
148 // Refresh domains in case this site was mapped
149 await fetchDomains()
150 setConfiguringSite(null)
151 }
152 setIsDeletingSite(false)
153 }
154
155 const handleUploadComplete = async () => {
156 await fetchSites()
157 }
158
159 if (loading) {
160 return (
161 <div className="w-full min-h-screen bg-background flex items-center justify-center">
162 <Loader2 className="w-8 h-8 animate-spin text-primary" />
163 </div>
164 )
165 }
166
167 return (
168 <div className="w-full min-h-screen bg-background">
169 {/* Header */}
170 <header className="border-b border-border/40 bg-background/80 backdrop-blur-sm sticky top-0 z-50">
171 <div className="container mx-auto px-4 py-4 flex items-center justify-between">
172 <div className="flex items-center gap-2">
173 <div className="w-8 h-8 bg-primary rounded-lg flex items-center justify-center">
174 <Globe className="w-5 h-5 text-primary-foreground" />
175 </div>
176 <span className="text-xl font-semibold text-foreground">
177 wisp.place
178 </span>
179 </div>
180 <div className="flex items-center gap-3">
181 <span className="text-sm text-muted-foreground">
182 {userInfo?.handle || 'Loading...'}
183 </span>
184 </div>
185 </div>
186 </header>
187
188 <div className="container mx-auto px-4 py-8 max-w-6xl w-full">
189 <div className="mb-8">
190 <h1 className="text-3xl font-bold mb-2">Dashboard</h1>
191 <p className="text-muted-foreground">
192 Manage your sites and domains
193 </p>
194 </div>
195
196 <Tabs defaultValue="sites" className="space-y-6 w-full">
197 <TabsList className="grid w-full grid-cols-4">
198 <TabsTrigger value="sites">Sites</TabsTrigger>
199 <TabsTrigger value="domains">Domains</TabsTrigger>
200 <TabsTrigger value="upload">Upload</TabsTrigger>
201 <TabsTrigger value="cli">CLI</TabsTrigger>
202 </TabsList>
203
204 {/* Sites Tab */}
205 <TabsContent value="sites">
206 <SitesTab
207 sites={sites}
208 sitesLoading={sitesLoading}
209 isSyncing={isSyncing}
210 userInfo={userInfo}
211 onSyncSites={syncSites}
212 onConfigureSite={handleConfigureSite}
213 />
214 </TabsContent>
215
216 {/* Domains Tab */}
217 <TabsContent value="domains">
218 <DomainsTab
219 wispDomain={wispDomain}
220 customDomains={customDomains}
221 domainsLoading={domainsLoading}
222 verificationStatus={verificationStatus}
223 userInfo={userInfo}
224 onAddCustomDomain={addCustomDomain}
225 onVerifyDomain={verifyDomain}
226 onDeleteCustomDomain={deleteCustomDomain}
227 onClaimWispDomain={claimWispDomain}
228 onCheckWispAvailability={checkWispAvailability}
229 />
230 </TabsContent>
231
232 {/* Upload Tab */}
233 <TabsContent value="upload">
234 <UploadTab
235 sites={sites}
236 sitesLoading={sitesLoading}
237 onUploadComplete={handleUploadComplete}
238 />
239 </TabsContent>
240
241 {/* CLI Tab */}
242 <TabsContent value="cli">
243 <CLITab />
244 </TabsContent>
245 </Tabs>
246 </div>
247
248 {/* Site Configuration Modal */}
249 <Dialog
250 open={configuringSite !== null}
251 onOpenChange={(open) => !open && setConfiguringSite(null)}
252 >
253 <DialogContent className="sm:max-w-lg">
254 <DialogHeader>
255 <DialogTitle>Configure Site Domains</DialogTitle>
256 <DialogDescription>
257 Select which domains should be mapped to this site. You can select multiple domains.
258 </DialogDescription>
259 </DialogHeader>
260 {configuringSite && (
261 <div className="space-y-4 py-4">
262 <div className="p-3 bg-muted/30 rounded-lg">
263 <p className="text-sm font-medium mb-1">Site:</p>
264 <p className="font-mono text-sm">
265 {configuringSite.display_name ||
266 configuringSite.rkey}
267 </p>
268 </div>
269
270 <div className="space-y-3">
271 <p className="text-sm font-medium">Available Domains:</p>
272
273 {wispDomain && (
274 <div className="flex items-center space-x-3 p-3 border rounded-lg hover:bg-muted/30">
275 <Checkbox
276 id="wisp"
277 checked={selectedDomains.has('wisp')}
278 onCheckedChange={(checked) => {
279 const newSelected = new Set(selectedDomains)
280 if (checked) {
281 newSelected.add('wisp')
282 } else {
283 newSelected.delete('wisp')
284 }
285 setSelectedDomains(newSelected)
286 }}
287 />
288 <Label
289 htmlFor="wisp"
290 className="flex-1 cursor-pointer"
291 >
292 <div className="flex items-center justify-between">
293 <span className="font-mono text-sm">
294 {wispDomain.domain}
295 </span>
296 <Badge variant="secondary" className="text-xs ml-2">
297 Wisp
298 </Badge>
299 </div>
300 </Label>
301 </div>
302 )}
303
304 {customDomains
305 .filter((d) => d.verified)
306 .map((domain) => (
307 <div
308 key={domain.id}
309 className="flex items-center space-x-3 p-3 border rounded-lg hover:bg-muted/30"
310 >
311 <Checkbox
312 id={domain.id}
313 checked={selectedDomains.has(domain.id)}
314 onCheckedChange={(checked) => {
315 const newSelected = new Set(selectedDomains)
316 if (checked) {
317 newSelected.add(domain.id)
318 } else {
319 newSelected.delete(domain.id)
320 }
321 setSelectedDomains(newSelected)
322 }}
323 />
324 <Label
325 htmlFor={domain.id}
326 className="flex-1 cursor-pointer"
327 >
328 <div className="flex items-center justify-between">
329 <span className="font-mono text-sm">
330 {domain.domain}
331 </span>
332 <Badge
333 variant="outline"
334 className="text-xs ml-2"
335 >
336 Custom
337 </Badge>
338 </div>
339 </Label>
340 </div>
341 ))}
342
343 {customDomains.filter(d => d.verified).length === 0 && !wispDomain && (
344 <p className="text-sm text-muted-foreground py-4 text-center">
345 No domains available. Add a custom domain or claim your wisp.place subdomain.
346 </p>
347 )}
348 </div>
349
350 <div className="p-3 bg-muted/20 rounded-lg border-l-4 border-blue-500/50">
351 <p className="text-xs text-muted-foreground">
352 <strong>Note:</strong> If no domains are selected, the site will be accessible at:{' '}
353 <span className="font-mono">
354 sites.wisp.place/{userInfo?.handle || '...'}/{configuringSite.rkey}
355 </span>
356 </p>
357 </div>
358 </div>
359 )}
360 <DialogFooter className="flex flex-col sm:flex-row sm:justify-between gap-2">
361 <Button
362 variant="destructive"
363 onClick={handleDeleteSite}
364 disabled={isSavingConfig || isDeletingSite}
365 className="sm:mr-auto"
366 >
367 {isDeletingSite ? (
368 <>
369 <Loader2 className="w-4 h-4 mr-2 animate-spin" />
370 Deleting...
371 </>
372 ) : (
373 <>
374 <Trash2 className="w-4 h-4 mr-2" />
375 Delete Site
376 </>
377 )}
378 </Button>
379 <div className="flex flex-col sm:flex-row gap-2 w-full sm:w-auto">
380 <Button
381 variant="outline"
382 onClick={() => setConfiguringSite(null)}
383 disabled={isSavingConfig || isDeletingSite}
384 className="w-full sm:w-auto"
385 >
386 Cancel
387 </Button>
388 <Button
389 onClick={handleSaveSiteConfig}
390 disabled={isSavingConfig || isDeletingSite}
391 className="w-full sm:w-auto"
392 >
393 {isSavingConfig ? (
394 <>
395 <Loader2 className="w-4 h-4 mr-2 animate-spin" />
396 Saving...
397 </>
398 ) : (
399 'Save'
400 )}
401 </Button>
402 </div>
403 </DialogFooter>
404 </DialogContent>
405 </Dialog>
406 </div>
407 )
408}
409
410const root = createRoot(document.getElementById('elysia')!)
411root.render(
412 <Layout className="gap-6">
413 <Dashboard />
414 </Layout>
415)