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 { 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)