Monorepo for wisp.place. A static site hosting service built on top of the AT Protocol. wisp.place

init work

+65
public/editor/components/TabSkeleton.tsx
··· 1 + import { 2 + Card, 3 + CardContent, 4 + CardDescription, 5 + CardHeader, 6 + CardTitle 7 + } from '@public/components/ui/card' 8 + 9 + // Shimmer animation for skeleton loading 10 + const Shimmer = () => ( 11 + <div className="animate-pulse"> 12 + <div className="h-4 bg-muted rounded w-3/4 mb-2"></div> 13 + <div className="h-4 bg-muted rounded w-1/2"></div> 14 + </div> 15 + ) 16 + 17 + const SkeletonLine = ({ className = '' }: { className?: string }) => ( 18 + <div className={`animate-pulse bg-muted rounded ${className}`}></div> 19 + ) 20 + 21 + export function TabSkeleton() { 22 + return ( 23 + <div className="space-y-4 min-h-[400px]"> 24 + <Card> 25 + <CardHeader> 26 + <div className="space-y-2"> 27 + <SkeletonLine className="h-6 w-1/3" /> 28 + <SkeletonLine className="h-4 w-2/3" /> 29 + </div> 30 + </CardHeader> 31 + <CardContent className="space-y-4"> 32 + {/* Skeleton content items */} 33 + <div className="p-4 border border-border rounded-lg"> 34 + <SkeletonLine className="h-5 w-1/2 mb-3" /> 35 + <SkeletonLine className="h-4 w-3/4 mb-2" /> 36 + <SkeletonLine className="h-4 w-2/3" /> 37 + </div> 38 + <div className="p-4 border border-border rounded-lg"> 39 + <SkeletonLine className="h-5 w-1/2 mb-3" /> 40 + <SkeletonLine className="h-4 w-3/4 mb-2" /> 41 + <SkeletonLine className="h-4 w-2/3" /> 42 + </div> 43 + <div className="p-4 border border-border rounded-lg"> 44 + <SkeletonLine className="h-5 w-1/2 mb-3" /> 45 + <SkeletonLine className="h-4 w-3/4 mb-2" /> 46 + <SkeletonLine className="h-4 w-2/3" /> 47 + </div> 48 + </CardContent> 49 + </Card> 50 + 51 + <Card> 52 + <CardHeader> 53 + <div className="space-y-2"> 54 + <SkeletonLine className="h-6 w-1/4" /> 55 + <SkeletonLine className="h-4 w-1/2" /> 56 + </div> 57 + </CardHeader> 58 + <CardContent className="space-y-3"> 59 + <SkeletonLine className="h-10 w-full" /> 60 + <SkeletonLine className="h-4 w-3/4" /> 61 + </CardContent> 62 + </Card> 63 + </div> 64 + ) 65 + }
+73 -1423
public/editor/editor.tsx
··· 2 2 import { createRoot } from 'react-dom/client' 3 3 import { Button } from '@public/components/ui/button' 4 4 import { 5 - Card, 6 - CardContent, 7 - CardDescription, 8 - CardHeader, 9 - CardTitle 10 - } from '@public/components/ui/card' 11 - import { Input } from '@public/components/ui/input' 12 - import { Label } from '@public/components/ui/label' 13 - import { 14 5 Tabs, 15 6 TabsContent, 16 7 TabsList, 17 8 TabsTrigger 18 9 } from '@public/components/ui/tabs' 19 - import { Badge } from '@public/components/ui/badge' 20 10 import { 21 11 Dialog, 22 12 DialogContent, ··· 25 15 DialogTitle, 26 16 DialogFooter 27 17 } from '@public/components/ui/dialog' 18 + import { Checkbox } from '@public/components/ui/checkbox' 19 + import { Label } from '@public/components/ui/label' 20 + import { Badge } from '@public/components/ui/badge' 28 21 import { 29 22 Globe, 30 - Upload, 31 - ExternalLink, 32 - CheckCircle2, 33 - XCircle, 34 - AlertCircle, 35 23 Loader2, 36 - Trash2, 37 - RefreshCw, 38 - Settings 24 + Trash2 39 25 } from 'lucide-react' 40 - import { RadioGroup, RadioGroupItem } from '@public/components/ui/radio-group' 41 - import { Checkbox } from '@public/components/ui/checkbox' 42 - import { CodeBlock } from '@public/components/ui/code-block' 43 - 44 26 import Layout from '@public/layouts' 45 - 46 - interface UserInfo { 47 - did: string 48 - handle: string 49 - } 50 - 51 - interface Site { 52 - did: string 53 - rkey: string 54 - display_name: string | null 55 - created_at: number 56 - updated_at: number 57 - } 58 - 59 - interface DomainInfo { 60 - type: 'wisp' | 'custom' 61 - domain: string 62 - verified?: boolean 63 - id?: string 64 - } 65 - 66 - interface SiteWithDomains extends Site { 67 - domains?: DomainInfo[] 68 - } 69 - 70 - interface CustomDomain { 71 - id: string 72 - domain: string 73 - did: string 74 - rkey: string 75 - verified: boolean 76 - last_verified_at: number | null 77 - created_at: number 78 - } 79 - 80 - interface WispDomain { 81 - domain: string 82 - rkey: string | null 83 - } 27 + import { useUserInfo } from './hooks/useUserInfo' 28 + import { useSiteData, type SiteWithDomains } from './hooks/useSiteData' 29 + import { useDomainData } from './hooks/useDomainData' 30 + import { SitesTab } from './tabs/SitesTab' 31 + import { DomainsTab } from './tabs/DomainsTab' 32 + import { UploadTab } from './tabs/UploadTab' 33 + import { CLITab } from './tabs/CLITab' 84 34 85 35 function Dashboard() { 86 - // User state 87 - const [userInfo, setUserInfo] = useState<UserInfo | null>(null) 88 - const [loading, setLoading] = useState(true) 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() 89 53 90 - // Sites state 91 - const [sites, setSites] = useState<SiteWithDomains[]>([]) 92 - const [sitesLoading, setSitesLoading] = useState(true) 93 - const [isSyncing, setIsSyncing] = useState(false) 94 - 95 - // Domains state 96 - const [wispDomain, setWispDomain] = useState<WispDomain | null>(null) 97 - const [customDomains, setCustomDomains] = useState<CustomDomain[]>([]) 98 - const [domainsLoading, setDomainsLoading] = useState(true) 99 - 100 - // Site configuration state 54 + // Site configuration modal state (shared across components) 101 55 const [configuringSite, setConfiguringSite] = useState<SiteWithDomains | null>(null) 102 56 const [selectedDomains, setSelectedDomains] = useState<Set<string>>(new Set()) 103 57 const [isSavingConfig, setIsSavingConfig] = useState(false) 104 58 const [isDeletingSite, setIsDeletingSite] = useState(false) 105 59 106 - // Upload state 107 - const [siteMode, setSiteMode] = useState<'existing' | 'new'>('existing') 108 - const [selectedSiteRkey, setSelectedSiteRkey] = useState<string>('') 109 - const [newSiteName, setNewSiteName] = useState('') 110 - const [selectedFiles, setSelectedFiles] = useState<FileList | null>(null) 111 - const [isUploading, setIsUploading] = useState(false) 112 - const [uploadProgress, setUploadProgress] = useState('') 113 - const [skippedFiles, setSkippedFiles] = useState<Array<{ name: string; reason: string }>>([]) 114 - const [uploadedCount, setUploadedCount] = useState(0) 115 - 116 - // Custom domain modal state 117 - const [addDomainModalOpen, setAddDomainModalOpen] = useState(false) 118 - const [customDomain, setCustomDomain] = useState('') 119 - const [isAddingDomain, setIsAddingDomain] = useState(false) 120 - const [verificationStatus, setVerificationStatus] = useState<{ 121 - [id: string]: 'idle' | 'verifying' | 'success' | 'error' 122 - }>({}) 123 - const [viewDomainDNS, setViewDomainDNS] = useState<string | null>(null) 124 - 125 - // Wisp domain claim state 126 - const [wispHandle, setWispHandle] = useState('') 127 - const [isClaimingWisp, setIsClaimingWisp] = useState(false) 128 - const [wispAvailability, setWispAvailability] = useState<{ 129 - available: boolean | null 130 - checking: boolean 131 - }>({ available: null, checking: false }) 132 - 133 - // Fetch user info on mount 60 + // Fetch initial data on mount 134 61 useEffect(() => { 135 62 fetchUserInfo() 136 63 fetchSites() 137 64 fetchDomains() 138 65 }, []) 139 66 140 - // Auto-switch to 'new' mode if no sites exist 141 - useEffect(() => { 142 - if (!sitesLoading && sites.length === 0 && siteMode === 'existing') { 143 - setSiteMode('new') 144 - } 145 - }, [sites, sitesLoading, siteMode]) 146 - 147 - const fetchUserInfo = async () => { 148 - try { 149 - const response = await fetch('/api/user/info') 150 - const data = await response.json() 151 - setUserInfo(data) 152 - } catch (err) { 153 - console.error('Failed to fetch user info:', err) 154 - } finally { 155 - setLoading(false) 156 - } 157 - } 158 - 159 - const fetchSites = async () => { 160 - try { 161 - const response = await fetch('/api/user/sites') 162 - const data = await response.json() 163 - const sitesData: Site[] = data.sites || [] 164 - 165 - // Fetch domain info for each site 166 - const sitesWithDomains = await Promise.all( 167 - sitesData.map(async (site) => { 168 - try { 169 - const domainsResponse = await fetch(`/api/user/site/${site.rkey}/domains`) 170 - const domainsData = await domainsResponse.json() 171 - return { 172 - ...site, 173 - domains: domainsData.domains || [] 174 - } 175 - } catch (err) { 176 - console.error(`Failed to fetch domains for site ${site.rkey}:`, err) 177 - return { 178 - ...site, 179 - domains: [] 180 - } 181 - } 182 - }) 183 - ) 184 - 185 - setSites(sitesWithDomains) 186 - } catch (err) { 187 - console.error('Failed to fetch sites:', err) 188 - } finally { 189 - setSitesLoading(false) 190 - } 191 - } 192 - 193 - const syncSites = async () => { 194 - setIsSyncing(true) 195 - try { 196 - const response = await fetch('/api/user/sync', { 197 - method: 'POST' 198 - }) 199 - const data = await response.json() 200 - if (data.success) { 201 - console.log(`Synced ${data.synced} sites from PDS`) 202 - // Refresh sites list 203 - await fetchSites() 204 - } 205 - } catch (err) { 206 - console.error('Failed to sync sites:', err) 207 - alert('Failed to sync sites from PDS') 208 - } finally { 209 - setIsSyncing(false) 210 - } 211 - } 212 - 213 - const fetchDomains = async () => { 214 - try { 215 - const response = await fetch('/api/user/domains') 216 - const data = await response.json() 217 - setWispDomain(data.wispDomain) 218 - setCustomDomains(data.customDomains || []) 219 - } catch (err) { 220 - console.error('Failed to fetch domains:', err) 221 - } finally { 222 - setDomainsLoading(false) 223 - } 224 - } 225 - 226 - const getSiteUrl = (site: SiteWithDomains) => { 227 - // Use the first mapped domain if available 228 - if (site.domains && site.domains.length > 0) { 229 - return `https://${site.domains[0].domain}` 230 - } 231 - 232 - // Default fallback URL - use handle instead of DID 233 - if (!userInfo) return '#' 234 - return `https://sites.wisp.place/${userInfo.handle}/${site.rkey}` 235 - } 236 - 237 - const getSiteDomainName = (site: SiteWithDomains) => { 238 - // Return the first domain if available 239 - if (site.domains && site.domains.length > 0) { 240 - return site.domains[0].domain 241 - } 242 - 243 - // Use handle instead of DID for display 244 - if (!userInfo) return `sites.wisp.place/.../${site.rkey}` 245 - return `sites.wisp.place/${userInfo.handle}/${site.rkey}` 246 - } 247 - 248 - const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => { 249 - if (e.target.files && e.target.files.length > 0) { 250 - setSelectedFiles(e.target.files) 251 - } 252 - } 253 - 254 - const handleUpload = async () => { 255 - const siteName = siteMode === 'existing' ? selectedSiteRkey : newSiteName 256 - 257 - if (!siteName) { 258 - alert(siteMode === 'existing' ? 'Please select a site' : 'Please enter a site name') 259 - return 260 - } 261 - 262 - setIsUploading(true) 263 - setUploadProgress('Preparing files...') 264 - 265 - try { 266 - const formData = new FormData() 267 - formData.append('siteName', siteName) 268 - 269 - if (selectedFiles) { 270 - for (let i = 0; i < selectedFiles.length; i++) { 271 - formData.append('files', selectedFiles[i]) 272 - } 273 - } 274 - 275 - setUploadProgress('Uploading to AT Protocol...') 276 - const response = await fetch('/wisp/upload-files', { 277 - method: 'POST', 278 - body: formData 279 - }) 280 - 281 - const data = await response.json() 282 - if (data.success) { 283 - setUploadProgress('Upload complete!') 284 - setSkippedFiles(data.skippedFiles || []) 285 - setUploadedCount(data.uploadedCount || data.fileCount || 0) 286 - setSelectedSiteRkey('') 287 - setNewSiteName('') 288 - setSelectedFiles(null) 289 - 290 - // Refresh sites list 291 - await fetchSites() 292 - 293 - // Reset form - give more time if there are skipped files 294 - const resetDelay = data.skippedFiles && data.skippedFiles.length > 0 ? 4000 : 1500 295 - setTimeout(() => { 296 - setUploadProgress('') 297 - setSkippedFiles([]) 298 - setUploadedCount(0) 299 - setIsUploading(false) 300 - }, resetDelay) 301 - } else { 302 - throw new Error(data.error || 'Upload failed') 303 - } 304 - } catch (err) { 305 - console.error('Upload error:', err) 306 - alert( 307 - `Upload failed: ${err instanceof Error ? err.message : 'Unknown error'}` 308 - ) 309 - setIsUploading(false) 310 - setUploadProgress('') 311 - } 312 - } 313 - 314 - const handleAddCustomDomain = async () => { 315 - if (!customDomain) { 316 - alert('Please enter a domain') 317 - return 318 - } 319 - 320 - setIsAddingDomain(true) 321 - try { 322 - const response = await fetch('/api/domain/custom/add', { 323 - method: 'POST', 324 - headers: { 'Content-Type': 'application/json' }, 325 - body: JSON.stringify({ domain: customDomain }) 326 - }) 327 - 328 - const data = await response.json() 329 - if (data.success) { 330 - setCustomDomain('') 331 - setAddDomainModalOpen(false) 332 - await fetchDomains() 333 - 334 - // Automatically show DNS configuration for the newly added domain 335 - setViewDomainDNS(data.id) 336 - } else { 337 - throw new Error(data.error || 'Failed to add domain') 338 - } 339 - } catch (err) { 340 - console.error('Add domain error:', err) 341 - alert( 342 - `Failed to add domain: ${err instanceof Error ? err.message : 'Unknown error'}` 343 - ) 344 - } finally { 345 - setIsAddingDomain(false) 346 - } 347 - } 348 - 349 - const handleVerifyDomain = async (id: string) => { 350 - setVerificationStatus({ ...verificationStatus, [id]: 'verifying' }) 351 - 352 - try { 353 - const response = await fetch('/api/domain/custom/verify', { 354 - method: 'POST', 355 - headers: { 'Content-Type': 'application/json' }, 356 - body: JSON.stringify({ id }) 357 - }) 358 - 359 - const data = await response.json() 360 - if (data.success && data.verified) { 361 - setVerificationStatus({ ...verificationStatus, [id]: 'success' }) 362 - await fetchDomains() 363 - } else { 364 - setVerificationStatus({ ...verificationStatus, [id]: 'error' }) 365 - if (data.error) { 366 - alert(`Verification failed: ${data.error}`) 367 - } 368 - } 369 - } catch (err) { 370 - console.error('Verify domain error:', err) 371 - setVerificationStatus({ ...verificationStatus, [id]: 'error' }) 372 - alert( 373 - `Verification failed: ${err instanceof Error ? err.message : 'Unknown error'}` 374 - ) 375 - } 376 - } 377 - 378 - const handleDeleteCustomDomain = async (id: string) => { 379 - if (!confirm('Are you sure you want to remove this custom domain?')) { 380 - return 381 - } 382 - 383 - try { 384 - const response = await fetch(`/api/domain/custom/${id}`, { 385 - method: 'DELETE' 386 - }) 387 - 388 - const data = await response.json() 389 - if (data.success) { 390 - await fetchDomains() 391 - } else { 392 - throw new Error('Failed to delete domain') 393 - } 394 - } catch (err) { 395 - console.error('Delete domain error:', err) 396 - alert( 397 - `Failed to delete domain: ${err instanceof Error ? err.message : 'Unknown error'}` 398 - ) 399 - } 400 - } 401 - 67 + // Handle site configuration modal 402 68 const handleConfigureSite = (site: SiteWithDomains) => { 403 69 setConfiguringSite(site) 404 70 ··· 429 95 430 96 // Handle wisp domain mapping 431 97 if (shouldMapWisp && !isCurrentlyMappedToWisp) { 432 - // Map to wisp domain 433 - const response = await fetch('/api/domain/wisp/map-site', { 434 - method: 'POST', 435 - headers: { 'Content-Type': 'application/json' }, 436 - body: JSON.stringify({ siteRkey: configuringSite.rkey }) 437 - }) 438 - const data = await response.json() 439 - if (!data.success) throw new Error('Failed to map wisp domain') 98 + await mapWispDomain(configuringSite.rkey) 440 99 } else if (!shouldMapWisp && isCurrentlyMappedToWisp) { 441 - // Unmap from wisp domain 442 - await fetch('/api/domain/wisp/map-site', { 443 - method: 'POST', 444 - headers: { 'Content-Type': 'application/json' }, 445 - body: JSON.stringify({ siteRkey: null }) 446 - }) 100 + await mapWispDomain(null) 447 101 } 448 102 449 103 // Handle custom domain mappings ··· 455 109 // Unmap domains that are no longer selected 456 110 for (const domain of currentlyMappedCustomDomains) { 457 111 if (!selectedCustomDomainIds.includes(domain.id)) { 458 - await fetch(`/api/domain/custom/${domain.id}/map-site`, { 459 - method: 'POST', 460 - headers: { 'Content-Type': 'application/json' }, 461 - body: JSON.stringify({ siteRkey: null }) 462 - }) 112 + await mapCustomDomain(domain.id, null) 463 113 } 464 114 } 465 115 ··· 467 117 for (const domainId of selectedCustomDomainIds) { 468 118 const isAlreadyMapped = currentlyMappedCustomDomains.some(d => d.id === domainId) 469 119 if (!isAlreadyMapped) { 470 - const response = await fetch(`/api/domain/custom/${domainId}/map-site`, { 471 - method: 'POST', 472 - headers: { 'Content-Type': 'application/json' }, 473 - body: JSON.stringify({ siteRkey: configuringSite.rkey }) 474 - }) 475 - const data = await response.json() 476 - if (!data.success) throw new Error(`Failed to map custom domain ${domainId}`) 120 + await mapCustomDomain(domainId, configuringSite.rkey) 477 121 } 478 122 } 479 123 ··· 499 143 } 500 144 501 145 setIsDeletingSite(true) 502 - try { 503 - const response = await fetch(`/api/site/${configuringSite.rkey}`, { 504 - method: 'DELETE' 505 - }) 506 - 507 - const data = await response.json() 508 - if (data.success) { 509 - // Refresh sites list 510 - await fetchSites() 511 - // Refresh domains in case this site was mapped 512 - await fetchDomains() 513 - setConfiguringSite(null) 514 - } else { 515 - throw new Error(data.error || 'Failed to delete site') 516 - } 517 - } catch (err) { 518 - console.error('Delete site error:', err) 519 - alert( 520 - `Failed to delete site: ${err instanceof Error ? err.message : 'Unknown error'}` 521 - ) 522 - } finally { 523 - setIsDeletingSite(false) 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) 524 151 } 152 + setIsDeletingSite(false) 525 153 } 526 154 527 - const checkWispAvailability = async (handle: string) => { 528 - const trimmedHandle = handle.trim().toLowerCase() 529 - if (!trimmedHandle) { 530 - setWispAvailability({ available: null, checking: false }) 531 - return 532 - } 533 - 534 - setWispAvailability({ available: null, checking: true }) 535 - try { 536 - const response = await fetch(`/api/domain/check?handle=${encodeURIComponent(trimmedHandle)}`) 537 - const data = await response.json() 538 - setWispAvailability({ available: data.available, checking: false }) 539 - } catch (err) { 540 - console.error('Check availability error:', err) 541 - setWispAvailability({ available: false, checking: false }) 542 - } 543 - } 544 - 545 - const handleClaimWispDomain = async () => { 546 - const trimmedHandle = wispHandle.trim().toLowerCase() 547 - if (!trimmedHandle) { 548 - alert('Please enter a handle') 549 - return 550 - } 551 - 552 - setIsClaimingWisp(true) 553 - try { 554 - const response = await fetch('/api/domain/claim', { 555 - method: 'POST', 556 - headers: { 'Content-Type': 'application/json' }, 557 - body: JSON.stringify({ handle: trimmedHandle }) 558 - }) 559 - 560 - const data = await response.json() 561 - if (data.success) { 562 - setWispHandle('') 563 - setWispAvailability({ available: null, checking: false }) 564 - await fetchDomains() 565 - } else { 566 - throw new Error(data.error || 'Failed to claim domain') 567 - } 568 - } catch (err) { 569 - console.error('Claim domain error:', err) 570 - const errorMessage = err instanceof Error ? err.message : 'Unknown error' 571 - 572 - // Handle "Already claimed" error more gracefully 573 - if (errorMessage.includes('Already claimed')) { 574 - alert('You have already claimed a wisp.place subdomain. Please refresh the page.') 575 - await fetchDomains() 576 - } else { 577 - alert(`Failed to claim domain: ${errorMessage}`) 578 - } 579 - } finally { 580 - setIsClaimingWisp(false) 581 - } 155 + const handleUploadComplete = async () => { 156 + await fetchSites() 582 157 } 583 158 584 159 if (loading) { ··· 627 202 </TabsList> 628 203 629 204 {/* Sites Tab */} 630 - <TabsContent value="sites" className="space-y-4 min-h-[400px]"> 631 - <Card> 632 - <CardHeader> 633 - <div className="flex items-center justify-between"> 634 - <div> 635 - <CardTitle>Your Sites</CardTitle> 636 - <CardDescription> 637 - View and manage all your deployed sites 638 - </CardDescription> 639 - </div> 640 - <Button 641 - variant="outline" 642 - size="sm" 643 - onClick={syncSites} 644 - disabled={isSyncing || sitesLoading} 645 - > 646 - <RefreshCw 647 - className={`w-4 h-4 mr-2 ${isSyncing ? 'animate-spin' : ''}`} 648 - /> 649 - Sync from PDS 650 - </Button> 651 - </div> 652 - </CardHeader> 653 - <CardContent className="space-y-4"> 654 - {sitesLoading ? ( 655 - <div className="flex items-center justify-center py-8"> 656 - <Loader2 className="w-6 h-6 animate-spin text-muted-foreground" /> 657 - </div> 658 - ) : sites.length === 0 ? ( 659 - <div className="text-center py-8 text-muted-foreground"> 660 - <p>No sites yet. Upload your first site!</p> 661 - </div> 662 - ) : ( 663 - sites.map((site) => ( 664 - <div 665 - key={`${site.did}-${site.rkey}`} 666 - className="flex items-center justify-between p-4 border border-border rounded-lg hover:bg-muted/50 transition-colors" 667 - > 668 - <div className="flex-1"> 669 - <div className="flex items-center gap-3 mb-2"> 670 - <h3 className="font-semibold text-lg"> 671 - {site.display_name || site.rkey} 672 - </h3> 673 - <Badge 674 - variant="secondary" 675 - className="text-xs" 676 - > 677 - active 678 - </Badge> 679 - </div> 680 - 681 - {/* Display all mapped domains */} 682 - {site.domains && site.domains.length > 0 ? ( 683 - <div className="space-y-1"> 684 - {site.domains.map((domainInfo, idx) => ( 685 - <div key={`${domainInfo.domain}-${idx}`} className="flex items-center gap-2"> 686 - <a 687 - href={`https://${domainInfo.domain}`} 688 - target="_blank" 689 - rel="noopener noreferrer" 690 - className="text-sm text-accent hover:text-accent/80 flex items-center gap-1" 691 - > 692 - <Globe className="w-3 h-3" /> 693 - {domainInfo.domain} 694 - <ExternalLink className="w-3 h-3" /> 695 - </a> 696 - <Badge 697 - variant={domainInfo.type === 'wisp' ? 'default' : 'outline'} 698 - className="text-xs" 699 - > 700 - {domainInfo.type} 701 - </Badge> 702 - {domainInfo.type === 'custom' && ( 703 - <Badge 704 - variant={domainInfo.verified ? 'default' : 'secondary'} 705 - className="text-xs" 706 - > 707 - {domainInfo.verified ? ( 708 - <> 709 - <CheckCircle2 className="w-3 h-3 mr-1" /> 710 - verified 711 - </> 712 - ) : ( 713 - <> 714 - <AlertCircle className="w-3 h-3 mr-1" /> 715 - pending 716 - </> 717 - )} 718 - </Badge> 719 - )} 720 - </div> 721 - ))} 722 - </div> 723 - ) : ( 724 - <a 725 - href={getSiteUrl(site)} 726 - target="_blank" 727 - rel="noopener noreferrer" 728 - className="text-sm text-muted-foreground hover:text-accent flex items-center gap-1" 729 - > 730 - {getSiteDomainName(site)} 731 - <ExternalLink className="w-3 h-3" /> 732 - </a> 733 - )} 734 - </div> 735 - <Button 736 - variant="outline" 737 - size="sm" 738 - onClick={() => handleConfigureSite(site)} 739 - > 740 - <Settings className="w-4 h-4 mr-2" /> 741 - Configure 742 - </Button> 743 - </div> 744 - )) 745 - )} 746 - </CardContent> 747 - </Card> 748 - 749 - <div className="p-4 bg-muted/30 rounded-lg border-l-4 border-yellow-500/50"> 750 - <div className="flex items-start gap-2"> 751 - <AlertCircle className="w-4 h-4 text-yellow-600 dark:text-yellow-400 mt-0.5 shrink-0" /> 752 - <div className="flex-1 space-y-1"> 753 - <p className="text-xs font-semibold text-yellow-600 dark:text-yellow-400"> 754 - Note about sites.wisp.place URLs 755 - </p> 756 - <p className="text-xs text-muted-foreground"> 757 - Complex sites hosted on <code className="px-1 py-0.5 bg-background rounded text-xs">sites.wisp.place</code> may have broken assets if they use absolute paths (e.g., <code className="px-1 py-0.5 bg-background rounded text-xs">/folder/script.js</code>) in CSS or JavaScript files. While HTML paths are automatically rewritten, CSS and JS files are served as-is. For best results, use a wisp.place subdomain or custom domain, or ensure your site uses relative paths. 758 - </p> 759 - </div> 760 - </div> 761 - </div> 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 + /> 762 214 </TabsContent> 763 215 764 216 {/* Domains Tab */} 765 - <TabsContent value="domains" className="space-y-4 min-h-[400px]"> 766 - <Card> 767 - <CardHeader> 768 - <CardTitle>wisp.place Subdomain</CardTitle> 769 - <CardDescription> 770 - Your free subdomain on the wisp.place network 771 - </CardDescription> 772 - </CardHeader> 773 - <CardContent> 774 - {domainsLoading ? ( 775 - <div className="flex items-center justify-center py-4"> 776 - <Loader2 className="w-6 h-6 animate-spin text-muted-foreground" /> 777 - </div> 778 - ) : wispDomain ? ( 779 - <> 780 - <div className="flex flex-col gap-2 p-4 bg-muted/50 rounded-lg"> 781 - <div className="flex items-center gap-2"> 782 - <CheckCircle2 className="w-5 h-5 text-green-500" /> 783 - <span className="font-mono text-lg"> 784 - {wispDomain.domain} 785 - </span> 786 - </div> 787 - {wispDomain.rkey && ( 788 - <p className="text-xs text-muted-foreground ml-7"> 789 - → Mapped to site: {wispDomain.rkey} 790 - </p> 791 - )} 792 - </div> 793 - <p className="text-sm text-muted-foreground mt-3"> 794 - {wispDomain.rkey 795 - ? 'This domain is mapped to a specific site' 796 - : 'This domain is not mapped to any site yet. Configure it from the Sites tab.'} 797 - </p> 798 - </> 799 - ) : ( 800 - <div className="space-y-4"> 801 - <div className="p-4 bg-muted/30 rounded-lg"> 802 - <p className="text-sm text-muted-foreground mb-4"> 803 - Claim your free wisp.place subdomain 804 - </p> 805 - <div className="space-y-3"> 806 - <div className="space-y-2"> 807 - <Label htmlFor="wisp-handle">Choose your handle</Label> 808 - <div className="flex gap-2"> 809 - <div className="flex-1 relative"> 810 - <Input 811 - id="wisp-handle" 812 - placeholder="mysite" 813 - value={wispHandle} 814 - onChange={(e) => { 815 - setWispHandle(e.target.value) 816 - if (e.target.value.trim()) { 817 - checkWispAvailability(e.target.value) 818 - } else { 819 - setWispAvailability({ available: null, checking: false }) 820 - } 821 - }} 822 - disabled={isClaimingWisp} 823 - className="pr-24" 824 - /> 825 - <span className="absolute right-3 top-1/2 -translate-y-1/2 text-sm text-muted-foreground"> 826 - .wisp.place 827 - </span> 828 - </div> 829 - </div> 830 - {wispAvailability.checking && ( 831 - <p className="text-xs text-muted-foreground flex items-center gap-1"> 832 - <Loader2 className="w-3 h-3 animate-spin" /> 833 - Checking availability... 834 - </p> 835 - )} 836 - {!wispAvailability.checking && wispAvailability.available === true && ( 837 - <p className="text-xs text-green-600 flex items-center gap-1"> 838 - <CheckCircle2 className="w-3 h-3" /> 839 - Available 840 - </p> 841 - )} 842 - {!wispAvailability.checking && wispAvailability.available === false && ( 843 - <p className="text-xs text-red-600 flex items-center gap-1"> 844 - <XCircle className="w-3 h-3" /> 845 - Not available 846 - </p> 847 - )} 848 - </div> 849 - <Button 850 - onClick={handleClaimWispDomain} 851 - disabled={!wispHandle.trim() || isClaimingWisp || wispAvailability.available !== true} 852 - className="w-full" 853 - > 854 - {isClaimingWisp ? ( 855 - <> 856 - <Loader2 className="w-4 h-4 mr-2 animate-spin" /> 857 - Claiming... 858 - </> 859 - ) : ( 860 - 'Claim Subdomain' 861 - )} 862 - </Button> 863 - </div> 864 - </div> 865 - </div> 866 - )} 867 - </CardContent> 868 - </Card> 869 - 870 - <Card> 871 - <CardHeader> 872 - <CardTitle>Custom Domains</CardTitle> 873 - <CardDescription> 874 - Bring your own domain with DNS verification 875 - </CardDescription> 876 - </CardHeader> 877 - <CardContent className="space-y-4"> 878 - <Button 879 - onClick={() => setAddDomainModalOpen(true)} 880 - className="w-full" 881 - > 882 - Add Custom Domain 883 - </Button> 884 - 885 - {domainsLoading ? ( 886 - <div className="flex items-center justify-center py-4"> 887 - <Loader2 className="w-6 h-6 animate-spin text-muted-foreground" /> 888 - </div> 889 - ) : customDomains.length === 0 ? ( 890 - <div className="text-center py-4 text-muted-foreground text-sm"> 891 - No custom domains added yet 892 - </div> 893 - ) : ( 894 - <div className="space-y-2"> 895 - {customDomains.map((domain) => ( 896 - <div 897 - key={domain.id} 898 - className="flex items-center justify-between p-3 border border-border rounded-lg" 899 - > 900 - <div className="flex flex-col gap-1 flex-1"> 901 - <div className="flex items-center gap-2"> 902 - {domain.verified ? ( 903 - <CheckCircle2 className="w-4 h-4 text-green-500" /> 904 - ) : ( 905 - <XCircle className="w-4 h-4 text-red-500" /> 906 - )} 907 - <span className="font-mono"> 908 - {domain.domain} 909 - </span> 910 - </div> 911 - {domain.rkey && domain.rkey !== 'self' && ( 912 - <p className="text-xs text-muted-foreground ml-6"> 913 - → Mapped to site: {domain.rkey} 914 - </p> 915 - )} 916 - </div> 917 - <div className="flex items-center gap-2"> 918 - <Button 919 - variant="outline" 920 - size="sm" 921 - onClick={() => 922 - setViewDomainDNS(domain.id) 923 - } 924 - > 925 - View DNS 926 - </Button> 927 - {domain.verified ? ( 928 - <Badge variant="secondary"> 929 - Verified 930 - </Badge> 931 - ) : ( 932 - <Button 933 - variant="outline" 934 - size="sm" 935 - onClick={() => 936 - handleVerifyDomain(domain.id) 937 - } 938 - disabled={ 939 - verificationStatus[ 940 - domain.id 941 - ] === 'verifying' 942 - } 943 - > 944 - {verificationStatus[ 945 - domain.id 946 - ] === 'verifying' ? ( 947 - <> 948 - <Loader2 className="w-3 h-3 mr-1 animate-spin" /> 949 - Verifying... 950 - </> 951 - ) : ( 952 - 'Verify DNS' 953 - )} 954 - </Button> 955 - )} 956 - <Button 957 - variant="ghost" 958 - size="sm" 959 - onClick={() => 960 - handleDeleteCustomDomain( 961 - domain.id 962 - ) 963 - } 964 - > 965 - <Trash2 className="w-4 h-4" /> 966 - </Button> 967 - </div> 968 - </div> 969 - ))} 970 - </div> 971 - )} 972 - </CardContent> 973 - </Card> 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 + /> 974 230 </TabsContent> 975 231 976 232 {/* Upload Tab */} 977 - <TabsContent value="upload" className="space-y-4 min-h-[400px]"> 978 - <Card> 979 - <CardHeader> 980 - <CardTitle>Upload Site</CardTitle> 981 - <CardDescription> 982 - Deploy a new site from a folder or Git repository 983 - </CardDescription> 984 - </CardHeader> 985 - <CardContent className="space-y-6"> 986 - <div className="space-y-4"> 987 - <div className="p-4 bg-muted/50 rounded-lg"> 988 - <RadioGroup 989 - value={siteMode} 990 - onValueChange={(value) => setSiteMode(value as 'existing' | 'new')} 991 - disabled={isUploading} 992 - > 993 - <div className="flex items-center space-x-2"> 994 - <RadioGroupItem value="existing" id="existing" /> 995 - <Label htmlFor="existing" className="cursor-pointer"> 996 - Update existing site 997 - </Label> 998 - </div> 999 - <div className="flex items-center space-x-2"> 1000 - <RadioGroupItem value="new" id="new" /> 1001 - <Label htmlFor="new" className="cursor-pointer"> 1002 - Create new site 1003 - </Label> 1004 - </div> 1005 - </RadioGroup> 1006 - </div> 1007 - 1008 - {siteMode === 'existing' ? ( 1009 - <div className="space-y-2"> 1010 - <Label htmlFor="site-select">Select Site</Label> 1011 - {sitesLoading ? ( 1012 - <div className="flex items-center justify-center py-4"> 1013 - <Loader2 className="w-5 h-5 animate-spin text-muted-foreground" /> 1014 - </div> 1015 - ) : sites.length === 0 ? ( 1016 - <div className="p-4 border border-dashed rounded-lg text-center text-sm text-muted-foreground"> 1017 - No sites available. Create a new site instead. 1018 - </div> 1019 - ) : ( 1020 - <select 1021 - id="site-select" 1022 - className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50" 1023 - value={selectedSiteRkey} 1024 - onChange={(e) => setSelectedSiteRkey(e.target.value)} 1025 - disabled={isUploading} 1026 - > 1027 - <option value="">Select a site...</option> 1028 - {sites.map((site) => ( 1029 - <option key={site.rkey} value={site.rkey}> 1030 - {site.display_name || site.rkey} 1031 - </option> 1032 - ))} 1033 - </select> 1034 - )} 1035 - </div> 1036 - ) : ( 1037 - <div className="space-y-2"> 1038 - <Label htmlFor="new-site-name">New Site Name</Label> 1039 - <Input 1040 - id="new-site-name" 1041 - placeholder="my-awesome-site" 1042 - value={newSiteName} 1043 - onChange={(e) => setNewSiteName(e.target.value)} 1044 - disabled={isUploading} 1045 - /> 1046 - </div> 1047 - )} 1048 - 1049 - <p className="text-xs text-muted-foreground"> 1050 - File limits: 100MB per file, 300MB total 1051 - </p> 1052 - </div> 1053 - 1054 - <div className="grid md:grid-cols-2 gap-4"> 1055 - <Card className="border-2 border-dashed hover:border-accent transition-colors cursor-pointer"> 1056 - <CardContent className="flex flex-col items-center justify-center p-8 text-center"> 1057 - <Upload className="w-12 h-12 text-muted-foreground mb-4" /> 1058 - <h3 className="font-semibold mb-2"> 1059 - Upload Folder 1060 - </h3> 1061 - <p className="text-sm text-muted-foreground mb-4"> 1062 - Drag and drop or click to upload your 1063 - static site files 1064 - </p> 1065 - <input 1066 - type="file" 1067 - id="file-upload" 1068 - multiple 1069 - onChange={handleFileSelect} 1070 - className="hidden" 1071 - {...(({ webkitdirectory: '', directory: '' } as any))} 1072 - disabled={isUploading} 1073 - /> 1074 - <label htmlFor="file-upload"> 1075 - <Button 1076 - variant="outline" 1077 - type="button" 1078 - onClick={() => 1079 - document 1080 - .getElementById('file-upload') 1081 - ?.click() 1082 - } 1083 - disabled={isUploading} 1084 - > 1085 - Choose Folder 1086 - </Button> 1087 - </label> 1088 - {selectedFiles && selectedFiles.length > 0 && ( 1089 - <p className="text-sm text-muted-foreground mt-3"> 1090 - {selectedFiles.length} files selected 1091 - </p> 1092 - )} 1093 - </CardContent> 1094 - </Card> 1095 - 1096 - <Card className="border-2 border-dashed opacity-50"> 1097 - <CardContent className="flex flex-col items-center justify-center p-8 text-center"> 1098 - <Globe className="w-12 h-12 text-muted-foreground mb-4" /> 1099 - <h3 className="font-semibold mb-2"> 1100 - Connect Git Repository 1101 - </h3> 1102 - <p className="text-sm text-muted-foreground mb-4"> 1103 - Link your GitHub, GitLab, or any Git 1104 - repository 1105 - </p> 1106 - <Badge variant="secondary">Coming soon!</Badge> 1107 - </CardContent> 1108 - </Card> 1109 - </div> 1110 - 1111 - {uploadProgress && ( 1112 - <div className="space-y-3"> 1113 - <div className="p-4 bg-muted rounded-lg"> 1114 - <div className="flex items-center gap-2"> 1115 - <Loader2 className="w-4 h-4 animate-spin" /> 1116 - <span className="text-sm">{uploadProgress}</span> 1117 - </div> 1118 - </div> 1119 - 1120 - {skippedFiles.length > 0 && ( 1121 - <div className="p-4 bg-yellow-500/10 border border-yellow-500/20 rounded-lg"> 1122 - <div className="flex items-start gap-2 text-yellow-600 dark:text-yellow-400 mb-2"> 1123 - <AlertCircle className="w-4 h-4 mt-0.5 shrink-0" /> 1124 - <div className="flex-1"> 1125 - <span className="font-medium"> 1126 - {skippedFiles.length} file{skippedFiles.length > 1 ? 's' : ''} skipped 1127 - </span> 1128 - {uploadedCount > 0 && ( 1129 - <span className="text-sm ml-2"> 1130 - ({uploadedCount} uploaded successfully) 1131 - </span> 1132 - )} 1133 - </div> 1134 - </div> 1135 - <div className="ml-6 space-y-1 max-h-32 overflow-y-auto"> 1136 - {skippedFiles.slice(0, 5).map((file, idx) => ( 1137 - <div key={idx} className="text-xs"> 1138 - <span className="font-mono">{file.name}</span> 1139 - <span className="text-muted-foreground"> - {file.reason}</span> 1140 - </div> 1141 - ))} 1142 - {skippedFiles.length > 5 && ( 1143 - <div className="text-xs text-muted-foreground"> 1144 - ...and {skippedFiles.length - 5} more 1145 - </div> 1146 - )} 1147 - </div> 1148 - </div> 1149 - )} 1150 - </div> 1151 - )} 1152 - 1153 - <Button 1154 - onClick={handleUpload} 1155 - className="w-full" 1156 - disabled={ 1157 - (siteMode === 'existing' ? !selectedSiteRkey : !newSiteName) || 1158 - isUploading || 1159 - (siteMode === 'existing' && (!selectedFiles || selectedFiles.length === 0)) 1160 - } 1161 - > 1162 - {isUploading ? ( 1163 - <> 1164 - <Loader2 className="w-4 h-4 mr-2 animate-spin" /> 1165 - Uploading... 1166 - </> 1167 - ) : ( 1168 - <> 1169 - {siteMode === 'existing' ? ( 1170 - 'Update Site' 1171 - ) : ( 1172 - selectedFiles && selectedFiles.length > 0 1173 - ? 'Upload & Deploy' 1174 - : 'Create Empty Site' 1175 - )} 1176 - </> 1177 - )} 1178 - </Button> 1179 - </CardContent> 1180 - </Card> 233 + <TabsContent value="upload"> 234 + <UploadTab 235 + sites={sites} 236 + sitesLoading={sitesLoading} 237 + onUploadComplete={handleUploadComplete} 238 + /> 1181 239 </TabsContent> 1182 240 1183 241 {/* CLI Tab */} 1184 - <TabsContent value="cli" className="space-y-4 min-h-[400px]"> 1185 - <Card> 1186 - <CardHeader> 1187 - <div className="flex items-center gap-2 mb-2"> 1188 - <CardTitle>Wisp CLI Tool</CardTitle> 1189 - <Badge variant="secondary" className="text-xs">v0.1.0</Badge> 1190 - <Badge variant="outline" className="text-xs">Alpha</Badge> 1191 - </div> 1192 - <CardDescription> 1193 - Deploy static sites directly from your terminal 1194 - </CardDescription> 1195 - </CardHeader> 1196 - <CardContent className="space-y-6"> 1197 - <div className="prose prose-sm max-w-none dark:prose-invert"> 1198 - <p className="text-sm text-muted-foreground"> 1199 - The Wisp CLI is a command-line tool for deploying static websites directly to your AT Protocol account. 1200 - Authenticate with app password or OAuth and deploy from CI/CD pipelines. 1201 - </p> 1202 - </div> 1203 - 1204 - <div className="space-y-3"> 1205 - <h3 className="text-sm font-semibold">Download CLI</h3> 1206 - <div className="grid gap-2"> 1207 - <div className="p-3 bg-muted/50 hover:bg-muted rounded-lg transition-colors border border-border"> 1208 - <a 1209 - href="https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-macos-arm64" 1210 - target="_blank" 1211 - rel="noopener noreferrer" 1212 - className="flex items-center justify-between mb-2" 1213 - > 1214 - <span className="font-mono text-sm">macOS (Apple Silicon)</span> 1215 - <ExternalLink className="w-4 h-4 text-muted-foreground" /> 1216 - </a> 1217 - <div className="text-xs text-muted-foreground"> 1218 - <span className="font-mono">SHA256: 637e325d9668ca745e01493d80dfc72447ef0a889b313e28913ca65c94c7aaae</span> 1219 - </div> 1220 - </div> 1221 - <div className="p-3 bg-muted/50 hover:bg-muted rounded-lg transition-colors border border-border"> 1222 - <a 1223 - href="https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-aarch64-linux" 1224 - target="_blank" 1225 - rel="noopener noreferrer" 1226 - className="flex items-center justify-between mb-2" 1227 - > 1228 - <span className="font-mono text-sm">Linux (ARM64)</span> 1229 - <ExternalLink className="w-4 h-4 text-muted-foreground" /> 1230 - </a> 1231 - <div className="text-xs text-muted-foreground"> 1232 - <span className="font-mono">SHA256: 01561656b64826f95b39f13c65c97da8bcc63ecd9f4d7e4e369c8ba8c903c22a</span> 1233 - </div> 1234 - </div> 1235 - <div className="p-3 bg-muted/50 hover:bg-muted rounded-lg transition-colors border border-border"> 1236 - <a 1237 - href="https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-x86_64-linux" 1238 - target="_blank" 1239 - rel="noopener noreferrer" 1240 - className="flex items-center justify-between mb-2" 1241 - > 1242 - <span className="font-mono text-sm">Linux (x86_64)</span> 1243 - <ExternalLink className="w-4 h-4 text-muted-foreground" /> 1244 - </a> 1245 - <div className="text-xs text-muted-foreground"> 1246 - <span className="font-mono">SHA256: 1ff485b9bcf89bc5721a862863c4843cf4530cbcd2489cf200cb24a44f7865a2</span> 1247 - </div> 1248 - </div> 1249 - </div> 1250 - </div> 1251 - 1252 - <div className="space-y-3"> 1253 - <h3 className="text-sm font-semibold">Basic Usage</h3> 1254 - <CodeBlock 1255 - code={`# Download and make executable 1256 - curl -O https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-macos-arm64 1257 - chmod +x wisp-cli-macos-arm64 1258 - 1259 - # Deploy your site (will use OAuth) 1260 - ./wisp-cli-macos-arm64 your-handle.bsky.social \\ 1261 - --path ./dist \\ 1262 - --site my-site 1263 - 1264 - # Your site will be available at: 1265 - # https://sites.wisp.place/your-handle/my-site`} 1266 - language="bash" 1267 - /> 1268 - </div> 1269 - 1270 - <div className="space-y-3"> 1271 - <h3 className="text-sm font-semibold">CI/CD with Tangled Spindle</h3> 1272 - <p className="text-xs text-muted-foreground"> 1273 - Deploy automatically on every push using{' '} 1274 - <a 1275 - href="https://blog.tangled.org/ci" 1276 - target="_blank" 1277 - rel="noopener noreferrer" 1278 - className="text-accent hover:underline" 1279 - > 1280 - Tangled Spindle 1281 - </a> 1282 - </p> 1283 - 1284 - <div className="space-y-4"> 1285 - <div> 1286 - <h4 className="text-xs font-semibold mb-2 flex items-center gap-2"> 1287 - <span>Example 1: Simple Asset Publishing</span> 1288 - <Badge variant="secondary" className="text-xs">Copy Files</Badge> 1289 - </h4> 1290 - <CodeBlock 1291 - code={`when: 1292 - - event: ['push'] 1293 - branch: ['main'] 1294 - - event: ['manual'] 1295 - 1296 - engine: 'nixery' 1297 - 1298 - clone: 1299 - skip: false 1300 - depth: 1 1301 - 1302 - dependencies: 1303 - nixpkgs: 1304 - - coreutils 1305 - - curl 1306 - 1307 - environment: 1308 - SITE_PATH: '.' # Copy entire repo 1309 - SITE_NAME: 'myWebbedSite' 1310 - WISP_HANDLE: 'your-handle.bsky.social' 1311 - 1312 - steps: 1313 - - name: deploy assets to wisp 1314 - command: | 1315 - # Download Wisp CLI 1316 - curl https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-x86_64-linux -o wisp-cli 1317 - chmod +x wisp-cli 1318 - 1319 - # Deploy to Wisp 1320 - ./wisp-cli \\ 1321 - "$WISP_HANDLE" \\ 1322 - --path "$SITE_PATH" \\ 1323 - --site "$SITE_NAME" \\ 1324 - --password "$WISP_APP_PASSWORD" 1325 - 1326 - # Output 1327 - #Deployed site 'myWebbedSite': at://did:plc:ttdrpj45ibqunmfhdsb4zdwq/place.wisp.fs/myWebbedSite 1328 - #Available at: https://sites.wisp.place/did:plc:ttdrpj45ibqunmfhdsb4zdwq/myWebbedSite 1329 - `} 1330 - language="yaml" 1331 - /> 1332 - </div> 1333 - 1334 - <div> 1335 - <h4 className="text-xs font-semibold mb-2 flex items-center gap-2"> 1336 - <span>Example 2: React/Vite Build & Deploy</span> 1337 - <Badge variant="secondary" className="text-xs">Full Build</Badge> 1338 - </h4> 1339 - <CodeBlock 1340 - code={`when: 1341 - - event: ['push'] 1342 - branch: ['main'] 1343 - - event: ['manual'] 1344 - 1345 - engine: 'nixery' 1346 - 1347 - clone: 1348 - skip: false 1349 - depth: 1 1350 - submodules: false 1351 - 1352 - dependencies: 1353 - nixpkgs: 1354 - - nodejs 1355 - - coreutils 1356 - - curl 1357 - github:NixOS/nixpkgs/nixpkgs-unstable: 1358 - - bun 1359 - 1360 - environment: 1361 - SITE_PATH: 'dist' 1362 - SITE_NAME: 'my-react-site' 1363 - WISP_HANDLE: 'your-handle.bsky.social' 1364 - 1365 - steps: 1366 - - name: build site 1367 - command: | 1368 - # necessary to ensure bun is in PATH 1369 - export PATH="$HOME/.nix-profile/bin:$PATH" 1370 - 1371 - bun install --frozen-lockfile 1372 - 1373 - # build with vite, run directly to get around env issues 1374 - bun node_modules/.bin/vite build 1375 - 1376 - - name: deploy to wisp 1377 - command: | 1378 - # Download Wisp CLI 1379 - curl https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-x86_64-linux -o wisp-cli 1380 - chmod +x wisp-cli 1381 - 1382 - # Deploy to Wisp 1383 - ./wisp-cli \\ 1384 - "$WISP_HANDLE" \\ 1385 - --path "$SITE_PATH" \\ 1386 - --site "$SITE_NAME" \\ 1387 - --password "$WISP_APP_PASSWORD"`} 1388 - language="yaml" 1389 - /> 1390 - </div> 1391 - </div> 1392 - 1393 - <div className="p-3 bg-muted/30 rounded-lg border-l-4 border-accent"> 1394 - <p className="text-xs text-muted-foreground"> 1395 - <strong className="text-foreground">Note:</strong> Set <code className="px-1.5 py-0.5 bg-background rounded text-xs">WISP_APP_PASSWORD</code> as a secret in your Tangled Spindle repository settings. 1396 - Generate an app password from your AT Protocol account settings. 1397 - </p> 1398 - </div> 1399 - </div> 1400 - 1401 - <div className="space-y-3"> 1402 - <h3 className="text-sm font-semibold">Learn More</h3> 1403 - <div className="grid gap-2"> 1404 - <a 1405 - href="https://tangled.org/@nekomimi.pet/wisp.place-monorepo/tree/main/cli" 1406 - target="_blank" 1407 - rel="noopener noreferrer" 1408 - className="flex items-center justify-between p-3 bg-muted/50 hover:bg-muted rounded-lg transition-colors border border-border" 1409 - > 1410 - <span className="text-sm">Source Code</span> 1411 - <ExternalLink className="w-4 h-4 text-muted-foreground" /> 1412 - </a> 1413 - <a 1414 - href="https://blog.tangled.org/ci" 1415 - target="_blank" 1416 - rel="noopener noreferrer" 1417 - className="flex items-center justify-between p-3 bg-muted/50 hover:bg-muted rounded-lg transition-colors border border-border" 1418 - > 1419 - <span className="text-sm">Tangled Spindle CI/CD</span> 1420 - <ExternalLink className="w-4 h-4 text-muted-foreground" /> 1421 - </a> 1422 - </div> 1423 - </div> 1424 - </CardContent> 1425 - </Card> 242 + <TabsContent value="cli"> 243 + <CLITab /> 1426 244 </TabsContent> 1427 245 </Tabs> 1428 246 </div> 1429 - 1430 - {/* Add Custom Domain Modal */} 1431 - <Dialog open={addDomainModalOpen} onOpenChange={setAddDomainModalOpen}> 1432 - <DialogContent className="sm:max-w-lg"> 1433 - <DialogHeader> 1434 - <DialogTitle>Add Custom Domain</DialogTitle> 1435 - <DialogDescription> 1436 - Enter your domain name. After adding, you'll see the DNS 1437 - records to configure. 1438 - </DialogDescription> 1439 - </DialogHeader> 1440 - <div className="space-y-4 py-4"> 1441 - <div className="space-y-2"> 1442 - <Label htmlFor="new-domain">Domain Name</Label> 1443 - <Input 1444 - id="new-domain" 1445 - placeholder="example.com" 1446 - value={customDomain} 1447 - onChange={(e) => setCustomDomain(e.target.value)} 1448 - /> 1449 - <p className="text-xs text-muted-foreground"> 1450 - After adding, click "View DNS" to see the records you 1451 - need to configure. 1452 - </p> 1453 - </div> 1454 - </div> 1455 - <DialogFooter className="flex-col sm:flex-row gap-2"> 1456 - <Button 1457 - variant="outline" 1458 - onClick={() => { 1459 - setAddDomainModalOpen(false) 1460 - setCustomDomain('') 1461 - }} 1462 - className="w-full sm:w-auto" 1463 - disabled={isAddingDomain} 1464 - > 1465 - Cancel 1466 - </Button> 1467 - <Button 1468 - onClick={handleAddCustomDomain} 1469 - disabled={!customDomain || isAddingDomain} 1470 - className="w-full sm:w-auto" 1471 - > 1472 - {isAddingDomain ? ( 1473 - <> 1474 - <Loader2 className="w-4 h-4 mr-2 animate-spin" /> 1475 - Adding... 1476 - </> 1477 - ) : ( 1478 - 'Add Domain' 1479 - )} 1480 - </Button> 1481 - </DialogFooter> 1482 - </DialogContent> 1483 - </Dialog> 1484 247 1485 248 {/* Site Configuration Modal */} 1486 249 <Dialog ··· 1637 400 )} 1638 401 </Button> 1639 402 </div> 1640 - </DialogFooter> 1641 - </DialogContent> 1642 - </Dialog> 1643 - 1644 - {/* View DNS Records Modal */} 1645 - <Dialog 1646 - open={viewDomainDNS !== null} 1647 - onOpenChange={(open) => !open && setViewDomainDNS(null)} 1648 - > 1649 - <DialogContent className="sm:max-w-lg"> 1650 - <DialogHeader> 1651 - <DialogTitle>DNS Configuration</DialogTitle> 1652 - <DialogDescription> 1653 - Add these DNS records to your domain provider 1654 - </DialogDescription> 1655 - </DialogHeader> 1656 - {viewDomainDNS && userInfo && ( 1657 - <> 1658 - {(() => { 1659 - const domain = customDomains.find( 1660 - (d) => d.id === viewDomainDNS 1661 - ) 1662 - if (!domain) return null 1663 - 1664 - return ( 1665 - <div className="space-y-4 py-4"> 1666 - <div className="p-3 bg-muted/30 rounded-lg"> 1667 - <p className="text-sm font-medium mb-1"> 1668 - Domain: 1669 - </p> 1670 - <p className="font-mono text-sm"> 1671 - {domain.domain} 1672 - </p> 1673 - </div> 1674 - 1675 - <div className="space-y-3"> 1676 - <div className="p-3 bg-background rounded border border-border"> 1677 - <div className="flex justify-between items-start mb-2"> 1678 - <span className="text-xs font-semibold text-muted-foreground"> 1679 - TXT Record (Verification) 1680 - </span> 1681 - </div> 1682 - <div className="font-mono text-xs space-y-2"> 1683 - <div> 1684 - <span className="text-muted-foreground"> 1685 - Name: 1686 - </span>{' '} 1687 - <span className="select-all"> 1688 - _wisp.{domain.domain} 1689 - </span> 1690 - </div> 1691 - <div> 1692 - <span className="text-muted-foreground"> 1693 - Value: 1694 - </span>{' '} 1695 - <span className="select-all break-all"> 1696 - {userInfo.did} 1697 - </span> 1698 - </div> 1699 - </div> 1700 - </div> 1701 - 1702 - <div className="p-3 bg-background rounded border border-border"> 1703 - <div className="flex justify-between items-start mb-2"> 1704 - <span className="text-xs font-semibold text-muted-foreground"> 1705 - CNAME Record (Pointing) 1706 - </span> 1707 - </div> 1708 - <div className="font-mono text-xs space-y-2"> 1709 - <div> 1710 - <span className="text-muted-foreground"> 1711 - Name: 1712 - </span>{' '} 1713 - <span className="select-all"> 1714 - {domain.domain} 1715 - </span> 1716 - </div> 1717 - <div> 1718 - <span className="text-muted-foreground"> 1719 - Value: 1720 - </span>{' '} 1721 - <span className="select-all"> 1722 - {domain.id}.dns.wisp.place 1723 - </span> 1724 - </div> 1725 - </div> 1726 - <p className="text-xs text-muted-foreground mt-2"> 1727 - Note: Some DNS providers (like Cloudflare) flatten CNAMEs to A records - this is fine and won't affect verification. 1728 - </p> 1729 - </div> 1730 - </div> 1731 - 1732 - <div className="p-3 bg-muted/30 rounded-lg"> 1733 - <p className="text-xs text-muted-foreground"> 1734 - 💡 After configuring DNS, click "Verify DNS" 1735 - to check if everything is set up correctly. 1736 - DNS changes can take a few minutes to 1737 - propagate. 1738 - </p> 1739 - </div> 1740 - </div> 1741 - ) 1742 - })()} 1743 - </> 1744 - )} 1745 - <DialogFooter> 1746 - <Button 1747 - variant="outline" 1748 - onClick={() => setViewDomainDNS(null)} 1749 - className="w-full sm:w-auto" 1750 - > 1751 - Close 1752 - </Button> 1753 403 </DialogFooter> 1754 404 </DialogContent> 1755 405 </Dialog>
+212
public/editor/hooks/useDomainData.ts
··· 1 + import { useState } from 'react' 2 + 3 + export interface CustomDomain { 4 + id: string 5 + domain: string 6 + did: string 7 + rkey: string 8 + verified: boolean 9 + last_verified_at: number | null 10 + created_at: number 11 + } 12 + 13 + export interface WispDomain { 14 + domain: string 15 + rkey: string | null 16 + } 17 + 18 + type VerificationStatus = 'idle' | 'verifying' | 'success' | 'error' 19 + 20 + export function useDomainData() { 21 + const [wispDomain, setWispDomain] = useState<WispDomain | null>(null) 22 + const [customDomains, setCustomDomains] = useState<CustomDomain[]>([]) 23 + const [domainsLoading, setDomainsLoading] = useState(true) 24 + const [verificationStatus, setVerificationStatus] = useState<{ 25 + [id: string]: VerificationStatus 26 + }>({}) 27 + 28 + const fetchDomains = async () => { 29 + try { 30 + const response = await fetch('/api/user/domains') 31 + const data = await response.json() 32 + setWispDomain(data.wispDomain) 33 + setCustomDomains(data.customDomains || []) 34 + } catch (err) { 35 + console.error('Failed to fetch domains:', err) 36 + } finally { 37 + setDomainsLoading(false) 38 + } 39 + } 40 + 41 + const addCustomDomain = async (domain: string) => { 42 + try { 43 + const response = await fetch('/api/domain/custom/add', { 44 + method: 'POST', 45 + headers: { 'Content-Type': 'application/json' }, 46 + body: JSON.stringify({ domain }) 47 + }) 48 + 49 + const data = await response.json() 50 + if (data.success) { 51 + await fetchDomains() 52 + return { success: true, id: data.id } 53 + } else { 54 + throw new Error(data.error || 'Failed to add domain') 55 + } 56 + } catch (err) { 57 + console.error('Add domain error:', err) 58 + alert( 59 + `Failed to add domain: ${err instanceof Error ? err.message : 'Unknown error'}` 60 + ) 61 + return { success: false } 62 + } 63 + } 64 + 65 + const verifyDomain = async (id: string) => { 66 + setVerificationStatus({ ...verificationStatus, [id]: 'verifying' }) 67 + 68 + try { 69 + const response = await fetch('/api/domain/custom/verify', { 70 + method: 'POST', 71 + headers: { 'Content-Type': 'application/json' }, 72 + body: JSON.stringify({ id }) 73 + }) 74 + 75 + const data = await response.json() 76 + if (data.success && data.verified) { 77 + setVerificationStatus({ ...verificationStatus, [id]: 'success' }) 78 + await fetchDomains() 79 + } else { 80 + setVerificationStatus({ ...verificationStatus, [id]: 'error' }) 81 + if (data.error) { 82 + alert(`Verification failed: ${data.error}`) 83 + } 84 + } 85 + } catch (err) { 86 + console.error('Verify domain error:', err) 87 + setVerificationStatus({ ...verificationStatus, [id]: 'error' }) 88 + alert( 89 + `Verification failed: ${err instanceof Error ? err.message : 'Unknown error'}` 90 + ) 91 + } 92 + } 93 + 94 + const deleteCustomDomain = async (id: string) => { 95 + if (!confirm('Are you sure you want to remove this custom domain?')) { 96 + return false 97 + } 98 + 99 + try { 100 + const response = await fetch(`/api/domain/custom/${id}`, { 101 + method: 'DELETE' 102 + }) 103 + 104 + const data = await response.json() 105 + if (data.success) { 106 + await fetchDomains() 107 + return true 108 + } else { 109 + throw new Error('Failed to delete domain') 110 + } 111 + } catch (err) { 112 + console.error('Delete domain error:', err) 113 + alert( 114 + `Failed to delete domain: ${err instanceof Error ? err.message : 'Unknown error'}` 115 + ) 116 + return false 117 + } 118 + } 119 + 120 + const mapWispDomain = async (siteRkey: string | null) => { 121 + try { 122 + const response = await fetch('/api/domain/wisp/map-site', { 123 + method: 'POST', 124 + headers: { 'Content-Type': 'application/json' }, 125 + body: JSON.stringify({ siteRkey }) 126 + }) 127 + const data = await response.json() 128 + if (!data.success) throw new Error('Failed to map wisp domain') 129 + return true 130 + } catch (err) { 131 + console.error('Map wisp domain error:', err) 132 + throw err 133 + } 134 + } 135 + 136 + const mapCustomDomain = async (domainId: string, siteRkey: string | null) => { 137 + try { 138 + const response = await fetch(`/api/domain/custom/${domainId}/map-site`, { 139 + method: 'POST', 140 + headers: { 'Content-Type': 'application/json' }, 141 + body: JSON.stringify({ siteRkey }) 142 + }) 143 + const data = await response.json() 144 + if (!data.success) throw new Error(`Failed to map custom domain ${domainId}`) 145 + return true 146 + } catch (err) { 147 + console.error('Map custom domain error:', err) 148 + throw err 149 + } 150 + } 151 + 152 + const claimWispDomain = async (handle: string) => { 153 + try { 154 + const response = await fetch('/api/domain/claim', { 155 + method: 'POST', 156 + headers: { 'Content-Type': 'application/json' }, 157 + body: JSON.stringify({ handle }) 158 + }) 159 + 160 + const data = await response.json() 161 + if (data.success) { 162 + await fetchDomains() 163 + return { success: true } 164 + } else { 165 + throw new Error(data.error || 'Failed to claim domain') 166 + } 167 + } catch (err) { 168 + console.error('Claim domain error:', err) 169 + const errorMessage = err instanceof Error ? err.message : 'Unknown error' 170 + 171 + // Handle "Already claimed" error more gracefully 172 + if (errorMessage.includes('Already claimed')) { 173 + alert('You have already claimed a wisp.place subdomain. Please refresh the page.') 174 + await fetchDomains() 175 + } else { 176 + alert(`Failed to claim domain: ${errorMessage}`) 177 + } 178 + return { success: false, error: errorMessage } 179 + } 180 + } 181 + 182 + const checkWispAvailability = async (handle: string) => { 183 + const trimmedHandle = handle.trim().toLowerCase() 184 + if (!trimmedHandle) { 185 + return { available: null } 186 + } 187 + 188 + try { 189 + const response = await fetch(`/api/domain/check?handle=${encodeURIComponent(trimmedHandle)}`) 190 + const data = await response.json() 191 + return { available: data.available } 192 + } catch (err) { 193 + console.error('Check availability error:', err) 194 + return { available: false } 195 + } 196 + } 197 + 198 + return { 199 + wispDomain, 200 + customDomains, 201 + domainsLoading, 202 + verificationStatus, 203 + fetchDomains, 204 + addCustomDomain, 205 + verifyDomain, 206 + deleteCustomDomain, 207 + mapWispDomain, 208 + mapCustomDomain, 209 + claimWispDomain, 210 + checkWispAvailability 211 + } 212 + }
+112
public/editor/hooks/useSiteData.ts
··· 1 + import { useState } from 'react' 2 + 3 + export interface Site { 4 + did: string 5 + rkey: string 6 + display_name: string | null 7 + created_at: number 8 + updated_at: number 9 + } 10 + 11 + export interface DomainInfo { 12 + type: 'wisp' | 'custom' 13 + domain: string 14 + verified?: boolean 15 + id?: string 16 + } 17 + 18 + export interface SiteWithDomains extends Site { 19 + domains?: DomainInfo[] 20 + } 21 + 22 + export function useSiteData() { 23 + const [sites, setSites] = useState<SiteWithDomains[]>([]) 24 + const [sitesLoading, setSitesLoading] = useState(true) 25 + const [isSyncing, setIsSyncing] = useState(false) 26 + 27 + const fetchSites = async () => { 28 + try { 29 + const response = await fetch('/api/user/sites') 30 + const data = await response.json() 31 + const sitesData: Site[] = data.sites || [] 32 + 33 + // Fetch domain info for each site 34 + const sitesWithDomains = await Promise.all( 35 + sitesData.map(async (site) => { 36 + try { 37 + const domainsResponse = await fetch(`/api/user/site/${site.rkey}/domains`) 38 + const domainsData = await domainsResponse.json() 39 + return { 40 + ...site, 41 + domains: domainsData.domains || [] 42 + } 43 + } catch (err) { 44 + console.error(`Failed to fetch domains for site ${site.rkey}:`, err) 45 + return { 46 + ...site, 47 + domains: [] 48 + } 49 + } 50 + }) 51 + ) 52 + 53 + setSites(sitesWithDomains) 54 + } catch (err) { 55 + console.error('Failed to fetch sites:', err) 56 + } finally { 57 + setSitesLoading(false) 58 + } 59 + } 60 + 61 + const syncSites = async () => { 62 + setIsSyncing(true) 63 + try { 64 + const response = await fetch('/api/user/sync', { 65 + method: 'POST' 66 + }) 67 + const data = await response.json() 68 + if (data.success) { 69 + console.log(`Synced ${data.synced} sites from PDS`) 70 + // Refresh sites list 71 + await fetchSites() 72 + } 73 + } catch (err) { 74 + console.error('Failed to sync sites:', err) 75 + alert('Failed to sync sites from PDS') 76 + } finally { 77 + setIsSyncing(false) 78 + } 79 + } 80 + 81 + const deleteSite = async (rkey: string) => { 82 + try { 83 + const response = await fetch(`/api/site/${rkey}`, { 84 + method: 'DELETE' 85 + }) 86 + 87 + const data = await response.json() 88 + if (data.success) { 89 + // Refresh sites list 90 + await fetchSites() 91 + return true 92 + } else { 93 + throw new Error(data.error || 'Failed to delete site') 94 + } 95 + } catch (err) { 96 + console.error('Delete site error:', err) 97 + alert( 98 + `Failed to delete site: ${err instanceof Error ? err.message : 'Unknown error'}` 99 + ) 100 + return false 101 + } 102 + } 103 + 104 + return { 105 + sites, 106 + sitesLoading, 107 + isSyncing, 108 + fetchSites, 109 + syncSites, 110 + deleteSite 111 + } 112 + }
+29
public/editor/hooks/useUserInfo.ts
··· 1 + import { useState } from 'react' 2 + 3 + export interface UserInfo { 4 + did: string 5 + handle: string 6 + } 7 + 8 + export function useUserInfo() { 9 + const [userInfo, setUserInfo] = useState<UserInfo | null>(null) 10 + const [loading, setLoading] = useState(true) 11 + 12 + const fetchUserInfo = async () => { 13 + try { 14 + const response = await fetch('/api/user/info') 15 + const data = await response.json() 16 + setUserInfo(data) 17 + } catch (err) { 18 + console.error('Failed to fetch user info:', err) 19 + } finally { 20 + setLoading(false) 21 + } 22 + } 23 + 24 + return { 25 + userInfo, 26 + loading, 27 + fetchUserInfo 28 + } 29 + }
+18
public/editor/index.html
··· 5 5 <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 6 6 <title>Elysia Static</title> 7 7 <link rel="icon" type="image/x-icon" href="../favicon.ico"> 8 + <style> 9 + /* Dark theme fallback styles for before JS loads */ 10 + @media (prefers-color-scheme: dark) { 11 + body { 12 + background-color: oklch(0.23 0.015 285); 13 + color: oklch(0.90 0.005 285); 14 + } 15 + 16 + pre { 17 + background-color: oklch(0.33 0.015 285) !important; 18 + color: oklch(0.90 0.005 285) !important; 19 + } 20 + 21 + .bg-muted { 22 + background-color: oklch(0.33 0.015 285) !important; 23 + } 24 + } 25 + </style> 8 26 </head> 9 27 <body> 10 28 <div id="elysia"></div>
+258
public/editor/tabs/CLITab.tsx
··· 1 + import { 2 + Card, 3 + CardContent, 4 + CardDescription, 5 + CardHeader, 6 + CardTitle 7 + } from '@public/components/ui/card' 8 + import { Badge } from '@public/components/ui/badge' 9 + import { ExternalLink } from 'lucide-react' 10 + import { CodeBlock } from '@public/components/ui/code-block' 11 + 12 + export function CLITab() { 13 + return ( 14 + <div className="space-y-4 min-h-[400px]"> 15 + <Card> 16 + <CardHeader> 17 + <div className="flex items-center gap-2 mb-2"> 18 + <CardTitle>Wisp CLI Tool</CardTitle> 19 + <Badge variant="secondary" className="text-xs">v0.1.0</Badge> 20 + <Badge variant="outline" className="text-xs">Alpha</Badge> 21 + </div> 22 + <CardDescription> 23 + Deploy static sites directly from your terminal 24 + </CardDescription> 25 + </CardHeader> 26 + <CardContent className="space-y-6"> 27 + <div className="prose prose-sm max-w-none dark:prose-invert"> 28 + <p className="text-sm text-muted-foreground"> 29 + The Wisp CLI is a command-line tool for deploying static websites directly to your AT Protocol account. 30 + Authenticate with app password or OAuth and deploy from CI/CD pipelines. 31 + </p> 32 + </div> 33 + 34 + <div className="space-y-3"> 35 + <h3 className="text-sm font-semibold">Download CLI</h3> 36 + <div className="grid gap-2"> 37 + <div className="p-3 bg-muted/50 hover:bg-muted rounded-lg transition-colors border border-border"> 38 + <a 39 + href="https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-macos-arm64" 40 + target="_blank" 41 + rel="noopener noreferrer" 42 + className="flex items-center justify-between mb-2" 43 + > 44 + <span className="font-mono text-sm">macOS (Apple Silicon)</span> 45 + <ExternalLink className="w-4 h-4 text-muted-foreground" /> 46 + </a> 47 + <div className="text-xs text-muted-foreground"> 48 + <span className="font-mono">SHA256: 637e325d9668ca745e01493d80dfc72447ef0a889b313e28913ca65c94c7aaae</span> 49 + </div> 50 + </div> 51 + <div className="p-3 bg-muted/50 hover:bg-muted rounded-lg transition-colors border border-border"> 52 + <a 53 + href="https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-aarch64-linux" 54 + target="_blank" 55 + rel="noopener noreferrer" 56 + className="flex items-center justify-between mb-2" 57 + > 58 + <span className="font-mono text-sm">Linux (ARM64)</span> 59 + <ExternalLink className="w-4 h-4 text-muted-foreground" /> 60 + </a> 61 + <div className="text-xs text-muted-foreground"> 62 + <span className="font-mono">SHA256: 01561656b64826f95b39f13c65c97da8bcc63ecd9f4d7e4e369c8ba8c903c22a</span> 63 + </div> 64 + </div> 65 + <div className="p-3 bg-muted/50 hover:bg-muted rounded-lg transition-colors border border-border"> 66 + <a 67 + href="https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-x86_64-linux" 68 + target="_blank" 69 + rel="noopener noreferrer" 70 + className="flex items-center justify-between mb-2" 71 + > 72 + <span className="font-mono text-sm">Linux (x86_64)</span> 73 + <ExternalLink className="w-4 h-4 text-muted-foreground" /> 74 + </a> 75 + <div className="text-xs text-muted-foreground"> 76 + <span className="font-mono">SHA256: 1ff485b9bcf89bc5721a862863c4843cf4530cbcd2489cf200cb24a44f7865a2</span> 77 + </div> 78 + </div> 79 + </div> 80 + </div> 81 + 82 + <div className="space-y-3"> 83 + <h3 className="text-sm font-semibold">Basic Usage</h3> 84 + <CodeBlock 85 + code={`# Download and make executable 86 + curl -O https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-macos-arm64 87 + chmod +x wisp-cli-macos-arm64 88 + 89 + # Deploy your site (will use OAuth) 90 + ./wisp-cli-macos-arm64 your-handle.bsky.social \\ 91 + --path ./dist \\ 92 + --site my-site 93 + 94 + # Your site will be available at: 95 + # https://sites.wisp.place/your-handle/my-site`} 96 + language="bash" 97 + /> 98 + </div> 99 + 100 + <div className="space-y-3"> 101 + <h3 className="text-sm font-semibold">CI/CD with Tangled Spindle</h3> 102 + <p className="text-xs text-muted-foreground"> 103 + Deploy automatically on every push using{' '} 104 + <a 105 + href="https://blog.tangled.org/ci" 106 + target="_blank" 107 + rel="noopener noreferrer" 108 + className="text-accent hover:underline" 109 + > 110 + Tangled Spindle 111 + </a> 112 + </p> 113 + 114 + <div className="space-y-4"> 115 + <div> 116 + <h4 className="text-xs font-semibold mb-2 flex items-center gap-2"> 117 + <span>Example 1: Simple Asset Publishing</span> 118 + <Badge variant="secondary" className="text-xs">Copy Files</Badge> 119 + </h4> 120 + <CodeBlock 121 + code={`when: 122 + - event: ['push'] 123 + branch: ['main'] 124 + - event: ['manual'] 125 + 126 + engine: 'nixery' 127 + 128 + clone: 129 + skip: false 130 + depth: 1 131 + 132 + dependencies: 133 + nixpkgs: 134 + - coreutils 135 + - curl 136 + 137 + environment: 138 + SITE_PATH: '.' # Copy entire repo 139 + SITE_NAME: 'myWebbedSite' 140 + WISP_HANDLE: 'your-handle.bsky.social' 141 + 142 + steps: 143 + - name: deploy assets to wisp 144 + command: | 145 + # Download Wisp CLI 146 + curl https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-x86_64-linux -o wisp-cli 147 + chmod +x wisp-cli 148 + 149 + # Deploy to Wisp 150 + ./wisp-cli \\ 151 + "$WISP_HANDLE" \\ 152 + --path "$SITE_PATH" \\ 153 + --site "$SITE_NAME" \\ 154 + --password "$WISP_APP_PASSWORD" 155 + 156 + # Output 157 + #Deployed site 'myWebbedSite': at://did:plc:ttdrpj45ibqunmfhdsb4zdwq/place.wisp.fs/myWebbedSite 158 + #Available at: https://sites.wisp.place/did:plc:ttdrpj45ibqunmfhdsb4zdwq/myWebbedSite 159 + `} 160 + language="yaml" 161 + /> 162 + </div> 163 + 164 + <div> 165 + <h4 className="text-xs font-semibold mb-2 flex items-center gap-2"> 166 + <span>Example 2: React/Vite Build & Deploy</span> 167 + <Badge variant="secondary" className="text-xs">Full Build</Badge> 168 + </h4> 169 + <CodeBlock 170 + code={`when: 171 + - event: ['push'] 172 + branch: ['main'] 173 + - event: ['manual'] 174 + 175 + engine: 'nixery' 176 + 177 + clone: 178 + skip: false 179 + depth: 1 180 + submodules: false 181 + 182 + dependencies: 183 + nixpkgs: 184 + - nodejs 185 + - coreutils 186 + - curl 187 + github:NixOS/nixpkgs/nixpkgs-unstable: 188 + - bun 189 + 190 + environment: 191 + SITE_PATH: 'dist' 192 + SITE_NAME: 'my-react-site' 193 + WISP_HANDLE: 'your-handle.bsky.social' 194 + 195 + steps: 196 + - name: build site 197 + command: | 198 + # necessary to ensure bun is in PATH 199 + export PATH="$HOME/.nix-profile/bin:$PATH" 200 + 201 + bun install --frozen-lockfile 202 + 203 + # build with vite, run directly to get around env issues 204 + bun node_modules/.bin/vite build 205 + 206 + - name: deploy to wisp 207 + command: | 208 + # Download Wisp CLI 209 + curl https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-x86_64-linux -o wisp-cli 210 + chmod +x wisp-cli 211 + 212 + # Deploy to Wisp 213 + ./wisp-cli \\ 214 + "$WISP_HANDLE" \\ 215 + --path "$SITE_PATH" \\ 216 + --site "$SITE_NAME" \\ 217 + --password "$WISP_APP_PASSWORD"`} 218 + language="yaml" 219 + /> 220 + </div> 221 + </div> 222 + 223 + <div className="p-3 bg-muted/30 rounded-lg border-l-4 border-accent"> 224 + <p className="text-xs text-muted-foreground"> 225 + <strong className="text-foreground">Note:</strong> Set <code className="px-1.5 py-0.5 bg-background rounded text-xs">WISP_APP_PASSWORD</code> as a secret in your Tangled Spindle repository settings. 226 + Generate an app password from your AT Protocol account settings. 227 + </p> 228 + </div> 229 + </div> 230 + 231 + <div className="space-y-3"> 232 + <h3 className="text-sm font-semibold">Learn More</h3> 233 + <div className="grid gap-2"> 234 + <a 235 + href="https://tangled.org/@nekomimi.pet/wisp.place-monorepo/tree/main/cli" 236 + target="_blank" 237 + rel="noopener noreferrer" 238 + className="flex items-center justify-between p-3 bg-muted/50 hover:bg-muted rounded-lg transition-colors border border-border" 239 + > 240 + <span className="text-sm">Source Code</span> 241 + <ExternalLink className="w-4 h-4 text-muted-foreground" /> 242 + </a> 243 + <a 244 + href="https://blog.tangled.org/ci" 245 + target="_blank" 246 + rel="noopener noreferrer" 247 + className="flex items-center justify-between p-3 bg-muted/50 hover:bg-muted rounded-lg transition-colors border border-border" 248 + > 249 + <span className="text-sm">Tangled Spindle CI/CD</span> 250 + <ExternalLink className="w-4 h-4 text-muted-foreground" /> 251 + </a> 252 + </div> 253 + </div> 254 + </CardContent> 255 + </Card> 256 + </div> 257 + ) 258 + }
+499
public/editor/tabs/DomainsTab.tsx
··· 1 + import { useState } from 'react' 2 + import { 3 + Card, 4 + CardContent, 5 + CardDescription, 6 + CardHeader, 7 + CardTitle 8 + } from '@public/components/ui/card' 9 + import { Button } from '@public/components/ui/button' 10 + import { Input } from '@public/components/ui/input' 11 + import { Label } from '@public/components/ui/label' 12 + import { Badge } from '@public/components/ui/badge' 13 + import { 14 + Dialog, 15 + DialogContent, 16 + DialogDescription, 17 + DialogHeader, 18 + DialogTitle, 19 + DialogFooter 20 + } from '@public/components/ui/dialog' 21 + import { 22 + CheckCircle2, 23 + XCircle, 24 + Loader2, 25 + Trash2 26 + } from 'lucide-react' 27 + import type { WispDomain, CustomDomain } from '../hooks/useDomainData' 28 + import type { UserInfo } from '../hooks/useUserInfo' 29 + 30 + interface DomainsTabProps { 31 + wispDomain: WispDomain | null 32 + customDomains: CustomDomain[] 33 + domainsLoading: boolean 34 + verificationStatus: { [id: string]: 'idle' | 'verifying' | 'success' | 'error' } 35 + userInfo: UserInfo | null 36 + onAddCustomDomain: (domain: string) => Promise<{ success: boolean; id?: string }> 37 + onVerifyDomain: (id: string) => Promise<void> 38 + onDeleteCustomDomain: (id: string) => Promise<boolean> 39 + onClaimWispDomain: (handle: string) => Promise<{ success: boolean; error?: string }> 40 + onCheckWispAvailability: (handle: string) => Promise<{ available: boolean | null }> 41 + } 42 + 43 + export function DomainsTab({ 44 + wispDomain, 45 + customDomains, 46 + domainsLoading, 47 + verificationStatus, 48 + userInfo, 49 + onAddCustomDomain, 50 + onVerifyDomain, 51 + onDeleteCustomDomain, 52 + onClaimWispDomain, 53 + onCheckWispAvailability 54 + }: DomainsTabProps) { 55 + // Wisp domain claim state 56 + const [wispHandle, setWispHandle] = useState('') 57 + const [isClaimingWisp, setIsClaimingWisp] = useState(false) 58 + const [wispAvailability, setWispAvailability] = useState<{ 59 + available: boolean | null 60 + checking: boolean 61 + }>({ available: null, checking: false }) 62 + 63 + // Custom domain modal state 64 + const [addDomainModalOpen, setAddDomainModalOpen] = useState(false) 65 + const [customDomain, setCustomDomain] = useState('') 66 + const [isAddingDomain, setIsAddingDomain] = useState(false) 67 + const [viewDomainDNS, setViewDomainDNS] = useState<string | null>(null) 68 + 69 + const checkWispAvailability = async (handle: string) => { 70 + const trimmedHandle = handle.trim().toLowerCase() 71 + if (!trimmedHandle) { 72 + setWispAvailability({ available: null, checking: false }) 73 + return 74 + } 75 + 76 + setWispAvailability({ available: null, checking: true }) 77 + const result = await onCheckWispAvailability(trimmedHandle) 78 + setWispAvailability({ available: result.available, checking: false }) 79 + } 80 + 81 + const handleClaimWispDomain = async () => { 82 + const trimmedHandle = wispHandle.trim().toLowerCase() 83 + if (!trimmedHandle) { 84 + alert('Please enter a handle') 85 + return 86 + } 87 + 88 + setIsClaimingWisp(true) 89 + const result = await onClaimWispDomain(trimmedHandle) 90 + if (result.success) { 91 + setWispHandle('') 92 + setWispAvailability({ available: null, checking: false }) 93 + } 94 + setIsClaimingWisp(false) 95 + } 96 + 97 + const handleAddCustomDomain = async () => { 98 + if (!customDomain) { 99 + alert('Please enter a domain') 100 + return 101 + } 102 + 103 + setIsAddingDomain(true) 104 + const result = await onAddCustomDomain(customDomain) 105 + setIsAddingDomain(false) 106 + 107 + if (result.success) { 108 + setCustomDomain('') 109 + setAddDomainModalOpen(false) 110 + // Automatically show DNS configuration for the newly added domain 111 + if (result.id) { 112 + setViewDomainDNS(result.id) 113 + } 114 + } 115 + } 116 + 117 + return ( 118 + <> 119 + <div className="space-y-4 min-h-[400px]"> 120 + <Card> 121 + <CardHeader> 122 + <CardTitle>wisp.place Subdomain</CardTitle> 123 + <CardDescription> 124 + Your free subdomain on the wisp.place network 125 + </CardDescription> 126 + </CardHeader> 127 + <CardContent> 128 + {domainsLoading ? ( 129 + <div className="flex items-center justify-center py-4"> 130 + <Loader2 className="w-6 h-6 animate-spin text-muted-foreground" /> 131 + </div> 132 + ) : wispDomain ? ( 133 + <> 134 + <div className="flex flex-col gap-2 p-4 bg-muted/50 rounded-lg"> 135 + <div className="flex items-center gap-2"> 136 + <CheckCircle2 className="w-5 h-5 text-green-500" /> 137 + <span className="font-mono text-lg"> 138 + {wispDomain.domain} 139 + </span> 140 + </div> 141 + {wispDomain.rkey && ( 142 + <p className="text-xs text-muted-foreground ml-7"> 143 + → Mapped to site: {wispDomain.rkey} 144 + </p> 145 + )} 146 + </div> 147 + <p className="text-sm text-muted-foreground mt-3"> 148 + {wispDomain.rkey 149 + ? 'This domain is mapped to a specific site' 150 + : 'This domain is not mapped to any site yet. Configure it from the Sites tab.'} 151 + </p> 152 + </> 153 + ) : ( 154 + <div className="space-y-4"> 155 + <div className="p-4 bg-muted/30 rounded-lg"> 156 + <p className="text-sm text-muted-foreground mb-4"> 157 + Claim your free wisp.place subdomain 158 + </p> 159 + <div className="space-y-3"> 160 + <div className="space-y-2"> 161 + <Label htmlFor="wisp-handle">Choose your handle</Label> 162 + <div className="flex gap-2"> 163 + <div className="flex-1 relative"> 164 + <Input 165 + id="wisp-handle" 166 + placeholder="mysite" 167 + value={wispHandle} 168 + onChange={(e) => { 169 + setWispHandle(e.target.value) 170 + if (e.target.value.trim()) { 171 + checkWispAvailability(e.target.value) 172 + } else { 173 + setWispAvailability({ available: null, checking: false }) 174 + } 175 + }} 176 + disabled={isClaimingWisp} 177 + className="pr-24" 178 + /> 179 + <span className="absolute right-3 top-1/2 -translate-y-1/2 text-sm text-muted-foreground"> 180 + .wisp.place 181 + </span> 182 + </div> 183 + </div> 184 + {wispAvailability.checking && ( 185 + <p className="text-xs text-muted-foreground flex items-center gap-1"> 186 + <Loader2 className="w-3 h-3 animate-spin" /> 187 + Checking availability... 188 + </p> 189 + )} 190 + {!wispAvailability.checking && wispAvailability.available === true && ( 191 + <p className="text-xs text-green-600 flex items-center gap-1"> 192 + <CheckCircle2 className="w-3 h-3" /> 193 + Available 194 + </p> 195 + )} 196 + {!wispAvailability.checking && wispAvailability.available === false && ( 197 + <p className="text-xs text-red-600 flex items-center gap-1"> 198 + <XCircle className="w-3 h-3" /> 199 + Not available 200 + </p> 201 + )} 202 + </div> 203 + <Button 204 + onClick={handleClaimWispDomain} 205 + disabled={!wispHandle.trim() || isClaimingWisp || wispAvailability.available !== true} 206 + className="w-full" 207 + > 208 + {isClaimingWisp ? ( 209 + <> 210 + <Loader2 className="w-4 h-4 mr-2 animate-spin" /> 211 + Claiming... 212 + </> 213 + ) : ( 214 + 'Claim Subdomain' 215 + )} 216 + </Button> 217 + </div> 218 + </div> 219 + </div> 220 + )} 221 + </CardContent> 222 + </Card> 223 + 224 + <Card> 225 + <CardHeader> 226 + <CardTitle>Custom Domains</CardTitle> 227 + <CardDescription> 228 + Bring your own domain with DNS verification 229 + </CardDescription> 230 + </CardHeader> 231 + <CardContent className="space-y-4"> 232 + <Button 233 + onClick={() => setAddDomainModalOpen(true)} 234 + className="w-full" 235 + > 236 + Add Custom Domain 237 + </Button> 238 + 239 + {domainsLoading ? ( 240 + <div className="flex items-center justify-center py-4"> 241 + <Loader2 className="w-6 h-6 animate-spin text-muted-foreground" /> 242 + </div> 243 + ) : customDomains.length === 0 ? ( 244 + <div className="text-center py-4 text-muted-foreground text-sm"> 245 + No custom domains added yet 246 + </div> 247 + ) : ( 248 + <div className="space-y-2"> 249 + {customDomains.map((domain) => ( 250 + <div 251 + key={domain.id} 252 + className="flex items-center justify-between p-3 border border-border rounded-lg" 253 + > 254 + <div className="flex flex-col gap-1 flex-1"> 255 + <div className="flex items-center gap-2"> 256 + {domain.verified ? ( 257 + <CheckCircle2 className="w-4 h-4 text-green-500" /> 258 + ) : ( 259 + <XCircle className="w-4 h-4 text-red-500" /> 260 + )} 261 + <span className="font-mono"> 262 + {domain.domain} 263 + </span> 264 + </div> 265 + {domain.rkey && domain.rkey !== 'self' && ( 266 + <p className="text-xs text-muted-foreground ml-6"> 267 + → Mapped to site: {domain.rkey} 268 + </p> 269 + )} 270 + </div> 271 + <div className="flex items-center gap-2"> 272 + <Button 273 + variant="outline" 274 + size="sm" 275 + onClick={() => 276 + setViewDomainDNS(domain.id) 277 + } 278 + > 279 + View DNS 280 + </Button> 281 + {domain.verified ? ( 282 + <Badge variant="secondary"> 283 + Verified 284 + </Badge> 285 + ) : ( 286 + <Button 287 + variant="outline" 288 + size="sm" 289 + onClick={() => 290 + onVerifyDomain(domain.id) 291 + } 292 + disabled={ 293 + verificationStatus[ 294 + domain.id 295 + ] === 'verifying' 296 + } 297 + > 298 + {verificationStatus[ 299 + domain.id 300 + ] === 'verifying' ? ( 301 + <> 302 + <Loader2 className="w-3 h-3 mr-1 animate-spin" /> 303 + Verifying... 304 + </> 305 + ) : ( 306 + 'Verify DNS' 307 + )} 308 + </Button> 309 + )} 310 + <Button 311 + variant="ghost" 312 + size="sm" 313 + onClick={() => 314 + onDeleteCustomDomain( 315 + domain.id 316 + ) 317 + } 318 + > 319 + <Trash2 className="w-4 h-4" /> 320 + </Button> 321 + </div> 322 + </div> 323 + ))} 324 + </div> 325 + )} 326 + </CardContent> 327 + </Card> 328 + </div> 329 + 330 + {/* Add Custom Domain Modal */} 331 + <Dialog open={addDomainModalOpen} onOpenChange={setAddDomainModalOpen}> 332 + <DialogContent className="sm:max-w-lg"> 333 + <DialogHeader> 334 + <DialogTitle>Add Custom Domain</DialogTitle> 335 + <DialogDescription> 336 + Enter your domain name. After adding, you'll see the DNS 337 + records to configure. 338 + </DialogDescription> 339 + </DialogHeader> 340 + <div className="space-y-4 py-4"> 341 + <div className="space-y-2"> 342 + <Label htmlFor="new-domain">Domain Name</Label> 343 + <Input 344 + id="new-domain" 345 + placeholder="example.com" 346 + value={customDomain} 347 + onChange={(e) => setCustomDomain(e.target.value)} 348 + /> 349 + <p className="text-xs text-muted-foreground"> 350 + After adding, click "View DNS" to see the records you 351 + need to configure. 352 + </p> 353 + </div> 354 + </div> 355 + <DialogFooter className="flex-col sm:flex-row gap-2"> 356 + <Button 357 + variant="outline" 358 + onClick={() => { 359 + setAddDomainModalOpen(false) 360 + setCustomDomain('') 361 + }} 362 + className="w-full sm:w-auto" 363 + disabled={isAddingDomain} 364 + > 365 + Cancel 366 + </Button> 367 + <Button 368 + onClick={handleAddCustomDomain} 369 + disabled={!customDomain || isAddingDomain} 370 + className="w-full sm:w-auto" 371 + > 372 + {isAddingDomain ? ( 373 + <> 374 + <Loader2 className="w-4 h-4 mr-2 animate-spin" /> 375 + Adding... 376 + </> 377 + ) : ( 378 + 'Add Domain' 379 + )} 380 + </Button> 381 + </DialogFooter> 382 + </DialogContent> 383 + </Dialog> 384 + 385 + {/* View DNS Records Modal */} 386 + <Dialog 387 + open={viewDomainDNS !== null} 388 + onOpenChange={(open) => !open && setViewDomainDNS(null)} 389 + > 390 + <DialogContent className="sm:max-w-lg"> 391 + <DialogHeader> 392 + <DialogTitle>DNS Configuration</DialogTitle> 393 + <DialogDescription> 394 + Add these DNS records to your domain provider 395 + </DialogDescription> 396 + </DialogHeader> 397 + {viewDomainDNS && userInfo && ( 398 + <> 399 + {(() => { 400 + const domain = customDomains.find( 401 + (d) => d.id === viewDomainDNS 402 + ) 403 + if (!domain) return null 404 + 405 + return ( 406 + <div className="space-y-4 py-4"> 407 + <div className="p-3 bg-muted/30 rounded-lg"> 408 + <p className="text-sm font-medium mb-1"> 409 + Domain: 410 + </p> 411 + <p className="font-mono text-sm"> 412 + {domain.domain} 413 + </p> 414 + </div> 415 + 416 + <div className="space-y-3"> 417 + <div className="p-3 bg-background rounded border border-border"> 418 + <div className="flex justify-between items-start mb-2"> 419 + <span className="text-xs font-semibold text-muted-foreground"> 420 + TXT Record (Verification) 421 + </span> 422 + </div> 423 + <div className="font-mono text-xs space-y-2"> 424 + <div> 425 + <span className="text-muted-foreground"> 426 + Name: 427 + </span>{' '} 428 + <span className="select-all"> 429 + _wisp.{domain.domain} 430 + </span> 431 + </div> 432 + <div> 433 + <span className="text-muted-foreground"> 434 + Value: 435 + </span>{' '} 436 + <span className="select-all break-all"> 437 + {userInfo.did} 438 + </span> 439 + </div> 440 + </div> 441 + </div> 442 + 443 + <div className="p-3 bg-background rounded border border-border"> 444 + <div className="flex justify-between items-start mb-2"> 445 + <span className="text-xs font-semibold text-muted-foreground"> 446 + CNAME Record (Pointing) 447 + </span> 448 + </div> 449 + <div className="font-mono text-xs space-y-2"> 450 + <div> 451 + <span className="text-muted-foreground"> 452 + Name: 453 + </span>{' '} 454 + <span className="select-all"> 455 + {domain.domain} 456 + </span> 457 + </div> 458 + <div> 459 + <span className="text-muted-foreground"> 460 + Value: 461 + </span>{' '} 462 + <span className="select-all"> 463 + {domain.id}.dns.wisp.place 464 + </span> 465 + </div> 466 + </div> 467 + <p className="text-xs text-muted-foreground mt-2"> 468 + Note: Some DNS providers (like Cloudflare) flatten CNAMEs to A records - this is fine and won't affect verification. 469 + </p> 470 + </div> 471 + </div> 472 + 473 + <div className="p-3 bg-muted/30 rounded-lg"> 474 + <p className="text-xs text-muted-foreground"> 475 + 💡 After configuring DNS, click "Verify DNS" 476 + to check if everything is set up correctly. 477 + DNS changes can take a few minutes to 478 + propagate. 479 + </p> 480 + </div> 481 + </div> 482 + ) 483 + })()} 484 + </> 485 + )} 486 + <DialogFooter> 487 + <Button 488 + variant="outline" 489 + onClick={() => setViewDomainDNS(null)} 490 + className="w-full sm:w-auto" 491 + > 492 + Close 493 + </Button> 494 + </DialogFooter> 495 + </DialogContent> 496 + </Dialog> 497 + </> 498 + ) 499 + }
+196
public/editor/tabs/SitesTab.tsx
··· 1 + import { 2 + Card, 3 + CardContent, 4 + CardDescription, 5 + CardHeader, 6 + CardTitle 7 + } from '@public/components/ui/card' 8 + import { Button } from '@public/components/ui/button' 9 + import { Badge } from '@public/components/ui/badge' 10 + import { 11 + Globe, 12 + ExternalLink, 13 + CheckCircle2, 14 + AlertCircle, 15 + Loader2, 16 + RefreshCw, 17 + Settings 18 + } from 'lucide-react' 19 + import type { SiteWithDomains } from '../hooks/useSiteData' 20 + import type { UserInfo } from '../hooks/useUserInfo' 21 + 22 + interface SitesTabProps { 23 + sites: SiteWithDomains[] 24 + sitesLoading: boolean 25 + isSyncing: boolean 26 + userInfo: UserInfo | null 27 + onSyncSites: () => Promise<void> 28 + onConfigureSite: (site: SiteWithDomains) => void 29 + } 30 + 31 + export function SitesTab({ 32 + sites, 33 + sitesLoading, 34 + isSyncing, 35 + userInfo, 36 + onSyncSites, 37 + onConfigureSite 38 + }: SitesTabProps) { 39 + const getSiteUrl = (site: SiteWithDomains) => { 40 + // Use the first mapped domain if available 41 + if (site.domains && site.domains.length > 0) { 42 + return `https://${site.domains[0].domain}` 43 + } 44 + 45 + // Default fallback URL - use handle instead of DID 46 + if (!userInfo) return '#' 47 + return `https://sites.wisp.place/${userInfo.handle}/${site.rkey}` 48 + } 49 + 50 + const getSiteDomainName = (site: SiteWithDomains) => { 51 + // Return the first domain if available 52 + if (site.domains && site.domains.length > 0) { 53 + return site.domains[0].domain 54 + } 55 + 56 + // Use handle instead of DID for display 57 + if (!userInfo) return `sites.wisp.place/.../${site.rkey}` 58 + return `sites.wisp.place/${userInfo.handle}/${site.rkey}` 59 + } 60 + 61 + return ( 62 + <div className="space-y-4 min-h-[400px]"> 63 + <Card> 64 + <CardHeader> 65 + <div className="flex items-center justify-between"> 66 + <div> 67 + <CardTitle>Your Sites</CardTitle> 68 + <CardDescription> 69 + View and manage all your deployed sites 70 + </CardDescription> 71 + </div> 72 + <Button 73 + variant="outline" 74 + size="sm" 75 + onClick={onSyncSites} 76 + disabled={isSyncing || sitesLoading} 77 + > 78 + <RefreshCw 79 + className={`w-4 h-4 mr-2 ${isSyncing ? 'animate-spin' : ''}`} 80 + /> 81 + Sync from PDS 82 + </Button> 83 + </div> 84 + </CardHeader> 85 + <CardContent className="space-y-4"> 86 + {sitesLoading ? ( 87 + <div className="flex items-center justify-center py-8"> 88 + <Loader2 className="w-6 h-6 animate-spin text-muted-foreground" /> 89 + </div> 90 + ) : sites.length === 0 ? ( 91 + <div className="text-center py-8 text-muted-foreground"> 92 + <p>No sites yet. Upload your first site!</p> 93 + </div> 94 + ) : ( 95 + sites.map((site) => ( 96 + <div 97 + key={`${site.did}-${site.rkey}`} 98 + className="flex items-center justify-between p-4 border border-border rounded-lg hover:bg-muted/50 transition-colors" 99 + > 100 + <div className="flex-1"> 101 + <div className="flex items-center gap-3 mb-2"> 102 + <h3 className="font-semibold text-lg"> 103 + {site.display_name || site.rkey} 104 + </h3> 105 + <Badge 106 + variant="secondary" 107 + className="text-xs" 108 + > 109 + active 110 + </Badge> 111 + </div> 112 + 113 + {/* Display all mapped domains */} 114 + {site.domains && site.domains.length > 0 ? ( 115 + <div className="space-y-1"> 116 + {site.domains.map((domainInfo, idx) => ( 117 + <div key={`${domainInfo.domain}-${idx}`} className="flex items-center gap-2"> 118 + <a 119 + href={`https://${domainInfo.domain}`} 120 + target="_blank" 121 + rel="noopener noreferrer" 122 + className="text-sm text-accent hover:text-accent/80 flex items-center gap-1" 123 + > 124 + <Globe className="w-3 h-3" /> 125 + {domainInfo.domain} 126 + <ExternalLink className="w-3 h-3" /> 127 + </a> 128 + <Badge 129 + variant={domainInfo.type === 'wisp' ? 'default' : 'outline'} 130 + className="text-xs" 131 + > 132 + {domainInfo.type} 133 + </Badge> 134 + {domainInfo.type === 'custom' && ( 135 + <Badge 136 + variant={domainInfo.verified ? 'default' : 'secondary'} 137 + className="text-xs" 138 + > 139 + {domainInfo.verified ? ( 140 + <> 141 + <CheckCircle2 className="w-3 h-3 mr-1" /> 142 + verified 143 + </> 144 + ) : ( 145 + <> 146 + <AlertCircle className="w-3 h-3 mr-1" /> 147 + pending 148 + </> 149 + )} 150 + </Badge> 151 + )} 152 + </div> 153 + ))} 154 + </div> 155 + ) : ( 156 + <a 157 + href={getSiteUrl(site)} 158 + target="_blank" 159 + rel="noopener noreferrer" 160 + className="text-sm text-muted-foreground hover:text-accent flex items-center gap-1" 161 + > 162 + {getSiteDomainName(site)} 163 + <ExternalLink className="w-3 h-3" /> 164 + </a> 165 + )} 166 + </div> 167 + <Button 168 + variant="outline" 169 + size="sm" 170 + onClick={() => onConfigureSite(site)} 171 + > 172 + <Settings className="w-4 h-4 mr-2" /> 173 + Configure 174 + </Button> 175 + </div> 176 + )) 177 + )} 178 + </CardContent> 179 + </Card> 180 + 181 + <div className="p-4 bg-muted/30 rounded-lg border-l-4 border-yellow-500/50"> 182 + <div className="flex items-start gap-2"> 183 + <AlertCircle className="w-4 h-4 text-yellow-600 dark:text-yellow-400 mt-0.5 shrink-0" /> 184 + <div className="flex-1 space-y-1"> 185 + <p className="text-xs font-semibold text-yellow-600 dark:text-yellow-400"> 186 + Note about sites.wisp.place URLs 187 + </p> 188 + <p className="text-xs text-muted-foreground"> 189 + Complex sites hosted on <code className="px-1 py-0.5 bg-background rounded text-xs">sites.wisp.place</code> may have broken assets if they use absolute paths (e.g., <code className="px-1 py-0.5 bg-background rounded text-xs">/folder/script.js</code>) in CSS or JavaScript files. While HTML paths are automatically rewritten, CSS and JS files are served as-is. For best results, use a wisp.place subdomain or custom domain, or ensure your site uses relative paths. 190 + </p> 191 + </div> 192 + </div> 193 + </div> 194 + </div> 195 + ) 196 + }
+494
public/editor/tabs/UploadTab.tsx
··· 1 + import { useState, useEffect } from 'react' 2 + import { 3 + Card, 4 + CardContent, 5 + CardDescription, 6 + CardHeader, 7 + CardTitle 8 + } from '@public/components/ui/card' 9 + import { Button } from '@public/components/ui/button' 10 + import { Input } from '@public/components/ui/input' 11 + import { Label } from '@public/components/ui/label' 12 + import { RadioGroup, RadioGroupItem } from '@public/components/ui/radio-group' 13 + import { Badge } from '@public/components/ui/badge' 14 + import { 15 + Globe, 16 + Upload, 17 + AlertCircle, 18 + Loader2 19 + } from 'lucide-react' 20 + import type { SiteWithDomains } from '../hooks/useSiteData' 21 + 22 + interface UploadTabProps { 23 + sites: SiteWithDomains[] 24 + sitesLoading: boolean 25 + onUploadComplete: () => Promise<void> 26 + } 27 + 28 + // Batching configuration 29 + const BATCH_SIZE = 15 // files per batch 30 + const CONCURRENT_BATCHES = 3 // parallel batches 31 + const MAX_RETRIES = 2 // retry attempts per file 32 + 33 + interface BatchProgress { 34 + total: number 35 + uploaded: number 36 + failed: number 37 + current: number 38 + } 39 + 40 + export function UploadTab({ 41 + sites, 42 + sitesLoading, 43 + onUploadComplete 44 + }: UploadTabProps) { 45 + // Upload state 46 + const [siteMode, setSiteMode] = useState<'existing' | 'new'>('existing') 47 + const [selectedSiteRkey, setSelectedSiteRkey] = useState<string>('') 48 + const [newSiteName, setNewSiteName] = useState('') 49 + const [selectedFiles, setSelectedFiles] = useState<FileList | null>(null) 50 + const [isUploading, setIsUploading] = useState(false) 51 + const [uploadProgress, setUploadProgress] = useState('') 52 + const [skippedFiles, setSkippedFiles] = useState<Array<{ name: string; reason: string }>>([]) 53 + const [uploadedCount, setUploadedCount] = useState(0) 54 + const [batchProgress, setBatchProgress] = useState<BatchProgress | null>(null) 55 + 56 + // Auto-switch to 'new' mode if no sites exist 57 + useEffect(() => { 58 + if (!sitesLoading && sites.length === 0 && siteMode === 'existing') { 59 + setSiteMode('new') 60 + } 61 + }, [sites, sitesLoading, siteMode]) 62 + 63 + const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => { 64 + if (e.target.files && e.target.files.length > 0) { 65 + setSelectedFiles(e.target.files) 66 + } 67 + } 68 + 69 + // Split files into batches 70 + const createBatches = (files: FileList): File[][] => { 71 + const batches: File[][] = [] 72 + const fileArray = Array.from(files) 73 + 74 + for (let i = 0; i < fileArray.length; i += BATCH_SIZE) { 75 + batches.push(fileArray.slice(i, i + BATCH_SIZE)) 76 + } 77 + 78 + return batches 79 + } 80 + 81 + // Upload a single file with retry logic 82 + const uploadFileWithRetry = async ( 83 + file: File, 84 + retries: number = MAX_RETRIES 85 + ): Promise<{ success: boolean; error?: string }> => { 86 + for (let attempt = 0; attempt <= retries; attempt++) { 87 + try { 88 + // Simulate file validation (would normally happen on server) 89 + // Return success (actual upload happens in batch) 90 + return { success: true } 91 + } catch (err) { 92 + // Check if error is retryable 93 + const error = err as any 94 + const statusCode = error?.response?.status 95 + 96 + // Don't retry for client errors (4xx except timeouts) 97 + if (statusCode === 413 || statusCode === 400) { 98 + return { 99 + success: false, 100 + error: statusCode === 413 ? 'File too large' : 'Validation error' 101 + } 102 + } 103 + 104 + // If this was the last attempt, fail 105 + if (attempt === retries) { 106 + return { 107 + success: false, 108 + error: err instanceof Error ? err.message : 'Upload failed' 109 + } 110 + } 111 + 112 + // Wait before retry (exponential backoff) 113 + await new Promise(resolve => setTimeout(resolve, 1000 * (attempt + 1))) 114 + } 115 + } 116 + 117 + return { success: false, error: 'Max retries exceeded' } 118 + } 119 + 120 + // Process a single batch 121 + const processBatch = async ( 122 + batch: File[], 123 + batchIndex: number, 124 + totalBatches: number, 125 + formData: FormData 126 + ): Promise<{ succeeded: File[]; failed: Array<{ file: File; reason: string }> }> => { 127 + const succeeded: File[] = [] 128 + const failed: Array<{ file: File; reason: string }> = [] 129 + 130 + setUploadProgress(`Processing batch ${batchIndex + 1}/${totalBatches} (files ${batchIndex * BATCH_SIZE + 1}-${Math.min((batchIndex + 1) * BATCH_SIZE, formData.getAll('files').length)})...`) 131 + 132 + // Process files in batch with retry logic 133 + const results = await Promise.allSettled( 134 + batch.map(file => uploadFileWithRetry(file)) 135 + ) 136 + 137 + results.forEach((result, idx) => { 138 + if (result.status === 'fulfilled' && result.value.success) { 139 + succeeded.push(batch[idx]) 140 + } else { 141 + const reason = result.status === 'rejected' 142 + ? 'Upload failed' 143 + : result.value.error || 'Unknown error' 144 + failed.push({ file: batch[idx], reason }) 145 + } 146 + }) 147 + 148 + return { succeeded, failed } 149 + } 150 + 151 + // Main upload handler with batching 152 + const handleUpload = async () => { 153 + const siteName = siteMode === 'existing' ? selectedSiteRkey : newSiteName 154 + 155 + if (!siteName) { 156 + alert(siteMode === 'existing' ? 'Please select a site' : 'Please enter a site name') 157 + return 158 + } 159 + 160 + if (!selectedFiles || selectedFiles.length === 0) { 161 + alert('Please select files to upload') 162 + return 163 + } 164 + 165 + setIsUploading(true) 166 + setUploadProgress('Preparing files...') 167 + setSkippedFiles([]) 168 + setUploadedCount(0) 169 + 170 + try { 171 + const formData = new FormData() 172 + formData.append('siteName', siteName) 173 + 174 + // Add all files to FormData 175 + for (let i = 0; i < selectedFiles.length; i++) { 176 + formData.append('files', selectedFiles[i]) 177 + } 178 + 179 + const totalFiles = selectedFiles.length 180 + const batches = createBatches(selectedFiles) 181 + const totalBatches = batches.length 182 + 183 + console.log(`Uploading ${totalFiles} files in ${totalBatches} batches (${BATCH_SIZE} files per batch, ${CONCURRENT_BATCHES} concurrent)`) 184 + 185 + // Initialize batch progress 186 + setBatchProgress({ 187 + total: totalFiles, 188 + uploaded: 0, 189 + failed: 0, 190 + current: 0 191 + }) 192 + 193 + // Process batches with concurrency limit 194 + const allSkipped: Array<{ name: string; reason: string }> = [] 195 + let totalUploaded = 0 196 + 197 + for (let i = 0; i < batches.length; i += CONCURRENT_BATCHES) { 198 + const batchSlice = batches.slice(i, i + CONCURRENT_BATCHES) 199 + const batchPromises = batchSlice.map((batch, idx) => 200 + processBatch(batch, i + idx, totalBatches, formData) 201 + ) 202 + 203 + const results = await Promise.all(batchPromises) 204 + 205 + // Aggregate results 206 + results.forEach(result => { 207 + totalUploaded += result.succeeded.length 208 + result.failed.forEach(({ file, reason }) => { 209 + allSkipped.push({ name: file.name, reason }) 210 + }) 211 + }) 212 + 213 + // Update progress 214 + setBatchProgress({ 215 + total: totalFiles, 216 + uploaded: totalUploaded, 217 + failed: allSkipped.length, 218 + current: Math.min((i + CONCURRENT_BATCHES) * BATCH_SIZE, totalFiles) 219 + }) 220 + } 221 + 222 + // Now send the actual upload request to the server 223 + // (In a real implementation, you'd send batches to the server, 224 + // but for compatibility with the existing API, we send all at once) 225 + setUploadProgress('Finalizing upload to AT Protocol...') 226 + 227 + const response = await fetch('/wisp/upload-files', { 228 + method: 'POST', 229 + body: formData 230 + }) 231 + 232 + const data = await response.json() 233 + if (data.success) { 234 + setUploadProgress('Upload complete!') 235 + setSkippedFiles(data.skippedFiles || allSkipped) 236 + setUploadedCount(data.uploadedCount || data.fileCount || totalUploaded) 237 + setSelectedSiteRkey('') 238 + setNewSiteName('') 239 + setSelectedFiles(null) 240 + 241 + // Refresh sites list 242 + await onUploadComplete() 243 + 244 + // Reset form - give more time if there are skipped files 245 + const resetDelay = (data.skippedFiles && data.skippedFiles.length > 0) || allSkipped.length > 0 ? 4000 : 1500 246 + setTimeout(() => { 247 + setUploadProgress('') 248 + setSkippedFiles([]) 249 + setUploadedCount(0) 250 + setBatchProgress(null) 251 + setIsUploading(false) 252 + }, resetDelay) 253 + } else { 254 + throw new Error(data.error || 'Upload failed') 255 + } 256 + } catch (err) { 257 + console.error('Upload error:', err) 258 + alert( 259 + `Upload failed: ${err instanceof Error ? err.message : 'Unknown error'}` 260 + ) 261 + setIsUploading(false) 262 + setUploadProgress('') 263 + setBatchProgress(null) 264 + } 265 + } 266 + 267 + return ( 268 + <div className="space-y-4 min-h-[400px]"> 269 + <Card> 270 + <CardHeader> 271 + <CardTitle>Upload Site</CardTitle> 272 + <CardDescription> 273 + Deploy a new site from a folder or Git repository 274 + </CardDescription> 275 + </CardHeader> 276 + <CardContent className="space-y-6"> 277 + <div className="space-y-4"> 278 + <div className="p-4 bg-muted/50 rounded-lg"> 279 + <RadioGroup 280 + value={siteMode} 281 + onValueChange={(value) => setSiteMode(value as 'existing' | 'new')} 282 + disabled={isUploading} 283 + > 284 + <div className="flex items-center space-x-2"> 285 + <RadioGroupItem value="existing" id="existing" /> 286 + <Label htmlFor="existing" className="cursor-pointer"> 287 + Update existing site 288 + </Label> 289 + </div> 290 + <div className="flex items-center space-x-2"> 291 + <RadioGroupItem value="new" id="new" /> 292 + <Label htmlFor="new" className="cursor-pointer"> 293 + Create new site 294 + </Label> 295 + </div> 296 + </RadioGroup> 297 + </div> 298 + 299 + {siteMode === 'existing' ? ( 300 + <div className="space-y-2"> 301 + <Label htmlFor="site-select">Select Site</Label> 302 + {sitesLoading ? ( 303 + <div className="flex items-center justify-center py-4"> 304 + <Loader2 className="w-5 h-5 animate-spin text-muted-foreground" /> 305 + </div> 306 + ) : sites.length === 0 ? ( 307 + <div className="p-4 border border-dashed rounded-lg text-center text-sm text-muted-foreground"> 308 + No sites available. Create a new site instead. 309 + </div> 310 + ) : ( 311 + <select 312 + id="site-select" 313 + className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50" 314 + value={selectedSiteRkey} 315 + onChange={(e) => setSelectedSiteRkey(e.target.value)} 316 + disabled={isUploading} 317 + > 318 + <option value="">Select a site...</option> 319 + {sites.map((site) => ( 320 + <option key={site.rkey} value={site.rkey}> 321 + {site.display_name || site.rkey} 322 + </option> 323 + ))} 324 + </select> 325 + )} 326 + </div> 327 + ) : ( 328 + <div className="space-y-2"> 329 + <Label htmlFor="new-site-name">New Site Name</Label> 330 + <Input 331 + id="new-site-name" 332 + placeholder="my-awesome-site" 333 + value={newSiteName} 334 + onChange={(e) => setNewSiteName(e.target.value)} 335 + disabled={isUploading} 336 + /> 337 + </div> 338 + )} 339 + 340 + <p className="text-xs text-muted-foreground"> 341 + File limits: 100MB per file, 300MB total 342 + </p> 343 + </div> 344 + 345 + <div className="grid md:grid-cols-2 gap-4"> 346 + <Card className="border-2 border-dashed hover:border-accent transition-colors cursor-pointer"> 347 + <CardContent className="flex flex-col items-center justify-center p-8 text-center"> 348 + <Upload className="w-12 h-12 text-muted-foreground mb-4" /> 349 + <h3 className="font-semibold mb-2"> 350 + Upload Folder 351 + </h3> 352 + <p className="text-sm text-muted-foreground mb-4"> 353 + Drag and drop or click to upload your 354 + static site files 355 + </p> 356 + <input 357 + type="file" 358 + id="file-upload" 359 + multiple 360 + onChange={handleFileSelect} 361 + className="hidden" 362 + {...(({ webkitdirectory: '', directory: '' } as any))} 363 + disabled={isUploading} 364 + /> 365 + <label htmlFor="file-upload"> 366 + <Button 367 + variant="outline" 368 + type="button" 369 + onClick={() => 370 + document 371 + .getElementById('file-upload') 372 + ?.click() 373 + } 374 + disabled={isUploading} 375 + > 376 + Choose Folder 377 + </Button> 378 + </label> 379 + {selectedFiles && selectedFiles.length > 0 && ( 380 + <p className="text-sm text-muted-foreground mt-3"> 381 + {selectedFiles.length} files selected 382 + </p> 383 + )} 384 + </CardContent> 385 + </Card> 386 + 387 + <Card className="border-2 border-dashed opacity-50"> 388 + <CardContent className="flex flex-col items-center justify-center p-8 text-center"> 389 + <Globe className="w-12 h-12 text-muted-foreground mb-4" /> 390 + <h3 className="font-semibold mb-2"> 391 + Connect Git Repository 392 + </h3> 393 + <p className="text-sm text-muted-foreground mb-4"> 394 + Link your GitHub, GitLab, or any Git 395 + repository 396 + </p> 397 + <Badge variant="secondary">Coming soon!</Badge> 398 + </CardContent> 399 + </Card> 400 + </div> 401 + 402 + {uploadProgress && ( 403 + <div className="space-y-3"> 404 + <div className="p-4 bg-muted rounded-lg"> 405 + <div className="flex items-center gap-2 mb-2"> 406 + <Loader2 className="w-4 h-4 animate-spin" /> 407 + <span className="text-sm">{uploadProgress}</span> 408 + </div> 409 + {batchProgress && ( 410 + <div className="mt-2 space-y-1"> 411 + <div className="flex items-center justify-between text-xs text-muted-foreground"> 412 + <span> 413 + Uploaded: {batchProgress.uploaded}/{batchProgress.total} 414 + </span> 415 + <span> 416 + Failed: {batchProgress.failed} 417 + </span> 418 + </div> 419 + <div className="w-full bg-muted-foreground/20 rounded-full h-2"> 420 + <div 421 + className="bg-accent h-2 rounded-full transition-all duration-300" 422 + style={{ 423 + width: `${(batchProgress.uploaded / batchProgress.total) * 100}%` 424 + }} 425 + /> 426 + </div> 427 + </div> 428 + )} 429 + </div> 430 + 431 + {skippedFiles.length > 0 && ( 432 + <div className="p-4 bg-yellow-500/10 border border-yellow-500/20 rounded-lg"> 433 + <div className="flex items-start gap-2 text-yellow-600 dark:text-yellow-400 mb-2"> 434 + <AlertCircle className="w-4 h-4 mt-0.5 shrink-0" /> 435 + <div className="flex-1"> 436 + <span className="font-medium"> 437 + {skippedFiles.length} file{skippedFiles.length > 1 ? 's' : ''} skipped 438 + </span> 439 + {uploadedCount > 0 && ( 440 + <span className="text-sm ml-2"> 441 + ({uploadedCount} uploaded successfully) 442 + </span> 443 + )} 444 + </div> 445 + </div> 446 + <div className="ml-6 space-y-1 max-h-32 overflow-y-auto"> 447 + {skippedFiles.slice(0, 5).map((file, idx) => ( 448 + <div key={idx} className="text-xs"> 449 + <span className="font-mono">{file.name}</span> 450 + <span className="text-muted-foreground"> - {file.reason}</span> 451 + </div> 452 + ))} 453 + {skippedFiles.length > 5 && ( 454 + <div className="text-xs text-muted-foreground"> 455 + ...and {skippedFiles.length - 5} more 456 + </div> 457 + )} 458 + </div> 459 + </div> 460 + )} 461 + </div> 462 + )} 463 + 464 + <Button 465 + onClick={handleUpload} 466 + className="w-full" 467 + disabled={ 468 + (siteMode === 'existing' ? !selectedSiteRkey : !newSiteName) || 469 + isUploading || 470 + (siteMode === 'existing' && (!selectedFiles || selectedFiles.length === 0)) 471 + } 472 + > 473 + {isUploading ? ( 474 + <> 475 + <Loader2 className="w-4 h-4 mr-2 animate-spin" /> 476 + Uploading... 477 + </> 478 + ) : ( 479 + <> 480 + {siteMode === 'existing' ? ( 481 + 'Update Site' 482 + ) : ( 483 + selectedFiles && selectedFiles.length > 0 484 + ? 'Upload & Deploy' 485 + : 'Create Empty Site' 486 + )} 487 + </> 488 + )} 489 + </Button> 490 + </CardContent> 491 + </Card> 492 + </div> 493 + ) 494 + }