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)