Monorepo for wisp.place. A static site hosting service built on top of the AT Protocol. wisp.place
at main 31 kB view raw
1import { useState, useEffect, useRef } from 'react' 2import { 3 Card, 4 CardContent, 5 CardDescription, 6 CardHeader, 7 CardTitle 8} from '@public/components/ui/card' 9import { Button } from '@public/components/ui/button' 10import { Input } from '@public/components/ui/input' 11import { Label } from '@public/components/ui/label' 12import { RadioGroup, RadioGroupItem } from '@public/components/ui/radio-group' 13import { Badge } from '@public/components/ui/badge' 14import { 15 Globe, 16 Upload, 17 AlertCircle, 18 Loader2, 19 ChevronDown, 20 ChevronUp, 21 CheckCircle2, 22 XCircle, 23 RefreshCw 24} from 'lucide-react' 25import type { SiteWithDomains } from '../hooks/useSiteData' 26 27type FileStatus = 'pending' | 'checking' | 'uploading' | 'uploaded' | 'reused' | 'failed' 28 29interface FileProgress { 30 name: string 31 status: FileStatus 32 error?: string 33} 34 35interface UploadTabProps { 36 sites: SiteWithDomains[] 37 sitesLoading: boolean 38 onUploadComplete: () => Promise<void> 39} 40 41export function UploadTab({ 42 sites, 43 sitesLoading, 44 onUploadComplete 45}: UploadTabProps) { 46 // Upload state 47 const [siteMode, setSiteMode] = useState<'existing' | 'new'>('existing') 48 const [selectedSiteRkey, setSelectedSiteRkey] = useState<string>('') 49 const [newSiteName, setNewSiteName] = useState('') 50 const [selectedFiles, setSelectedFiles] = useState<FileList | null>(null) 51 const [isUploading, setIsUploading] = useState(false) 52 const [uploadProgress, setUploadProgress] = useState('') 53 const [skippedFiles, setSkippedFiles] = useState<Array<{ name: string; reason: string }>>([]) 54 const [failedFiles, setFailedFiles] = useState<Array<{ name: string; index: number; error: string; size: number }>>([]) 55 const [uploadedCount, setUploadedCount] = useState(0) 56 const [fileProgressList, setFileProgressList] = useState<FileProgress[]>([]) 57 const [showFileProgress, setShowFileProgress] = useState(false) 58 59 // Keep SSE connection alive across tab switches 60 const eventSourceRef = useRef<EventSource | null>(null) 61 const currentJobIdRef = useRef<string | null>(null) 62 63 // Auto-switch to 'new' mode if no sites exist 64 useEffect(() => { 65 if (!sitesLoading && sites.length === 0 && siteMode === 'existing') { 66 setSiteMode('new') 67 } 68 }, [sites, sitesLoading, siteMode]) 69 70 // Cleanup SSE connection on unmount 71 useEffect(() => { 72 return () => { 73 // Don't close the connection on unmount (tab switch) 74 // It will be reused when the component remounts 75 } 76 }, []) 77 78 const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => { 79 if (e.target.files && e.target.files.length > 0) { 80 setSelectedFiles(e.target.files) 81 } 82 } 83 84 const setupSSE = (jobId: string) => { 85 // Close existing connection if any 86 if (eventSourceRef.current) { 87 eventSourceRef.current.close() 88 } 89 90 currentJobIdRef.current = jobId 91 const eventSource = new EventSource(`/wisp/upload-progress/${jobId}`) 92 eventSourceRef.current = eventSource 93 94 eventSource.addEventListener('progress', (event) => { 95 const progressData = JSON.parse(event.data) 96 const { progress, status } = progressData 97 98 // Update file progress list if we have current file info 99 if (progress.currentFile && progress.currentFileStatus) { 100 setFileProgressList(prev => { 101 const existingIndex = prev.findIndex(f => f.name === progress.currentFile) 102 if (existingIndex !== -1) { 103 // Update existing file status - create new array with single update 104 const updated = [...prev] 105 updated[existingIndex] = { ...updated[existingIndex], status: progress.currentFileStatus as FileStatus } 106 return updated 107 } else { 108 // Add new file 109 return [...prev, { 110 name: progress.currentFile, 111 status: progress.currentFileStatus as FileStatus 112 }] 113 } 114 }) 115 } 116 117 // Update progress message based on phase 118 let message = 'Processing...' 119 if (progress.phase === 'validating') { 120 message = 'Validating files...' 121 } else if (progress.phase === 'compressing') { 122 const current = progress.filesProcessed || 0 123 const total = progress.totalFiles || 0 124 message = `Compressing files (${current}/${total})...` 125 if (progress.currentFile) { 126 message += ` - ${progress.currentFile}` 127 } 128 } else if (progress.phase === 'uploading') { 129 const uploaded = progress.filesUploaded || 0 130 const reused = progress.filesReused || 0 131 const total = progress.totalFiles || 0 132 message = `Uploading to PDS (${uploaded + reused}/${total})...` 133 } else if (progress.phase === 'creating_manifest') { 134 message = 'Creating manifest...' 135 } else if (progress.phase === 'finalizing') { 136 message = 'Finalizing upload...' 137 } 138 139 setUploadProgress(message) 140 }) 141 142 eventSource.addEventListener('done', (event) => { 143 const result = JSON.parse(event.data) 144 eventSource.close() 145 eventSourceRef.current = null 146 currentJobIdRef.current = null 147 148 const hasIssues = (result.skippedFiles && result.skippedFiles.length > 0) || 149 (result.failedFiles && result.failedFiles.length > 0) 150 151 // Update file progress list with failed files 152 if (result.failedFiles && result.failedFiles.length > 0) { 153 setFileProgressList(prev => { 154 const updated = [...prev] 155 result.failedFiles.forEach((failedFile: any) => { 156 const existingIndex = updated.findIndex(f => f.name === failedFile.name) 157 if (existingIndex !== -1) { 158 updated[existingIndex] = { 159 ...updated[existingIndex], 160 status: 'failed', 161 error: failedFile.error 162 } 163 } else { 164 updated.push({ 165 name: failedFile.name, 166 status: 'failed', 167 error: failedFile.error 168 }) 169 } 170 }) 171 return updated 172 }) 173 } 174 175 setUploadProgress(hasIssues ? 'Upload completed with issues' : 'Upload complete!') 176 setSkippedFiles(result.skippedFiles || []) 177 setFailedFiles(result.failedFiles || []) 178 setUploadedCount(result.uploadedCount || result.fileCount || 0) 179 180 // Debug: log failed files 181 console.log('Failed files:', result.failedFiles) 182 183 // Check for 419/413 errors and show alert 184 const hasSizeError = result.failedFiles?.some((file: any) => { 185 const error = file.error?.toLowerCase() || '' 186 console.log('Checking error:', error, 'contains PDS?', error.includes('pds')) 187 return error.includes('pds is not allowing') || 188 error.includes('your pds is not allowing') || 189 error.includes('request entity too large') 190 }) 191 192 console.log('Has size error:', hasSizeError) 193 194 if (hasSizeError) { 195 window.alert('Some files were too large for your PDS. Your PDS is not allowing uploads large enough to store your site. Please contact your PDS host. This could also possibly be a result of it being behind Cloudflare free tier.') 196 } 197 198 setSelectedSiteRkey('') 199 setNewSiteName('') 200 setSelectedFiles(null) 201 202 // Refresh sites list 203 onUploadComplete() 204 205 // Reset form (wait longer if there are issues to show) 206 const resetDelay = hasIssues ? 6000 : 1500 207 setTimeout(() => { 208 setUploadProgress('') 209 setSkippedFiles([]) 210 setFailedFiles([]) 211 setUploadedCount(0) 212 setFileProgressList([]) 213 setIsUploading(false) 214 }, resetDelay) 215 }) 216 217 eventSource.addEventListener('error', (event) => { 218 const errorData = JSON.parse((event as any).data || '{}') 219 eventSource.close() 220 eventSourceRef.current = null 221 currentJobIdRef.current = null 222 223 console.error('Upload error:', errorData) 224 alert( 225 `Upload failed: ${errorData.error || 'Unknown error'}` 226 ) 227 setIsUploading(false) 228 setUploadProgress('') 229 }) 230 231 eventSource.onerror = () => { 232 eventSource.close() 233 eventSourceRef.current = null 234 currentJobIdRef.current = null 235 236 console.error('SSE connection error') 237 alert('Lost connection to upload progress. The upload may still be processing.') 238 setIsUploading(false) 239 setUploadProgress('') 240 } 241 } 242 243 const handleUpload = async () => { 244 const siteName = siteMode === 'existing' ? selectedSiteRkey : newSiteName 245 246 if (!siteName) { 247 alert(siteMode === 'existing' ? 'Please select a site' : 'Please enter a site name') 248 return 249 } 250 251 setIsUploading(true) 252 setUploadProgress('Preparing files...') 253 254 try { 255 const formData = new FormData() 256 formData.append('siteName', siteName) 257 258 if (selectedFiles) { 259 for (let i = 0; i < selectedFiles.length; i++) { 260 formData.append('files', selectedFiles[i]) 261 } 262 } 263 264 // If no files, handle synchronously (old behavior) 265 if (!selectedFiles || selectedFiles.length === 0) { 266 setUploadProgress('Creating empty site...') 267 const response = await fetch('/wisp/upload-files', { 268 method: 'POST', 269 body: formData 270 }) 271 272 const data = await response.json() 273 if (data.success) { 274 setUploadProgress('Site created!') 275 setSelectedSiteRkey('') 276 setNewSiteName('') 277 setSelectedFiles(null) 278 279 await onUploadComplete() 280 281 setTimeout(() => { 282 setUploadProgress('') 283 setIsUploading(false) 284 }, 1500) 285 } else { 286 throw new Error(data.error || 'Upload failed') 287 } 288 return 289 } 290 291 // For file uploads, use SSE for progress 292 setUploadProgress('Starting upload...') 293 const response = await fetch('/wisp/upload-files', { 294 method: 'POST', 295 body: formData 296 }) 297 298 const data = await response.json() 299 if (!data.success || !data.jobId) { 300 throw new Error(data.error || 'Failed to start upload') 301 } 302 303 const jobId = data.jobId 304 setUploadProgress('Connecting to progress stream...') 305 306 // Setup SSE connection (persists across tab switches via ref) 307 setupSSE(jobId) 308 309 } catch (err) { 310 console.error('Upload error:', err) 311 alert( 312 `Upload failed: ${err instanceof Error ? err.message : 'Unknown error'}` 313 ) 314 setIsUploading(false) 315 setUploadProgress('') 316 } 317 } 318 319 return ( 320 <div className="space-y-4 min-h-[400px]"> 321 <Card> 322 <CardHeader> 323 <CardTitle>Upload Site</CardTitle> 324 <CardDescription> 325 Deploy a new site from a folder or Git repository 326 </CardDescription> 327 </CardHeader> 328 <CardContent className="space-y-6"> 329 <div className="space-y-4"> 330 <div className="p-4 bg-muted/50 rounded-lg"> 331 <RadioGroup 332 value={siteMode} 333 onValueChange={(value) => setSiteMode(value as 'existing' | 'new')} 334 disabled={isUploading} 335 > 336 <div className="flex items-center space-x-2"> 337 <RadioGroupItem value="existing" id="existing" /> 338 <Label htmlFor="existing" className="cursor-pointer"> 339 Update existing site 340 </Label> 341 </div> 342 <div className="flex items-center space-x-2"> 343 <RadioGroupItem value="new" id="new" /> 344 <Label htmlFor="new" className="cursor-pointer"> 345 Create new site 346 </Label> 347 </div> 348 </RadioGroup> 349 </div> 350 351 {siteMode === 'existing' ? ( 352 <div className="space-y-2"> 353 <Label htmlFor="site-select">Select Site</Label> 354 {sitesLoading ? ( 355 <div className="flex items-center justify-center py-4"> 356 <Loader2 className="w-5 h-5 animate-spin text-muted-foreground" /> 357 </div> 358 ) : sites.length === 0 ? ( 359 <div className="p-4 border border-dashed rounded-lg text-center text-sm text-muted-foreground"> 360 No sites available. Create a new site instead. 361 </div> 362 ) : ( 363 <select 364 id="site-select" 365 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" 366 value={selectedSiteRkey} 367 onChange={(e) => setSelectedSiteRkey(e.target.value)} 368 disabled={isUploading} 369 > 370 <option value="">Select a site...</option> 371 {sites.map((site) => ( 372 <option key={site.rkey} value={site.rkey}> 373 {site.display_name || site.rkey} 374 </option> 375 ))} 376 </select> 377 )} 378 </div> 379 ) : ( 380 <div className="space-y-2"> 381 <Label htmlFor="new-site-name">New Site Name</Label> 382 <Input 383 id="new-site-name" 384 placeholder="my-awesome-site" 385 value={newSiteName} 386 onChange={(e) => setNewSiteName(e.target.value)} 387 disabled={isUploading} 388 /> 389 </div> 390 )} 391 392 <p className="text-xs text-muted-foreground"> 393 File limits: 100MB per file, 300MB total 394 </p> 395 </div> 396 397 <div className="grid md:grid-cols-2 gap-4"> 398 <Card className="border-2 border-dashed hover:border-accent transition-colors cursor-pointer"> 399 <CardContent className="flex flex-col items-center justify-center p-8 text-center"> 400 <Upload className="w-12 h-12 text-muted-foreground mb-4" /> 401 <h3 className="font-semibold mb-2"> 402 Upload Folder 403 </h3> 404 <p className="text-sm text-muted-foreground mb-4"> 405 Drag and drop or click to upload your 406 static site files 407 </p> 408 <input 409 type="file" 410 id="file-upload" 411 multiple 412 onChange={handleFileSelect} 413 className="hidden" 414 {...(({ webkitdirectory: '', directory: '' } as any))} 415 disabled={isUploading} 416 /> 417 <label htmlFor="file-upload"> 418 <Button 419 variant="outline" 420 type="button" 421 onClick={() => 422 document 423 .getElementById('file-upload') 424 ?.click() 425 } 426 disabled={isUploading} 427 > 428 Choose Folder 429 </Button> 430 </label> 431 {selectedFiles && selectedFiles.length > 0 && ( 432 <p className="text-sm text-muted-foreground mt-3"> 433 {selectedFiles.length} files selected 434 </p> 435 )} 436 </CardContent> 437 </Card> 438 439 <Card className="border-2 border-dashed opacity-50"> 440 <CardContent className="flex flex-col items-center justify-center p-8 text-center"> 441 <Globe className="w-12 h-12 text-muted-foreground mb-4" /> 442 <h3 className="font-semibold mb-2"> 443 Connect Git Repository 444 </h3> 445 <p className="text-sm text-muted-foreground mb-4"> 446 Link your GitHub, GitLab, or any Git 447 repository 448 </p> 449 <Badge variant="secondary">Coming soon!</Badge> 450 </CardContent> 451 </Card> 452 </div> 453 454 {uploadProgress && ( 455 <div className="space-y-3"> 456 <div className="p-4 bg-muted rounded-lg"> 457 <div className="flex items-center gap-2"> 458 <Loader2 className="w-4 h-4 animate-spin" /> 459 <span className="text-sm">{uploadProgress}</span> 460 </div> 461 </div> 462 463 {fileProgressList.length > 0 && ( 464 <div className="border rounded-lg overflow-hidden"> 465 <button 466 onClick={() => setShowFileProgress(!showFileProgress)} 467 className="w-full p-3 bg-muted/50 hover:bg-muted transition-colors flex items-center justify-between text-sm font-medium" 468 > 469 <span> 470 Processing files ({fileProgressList.filter(f => f.status === 'uploaded' || f.status === 'reused').length}/{fileProgressList.length}) 471 </span> 472 {showFileProgress ? ( 473 <ChevronUp className="w-4 h-4" /> 474 ) : ( 475 <ChevronDown className="w-4 h-4" /> 476 )} 477 </button> 478 {showFileProgress && ( 479 <div className="max-h-64 overflow-y-auto p-3 space-y-1 bg-background"> 480 {fileProgressList.map((file, idx) => ( 481 <div 482 key={idx} 483 className="flex items-start gap-2 text-xs p-2 rounded hover:bg-muted/50 transition-colors" 484 > 485 {file.status === 'checking' && ( 486 <Loader2 className="w-3 h-3 mt-0.5 animate-spin text-blue-500 shrink-0" /> 487 )} 488 {file.status === 'uploading' && ( 489 <Loader2 className="w-3 h-3 mt-0.5 animate-spin text-purple-500 shrink-0" /> 490 )} 491 {file.status === 'uploaded' && ( 492 <CheckCircle2 className="w-3 h-3 mt-0.5 text-green-500 shrink-0" /> 493 )} 494 {file.status === 'reused' && ( 495 <RefreshCw className="w-3 h-3 mt-0.5 text-cyan-500 shrink-0" /> 496 )} 497 {file.status === 'failed' && ( 498 <XCircle className="w-3 h-3 mt-0.5 text-red-500 shrink-0" /> 499 )} 500 <div className="flex-1 min-w-0"> 501 <div className="font-mono truncate">{file.name}</div> 502 {file.error && ( 503 <div className="text-red-500 mt-0.5"> 504 {file.error} 505 </div> 506 )} 507 {file.status === 'checking' && ( 508 <div className="text-muted-foreground">Checking for changes...</div> 509 )} 510 {file.status === 'uploading' && ( 511 <div className="text-muted-foreground">Uploading to PDS...</div> 512 )} 513 {file.status === 'reused' && ( 514 <div className="text-muted-foreground">Reused (unchanged)</div> 515 )} 516 </div> 517 </div> 518 ))} 519 </div> 520 )} 521 </div> 522 )} 523 524 {failedFiles.length > 0 && ( 525 <div className="p-4 bg-red-500/10 border border-red-500/20 rounded-lg"> 526 <div className="flex items-start gap-2 text-red-600 dark:text-red-400 mb-2"> 527 <AlertCircle className="w-4 h-4 mt-0.5 shrink-0" /> 528 <div className="flex-1"> 529 <span className="font-medium"> 530 {failedFiles.length} file{failedFiles.length > 1 ? 's' : ''} failed to upload 531 </span> 532 {uploadedCount > 0 && ( 533 <span className="text-sm ml-2"> 534 ({uploadedCount} uploaded successfully) 535 </span> 536 )} 537 </div> 538 </div> 539 <div className="ml-6 space-y-1 max-h-40 overflow-y-auto"> 540 {failedFiles.slice(0, 10).map((file, idx) => ( 541 <div key={idx} className="text-xs"> 542 <div className="font-mono font-semibold">{file.name}</div> 543 <div className="text-muted-foreground ml-2"> 544 Error: {file.error} 545 {file.size > 0 && ` (${(file.size / 1024).toFixed(1)} KB)`} 546 </div> 547 </div> 548 ))} 549 {failedFiles.length > 10 && ( 550 <div className="text-xs text-muted-foreground"> 551 ...and {failedFiles.length - 10} more 552 </div> 553 )} 554 </div> 555 </div> 556 )} 557 558 {skippedFiles.length > 0 && ( 559 <div className="p-4 bg-yellow-500/10 border border-yellow-500/20 rounded-lg"> 560 <div className="flex items-start gap-2 text-yellow-600 dark:text-yellow-400 mb-2"> 561 <AlertCircle className="w-4 h-4 mt-0.5 shrink-0" /> 562 <div className="flex-1"> 563 <span className="font-medium"> 564 {skippedFiles.length} file{skippedFiles.length > 1 ? 's' : ''} skipped 565 </span> 566 </div> 567 </div> 568 <div className="ml-6 space-y-1 max-h-32 overflow-y-auto"> 569 {skippedFiles.slice(0, 5).map((file, idx) => ( 570 <div key={idx} className="text-xs"> 571 <span className="font-mono">{file.name}</span> 572 <span className="text-muted-foreground"> - {file.reason}</span> 573 </div> 574 ))} 575 {skippedFiles.length > 5 && ( 576 <div className="text-xs text-muted-foreground"> 577 ...and {skippedFiles.length - 5} more 578 </div> 579 )} 580 </div> 581 </div> 582 )} 583 </div> 584 )} 585 586 <Button 587 onClick={handleUpload} 588 className="w-full" 589 disabled={ 590 (siteMode === 'existing' ? !selectedSiteRkey : !newSiteName) || 591 isUploading || 592 (siteMode === 'existing' && (!selectedFiles || selectedFiles.length === 0)) 593 } 594 > 595 {isUploading ? ( 596 <> 597 <Loader2 className="w-4 h-4 mr-2 animate-spin" /> 598 Uploading... 599 </> 600 ) : ( 601 <> 602 {siteMode === 'existing' ? ( 603 'Update Site' 604 ) : ( 605 selectedFiles && selectedFiles.length > 0 606 ? 'Upload & Deploy' 607 : 'Create Empty Site' 608 )} 609 </> 610 )} 611 </Button> 612 </CardContent> 613 </Card> 614 </div> 615 ) 616}