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
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}