Skip to content

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.

Released under the MIT License.