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)