Skip to content

${redev}

Published 1/12/2024 · 5 min read

Tags: vue , file-uploads , components , typescript , forms

Let’s build a reusable file upload component for Vue 3 with TypeScript support.

File Upload Service

Create src/services/files.ts:

import api from "./api";

export interface UploadProgress {
  loaded: number;
  total: number;
  percentage: number;
}

export const fileService = {
  async uploadAvatar(
    file: File,
    onProgress?: (progress: UploadProgress) => void
  ) {
    const formData = new FormData();
    formData.append("avatar", file);

    return api.post("/api/user/avatar", formData, {
      headers: {
        "Content-Type": "multipart/form-data",
      },
      onUploadProgress: (progressEvent) => {
        if (onProgress && progressEvent.total) {
          onProgress({
            loaded: progressEvent.loaded,
            total: progressEvent.total,
            percentage: Math.round(
              (progressEvent.loaded * 100) / progressEvent.total
            ),
          });
        }
      },
    });
  },

  async deleteAvatar() {
    return api.delete("/api/user/avatar");
  },
};

Avatar Upload Component

Create src/components/AvatarUpload.vue:

<script setup lang="ts">
import { ref, computed } from "vue";
import { useAuthStore } from "@/stores/auth";
import { fileService, type UploadProgress } from "@/services/files";

const auth = useAuthStore();

const fileInput = ref<HTMLInputElement>();
const isDragging = ref(false);
const isUploading = ref(false);
const uploadProgress = ref(0);
const error = ref<string | null>(null);

const avatarUrl = computed(() => auth.user?.avatar);

function triggerFileInput() {
  fileInput.value?.click();
}

function handleDragOver(e: DragEvent) {
  e.preventDefault();
  isDragging.value = true;
}

function handleDragLeave() {
  isDragging.value = false;
}

function handleDrop(e: DragEvent) {
  e.preventDefault();
  isDragging.value = false;

  const files = e.dataTransfer?.files;
  if (files?.length) {
    handleFile(files[0]);
  }
}

function handleFileSelect(e: Event) {
  const target = e.target as HTMLInputElement;
  const files = target.files;
  if (files?.length) {
    handleFile(files[0]);
  }
}

async function handleFile(file: File) {
  // Validate file type
  if (!file.type.startsWith("image/")) {
    error.value = "Please select an image file";
    return;
  }

  // Validate file size (2MB max)
  if (file.size > 2 * 1024 * 1024) {
    error.value = "File size must be less than 2MB";
    return;
  }

  error.value = null;
  isUploading.value = true;
  uploadProgress.value = 0;

  try {
    await fileService.uploadAvatar(file, (progress: UploadProgress) => {
      uploadProgress.value = progress.percentage;
    });

    await auth.fetchUser();
  } catch (e: any) {
    error.value = e.response?.data?.message || "Upload failed";
  } finally {
    isUploading.value = false;
    uploadProgress.value = 0;

    // Reset file input
    if (fileInput.value) {
      fileInput.value.value = "";
    }
  }
}

async function removeAvatar() {
  if (!confirm("Are you sure you want to remove your avatar?")) {
    return;
  }

  try {
    await fileService.deleteAvatar();
    await auth.fetchUser();
  } catch (e: any) {
    error.value = e.response?.data?.message || "Failed to remove avatar";
  }
}
</script>

<template>
  <div class="space-y-4">
    <!-- Current Avatar -->
    <div class="flex items-center gap-4">
      <div class="w-20 h-20 rounded-full overflow-hidden bg-gray-200">
        <img
          v-if="avatarUrl"
          :src="avatarUrl"
          alt="Avatar"
          class="w-full h-full object-cover"
        />
        <div
          v-else
          class="w-full h-full flex items-center justify-center text-gray-400"
        >
          <svg class="w-10 h-10" fill="currentColor" viewBox="0 0 24 24">
            <path
              d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"
            />
          </svg>
        </div>
      </div>

      <div>
        <button
          @click="triggerFileInput"
          :disabled="isUploading"
          class="text-blue-600 hover:underline disabled:opacity-50"
        >
          Change avatar
        </button>
        <button
          v-if="avatarUrl"
          @click="removeAvatar"
          :disabled="isUploading"
          class="ml-4 text-red-600 hover:underline disabled:opacity-50"
        >
          Remove
        </button>
      </div>
    </div>

    <!-- Drop Zone -->
    <div
      @dragover="handleDragOver"
      @dragleave="handleDragLeave"
      @drop="handleDrop"
      @click="triggerFileInput"
      :class="[
        'border-2 border-dashed rounded-lg p-6 text-center cursor-pointer transition-colors',
        isDragging
          ? 'border-blue-500 bg-blue-50'
          : 'border-gray-300 hover:border-gray-400',
      ]"
    >
      <input
        ref="fileInput"
        type="file"
        accept="image/*"
        class="hidden"
        @change="handleFileSelect"
      />

      <div v-if="isUploading" class="space-y-2">
        <p>Uploading... {{ uploadProgress }}%</p>
        <div class="w-full bg-gray-200 rounded-full h-2">
          <div
            class="bg-blue-600 h-2 rounded-full transition-all"
            :style="{ width: `${uploadProgress}%` }"
          />
        </div>
      </div>

      <div v-else>
        <p class="text-gray-600">Drag and drop an image, or click to select</p>
        <p class="text-sm text-gray-400 mt-1">PNG, JPG, GIF up to 2MB</p>
      </div>
    </div>

    <!-- Error Message -->
    <p v-if="error" class="text-red-500 text-sm">
      {{ error }}
    </p>
  </div>
</template>

Update User Type

Update src/services/auth.ts to include avatar:

export interface User {
  id: number;
  name: string;
  email: string;
  email_verified_at: string | null;
  is_admin?: boolean;
  avatar?: string | null;
}

Add to Settings Page

Update src/views/SettingsView.vue:

<script setup lang="ts">
import ProfileForm from "@/components/ProfileForm.vue";
import PasswordForm from "@/components/PasswordForm.vue";
import AvatarUpload from "@/components/AvatarUpload.vue";
</script>

<template>
  <div class="max-w-2xl mx-auto mt-10 space-y-8">
    <div class="bg-white shadow rounded p-6">
      <h2 class="text-xl font-semibold mb-4">Avatar</h2>
      <AvatarUpload />
    </div>

    <div class="bg-white shadow rounded p-6">
      <h2 class="text-xl font-semibold mb-4">Profile Information</h2>
      <ProfileForm />
    </div>

    <div class="bg-white shadow rounded p-6">
      <h2 class="text-xl font-semibold mb-4">Update Password</h2>
      <PasswordForm />
    </div>
  </div>
</template>

Generic File Upload Component

For more flexibility, here’s a generic file upload component. Create src/components/FileUpload.vue:

<script setup lang="ts">
import { ref } from "vue";

interface Props {
  accept?: string;
  maxSize?: number; // in MB
  multiple?: boolean;
}

const props = withDefaults(defineProps<Props>(), {
  accept: "*",
  maxSize: 5,
  multiple: false,
});

const emit = defineEmits<{
  (e: "files", files: File[]): void;
  (e: "error", message: string): void;
}>();

const fileInput = ref<HTMLInputElement>();
const isDragging = ref(false);

function triggerFileInput() {
  fileInput.value?.click();
}

function handleDragOver(e: DragEvent) {
  e.preventDefault();
  isDragging.value = true;
}

function handleDragLeave() {
  isDragging.value = false;
}

function handleDrop(e: DragEvent) {
  e.preventDefault();
  isDragging.value = false;

  const files = e.dataTransfer?.files;
  if (files) {
    handleFiles(Array.from(files));
  }
}

function handleFileSelect(e: Event) {
  const target = e.target as HTMLInputElement;
  const files = target.files;
  if (files) {
    handleFiles(Array.from(files));
  }
}

function handleFiles(files: File[]) {
  const maxBytes = props.maxSize * 1024 * 1024;

  for (const file of files) {
    if (file.size > maxBytes) {
      emit("error", `File "${file.name}" exceeds ${props.maxSize}MB limit`);
      return;
    }
  }

  emit("files", files);

  // Reset input
  if (fileInput.value) {
    fileInput.value.value = "";
  }
}
</script>

<template>
  <div
    @dragover="handleDragOver"
    @dragleave="handleDragLeave"
    @drop="handleDrop"
    @click="triggerFileInput"
    :class="[
      'border-2 border-dashed rounded-lg p-6 text-center cursor-pointer transition-colors',
      isDragging
        ? 'border-blue-500 bg-blue-50'
        : 'border-gray-300 hover:border-gray-400',
    ]"
  >
    <input
      ref="fileInput"
      type="file"
      :accept="accept"
      :multiple="multiple"
      class="hidden"
      @change="handleFileSelect"
    />

    <slot>
      <p class="text-gray-600">Drag and drop files, or click to select</p>
      <p class="text-sm text-gray-400 mt-1">Max file size: {{ maxSize }}MB</p>
    </slot>
  </div>
</template>

Next up: Shaping API responses with Laravel Resources.

Related Articles

  • Your First Component

    Learn the anatomy of a Svelte component file. Understand how script, markup, and styles work together in a single .svelte file.

  • Two-Way Binding

    Master Svelte's bind directive for seamless form handling. Learn to bind inputs, selects, checkboxes, and component props.

  • Component Lifecycle

    Understand when Svelte components mount, update, and destroy. Master onMount, onDestroy, beforeUpdate, afterUpdate, and tick.