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}