From 961f6ff6b41d9c41d968d642de9b73224eecfcc5 Mon Sep 17 00:00:00 2001 From: Mondo Diaz Date: Wed, 7 Jan 2026 13:49:45 -0600 Subject: [PATCH] Add drag-and-drop upload component with progress tracking (#8) --- CHANGELOG.md | 12 + frontend/src/components/DragDropUpload.css | 281 ++++++++++ frontend/src/components/DragDropUpload.tsx | 569 +++++++++++++++++++++ frontend/src/components/index.ts | 2 + frontend/src/pages/PackagePage.tsx | 77 ++- 5 files changed, 898 insertions(+), 43 deletions(-) create mode 100644 frontend/src/components/DragDropUpload.css create mode 100644 frontend/src/components/DragDropUpload.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d057f7..e35f031 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- Added reusable `DragDropUpload` component for artifact uploads (#8) + - Drag-and-drop file selection with visual feedback + - Click-to-browse fallback + - Multiple file upload support with queue management + - Real-time progress indicators with speed and ETA + - File type and size validation (configurable) + - Concurrent upload handling (configurable max concurrent) + - Automatic retry with exponential backoff for network errors + - Individual file status (pending, uploading, complete, failed) + - Retry and remove actions per file + - Auto-dismiss success messages after 5 seconds +- Integrated DragDropUpload into PackagePage replacing basic file input (#8) - Added download verification with `verify` and `verify_mode` query parameters (#26) - `?verify=true&verify_mode=pre` - Pre-verification: verify before streaming (guaranteed no corrupt data) - `?verify=true&verify_mode=stream` - Streaming verification: verify while streaming (logs error if mismatch) diff --git a/frontend/src/components/DragDropUpload.css b/frontend/src/components/DragDropUpload.css new file mode 100644 index 0000000..4f2be22 --- /dev/null +++ b/frontend/src/components/DragDropUpload.css @@ -0,0 +1,281 @@ +.drag-drop-upload { + width: 100%; +} + +/* Drop Zone */ +.drop-zone { + border: 2px dashed var(--border-color, #ddd); + border-radius: 8px; + padding: 2rem; + text-align: center; + cursor: pointer; + transition: all 0.2s ease; + background: var(--bg-secondary, #f9f9f9); +} + +.drop-zone:hover { + border-color: var(--accent-color, #007bff); + background: var(--bg-hover, #f0f7ff); +} + +.drop-zone--active { + border-color: var(--accent-color, #007bff); + background: var(--bg-active, #e6f0ff); + border-style: solid; +} + +.drop-zone__input { + display: none; +} + +.drop-zone__content { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.75rem; + color: var(--text-secondary, #666); +} + +.drop-zone__content svg { + opacity: 0.5; +} + +.drop-zone--active .drop-zone__content svg { + opacity: 1; + color: var(--accent-color, #007bff); +} + +.drop-zone__text { + margin: 0; + font-size: 1rem; +} + +.drop-zone__text strong { + color: var(--text-primary, #333); +} + +.drop-zone__hint { + margin: 0; + font-size: 0.8rem; + opacity: 0.7; +} + +/* Upload Queue */ +.upload-queue { + margin-top: 1rem; + border: 1px solid var(--border-color, #ddd); + border-radius: 8px; + overflow: hidden; +} + +.upload-queue__header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.75rem 1rem; + background: var(--bg-secondary, #f9f9f9); + border-bottom: 1px solid var(--border-color, #ddd); +} + +.upload-queue__title { + font-size: 0.875rem; + font-weight: 500; + color: var(--text-primary, #333); +} + +.upload-queue__clear { + padding: 0.25rem 0.5rem; + font-size: 0.75rem; + border: none; + background: none; + color: var(--accent-color, #007bff); + cursor: pointer; +} + +.upload-queue__clear:hover { + text-decoration: underline; +} + +.upload-queue__overall { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.5rem 1rem; + background: var(--bg-secondary, #f9f9f9); + border-bottom: 1px solid var(--border-color, #ddd); +} + +.upload-queue__overall .progress-bar { + flex: 1; +} + +.upload-queue__overall .progress-bar__text { + font-size: 0.75rem; + font-weight: 500; + color: var(--text-secondary, #666); + min-width: 3rem; + text-align: right; +} + +.upload-queue__list { + list-style: none; + margin: 0; + padding: 0; + max-height: 300px; + overflow-y: auto; +} + +/* Upload Item */ +.upload-item { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.75rem 1rem; + border-bottom: 1px solid var(--border-color-light, #eee); +} + +.upload-item:last-child { + border-bottom: none; +} + +.upload-item__icon { + flex-shrink: 0; + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + color: var(--text-secondary, #666); +} + +.upload-item--complete .upload-item__icon { + color: var(--success-color, #28a745); +} + +.upload-item--failed .upload-item__icon { + color: var(--error-color, #dc3545); +} + +.upload-item--uploading .upload-item__icon { + color: var(--accent-color, #007bff); +} + +.upload-item__info { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.upload-item__name { + font-size: 0.875rem; + font-weight: 500; + color: var(--text-primary, #333); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.upload-item__meta { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + font-size: 0.75rem; + color: var(--text-secondary, #666); +} + +.upload-item__size { + color: var(--text-secondary, #666); +} + +.upload-item__speed, +.upload-item__eta { + color: var(--accent-color, #007bff); +} + +.upload-item__artifact { + color: var(--success-color, #28a745); + font-family: monospace; +} + +.upload-item__error { + color: var(--error-color, #dc3545); +} + +.upload-item__retry-count { + color: var(--warning-color, #ffc107); +} + +.upload-item__actions { + display: flex; + gap: 0.25rem; + flex-shrink: 0; +} + +.upload-item__btn { + width: 28px; + height: 28px; + border: none; + background: none; + cursor: pointer; + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; + color: var(--text-secondary, #666); + transition: all 0.15s ease; +} + +.upload-item__btn:hover { + background: var(--bg-hover, #f0f0f0); +} + +.upload-item__btn--retry:hover { + color: var(--accent-color, #007bff); +} + +.upload-item__btn--remove:hover { + color: var(--error-color, #dc3545); +} + +/* Progress Bar */ +.progress-bar { + height: 8px; + background: var(--border-color, #ddd); + border-radius: 4px; + overflow: hidden; +} + +.progress-bar--small { + height: 4px; + margin-top: 0.25rem; +} + +.progress-bar__fill { + height: 100%; + background: var(--accent-color, #007bff); + border-radius: 4px; + transition: width 0.2s ease; +} + +.upload-item--complete .progress-bar__fill { + background: var(--success-color, #28a745); +} + +/* Responsive */ +@media (max-width: 480px) { + .drop-zone { + padding: 1.5rem 1rem; + } + + .upload-item__meta { + flex-direction: column; + gap: 0.125rem; + } + + .upload-item__speed, + .upload-item__eta { + display: none; + } +} diff --git a/frontend/src/components/DragDropUpload.tsx b/frontend/src/components/DragDropUpload.tsx new file mode 100644 index 0000000..9acfc54 --- /dev/null +++ b/frontend/src/components/DragDropUpload.tsx @@ -0,0 +1,569 @@ +import { useState, useRef, useCallback, useEffect } from 'react'; +import './DragDropUpload.css'; + +// Types +export type UploadStatus = 'pending' | 'uploading' | 'complete' | 'failed' | 'validating'; + +export interface UploadItem { + id: string; + file: File; + status: UploadStatus; + progress: number; + speed: number; // bytes per second + error?: string; + artifactId?: string; + retryCount: number; + startTime?: number; +} + +export interface UploadResult { + artifact_id: string; + size: number; + deduplicated?: boolean; +} + +export interface DragDropUploadProps { + projectName: string; + packageName: string; + onUploadComplete?: (results: UploadResult[]) => void; + onUploadError?: (error: string) => void; + allowedTypes?: string[]; // e.g., ['.tar.gz', '.zip', '.deb'] + allowAllTypes?: boolean; + maxFileSize?: number; // in bytes + maxConcurrentUploads?: number; + maxRetries?: number; + tag?: string; + className?: string; +} + +// Utility functions +function generateId(): string { + return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; +} + +function formatBytes(bytes: number): string { + if (bytes === 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]; +} + +function formatSpeed(bytesPerSecond: number): string { + return `${formatBytes(bytesPerSecond)}/s`; +} + +function formatTimeRemaining(seconds: number): string { + if (!isFinite(seconds) || seconds < 0) return '--:--'; + if (seconds < 60) return `${Math.round(seconds)}s`; + if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${Math.round(seconds % 60)}s`; + return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`; +} + +function getFileExtension(filename: string): string { + // Handle compound extensions like .tar.gz + const parts = filename.toLowerCase().split('.'); + if (parts.length >= 3 && parts[parts.length - 2] === 'tar') { + return `.${parts.slice(-2).join('.')}`; + } + return parts.length > 1 ? `.${parts[parts.length - 1]}` : ''; +} + +// Icons +function UploadIcon() { + return ( + + + + + + ); +} + +function CheckIcon() { + return ( + + + + ); +} + +function ErrorIcon() { + return ( + + + + + + ); +} + +function RetryIcon() { + return ( + + + + + ); +} + +function RemoveIcon() { + return ( + + + + + ); +} + +function FileIcon() { + return ( + + + + + ); +} + +export function DragDropUpload({ + projectName, + packageName, + onUploadComplete, + onUploadError, + allowedTypes, + allowAllTypes = true, + maxFileSize, + maxConcurrentUploads = 3, + maxRetries = 3, + tag, + className = '', +}: DragDropUploadProps) { + const [isDragOver, setIsDragOver] = useState(false); + const [uploadQueue, setUploadQueue] = useState([]); + const fileInputRef = useRef(null); + const dragCounterRef = useRef(0); + const activeUploadsRef = useRef(0); + + // Validate a single file + const validateFile = useCallback((file: File): string | null => { + // Check file size + if (maxFileSize && file.size > maxFileSize) { + return `File exceeds ${formatBytes(maxFileSize)} limit`; + } + + // Check file type if not allowing all types + if (!allowAllTypes && allowedTypes && allowedTypes.length > 0) { + const ext = getFileExtension(file.name); + if (!allowedTypes.some(t => t.toLowerCase() === ext)) { + return `File type ${ext || 'unknown'} not allowed. Accepted: ${allowedTypes.join(', ')}`; + } + } + + // Check for empty file + if (file.size === 0) { + return 'Cannot upload empty file'; + } + + return null; + }, [allowedTypes, allowAllTypes, maxFileSize]); + + // Add files to queue + const addFiles = useCallback((files: FileList | File[]) => { + const newItems: UploadItem[] = Array.from(files).map(file => { + const validationError = validateFile(file); + return { + id: generateId(), + file, + status: validationError ? 'failed' : 'pending', + progress: 0, + speed: 0, + error: validationError || undefined, + retryCount: 0, + }; + }); + + setUploadQueue(prev => [...prev, ...newItems]); + }, [validateFile]); + + // Upload a single file with progress tracking + const uploadFile = useCallback((item: UploadItem): Promise => { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + const formData = new FormData(); + formData.append('file', item.file); + if (tag) { + formData.append('tag', tag); + } + + let lastLoaded = 0; + let lastTime = Date.now(); + + xhr.upload.addEventListener('progress', (e) => { + if (e.lengthComputable) { + const now = Date.now(); + const timeDiff = (now - lastTime) / 1000; + const loadedDiff = e.loaded - lastLoaded; + + const speed = timeDiff > 0 ? loadedDiff / timeDiff : 0; + const progress = Math.round((e.loaded / e.total) * 100); + + setUploadQueue(prev => prev.map(u => + u.id === item.id + ? { ...u, progress, speed, status: 'uploading' as UploadStatus } + : u + )); + + lastLoaded = e.loaded; + lastTime = now; + } + }); + + xhr.addEventListener('load', () => { + if (xhr.status >= 200 && xhr.status < 300) { + try { + const result = JSON.parse(xhr.responseText) as UploadResult; + resolve(result); + } catch { + reject(new Error('Invalid response from server')); + } + } else { + try { + const error = JSON.parse(xhr.responseText); + reject(new Error(error.detail || `Upload failed: ${xhr.status}`)); + } catch { + reject(new Error(`Upload failed: ${xhr.status}`)); + } + } + }); + + xhr.addEventListener('error', () => { + reject(new Error('Network error - check your connection')); + }); + + xhr.addEventListener('timeout', () => { + reject(new Error('Upload timed out')); + }); + + xhr.open('POST', `/api/v1/project/${projectName}/${packageName}/upload`); + xhr.timeout = 300000; // 5 minute timeout + xhr.send(formData); + + // Store xhr for potential cancellation + setUploadQueue(prev => prev.map(u => + u.id === item.id + ? { ...u, status: 'uploading' as UploadStatus, startTime: Date.now() } + : u + )); + }); + }, [projectName, packageName, tag]); + + // Process upload queue + const processQueue = useCallback(async () => { + const pendingItems = uploadQueue.filter(item => item.status === 'pending'); + + for (const item of pendingItems) { + if (activeUploadsRef.current >= maxConcurrentUploads) { + break; + } + + activeUploadsRef.current++; + + // Start upload + setUploadQueue(prev => prev.map(u => + u.id === item.id ? { ...u, status: 'uploading' as UploadStatus } : u + )); + + try { + const result = await uploadFile(item); + + setUploadQueue(prev => prev.map(u => + u.id === item.id + ? { ...u, status: 'complete' as UploadStatus, progress: 100, artifactId: result.artifact_id } + : u + )); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Upload failed'; + const shouldRetry = item.retryCount < maxRetries && + (errorMessage.includes('Network') || errorMessage.includes('timeout')); + + if (shouldRetry) { + // Exponential backoff retry + const delay = Math.pow(2, item.retryCount) * 1000; + setTimeout(() => { + setUploadQueue(prev => prev.map(u => + u.id === item.id + ? { ...u, status: 'pending' as UploadStatus, retryCount: u.retryCount + 1, progress: 0 } + : u + )); + }, delay); + } else { + setUploadQueue(prev => prev.map(u => + u.id === item.id + ? { ...u, status: 'failed' as UploadStatus, error: errorMessage } + : u + )); + onUploadError?.(errorMessage); + } + } finally { + activeUploadsRef.current--; + } + } + }, [uploadQueue, maxConcurrentUploads, maxRetries, uploadFile, onUploadError]); + + // Process queue when items are added or status changes + useEffect(() => { + const hasPending = uploadQueue.some(item => item.status === 'pending'); + if (hasPending && activeUploadsRef.current < maxConcurrentUploads) { + processQueue(); + } + + // Notify when all uploads complete + const allComplete = uploadQueue.length > 0 && + uploadQueue.every(item => item.status === 'complete' || item.status === 'failed'); + + if (allComplete) { + const completedResults = uploadQueue + .filter(item => item.status === 'complete' && item.artifactId) + .map(item => ({ + artifact_id: item.artifactId!, + size: item.file.size, + })); + + if (completedResults.length > 0) { + onUploadComplete?.(completedResults); + } + } + }, [uploadQueue, maxConcurrentUploads, processQueue, onUploadComplete]); + + // Drag event handlers + const handleDragEnter = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + dragCounterRef.current++; + if (e.dataTransfer.items && e.dataTransfer.items.length > 0) { + setIsDragOver(true); + } + }, []); + + const handleDragLeave = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + dragCounterRef.current--; + if (dragCounterRef.current === 0) { + setIsDragOver(false); + } + }, []); + + const handleDragOver = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + }, []); + + const handleDrop = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragOver(false); + dragCounterRef.current = 0; + + const files = e.dataTransfer.files; + if (files && files.length > 0) { + addFiles(files); + } + }, [addFiles]); + + // Click to browse + const handleClick = useCallback(() => { + fileInputRef.current?.click(); + }, []); + + const handleFileChange = useCallback((e: React.ChangeEvent) => { + const files = e.target.files; + if (files && files.length > 0) { + addFiles(files); + } + // Reset input so same file can be selected again + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + }, [addFiles]); + + // Remove item from queue + const removeItem = useCallback((id: string) => { + setUploadQueue(prev => prev.filter(item => item.id !== id)); + }, []); + + // Retry failed upload + const retryItem = useCallback((id: string) => { + setUploadQueue(prev => prev.map(item => + item.id === id + ? { ...item, status: 'pending' as UploadStatus, error: undefined, progress: 0, retryCount: 0 } + : item + )); + }, []); + + // Clear completed/failed items + const clearCompleted = useCallback(() => { + setUploadQueue(prev => prev.filter(item => + item.status !== 'complete' && item.status !== 'failed' + )); + }, []); + + // Calculate overall progress + const overallProgress = uploadQueue.length > 0 + ? Math.round(uploadQueue.reduce((sum, item) => sum + item.progress, 0) / uploadQueue.length) + : 0; + + const completedCount = uploadQueue.filter(item => item.status === 'complete').length; + const failedCount = uploadQueue.filter(item => item.status === 'failed').length; + const uploadingCount = uploadQueue.filter(item => item.status === 'uploading').length; + + return ( +
+ {/* Drop Zone */} +
e.key === 'Enter' && handleClick()} + > + +
+ +

+ Drag files here or click to browse +

+

+ {maxFileSize && `Max file size: ${formatBytes(maxFileSize)}`} + {!allowAllTypes && allowedTypes && ` • Accepted: ${allowedTypes.join(', ')}`} +

+
+
+ + {/* Upload Queue */} + {uploadQueue.length > 0 && ( +
+
+ + {uploadingCount > 0 + ? `Uploading ${uploadingCount} of ${uploadQueue.length} files` + : `${completedCount} of ${uploadQueue.length} files uploaded` + } + {failedCount > 0 && ` (${failedCount} failed)`} + + {(completedCount > 0 || failedCount > 0) && ( + + )} +
+ + {/* Overall progress bar */} + {uploadingCount > 0 && ( +
+
+
+
+ {overallProgress}% +
+ )} + + {/* Individual file items */} +
    + {uploadQueue.map(item => ( +
  • +
    + {item.status === 'complete' ? : + item.status === 'failed' ? : + } +
    + +
    +
    + {item.file.name} +
    +
    + {formatBytes(item.file.size)} + {item.status === 'uploading' && item.speed > 0 && ( + <> + {formatSpeed(item.speed)} + {item.startTime && ( + + {formatTimeRemaining( + (item.file.size - (item.file.size * item.progress / 100)) / item.speed + )} remaining + + )} + + )} + {item.status === 'complete' && item.artifactId && ( + + ID: {item.artifactId.substring(0, 12)}... + + )} + {item.error && ( + {item.error} + )} + {item.retryCount > 0 && item.status === 'uploading' && ( + Retry {item.retryCount} + )} +
    + + {item.status === 'uploading' && ( +
    +
    +
    + )} +
    + +
    + {item.status === 'failed' && ( + + )} + {(item.status === 'complete' || item.status === 'failed' || item.status === 'pending') && ( + + )} +
    +
  • + ))} +
+
+ )} +
+ ); +} diff --git a/frontend/src/components/index.ts b/frontend/src/components/index.ts index 977782c..8078551 100644 --- a/frontend/src/components/index.ts +++ b/frontend/src/components/index.ts @@ -10,3 +10,5 @@ export { FilterChip, FilterChipGroup } from './FilterChip'; export { DataTable } from './DataTable'; export { Pagination } from './Pagination'; export { GlobalSearch } from './GlobalSearch'; +export { DragDropUpload } from './DragDropUpload'; +export type { DragDropUploadProps, UploadItem, UploadResult, UploadStatus } from './DragDropUpload'; diff --git a/frontend/src/pages/PackagePage.tsx b/frontend/src/pages/PackagePage.tsx index e7ea463..1997aa5 100644 --- a/frontend/src/pages/PackagePage.tsx +++ b/frontend/src/pages/PackagePage.tsx @@ -1,7 +1,7 @@ -import { useState, useEffect, useRef, useCallback } from 'react'; +import { useState, useEffect, useCallback } from 'react'; import { useParams, useSearchParams, useNavigate } from 'react-router-dom'; import { TagDetail, Package, PaginatedResponse } from '../types'; -import { listTags, uploadArtifact, getDownloadUrl, getPackage } from '../api'; +import { listTags, getDownloadUrl, getPackage } from '../api'; import { Breadcrumb } from '../components/Breadcrumb'; import { Badge } from '../components/Badge'; import { SearchInput } from '../components/SearchInput'; @@ -9,6 +9,7 @@ import { SortDropdown, SortOption } from '../components/SortDropdown'; import { FilterChip, FilterChipGroup } from '../components/FilterChip'; import { DataTable } from '../components/DataTable'; import { Pagination } from '../components/Pagination'; +import { DragDropUpload, UploadResult } from '../components/DragDropUpload'; import './Home.css'; import './PackagePage.css'; @@ -61,10 +62,8 @@ function PackagePage() { const [tagsData, setTagsData] = useState | null>(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const [uploading, setUploading] = useState(false); - const [uploadResult, setUploadResult] = useState(null); - const [tag, setTag] = useState(''); - const fileInputRef = useRef(null); + const [uploadTag, setUploadTag] = useState(''); + const [uploadSuccess, setUploadSuccess] = useState(null); // Get params from URL const page = parseInt(searchParams.get('page') || '1', 10); @@ -122,30 +121,22 @@ function PackagePage() { return () => window.removeEventListener('keydown', handleKeyDown); }, [navigate, projectName]); - async function handleUpload(e: React.FormEvent) { - e.preventDefault(); - const file = fileInputRef.current?.files?.[0]; - if (!file) { - setError('Please select a file'); - return; - } + const handleUploadComplete = useCallback((results: UploadResult[]) => { + const count = results.length; + const message = count === 1 + ? `Uploaded successfully! Artifact ID: ${results[0].artifact_id}` + : `${count} files uploaded successfully!`; + setUploadSuccess(message); + setUploadTag(''); + loadData(); + + // Auto-dismiss success message after 5 seconds + setTimeout(() => setUploadSuccess(null), 5000); + }, [loadData]); - try { - setUploading(true); - setError(null); - const result = await uploadArtifact(projectName!, packageName!, file, tag || undefined); - setUploadResult(`Uploaded successfully! Artifact ID: ${result.artifact_id}`); - setTag(''); - if (fileInputRef.current) { - fileInputRef.current.value = ''; - } - loadData(); - } catch (err) { - setError(err instanceof Error ? err.message : 'Upload failed'); - } finally { - setUploading(false); - } - } + const handleUploadError = useCallback((errorMsg: string) => { + setError(errorMsg); + }, []); const handleSearchChange = (value: string) => { updateParams({ search: value, page: '1' }); @@ -292,29 +283,29 @@ function PackagePage() {
{error &&
{error}
} - {uploadResult &&
{uploadResult}
} + {uploadSuccess &&
{uploadSuccess}
}

Upload Artifact

-
+
- - -
-
- + setTag(e.target.value)} + value={uploadTag} + onChange={(e) => setUploadTag(e.target.value)} placeholder="v1.0.0, latest, stable..." />
- - + +