Add drag-and-drop upload component with chunked uploads and offline support

This commit is contained in:
Mondo Diaz
2026-01-08 11:59:32 -06:00
parent bccbc71c13
commit 10d3694794
12 changed files with 6631 additions and 46 deletions

View File

@@ -127,6 +127,58 @@ h2 {
font-size: 0.75rem;
}
/* Download by Artifact ID Section */
.download-by-id-section {
margin-top: 32px;
background: var(--bg-secondary);
}
.download-by-id-section h3 {
margin-bottom: 12px;
color: var(--text-primary);
font-size: 1rem;
font-weight: 600;
}
.download-by-id-form {
display: flex;
gap: 12px;
align-items: center;
}
.artifact-id-input {
flex: 1;
padding: 10px 16px;
background: var(--bg-tertiary);
border: 1px solid var(--border-primary);
border-radius: var(--radius-md);
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
font-size: 0.8125rem;
color: var(--text-primary);
}
.artifact-id-input::placeholder {
color: var(--text-muted);
}
.artifact-id-input:focus {
outline: none;
border-color: var(--accent-primary);
}
.btn-disabled {
opacity: 0.5;
cursor: not-allowed;
pointer-events: none;
}
.validation-hint {
margin-top: 8px;
margin-bottom: 0;
font-size: 0.75rem;
color: var(--warning-color, #f59e0b);
}
/* Usage Section */
.usage-section {
margin-top: 32px;

View File

@@ -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,9 @@ function PackagePage() {
const [tagsData, setTagsData] = useState<PaginatedResponse<TagDetail> | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [uploading, setUploading] = useState(false);
const [uploadResult, setUploadResult] = useState<string | null>(null);
const [tag, setTag] = useState('');
const fileInputRef = useRef<HTMLInputElement>(null);
const [uploadTag, setUploadTag] = useState('');
const [uploadSuccess, setUploadSuccess] = useState<string | null>(null);
const [artifactIdInput, setArtifactIdInput] = useState('');
// Get params from URL
const page = parseInt(searchParams.get('page') || '1', 10);
@@ -122,30 +122,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 +284,29 @@ function PackagePage() {
</div>
{error && <div className="error-message">{error}</div>}
{uploadResult && <div className="success-message">{uploadResult}</div>}
{uploadSuccess && <div className="success-message">{uploadSuccess}</div>}
<div className="upload-section card">
<h3>Upload Artifact</h3>
<form onSubmit={handleUpload} className="upload-form">
<div className="upload-form">
<div className="form-group">
<label htmlFor="file">File</label>
<input id="file" type="file" ref={fileInputRef} required />
</div>
<div className="form-group">
<label htmlFor="tag">Tag (optional)</label>
<label htmlFor="upload-tag">Tag (optional)</label>
<input
id="tag"
id="upload-tag"
type="text"
value={tag}
onChange={(e) => setTag(e.target.value)}
value={uploadTag}
onChange={(e) => setUploadTag(e.target.value)}
placeholder="v1.0.0, latest, stable..."
/>
</div>
<button type="submit" className="btn btn-primary" disabled={uploading}>
{uploading ? 'Uploading...' : 'Upload'}
</button>
</form>
<DragDropUpload
projectName={projectName!}
packageName={packageName!}
tag={uploadTag || undefined}
onUploadComplete={handleUploadComplete}
onUploadError={handleUploadError}
/>
</div>
</div>
<div className="section-header">
@@ -367,6 +359,34 @@ function PackagePage() {
/>
)}
<div className="download-by-id-section card">
<h3>Download by Artifact ID</h3>
<div className="download-by-id-form">
<input
type="text"
value={artifactIdInput}
onChange={(e) => setArtifactIdInput(e.target.value.toLowerCase().replace(/[^a-f0-9]/g, '').slice(0, 64))}
placeholder="Enter SHA256 artifact ID (64 hex characters)"
className="artifact-id-input"
/>
<a
href={artifactIdInput.length === 64 ? getDownloadUrl(projectName!, packageName!, `artifact:${artifactIdInput}`) : '#'}
className={`btn btn-primary ${artifactIdInput.length !== 64 ? 'btn-disabled' : ''}`}
download
onClick={(e) => {
if (artifactIdInput.length !== 64) {
e.preventDefault();
}
}}
>
Download
</a>
</div>
{artifactIdInput.length > 0 && artifactIdInput.length !== 64 && (
<p className="validation-hint">Artifact ID must be exactly 64 hex characters ({artifactIdInput.length}/64)</p>
)}
</div>
<div className="usage-section card">
<h3>Usage</h3>
<p>Download artifacts using:</p>