Monorepo for wisp.place. A static site hosting service built on top of the AT Protocol. wisp.place
at main 28 kB view raw
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 { Input } from '@public/components/ui/input' 23import { RadioGroup, RadioGroupItem } from '@public/components/ui/radio-group' 24import { 25 Loader2, 26 Trash2, 27 LogOut 28} from 'lucide-react' 29import Layout from '@public/layouts' 30import { useUserInfo } from './hooks/useUserInfo' 31import { useSiteData, type SiteWithDomains } from './hooks/useSiteData' 32import { useDomainData } from './hooks/useDomainData' 33import { SitesTab } from './tabs/SitesTab' 34import { DomainsTab } from './tabs/DomainsTab' 35import { UploadTab } from './tabs/UploadTab' 36import { CLITab } from './tabs/CLITab' 37 38function Dashboard() { 39 // Use custom hooks 40 const { userInfo, loading, fetchUserInfo } = useUserInfo() 41 const { sites, sitesLoading, isSyncing, fetchSites, syncSites, deleteSite } = useSiteData() 42 const { 43 wispDomains, 44 customDomains, 45 domainsLoading, 46 verificationStatus, 47 fetchDomains, 48 addCustomDomain, 49 verifyDomain, 50 deleteCustomDomain, 51 mapWispDomain, 52 deleteWispDomain, 53 mapCustomDomain, 54 claimWispDomain, 55 checkWispAvailability 56 } = useDomainData() 57 58 // Site configuration modal state (shared across components) 59 const [configuringSite, setConfiguringSite] = useState<SiteWithDomains | null>(null) 60 const [selectedDomains, setSelectedDomains] = useState<Set<string>>(new Set()) 61 const [isSavingConfig, setIsSavingConfig] = useState(false) 62 const [isDeletingSite, setIsDeletingSite] = useState(false) 63 64 // Site settings state 65 type RoutingMode = 'default' | 'spa' | 'directory' | 'custom404' 66 const [routingMode, setRoutingMode] = useState<RoutingMode>('default') 67 const [spaFile, setSpaFile] = useState('index.html') 68 const [custom404File, setCustom404File] = useState('404.html') 69 const [indexFiles, setIndexFiles] = useState<string[]>(['index.html']) 70 const [newIndexFile, setNewIndexFile] = useState('') 71 const [cleanUrls, setCleanUrls] = useState(false) 72 const [corsEnabled, setCorsEnabled] = useState(false) 73 const [corsOrigin, setCorsOrigin] = useState('*') 74 75 // Fetch initial data on mount 76 useEffect(() => { 77 fetchUserInfo() 78 fetchSites() 79 fetchDomains() 80 }, []) 81 82 // Handle site configuration modal 83 const handleConfigureSite = async (site: SiteWithDomains) => { 84 setConfiguringSite(site) 85 86 // Build set of currently mapped domains 87 const mappedDomains = new Set<string>() 88 89 if (site.domains) { 90 site.domains.forEach(domainInfo => { 91 if (domainInfo.type === 'wisp') { 92 // For wisp domains, use the domain itself as the identifier 93 mappedDomains.add(`wisp:${domainInfo.domain}`) 94 } else if (domainInfo.id) { 95 mappedDomains.add(domainInfo.id) 96 } 97 }) 98 } 99 100 setSelectedDomains(mappedDomains) 101 102 // Fetch and populate settings for this site 103 try { 104 const response = await fetch(`/api/site/${site.rkey}/settings`, { 105 credentials: 'include' 106 }) 107 if (response.ok) { 108 const settings = await response.json() 109 110 // Determine routing mode based on settings 111 if (settings.spaMode) { 112 setRoutingMode('spa') 113 setSpaFile(settings.spaMode) 114 } else if (settings.directoryListing) { 115 setRoutingMode('directory') 116 } else if (settings.custom404) { 117 setRoutingMode('custom404') 118 setCustom404File(settings.custom404) 119 } else { 120 setRoutingMode('default') 121 } 122 123 // Set other settings 124 setIndexFiles(settings.indexFiles || ['index.html']) 125 setCleanUrls(settings.cleanUrls || false) 126 127 // Check for CORS headers 128 const corsHeader = settings.headers?.find((h: any) => h.name === 'Access-Control-Allow-Origin') 129 if (corsHeader) { 130 setCorsEnabled(true) 131 setCorsOrigin(corsHeader.value) 132 } else { 133 setCorsEnabled(false) 134 setCorsOrigin('*') 135 } 136 } else { 137 // Reset to defaults if no settings found 138 setRoutingMode('default') 139 setSpaFile('index.html') 140 setCustom404File('404.html') 141 setIndexFiles(['index.html']) 142 setCleanUrls(false) 143 setCorsEnabled(false) 144 setCorsOrigin('*') 145 } 146 } catch (err) { 147 console.error('Failed to fetch settings:', err) 148 // Use defaults on error 149 setRoutingMode('default') 150 setSpaFile('index.html') 151 setCustom404File('404.html') 152 setIndexFiles(['index.html']) 153 setCleanUrls(false) 154 setCorsEnabled(false) 155 setCorsOrigin('*') 156 } 157 } 158 159 const handleSaveSiteConfig = async () => { 160 if (!configuringSite) return 161 162 setIsSavingConfig(true) 163 try { 164 // Handle wisp domain mappings 165 const selectedWispDomainIds = Array.from(selectedDomains).filter(id => id.startsWith('wisp:')) 166 const selectedWispDomains = selectedWispDomainIds.map(id => id.replace('wisp:', '')) 167 168 // Get currently mapped wisp domains 169 const currentlyMappedWispDomains = wispDomains.filter( 170 d => d.rkey === configuringSite.rkey 171 ) 172 173 // Unmap wisp domains that are no longer selected 174 for (const domain of currentlyMappedWispDomains) { 175 if (!selectedWispDomains.includes(domain.domain)) { 176 await mapWispDomain(domain.domain, null) 177 } 178 } 179 180 // Map newly selected wisp domains 181 for (const domainName of selectedWispDomains) { 182 const isAlreadyMapped = currentlyMappedWispDomains.some(d => d.domain === domainName) 183 if (!isAlreadyMapped) { 184 await mapWispDomain(domainName, configuringSite.rkey) 185 } 186 } 187 188 // Handle custom domain mappings 189 const selectedCustomDomainIds = Array.from(selectedDomains).filter(id => !id.startsWith('wisp:')) 190 const currentlyMappedCustomDomains = customDomains.filter( 191 d => d.rkey === configuringSite.rkey 192 ) 193 194 // Unmap domains that are no longer selected 195 for (const domain of currentlyMappedCustomDomains) { 196 if (!selectedCustomDomainIds.includes(domain.id)) { 197 await mapCustomDomain(domain.id, null) 198 } 199 } 200 201 // Map newly selected domains 202 for (const domainId of selectedCustomDomainIds) { 203 const isAlreadyMapped = currentlyMappedCustomDomains.some(d => d.id === domainId) 204 if (!isAlreadyMapped) { 205 await mapCustomDomain(domainId, configuringSite.rkey) 206 } 207 } 208 209 // Save site settings 210 const settings: any = { 211 cleanUrls, 212 indexFiles: indexFiles.filter(f => f.trim() !== '') 213 } 214 215 // Set routing mode based on selection 216 if (routingMode === 'spa') { 217 settings.spaMode = spaFile 218 } else if (routingMode === 'directory') { 219 settings.directoryListing = true 220 } else if (routingMode === 'custom404') { 221 settings.custom404 = custom404File 222 } 223 224 // Add CORS header if enabled 225 if (corsEnabled) { 226 settings.headers = [ 227 { 228 name: 'Access-Control-Allow-Origin', 229 value: corsOrigin 230 } 231 ] 232 } 233 234 const settingsResponse = await fetch(`/api/site/${configuringSite.rkey}/settings`, { 235 method: 'POST', 236 headers: { 237 'Content-Type': 'application/json' 238 }, 239 credentials: 'include', 240 body: JSON.stringify(settings) 241 }) 242 243 if (!settingsResponse.ok) { 244 const error = await settingsResponse.json() 245 throw new Error(error.error || 'Failed to save settings') 246 } 247 248 // Refresh both domains and sites to get updated mappings 249 await fetchDomains() 250 await fetchSites() 251 setConfiguringSite(null) 252 } catch (err) { 253 console.error('Save config error:', err) 254 alert( 255 `Failed to save configuration: ${err instanceof Error ? err.message : 'Unknown error'}` 256 ) 257 } finally { 258 setIsSavingConfig(false) 259 } 260 } 261 262 const handleDeleteSite = async () => { 263 if (!configuringSite) return 264 265 if (!confirm(`Are you sure you want to delete "${configuringSite.display_name || configuringSite.rkey}"? This action cannot be undone.`)) { 266 return 267 } 268 269 setIsDeletingSite(true) 270 const success = await deleteSite(configuringSite.rkey) 271 if (success) { 272 // Refresh domains in case this site was mapped 273 await fetchDomains() 274 setConfiguringSite(null) 275 } 276 setIsDeletingSite(false) 277 } 278 279 const handleUploadComplete = async () => { 280 await fetchSites() 281 } 282 283 const handleLogout = async () => { 284 try { 285 const response = await fetch('/api/auth/logout', { 286 method: 'POST', 287 credentials: 'include' 288 }) 289 const result = await response.json() 290 if (result.success) { 291 // Redirect to home page after successful logout 292 window.location.href = '/' 293 } else { 294 alert('Logout failed: ' + (result.error || 'Unknown error')) 295 } 296 } catch (err) { 297 alert('Logout failed: ' + (err instanceof Error ? err.message : 'Unknown error')) 298 } 299 } 300 301 if (loading) { 302 return ( 303 <div className="w-full min-h-screen bg-background"> 304 {/* Header Skeleton */} 305 <header className="border-b border-border/40 bg-background/80 backdrop-blur-sm sticky top-0 z-50"> 306 <div className="container mx-auto px-4 py-4 flex items-center justify-between"> 307 <div className="flex items-center gap-2"> 308 <img src="/transparent-full-size-ico.png" alt="wisp.place" className="w-8 h-8" /> 309 <span className="text-xl font-semibold text-foreground"> 310 wisp.place 311 </span> 312 </div> 313 <div className="flex items-center gap-3"> 314 <SkeletonShimmer className="h-5 w-32" /> 315 <SkeletonShimmer className="h-8 w-8 rounded" /> 316 </div> 317 </div> 318 </header> 319 320 <div className="container mx-auto px-4 py-8 max-w-6xl w-full"> 321 {/* Title Skeleton */} 322 <div className="mb-8 space-y-2"> 323 <SkeletonShimmer className="h-9 w-48" /> 324 <SkeletonShimmer className="h-5 w-64" /> 325 </div> 326 327 {/* Tabs Skeleton */} 328 <div className="space-y-6 w-full"> 329 <div className="inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground w-full"> 330 <SkeletonShimmer className="h-8 w-1/4 mx-1" /> 331 <SkeletonShimmer className="h-8 w-1/4 mx-1" /> 332 <SkeletonShimmer className="h-8 w-1/4 mx-1" /> 333 <SkeletonShimmer className="h-8 w-1/4 mx-1" /> 334 </div> 335 336 {/* Content Skeleton */} 337 <div className="space-y-4"> 338 <div className="rounded-lg border border-border bg-card text-card-foreground shadow-sm"> 339 <div className="flex flex-col space-y-1.5 p-6"> 340 <SkeletonShimmer className="h-7 w-40" /> 341 <SkeletonShimmer className="h-4 w-64" /> 342 </div> 343 <div className="p-6 pt-0 space-y-4"> 344 {[...Array(3)].map((_, i) => ( 345 <div 346 key={i} 347 className="flex items-center justify-between p-4 border border-border rounded-lg" 348 > 349 <div className="flex-1 space-y-3"> 350 <div className="flex items-center gap-3"> 351 <SkeletonShimmer className="h-6 w-48" /> 352 <SkeletonShimmer className="h-5 w-16" /> 353 </div> 354 <SkeletonShimmer className="h-4 w-64" /> 355 </div> 356 <SkeletonShimmer className="h-9 w-28" /> 357 </div> 358 ))} 359 </div> 360 </div> 361 </div> 362 </div> 363 </div> 364 </div> 365 ) 366 } 367 368 return ( 369 <div className="w-full min-h-screen bg-background"> 370 {/* Header */} 371 <header className="border-b border-border/40 bg-background/80 backdrop-blur-sm sticky top-0 z-50"> 372 <div className="container mx-auto px-4 py-4 flex items-center justify-between"> 373 <div className="flex items-center gap-2"> 374 <img src="/transparent-full-size-ico.png" alt="wisp.place" className="w-8 h-8" /> 375 <span className="text-xl font-semibold text-foreground"> 376 wisp.place 377 </span> 378 </div> 379 <div className="flex items-center gap-3"> 380 <span className="text-sm text-muted-foreground"> 381 {userInfo?.handle || 'Loading...'} 382 </span> 383 <Button 384 variant="ghost" 385 size="sm" 386 onClick={handleLogout} 387 className="h-8 px-2" 388 > 389 <LogOut className="w-4 h-4" /> 390 </Button> 391 </div> 392 </div> 393 </header> 394 395 <div className="container mx-auto px-4 py-8 max-w-6xl w-full"> 396 <div className="mb-8"> 397 <h1 className="text-3xl font-bold mb-2">Dashboard</h1> 398 <p className="text-muted-foreground"> 399 Manage your sites and domains 400 </p> 401 </div> 402 403 <Tabs defaultValue="sites" className="space-y-6 w-full"> 404 <TabsList className="grid w-full grid-cols-4"> 405 <TabsTrigger value="sites">Sites</TabsTrigger> 406 <TabsTrigger value="domains">Domains</TabsTrigger> 407 <TabsTrigger value="upload">Upload</TabsTrigger> 408 <TabsTrigger value="cli">CLI</TabsTrigger> 409 </TabsList> 410 411 {/* Sites Tab */} 412 <TabsContent value="sites"> 413 <SitesTab 414 sites={sites} 415 sitesLoading={sitesLoading} 416 isSyncing={isSyncing} 417 userInfo={userInfo} 418 onSyncSites={syncSites} 419 onConfigureSite={handleConfigureSite} 420 /> 421 </TabsContent> 422 423 {/* Domains Tab */} 424 <TabsContent value="domains"> 425 <DomainsTab 426 wispDomains={wispDomains} 427 customDomains={customDomains} 428 domainsLoading={domainsLoading} 429 verificationStatus={verificationStatus} 430 userInfo={userInfo} 431 onAddCustomDomain={addCustomDomain} 432 onVerifyDomain={verifyDomain} 433 onDeleteCustomDomain={deleteCustomDomain} 434 onDeleteWispDomain={deleteWispDomain} 435 onClaimWispDomain={claimWispDomain} 436 onCheckWispAvailability={checkWispAvailability} 437 /> 438 </TabsContent> 439 440 {/* Upload Tab */} 441 <TabsContent value="upload"> 442 <UploadTab 443 sites={sites} 444 sitesLoading={sitesLoading} 445 onUploadComplete={handleUploadComplete} 446 /> 447 </TabsContent> 448 449 {/* CLI Tab */} 450 <TabsContent value="cli"> 451 <CLITab /> 452 </TabsContent> 453 </Tabs> 454 </div> 455 456 {/* Footer */} 457 <footer className="border-t border-border/40 bg-muted/20 mt-12"> 458 <div className="container mx-auto px-4 py-8"> 459 <div className="text-center text-sm text-muted-foreground"> 460 <p> 461 Built by{' '} 462 <a 463 href="https://bsky.app/profile/nekomimi.pet" 464 target="_blank" 465 rel="noopener noreferrer" 466 className="text-accent hover:text-accent/80 transition-colors font-medium" 467 > 468 @nekomimi.pet 469 </a> 470 {' • '} 471 Contact:{' '} 472 <a 473 href="mailto:contact@wisp.place" 474 className="text-accent hover:text-accent/80 transition-colors font-medium" 475 > 476 contact@wisp.place 477 </a> 478 {' • '} 479 Legal/DMCA:{' '} 480 <a 481 href="mailto:legal@wisp.place" 482 className="text-accent hover:text-accent/80 transition-colors font-medium" 483 > 484 legal@wisp.place 485 </a> 486 </p> 487 <p className="mt-2"> 488 <a 489 href="/acceptable-use" 490 className="text-accent hover:text-accent/80 transition-colors font-medium" 491 > 492 Acceptable Use Policy 493 </a> 494 </p> 495 </div> 496 </div> 497 </footer> 498 499 {/* Site Configuration Modal */} 500 <Dialog 501 open={configuringSite !== null} 502 onOpenChange={(open) => !open && setConfiguringSite(null)} 503 > 504 <DialogContent className="sm:max-w-2xl max-h-[90vh] overflow-y-auto"> 505 <DialogHeader> 506 <DialogTitle>Configure Site</DialogTitle> 507 <DialogDescription> 508 Configure domains and settings for this site. 509 </DialogDescription> 510 </DialogHeader> 511 {configuringSite && ( 512 <div className="space-y-4 py-4"> 513 <div className="p-3 bg-muted/30 rounded-lg"> 514 <p className="text-sm font-medium mb-1">Site:</p> 515 <p className="font-mono text-sm"> 516 {configuringSite.display_name || 517 configuringSite.rkey} 518 </p> 519 </div> 520 521 <Tabs defaultValue="domains" className="w-full"> 522 <TabsList className="grid w-full grid-cols-2"> 523 <TabsTrigger value="domains">Domains</TabsTrigger> 524 <TabsTrigger value="settings">Settings</TabsTrigger> 525 </TabsList> 526 527 {/* Domains Tab */} 528 <TabsContent value="domains" className="space-y-3 mt-4"> 529 <p className="text-sm font-medium">Available Domains:</p> 530 531 {wispDomains.map((wispDomain) => { 532 const domainId = `wisp:${wispDomain.domain}` 533 return ( 534 <div key={domainId} className="flex items-center space-x-3 p-3 border rounded-lg hover:bg-muted/30"> 535 <Checkbox 536 id={domainId} 537 checked={selectedDomains.has(domainId)} 538 onCheckedChange={(checked) => { 539 const newSelected = new Set(selectedDomains) 540 if (checked) { 541 newSelected.add(domainId) 542 } else { 543 newSelected.delete(domainId) 544 } 545 setSelectedDomains(newSelected) 546 }} 547 /> 548 <Label 549 htmlFor={domainId} 550 className="flex-1 cursor-pointer" 551 > 552 <div className="flex items-center justify-between"> 553 <span className="font-mono text-sm"> 554 {wispDomain.domain} 555 </span> 556 <Badge variant="secondary" className="text-xs ml-2"> 557 Wisp 558 </Badge> 559 </div> 560 </Label> 561 </div> 562 ) 563 })} 564 565 {customDomains 566 .filter((d) => d.verified) 567 .map((domain) => ( 568 <div 569 key={domain.id} 570 className="flex items-center space-x-3 p-3 border rounded-lg hover:bg-muted/30" 571 > 572 <Checkbox 573 id={domain.id} 574 checked={selectedDomains.has(domain.id)} 575 onCheckedChange={(checked) => { 576 const newSelected = new Set(selectedDomains) 577 if (checked) { 578 newSelected.add(domain.id) 579 } else { 580 newSelected.delete(domain.id) 581 } 582 setSelectedDomains(newSelected) 583 }} 584 /> 585 <Label 586 htmlFor={domain.id} 587 className="flex-1 cursor-pointer" 588 > 589 <div className="flex items-center justify-between"> 590 <span className="font-mono text-sm"> 591 {domain.domain} 592 </span> 593 <Badge 594 variant="outline" 595 className="text-xs ml-2" 596 > 597 Custom 598 </Badge> 599 </div> 600 </Label> 601 </div> 602 ))} 603 604 {customDomains.filter(d => d.verified).length === 0 && wispDomains.length === 0 && ( 605 <p className="text-sm text-muted-foreground py-4 text-center"> 606 No domains available. Add a custom domain or claim a wisp.place subdomain. 607 </p> 608 )} 609 610 <div className="p-3 bg-muted/20 rounded-lg border-l-4 border-blue-500/50 mt-4"> 611 <p className="text-xs text-muted-foreground"> 612 <strong>Note:</strong> If no domains are selected, the site will be accessible at:{' '} 613 <span className="font-mono"> 614 sites.wisp.place/{userInfo?.handle || '...'}/{configuringSite.rkey} 615 </span> 616 </p> 617 </div> 618 </TabsContent> 619 620 {/* Settings Tab */} 621 <TabsContent value="settings" className="space-y-4 mt-4"> 622 {/* Routing Mode */} 623 <div className="space-y-3"> 624 <Label className="text-sm font-medium">Routing Mode</Label> 625 <RadioGroup value={routingMode} onValueChange={(value) => setRoutingMode(value as RoutingMode)}> 626 <div className="flex items-center space-x-3 p-3 border rounded-lg"> 627 <RadioGroupItem value="default" id="mode-default" /> 628 <Label htmlFor="mode-default" className="flex-1 cursor-pointer"> 629 <div> 630 <p className="font-medium">Default</p> 631 <p className="text-xs text-muted-foreground">Standard static file serving</p> 632 </div> 633 </Label> 634 </div> 635 <div className="flex items-center space-x-3 p-3 border rounded-lg"> 636 <RadioGroupItem value="spa" id="mode-spa" /> 637 <Label htmlFor="mode-spa" className="flex-1 cursor-pointer"> 638 <div> 639 <p className="font-medium">SPA Mode</p> 640 <p className="text-xs text-muted-foreground">Route all requests to a single file</p> 641 </div> 642 </Label> 643 </div> 644 {routingMode === 'spa' && ( 645 <div className="ml-7 space-y-2"> 646 <Label htmlFor="spa-file" className="text-sm">SPA File</Label> 647 <Input 648 id="spa-file" 649 value={spaFile} 650 onChange={(e) => setSpaFile(e.target.value)} 651 placeholder="index.html" 652 /> 653 </div> 654 )} 655 <div className="flex items-center space-x-3 p-3 border rounded-lg"> 656 <RadioGroupItem value="directory" id="mode-directory" /> 657 <Label htmlFor="mode-directory" className="flex-1 cursor-pointer"> 658 <div> 659 <p className="font-medium">Directory Listing</p> 660 <p className="text-xs text-muted-foreground">Show directory contents on 404</p> 661 </div> 662 </Label> 663 </div> 664 <div className="flex items-center space-x-3 p-3 border rounded-lg"> 665 <RadioGroupItem value="custom404" id="mode-custom404" /> 666 <Label htmlFor="mode-custom404" className="flex-1 cursor-pointer"> 667 <div> 668 <p className="font-medium">Custom 404 Page</p> 669 <p className="text-xs text-muted-foreground">Serve custom error page</p> 670 </div> 671 </Label> 672 </div> 673 {routingMode === 'custom404' && ( 674 <div className="ml-7 space-y-2"> 675 <Label htmlFor="404-file" className="text-sm">404 File</Label> 676 <Input 677 id="404-file" 678 value={custom404File} 679 onChange={(e) => setCustom404File(e.target.value)} 680 placeholder="404.html" 681 /> 682 </div> 683 )} 684 </RadioGroup> 685 </div> 686 687 {/* Index Files */} 688 <div className="space-y-3"> 689 <Label className={`text-sm font-medium ${routingMode === 'spa' ? 'text-muted-foreground' : ''}`}> 690 Index Files 691 {routingMode === 'spa' && ( 692 <span className="ml-2 text-xs">(disabled in SPA mode)</span> 693 )} 694 </Label> 695 <p className="text-xs text-muted-foreground">Files to try when serving a directory (in order)</p> 696 <div className="space-y-2"> 697 {indexFiles.map((file, idx) => ( 698 <div key={idx} className="flex items-center gap-2"> 699 <Input 700 value={file} 701 onChange={(e) => { 702 const newFiles = [...indexFiles] 703 newFiles[idx] = e.target.value 704 setIndexFiles(newFiles) 705 }} 706 placeholder="index.html" 707 disabled={routingMode === 'spa'} 708 /> 709 <Button 710 variant="outline" 711 size="sm" 712 onClick={() => { 713 setIndexFiles(indexFiles.filter((_, i) => i !== idx)) 714 }} 715 disabled={routingMode === 'spa'} 716 className="w-20" 717 > 718 Remove 719 </Button> 720 </div> 721 ))} 722 <div className="flex items-center gap-2"> 723 <Input 724 value={newIndexFile} 725 onChange={(e) => setNewIndexFile(e.target.value)} 726 placeholder="Add index file..." 727 onKeyDown={(e) => { 728 if (e.key === 'Enter' && newIndexFile.trim()) { 729 setIndexFiles([...indexFiles, newIndexFile.trim()]) 730 setNewIndexFile('') 731 } 732 }} 733 disabled={routingMode === 'spa'} 734 /> 735 <Button 736 variant="outline" 737 size="sm" 738 onClick={() => { 739 if (newIndexFile.trim()) { 740 setIndexFiles([...indexFiles, newIndexFile.trim()]) 741 setNewIndexFile('') 742 } 743 }} 744 disabled={routingMode === 'spa'} 745 className="w-20" 746 > 747 Add 748 </Button> 749 </div> 750 </div> 751 </div> 752 753 {/* Clean URLs */} 754 <div className="flex items-center space-x-3 p-3 border rounded-lg"> 755 <Checkbox 756 id="clean-urls" 757 checked={cleanUrls} 758 onCheckedChange={(checked) => setCleanUrls(!!checked)} 759 /> 760 <Label htmlFor="clean-urls" className="flex-1 cursor-pointer"> 761 <div> 762 <p className="font-medium">Clean URLs</p> 763 <p className="text-xs text-muted-foreground"> 764 Serve /about as /about.html or /about/index.html 765 </p> 766 </div> 767 </Label> 768 </div> 769 770 {/* CORS */} 771 <div className="space-y-3"> 772 <div className="flex items-center space-x-3 p-3 border rounded-lg"> 773 <Checkbox 774 id="cors-enabled" 775 checked={corsEnabled} 776 onCheckedChange={(checked) => setCorsEnabled(!!checked)} 777 /> 778 <Label htmlFor="cors-enabled" className="flex-1 cursor-pointer"> 779 <div> 780 <p className="font-medium">Enable CORS</p> 781 <p className="text-xs text-muted-foreground"> 782 Allow cross-origin requests 783 </p> 784 </div> 785 </Label> 786 </div> 787 {corsEnabled && ( 788 <div className="ml-7 space-y-2"> 789 <Label htmlFor="cors-origin" className="text-sm">Allowed Origin</Label> 790 <Input 791 id="cors-origin" 792 value={corsOrigin} 793 onChange={(e) => setCorsOrigin(e.target.value)} 794 placeholder="*" 795 /> 796 <p className="text-xs text-muted-foreground"> 797 Use * for all origins, or specify a domain like https://example.com 798 </p> 799 </div> 800 )} 801 </div> 802 </TabsContent> 803 </Tabs> 804 </div> 805 )} 806 <DialogFooter className="flex flex-col sm:flex-row sm:justify-between gap-2"> 807 <Button 808 variant="destructive" 809 onClick={handleDeleteSite} 810 disabled={isSavingConfig || isDeletingSite} 811 className="sm:mr-auto" 812 > 813 {isDeletingSite ? ( 814 <> 815 <Loader2 className="w-4 h-4 mr-2 animate-spin" /> 816 Deleting... 817 </> 818 ) : ( 819 <> 820 <Trash2 className="w-4 h-4 mr-2" /> 821 Delete Site 822 </> 823 )} 824 </Button> 825 <div className="flex flex-col sm:flex-row gap-2 w-full sm:w-auto"> 826 <Button 827 variant="outline" 828 onClick={() => setConfiguringSite(null)} 829 disabled={isSavingConfig || isDeletingSite} 830 className="w-full sm:w-auto" 831 > 832 Cancel 833 </Button> 834 <Button 835 onClick={handleSaveSiteConfig} 836 disabled={isSavingConfig || isDeletingSite} 837 className="w-full sm:w-auto" 838 > 839 {isSavingConfig ? ( 840 <> 841 <Loader2 className="w-4 h-4 mr-2 animate-spin" /> 842 Saving... 843 </> 844 ) : ( 845 'Save' 846 )} 847 </Button> 848 </div> 849 </DialogFooter> 850 </DialogContent> 851 </Dialog> 852 </div> 853 ) 854} 855 856const root = createRoot(document.getElementById('elysia')!) 857root.render( 858 <Layout className="gap-6"> 859 <Dashboard /> 860 </Layout> 861)