Monorepo for wisp.place. A static site hosting service built on top of the AT Protocol. wisp.place
1import { useState, useEffect } 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} from 'lucide-react' 20import type { SiteWithDomains } from '../hooks/useSiteData' 21 22interface UploadTabProps { 23 sites: SiteWithDomains[] 24 sitesLoading: boolean 25 onUploadComplete: () => Promise<void> 26} 27 28// Batching configuration 29const BATCH_SIZE = 15 // files per batch 30const CONCURRENT_BATCHES = 3 // parallel batches 31const MAX_RETRIES = 2 // retry attempts per file 32 33interface BatchProgress { 34 total: number 35 uploaded: number 36 failed: number 37 current: number 38} 39 40export 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}