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