File Upload
Problem
You need file uploads with drag-and-drop, progress tracking, image preview, chunked upload for large files, cancellation, and validation.
Solution
Build a reusable upload zone using signals for state management and the Fetch API with XMLHttpRequest for progress tracking.
1. Upload State
ts
// src/upload.ts
import { signal, computed } from '@akashjs/runtime';
interface UploadFile {
id: string;
file: File;
progress: number; // 0-100
status: 'pending' | 'uploading' | 'done' | 'error';
error?: string;
previewUrl?: string;
abort?: AbortController;
}
const files = signal<UploadFile[]>([]);
const isUploading = computed(() => files().some((f) => f.status === 'uploading'));
const MAX_SIZE = 10 * 1024 * 1024; // 10 MB
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'application/pdf'];
function validate(file: File): string | null {
if (file.size > MAX_SIZE) return `File too large (max ${MAX_SIZE / 1024 / 1024}MB)`;
if (!ALLOWED_TYPES.includes(file.type)) return `Type not allowed: ${file.type}`;
return null;
}2. Drag-and-Drop Zone
ts
const isDragOver = signal(false);
function handleDragOver(e: DragEvent) {
e.preventDefault();
isDragOver.set(true);
}
function handleDragLeave() {
isDragOver.set(false);
}
function handleDrop(e: DragEvent) {
e.preventDefault();
isDragOver.set(false);
const dropped = Array.from(e.dataTransfer?.files ?? []);
addFiles(dropped);
}
function handleFileInput(e: Event) {
const input = e.target as HTMLInputElement;
const selected = Array.from(input.files ?? []);
addFiles(selected);
input.value = ''; // Reset so the same file can be re-selected
}
function addFiles(newFiles: File[]) {
const entries: UploadFile[] = newFiles.map((file) => {
const error = validate(file);
const entry: UploadFile = {
id: crypto.randomUUID(),
file,
progress: 0,
status: error ? 'error' : 'pending',
error: error ?? undefined,
};
// Generate preview for images
if (!error && file.type.startsWith('image/')) {
entry.previewUrl = URL.createObjectURL(file);
}
return entry;
});
files.update((prev) => [...prev, ...entries]);
}3. Template
html
<div class="upload-zone"
:class="{ 'drag-over': isDragOver() }"
@dragover={handleDragOver}
@dragleave={handleDragLeave}
@drop={handleDrop}>
<input type="file" id="file-input" multiple
accept={ALLOWED_TYPES.join(',')}
@change={handleFileInput}
class="sr-only" />
<label for="file-input" class="upload-label">
<p>Drag files here or <span class="link">browse</span></p>
<p class="muted">Max 10MB. JPEG, PNG, WebP, PDF.</p>
</label>
</div>
<!-- File list -->
<div :for={entry of files()} :key={entry.id} class="file-item">
<img :if={entry.previewUrl} :src={entry.previewUrl}
alt="Preview" class="file-preview" />
<div class="file-info">
<span class="file-name">{entry.file.name}</span>
<span class="file-size">{formatSize(entry.file.size)}</span>
</div>
<!-- Progress bar -->
<div :if={entry.status === 'uploading'} class="progress-bar">
<div class="progress-fill" :style="{ width: entry.progress + '%' }"></div>
</div>
<span :if={entry.status === 'done'} class="status-done">Uploaded</span>
<span :if={entry.status === 'error'} class="status-error">{entry.error}</span>
<button :if={entry.status === 'uploading'} @click={() => cancelUpload(entry.id)}>
Cancel
</button>
<button :if={entry.status === 'pending' || entry.status === 'error'}
@click={() => removeFile(entry.id)}>
Remove
</button>
</div>
<button :if={files().some(f => f.status === 'pending')}
@click={uploadAll} :disabled={isUploading()}>
Upload All
</button>TIP
Use URL.createObjectURL for image previews instead of FileReader.readAsDataURL. It is faster and does not block the main thread for large images.
4. Upload with Progress Tracking
ts
function uploadFile(id: string): Promise<void> {
return new Promise((resolve, reject) => {
const entry = files().find((f) => f.id === id);
if (!entry) return reject(new Error('File not found'));
const xhr = new XMLHttpRequest();
const abort = new AbortController();
updateFile(id, { status: 'uploading', progress: 0, abort });
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
updateFile(id, { progress: Math.round((e.loaded / e.total) * 100) });
}
});
xhr.addEventListener('load', () => {
if (xhr.status >= 200 && xhr.status < 300) {
updateFile(id, { status: 'done', progress: 100 });
resolve();
} else {
updateFile(id, { status: 'error', error: `Server error: ${xhr.status}` });
reject(new Error(`Upload failed: ${xhr.status}`));
}
});
xhr.addEventListener('error', () => {
updateFile(id, { status: 'error', error: 'Network error' });
reject(new Error('Network error'));
});
const formData = new FormData();
formData.append('file', entry.file);
xhr.open('POST', '/api/upload');
xhr.send(formData);
abort.signal.addEventListener('abort', () => xhr.abort());
});
}
function updateFile(id: string, patch: Partial<UploadFile>) {
files.update((list) =>
list.map((f) => (f.id === id ? { ...f, ...patch } : f))
);
}
function cancelUpload(id: string) {
const entry = files().find((f) => f.id === id);
entry?.abort?.abort();
updateFile(id, { status: 'error', error: 'Cancelled' });
}
function removeFile(id: string) {
files.update((list) => list.filter((f) => f.id !== id));
}
async function uploadAll() {
const pending = files().filter((f) => f.status === 'pending');
await Promise.allSettled(pending.map((f) => uploadFile(f.id)));
}WARNING
Use XMLHttpRequest instead of fetch when you need upload progress. The Fetch API does not support upload progress events in most browsers.
5. Chunked Upload for Large Files
ts
const CHUNK_SIZE = 5 * 1024 * 1024; // 5 MB chunks
async function uploadChunked(id: string) {
const entry = files().find((f) => f.id === id);
if (!entry) return;
const totalChunks = Math.ceil(entry.file.size / CHUNK_SIZE);
updateFile(id, { status: 'uploading', progress: 0 });
for (let i = 0; i < totalChunks; i++) {
const start = i * CHUNK_SIZE;
const end = Math.min(start + CHUNK_SIZE, entry.file.size);
const chunk = entry.file.slice(start, end);
const formData = new FormData();
formData.append('chunk', chunk);
formData.append('index', String(i));
formData.append('total', String(totalChunks));
formData.append('filename', entry.file.name);
await fetch('/api/upload/chunk', { method: 'POST', body: formData });
updateFile(id, { progress: Math.round(((i + 1) / totalChunks) * 100) });
}
updateFile(id, { status: 'done', progress: 100 });
}6. Helper
ts
function formatSize(bytes: number): string {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
}7. Styles
css
.upload-zone {
border: 2px dashed var(--color-border);
border-radius: 12px;
padding: 2rem;
text-align: center;
cursor: pointer;
transition: border-color 0.2s, background 0.2s;
}
.upload-zone.drag-over {
border-color: var(--color-primary);
background: rgba(103, 80, 164, 0.05);
}
.file-preview {
width: 48px;
height: 48px;
object-fit: cover;
border-radius: 6px;
}
.progress-bar {
height: 4px;
background: var(--color-border);
border-radius: 2px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: var(--color-primary);
transition: width 0.2s;
}
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0,0,0,0); }Result
A complete file upload component with drag-and-drop, instant image previews, per-file progress bars, cancellation, chunked upload for large files, and client-side validation for type and size.