Monorepo for wisp.place. A static site hosting service built on top of the AT Protocol. wisp.place
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 setSelectedSiteRkey('') 180 setNewSiteName('') 181 setSelectedFiles(null) 182 183 // Refresh sites list 184 onUploadComplete() 185 186 // Reset form (wait longer if there are issues to show) 187 const resetDelay = hasIssues ? 6000 : 1500 188 setTimeout(() => { 189 setUploadProgress('') 190 setSkippedFiles([]) 191 setFailedFiles([]) 192 setUploadedCount(0) 193 setFileProgressList([]) 194 setIsUploading(false) 195 }, resetDelay) 196 }) 197 198 eventSource.addEventListener('error', (event) => { 199 const errorData = JSON.parse((event as any).data || '{}') 200 eventSource.close() 201 eventSourceRef.current = null 202 currentJobIdRef.current = null 203 204 console.error('Upload error:', errorData) 205 alert( 206 `Upload failed: ${errorData.error || 'Unknown error'}` 207 ) 208 setIsUploading(false) 209 setUploadProgress('') 210 }) 211 212 eventSource.onerror = () => { 213 eventSource.close() 214 eventSourceRef.current = null 215 currentJobIdRef.current = null 216 217 console.error('SSE connection error') 218 alert('Lost connection to upload progress. The upload may still be processing.') 219 setIsUploading(false) 220 setUploadProgress('') 221 } 222 } 223 224 const handleUpload = async () => { 225 const siteName = siteMode === 'existing' ? selectedSiteRkey : newSiteName 226 227 if (!siteName) { 228 alert(siteMode === 'existing' ? 'Please select a site' : 'Please enter a site name') 229 return 230 } 231 232 setIsUploading(true) 233 setUploadProgress('Preparing files...') 234 235 try { 236 const formData = new FormData() 237 formData.append('siteName', siteName) 238 239 if (selectedFiles) { 240 for (let i = 0; i < selectedFiles.length; i++) { 241 formData.append('files', selectedFiles[i]) 242 } 243 } 244 245 // If no files, handle synchronously (old behavior) 246 if (!selectedFiles || selectedFiles.length === 0) { 247 setUploadProgress('Creating empty site...') 248 const response = await fetch('/wisp/upload-files', { 249 method: 'POST', 250 body: formData 251 }) 252 253 const data = await response.json() 254 if (data.success) { 255 setUploadProgress('Site created!') 256 setSelectedSiteRkey('') 257 setNewSiteName('') 258 setSelectedFiles(null) 259 260 await onUploadComplete() 261 262 setTimeout(() => { 263 setUploadProgress('') 264 setIsUploading(false) 265 }, 1500) 266 } else { 267 throw new Error(data.error || 'Upload failed') 268 } 269 return 270 } 271 272 // For file uploads, use SSE for progress 273 setUploadProgress('Starting upload...') 274 const response = await fetch('/wisp/upload-files', { 275 method: 'POST', 276 body: formData 277 }) 278 279 const data = await response.json() 280 if (!data.success || !data.jobId) { 281 throw new Error(data.error || 'Failed to start upload') 282 } 283 284 const jobId = data.jobId 285 setUploadProgress('Connecting to progress stream...') 286 287 // Setup SSE connection (persists across tab switches via ref) 288 setupSSE(jobId) 289 290 } catch (err) { 291 console.error('Upload error:', err) 292 alert( 293 `Upload failed: ${err instanceof Error ? err.message : 'Unknown error'}` 294 ) 295 setIsUploading(false) 296 setUploadProgress('') 297 } 298 } 299 300 return ( 301 <div className="space-y-4 min-h-[400px]"> 302 <Card> 303 <CardHeader> 304 <CardTitle>Upload Site</CardTitle> 305 <CardDescription> 306 Deploy a new site from a folder or Git repository 307 </CardDescription> 308 </CardHeader> 309 <CardContent className="space-y-6"> 310 <div className="space-y-4"> 311 <div className="p-4 bg-muted/50 rounded-lg"> 312 <RadioGroup 313 value={siteMode} 314 onValueChange={(value) => setSiteMode(value as 'existing' | 'new')} 315 disabled={isUploading} 316 > 317 <div className="flex items-center space-x-2"> 318 <RadioGroupItem value="existing" id="existing" /> 319 <Label htmlFor="existing" className="cursor-pointer"> 320 Update existing site 321 </Label> 322 </div> 323 <div className="flex items-center space-x-2"> 324 <RadioGroupItem value="new" id="new" /> 325 <Label htmlFor="new" className="cursor-pointer"> 326 Create new site 327 </Label> 328 </div> 329 </RadioGroup> 330 </div> 331 332 {siteMode === 'existing' ? ( 333 <div className="space-y-2"> 334 <Label htmlFor="site-select">Select Site</Label> 335 {sitesLoading ? ( 336 <div className="flex items-center justify-center py-4"> 337 <Loader2 className="w-5 h-5 animate-spin text-muted-foreground" /> 338 </div> 339 ) : sites.length === 0 ? ( 340 <div className="p-4 border border-dashed rounded-lg text-center text-sm text-muted-foreground"> 341 No sites available. Create a new site instead. 342 </div> 343 ) : ( 344 <select 345 id="site-select" 346 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" 347 value={selectedSiteRkey} 348 onChange={(e) => setSelectedSiteRkey(e.target.value)} 349 disabled={isUploading} 350 > 351 <option value="">Select a site...</option> 352 {sites.map((site) => ( 353 <option key={site.rkey} value={site.rkey}> 354 {site.display_name || site.rkey} 355 </option> 356 ))} 357 </select> 358 )} 359 </div> 360 ) : ( 361 <div className="space-y-2"> 362 <Label htmlFor="new-site-name">New Site Name</Label> 363 <Input 364 id="new-site-name" 365 placeholder="my-awesome-site" 366 value={newSiteName} 367 onChange={(e) => setNewSiteName(e.target.value)} 368 disabled={isUploading} 369 /> 370 </div> 371 )} 372 373 <p className="text-xs text-muted-foreground"> 374 File limits: 100MB per file, 300MB total 375 </p> 376 </div> 377 378 <div className="grid md:grid-cols-2 gap-4"> 379 <Card className="border-2 border-dashed hover:border-accent transition-colors cursor-pointer"> 380 <CardContent className="flex flex-col items-center justify-center p-8 text-center"> 381 <Upload className="w-12 h-12 text-muted-foreground mb-4" /> 382 <h3 className="font-semibold mb-2"> 383 Upload Folder 384 </h3> 385 <p className="text-sm text-muted-foreground mb-4"> 386 Drag and drop or click to upload your 387 static site files 388 </p> 389 <input 390 type="file" 391 id="file-upload" 392 multiple 393 onChange={handleFileSelect} 394 className="hidden" 395 {...(({ webkitdirectory: '', directory: '' } as any))} 396 disabled={isUploading} 397 /> 398 <label htmlFor="file-upload"> 399 <Button 400 variant="outline" 401 type="button" 402 onClick={() => 403 document 404 .getElementById('file-upload') 405 ?.click() 406 } 407 disabled={isUploading} 408 > 409 Choose Folder 410 </Button> 411 </label> 412 {selectedFiles && selectedFiles.length > 0 && ( 413 <p className="text-sm text-muted-foreground mt-3"> 414 {selectedFiles.length} files selected 415 </p> 416 )} 417 </CardContent> 418 </Card> 419 420 <Card className="border-2 border-dashed opacity-50"> 421 <CardContent className="flex flex-col items-center justify-center p-8 text-center"> 422 <Globe className="w-12 h-12 text-muted-foreground mb-4" /> 423 <h3 className="font-semibold mb-2"> 424 Connect Git Repository 425 </h3> 426 <p className="text-sm text-muted-foreground mb-4"> 427 Link your GitHub, GitLab, or any Git 428 repository 429 </p> 430 <Badge variant="secondary">Coming soon!</Badge> 431 </CardContent> 432 </Card> 433 </div> 434 435 {uploadProgress && ( 436 <div className="space-y-3"> 437 <div className="p-4 bg-muted rounded-lg"> 438 <div className="flex items-center gap-2"> 439 <Loader2 className="w-4 h-4 animate-spin" /> 440 <span className="text-sm">{uploadProgress}</span> 441 </div> 442 </div> 443 444 {fileProgressList.length > 0 && ( 445 <div className="border rounded-lg overflow-hidden"> 446 <button 447 onClick={() => setShowFileProgress(!showFileProgress)} 448 className="w-full p-3 bg-muted/50 hover:bg-muted transition-colors flex items-center justify-between text-sm font-medium" 449 > 450 <span> 451 Processing files ({fileProgressList.filter(f => f.status === 'uploaded' || f.status === 'reused').length}/{fileProgressList.length}) 452 </span> 453 {showFileProgress ? ( 454 <ChevronUp className="w-4 h-4" /> 455 ) : ( 456 <ChevronDown className="w-4 h-4" /> 457 )} 458 </button> 459 {showFileProgress && ( 460 <div className="max-h-64 overflow-y-auto p-3 space-y-1 bg-background"> 461 {fileProgressList.map((file, idx) => ( 462 <div 463 key={idx} 464 className="flex items-start gap-2 text-xs p-2 rounded hover:bg-muted/50 transition-colors" 465 > 466 {file.status === 'checking' && ( 467 <Loader2 className="w-3 h-3 mt-0.5 animate-spin text-blue-500 shrink-0" /> 468 )} 469 {file.status === 'uploading' && ( 470 <Loader2 className="w-3 h-3 mt-0.5 animate-spin text-purple-500 shrink-0" /> 471 )} 472 {file.status === 'uploaded' && ( 473 <CheckCircle2 className="w-3 h-3 mt-0.5 text-green-500 shrink-0" /> 474 )} 475 {file.status === 'reused' && ( 476 <RefreshCw className="w-3 h-3 mt-0.5 text-cyan-500 shrink-0" /> 477 )} 478 {file.status === 'failed' && ( 479 <XCircle className="w-3 h-3 mt-0.5 text-red-500 shrink-0" /> 480 )} 481 <div className="flex-1 min-w-0"> 482 <div className="font-mono truncate">{file.name}</div> 483 {file.error && ( 484 <div className="text-red-500 mt-0.5"> 485 {file.error} 486 </div> 487 )} 488 {file.status === 'checking' && ( 489 <div className="text-muted-foreground">Checking for changes...</div> 490 )} 491 {file.status === 'uploading' && ( 492 <div className="text-muted-foreground">Uploading to PDS...</div> 493 )} 494 {file.status === 'reused' && ( 495 <div className="text-muted-foreground">Reused (unchanged)</div> 496 )} 497 </div> 498 </div> 499 ))} 500 </div> 501 )} 502 </div> 503 )} 504 505 {failedFiles.length > 0 && ( 506 <div className="p-4 bg-red-500/10 border border-red-500/20 rounded-lg"> 507 <div className="flex items-start gap-2 text-red-600 dark:text-red-400 mb-2"> 508 <AlertCircle className="w-4 h-4 mt-0.5 shrink-0" /> 509 <div className="flex-1"> 510 <span className="font-medium"> 511 {failedFiles.length} file{failedFiles.length > 1 ? 's' : ''} failed to upload 512 </span> 513 {uploadedCount > 0 && ( 514 <span className="text-sm ml-2"> 515 ({uploadedCount} uploaded successfully) 516 </span> 517 )} 518 </div> 519 </div> 520 <div className="ml-6 space-y-1 max-h-40 overflow-y-auto"> 521 {failedFiles.slice(0, 10).map((file, idx) => ( 522 <div key={idx} className="text-xs"> 523 <div className="font-mono font-semibold">{file.name}</div> 524 <div className="text-muted-foreground ml-2"> 525 Error: {file.error} 526 {file.size > 0 && ` (${(file.size / 1024).toFixed(1)} KB)`} 527 </div> 528 </div> 529 ))} 530 {failedFiles.length > 10 && ( 531 <div className="text-xs text-muted-foreground"> 532 ...and {failedFiles.length - 10} more 533 </div> 534 )} 535 </div> 536 </div> 537 )} 538 539 {skippedFiles.length > 0 && ( 540 <div className="p-4 bg-yellow-500/10 border border-yellow-500/20 rounded-lg"> 541 <div className="flex items-start gap-2 text-yellow-600 dark:text-yellow-400 mb-2"> 542 <AlertCircle className="w-4 h-4 mt-0.5 shrink-0" /> 543 <div className="flex-1"> 544 <span className="font-medium"> 545 {skippedFiles.length} file{skippedFiles.length > 1 ? 's' : ''} skipped 546 </span> 547 </div> 548 </div> 549 <div className="ml-6 space-y-1 max-h-32 overflow-y-auto"> 550 {skippedFiles.slice(0, 5).map((file, idx) => ( 551 <div key={idx} className="text-xs"> 552 <span className="font-mono">{file.name}</span> 553 <span className="text-muted-foreground"> - {file.reason}</span> 554 </div> 555 ))} 556 {skippedFiles.length > 5 && ( 557 <div className="text-xs text-muted-foreground"> 558 ...and {skippedFiles.length - 5} more 559 </div> 560 )} 561 </div> 562 </div> 563 )} 564 </div> 565 )} 566 567 <Button 568 onClick={handleUpload} 569 className="w-full" 570 disabled={ 571 (siteMode === 'existing' ? !selectedSiteRkey : !newSiteName) || 572 isUploading || 573 (siteMode === 'existing' && (!selectedFiles || selectedFiles.length === 0)) 574 } 575 > 576 {isUploading ? ( 577 <> 578 <Loader2 className="w-4 h-4 mr-2 animate-spin" /> 579 Uploading... 580 </> 581 ) : ( 582 <> 583 {siteMode === 'existing' ? ( 584 'Update Site' 585 ) : ( 586 selectedFiles && selectedFiles.length > 0 587 ? 'Upload & Deploy' 588 : 'Create Empty Site' 589 )} 590 </> 591 )} 592 </Button> 593 </CardContent> 594 </Card> 595 </div> 596 ) 597}