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 { SkeletonShimmer } from '@public/components/ui/skeleton'
22import {
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 wispDomains,
42 customDomains,
43 domainsLoading,
44 verificationStatus,
45 fetchDomains,
46 addCustomDomain,
47 verifyDomain,
48 deleteCustomDomain,
49 mapWispDomain,
50 deleteWispDomain,
51 mapCustomDomain,
52 claimWispDomain,
53 checkWispAvailability
54 } = useDomainData()
55
56 // Site configuration modal state (shared across components)
57 const [configuringSite, setConfiguringSite] = useState<SiteWithDomains | null>(null)
58 const [selectedDomains, setSelectedDomains] = useState<Set<string>>(new Set())
59 const [isSavingConfig, setIsSavingConfig] = useState(false)
60 const [isDeletingSite, setIsDeletingSite] = useState(false)
61
62 // Fetch initial data on mount
63 useEffect(() => {
64 fetchUserInfo()
65 fetchSites()
66 fetchDomains()
67 }, [])
68
69 // Handle site configuration modal
70 const handleConfigureSite = (site: SiteWithDomains) => {
71 setConfiguringSite(site)
72
73 // Build set of currently mapped domains
74 const mappedDomains = new Set<string>()
75
76 if (site.domains) {
77 site.domains.forEach(domainInfo => {
78 if (domainInfo.type === 'wisp') {
79 // For wisp domains, use the domain itself as the identifier
80 mappedDomains.add(`wisp:${domainInfo.domain}`)
81 } else if (domainInfo.id) {
82 mappedDomains.add(domainInfo.id)
83 }
84 })
85 }
86
87 setSelectedDomains(mappedDomains)
88 }
89
90 const handleSaveSiteConfig = async () => {
91 if (!configuringSite) return
92
93 setIsSavingConfig(true)
94 try {
95 // Handle wisp domain mappings
96 const selectedWispDomainIds = Array.from(selectedDomains).filter(id => id.startsWith('wisp:'))
97 const selectedWispDomains = selectedWispDomainIds.map(id => id.replace('wisp:', ''))
98
99 // Get currently mapped wisp domains
100 const currentlyMappedWispDomains = wispDomains.filter(
101 d => d.rkey === configuringSite.rkey
102 )
103
104 // Unmap wisp domains that are no longer selected
105 for (const domain of currentlyMappedWispDomains) {
106 if (!selectedWispDomains.includes(domain.domain)) {
107 await mapWispDomain(domain.domain, null)
108 }
109 }
110
111 // Map newly selected wisp domains
112 for (const domainName of selectedWispDomains) {
113 const isAlreadyMapped = currentlyMappedWispDomains.some(d => d.domain === domainName)
114 if (!isAlreadyMapped) {
115 await mapWispDomain(domainName, configuringSite.rkey)
116 }
117 }
118
119 // Handle custom domain mappings
120 const selectedCustomDomainIds = Array.from(selectedDomains).filter(id => !id.startsWith('wisp:'))
121 const currentlyMappedCustomDomains = customDomains.filter(
122 d => d.rkey === configuringSite.rkey
123 )
124
125 // Unmap domains that are no longer selected
126 for (const domain of currentlyMappedCustomDomains) {
127 if (!selectedCustomDomainIds.includes(domain.id)) {
128 await mapCustomDomain(domain.id, null)
129 }
130 }
131
132 // Map newly selected domains
133 for (const domainId of selectedCustomDomainIds) {
134 const isAlreadyMapped = currentlyMappedCustomDomains.some(d => d.id === domainId)
135 if (!isAlreadyMapped) {
136 await mapCustomDomain(domainId, configuringSite.rkey)
137 }
138 }
139
140 // Refresh both domains and sites to get updated mappings
141 await fetchDomains()
142 await fetchSites()
143 setConfiguringSite(null)
144 } catch (err) {
145 console.error('Save config error:', err)
146 alert(
147 `Failed to save configuration: ${err instanceof Error ? err.message : 'Unknown error'}`
148 )
149 } finally {
150 setIsSavingConfig(false)
151 }
152 }
153
154 const handleDeleteSite = async () => {
155 if (!configuringSite) return
156
157 if (!confirm(`Are you sure you want to delete "${configuringSite.display_name || configuringSite.rkey}"? This action cannot be undone.`)) {
158 return
159 }
160
161 setIsDeletingSite(true)
162 const success = await deleteSite(configuringSite.rkey)
163 if (success) {
164 // Refresh domains in case this site was mapped
165 await fetchDomains()
166 setConfiguringSite(null)
167 }
168 setIsDeletingSite(false)
169 }
170
171 const handleUploadComplete = async () => {
172 await fetchSites()
173 }
174
175 const handleLogout = async () => {
176 try {
177 const response = await fetch('/api/auth/logout', {
178 method: 'POST',
179 credentials: 'include'
180 })
181 const result = await response.json()
182 if (result.success) {
183 // Redirect to home page after successful logout
184 window.location.href = '/'
185 } else {
186 alert('Logout failed: ' + (result.error || 'Unknown error'))
187 }
188 } catch (err) {
189 alert('Logout failed: ' + (err instanceof Error ? err.message : 'Unknown error'))
190 }
191 }
192
193 if (loading) {
194 return (
195 <div className="w-full min-h-screen bg-background">
196 {/* Header Skeleton */}
197 <header className="border-b border-border/40 bg-background/80 backdrop-blur-sm sticky top-0 z-50">
198 <div className="container mx-auto px-4 py-4 flex items-center justify-between">
199 <div className="flex items-center gap-2">
200 <img src="/transparent-full-size-ico.png" alt="wisp.place" className="w-8 h-8" />
201 <span className="text-xl font-semibold text-foreground">
202 wisp.place
203 </span>
204 </div>
205 <div className="flex items-center gap-3">
206 <SkeletonShimmer className="h-5 w-32" />
207 <SkeletonShimmer className="h-8 w-8 rounded" />
208 </div>
209 </div>
210 </header>
211
212 <div className="container mx-auto px-4 py-8 max-w-6xl w-full">
213 {/* Title Skeleton */}
214 <div className="mb-8 space-y-2">
215 <SkeletonShimmer className="h-9 w-48" />
216 <SkeletonShimmer className="h-5 w-64" />
217 </div>
218
219 {/* Tabs Skeleton */}
220 <div className="space-y-6 w-full">
221 <div className="inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground w-full">
222 <SkeletonShimmer className="h-8 w-1/4 mx-1" />
223 <SkeletonShimmer className="h-8 w-1/4 mx-1" />
224 <SkeletonShimmer className="h-8 w-1/4 mx-1" />
225 <SkeletonShimmer className="h-8 w-1/4 mx-1" />
226 </div>
227
228 {/* Content Skeleton */}
229 <div className="space-y-4">
230 <div className="rounded-lg border border-border bg-card text-card-foreground shadow-sm">
231 <div className="flex flex-col space-y-1.5 p-6">
232 <SkeletonShimmer className="h-7 w-40" />
233 <SkeletonShimmer className="h-4 w-64" />
234 </div>
235 <div className="p-6 pt-0 space-y-4">
236 {[...Array(3)].map((_, i) => (
237 <div
238 key={i}
239 className="flex items-center justify-between p-4 border border-border rounded-lg"
240 >
241 <div className="flex-1 space-y-3">
242 <div className="flex items-center gap-3">
243 <SkeletonShimmer className="h-6 w-48" />
244 <SkeletonShimmer className="h-5 w-16" />
245 </div>
246 <SkeletonShimmer className="h-4 w-64" />
247 </div>
248 <SkeletonShimmer className="h-9 w-28" />
249 </div>
250 ))}
251 </div>
252 </div>
253 </div>
254 </div>
255 </div>
256 </div>
257 )
258 }
259
260 return (
261 <div className="w-full min-h-screen bg-background">
262 {/* Header */}
263 <header className="border-b border-border/40 bg-background/80 backdrop-blur-sm sticky top-0 z-50">
264 <div className="container mx-auto px-4 py-4 flex items-center justify-between">
265 <div className="flex items-center gap-2">
266 <img src="/transparent-full-size-ico.png" alt="wisp.place" className="w-8 h-8" />
267 <span className="text-xl font-semibold text-foreground">
268 wisp.place
269 </span>
270 </div>
271 <div className="flex items-center gap-3">
272 <span className="text-sm text-muted-foreground">
273 {userInfo?.handle || 'Loading...'}
274 </span>
275 <Button
276 variant="ghost"
277 size="sm"
278 onClick={handleLogout}
279 className="h-8 px-2"
280 >
281 <LogOut className="w-4 h-4" />
282 </Button>
283 </div>
284 </div>
285 </header>
286
287 <div className="container mx-auto px-4 py-8 max-w-6xl w-full">
288 <div className="mb-8">
289 <h1 className="text-3xl font-bold mb-2">Dashboard</h1>
290 <p className="text-muted-foreground">
291 Manage your sites and domains
292 </p>
293 </div>
294
295 <Tabs defaultValue="sites" className="space-y-6 w-full">
296 <TabsList className="grid w-full grid-cols-4">
297 <TabsTrigger value="sites">Sites</TabsTrigger>
298 <TabsTrigger value="domains">Domains</TabsTrigger>
299 <TabsTrigger value="upload">Upload</TabsTrigger>
300 <TabsTrigger value="cli">CLI</TabsTrigger>
301 </TabsList>
302
303 {/* Sites Tab */}
304 <TabsContent value="sites">
305 <SitesTab
306 sites={sites}
307 sitesLoading={sitesLoading}
308 isSyncing={isSyncing}
309 userInfo={userInfo}
310 onSyncSites={syncSites}
311 onConfigureSite={handleConfigureSite}
312 />
313 </TabsContent>
314
315 {/* Domains Tab */}
316 <TabsContent value="domains">
317 <DomainsTab
318 wispDomains={wispDomains}
319 customDomains={customDomains}
320 domainsLoading={domainsLoading}
321 verificationStatus={verificationStatus}
322 userInfo={userInfo}
323 onAddCustomDomain={addCustomDomain}
324 onVerifyDomain={verifyDomain}
325 onDeleteCustomDomain={deleteCustomDomain}
326 onDeleteWispDomain={deleteWispDomain}
327 onClaimWispDomain={claimWispDomain}
328 onCheckWispAvailability={checkWispAvailability}
329 />
330 </TabsContent>
331
332 {/* Upload Tab */}
333 <TabsContent value="upload">
334 <UploadTab
335 sites={sites}
336 sitesLoading={sitesLoading}
337 onUploadComplete={handleUploadComplete}
338 />
339 </TabsContent>
340
341 {/* CLI Tab */}
342 <TabsContent value="cli">
343 <CLITab />
344 </TabsContent>
345 </Tabs>
346 </div>
347
348 {/* Footer */}
349 <footer className="border-t border-border/40 bg-muted/20 mt-12">
350 <div className="container mx-auto px-4 py-8">
351 <div className="text-center text-sm text-muted-foreground">
352 <p>
353 Built by{' '}
354 <a
355 href="https://bsky.app/profile/nekomimi.pet"
356 target="_blank"
357 rel="noopener noreferrer"
358 className="text-accent hover:text-accent/80 transition-colors font-medium"
359 >
360 @nekomimi.pet
361 </a>
362 {' • '}
363 Contact:{' '}
364 <a
365 href="mailto:contact@wisp.place"
366 className="text-accent hover:text-accent/80 transition-colors font-medium"
367 >
368 contact@wisp.place
369 </a>
370 {' • '}
371 Legal/DMCA:{' '}
372 <a
373 href="mailto:legal@wisp.place"
374 className="text-accent hover:text-accent/80 transition-colors font-medium"
375 >
376 legal@wisp.place
377 </a>
378 </p>
379 <p className="mt-2">
380 <a
381 href="/acceptable-use"
382 className="text-accent hover:text-accent/80 transition-colors font-medium"
383 >
384 Acceptable Use Policy
385 </a>
386 </p>
387 </div>
388 </div>
389 </footer>
390
391 {/* Site Configuration Modal */}
392 <Dialog
393 open={configuringSite !== null}
394 onOpenChange={(open) => !open && setConfiguringSite(null)}
395 >
396 <DialogContent className="sm:max-w-lg">
397 <DialogHeader>
398 <DialogTitle>Configure Site Domains</DialogTitle>
399 <DialogDescription>
400 Select which domains should be mapped to this site. You can select multiple domains.
401 </DialogDescription>
402 </DialogHeader>
403 {configuringSite && (
404 <div className="space-y-4 py-4">
405 <div className="p-3 bg-muted/30 rounded-lg">
406 <p className="text-sm font-medium mb-1">Site:</p>
407 <p className="font-mono text-sm">
408 {configuringSite.display_name ||
409 configuringSite.rkey}
410 </p>
411 </div>
412
413 <div className="space-y-3">
414 <p className="text-sm font-medium">Available Domains:</p>
415
416 {wispDomains.map((wispDomain) => {
417 const domainId = `wisp:${wispDomain.domain}`
418 return (
419 <div key={domainId} className="flex items-center space-x-3 p-3 border rounded-lg hover:bg-muted/30">
420 <Checkbox
421 id={domainId}
422 checked={selectedDomains.has(domainId)}
423 onCheckedChange={(checked) => {
424 const newSelected = new Set(selectedDomains)
425 if (checked) {
426 newSelected.add(domainId)
427 } else {
428 newSelected.delete(domainId)
429 }
430 setSelectedDomains(newSelected)
431 }}
432 />
433 <Label
434 htmlFor={domainId}
435 className="flex-1 cursor-pointer"
436 >
437 <div className="flex items-center justify-between">
438 <span className="font-mono text-sm">
439 {wispDomain.domain}
440 </span>
441 <Badge variant="secondary" className="text-xs ml-2">
442 Wisp
443 </Badge>
444 </div>
445 </Label>
446 </div>
447 )
448 })}
449
450 {customDomains
451 .filter((d) => d.verified)
452 .map((domain) => (
453 <div
454 key={domain.id}
455 className="flex items-center space-x-3 p-3 border rounded-lg hover:bg-muted/30"
456 >
457 <Checkbox
458 id={domain.id}
459 checked={selectedDomains.has(domain.id)}
460 onCheckedChange={(checked) => {
461 const newSelected = new Set(selectedDomains)
462 if (checked) {
463 newSelected.add(domain.id)
464 } else {
465 newSelected.delete(domain.id)
466 }
467 setSelectedDomains(newSelected)
468 }}
469 />
470 <Label
471 htmlFor={domain.id}
472 className="flex-1 cursor-pointer"
473 >
474 <div className="flex items-center justify-between">
475 <span className="font-mono text-sm">
476 {domain.domain}
477 </span>
478 <Badge
479 variant="outline"
480 className="text-xs ml-2"
481 >
482 Custom
483 </Badge>
484 </div>
485 </Label>
486 </div>
487 ))}
488
489 {customDomains.filter(d => d.verified).length === 0 && wispDomains.length === 0 && (
490 <p className="text-sm text-muted-foreground py-4 text-center">
491 No domains available. Add a custom domain or claim a wisp.place subdomain.
492 </p>
493 )}
494 </div>
495
496 <div className="p-3 bg-muted/20 rounded-lg border-l-4 border-blue-500/50">
497 <p className="text-xs text-muted-foreground">
498 <strong>Note:</strong> If no domains are selected, the site will be accessible at:{' '}
499 <span className="font-mono">
500 sites.wisp.place/{userInfo?.handle || '...'}/{configuringSite.rkey}
501 </span>
502 </p>
503 </div>
504 </div>
505 )}
506 <DialogFooter className="flex flex-col sm:flex-row sm:justify-between gap-2">
507 <Button
508 variant="destructive"
509 onClick={handleDeleteSite}
510 disabled={isSavingConfig || isDeletingSite}
511 className="sm:mr-auto"
512 >
513 {isDeletingSite ? (
514 <>
515 <Loader2 className="w-4 h-4 mr-2 animate-spin" />
516 Deleting...
517 </>
518 ) : (
519 <>
520 <Trash2 className="w-4 h-4 mr-2" />
521 Delete Site
522 </>
523 )}
524 </Button>
525 <div className="flex flex-col sm:flex-row gap-2 w-full sm:w-auto">
526 <Button
527 variant="outline"
528 onClick={() => setConfiguringSite(null)}
529 disabled={isSavingConfig || isDeletingSite}
530 className="w-full sm:w-auto"
531 >
532 Cancel
533 </Button>
534 <Button
535 onClick={handleSaveSiteConfig}
536 disabled={isSavingConfig || isDeletingSite}
537 className="w-full sm:w-auto"
538 >
539 {isSavingConfig ? (
540 <>
541 <Loader2 className="w-4 h-4 mr-2 animate-spin" />
542 Saving...
543 </>
544 ) : (
545 'Save'
546 )}
547 </Button>
548 </div>
549 </DialogFooter>
550 </DialogContent>
551 </Dialog>
552 </div>
553 )
554}
555
556const root = createRoot(document.getElementById('elysia')!)
557root.render(
558 <Layout className="gap-6">
559 <Dashboard />
560 </Layout>
561)