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)