Skip to main content

Forms

File Upload Form code

Copy the implementation or inspect every file that belongs to this component unit.

resources/views/components/ui/file-upload-form.blade.php

@props([
    'action' => '#',
    'method' => 'POST',
    'name' => 'files[]',
    'title' => 'Upload files',
    'description' => null,
    'accept' => null,
    'multiple' => true,
    'hint' => 'PNG, JPG, PDF or ZIP up to 10MB',
    'icon' => 'cloud-arrow-up',
    'maxSize' => null,
    'maxFiles' => null,
    'submitLabel' => 'Upload',
    'cancelHref' => null,
    'cancelLabel' => 'Cancel',
    'disabled' => false,
    'simulate' => false,
])

@php
    /*
    | Modern, self-contained file upload form. A drag-and-drop dropzone backed by
    | a real native file input, a queue with image thumbnails, per-file progress,
    | size/type/count validation, a total-size summary and submit/cancel actions.
    |
    | Selected files are synced into the hidden <input type="file"> via a
    | DataTransfer, so the surrounding <form> submits them as a normal multipart
    | request. Set `simulate` to animate per-file progress for demos/previews
    | without a backend.
    |
    | Usage:
    |   <x-ui.file-upload-form :action="route('media.store')" accept="image/*" :max-size="5120" :max-files="6" />
    |   <x-ui.file-upload-form title="Import data" name="imports[]" accept=".csv,.xlsx" simulate />
    */
    $method = strtoupper($method);
    $formMethod = in_array($method, ['GET', 'POST'], true) ? $method : 'POST';
@endphp

<form
    @if ($action) action="{{ $action }}" @endif
    method="{{ $formMethod }}"
    enctype="multipart/form-data"
    x-data="{
        files: [],
        errors: [],
        dragging: false,
        nextId: 1,
        disabled: @js((bool) $disabled),
        multiple: @js((bool) $multiple),
        accept: @js($accept),
        maxSize: @js($maxSize ? (int) $maxSize : null),
        maxFiles: @js($maxFiles ? (int) $maxFiles : null),
        simulate: @js((bool) $simulate),
        uploading: false,
        get hasFiles() { return this.files.length > 0; },
        get totalSize() { return this.files.reduce((sum, file) => sum + file.size, 0); },
        get allComplete() { return this.hasFiles && this.files.every((file) => file.status === 'done'); },
        addFiles(fileList) {
            this.errors = [];
            const incoming = Array.from(fileList);

            incoming.forEach((file) => {
                const error = this.validate(file);
                if (error) { this.errors.push(error); return; }

                if (this.maxFiles && this.files.length >= this.maxFiles) {
                    this.errors.push(`You can upload at most ${this.maxFiles} file${this.maxFiles === 1 ? '' : 's'}.`);
                    return;
                }

                const entry = {
                    id: this.nextId++,
                    raw: file,
                    name: file.name,
                    size: file.size,
                    type: file.type,
                    status: 'ready',
                    progress: 0,
                    icon: this.iconFor(file),
                    previewUrl: file.type.startsWith('image/') ? URL.createObjectURL(file) : null,
                };

                if (this.multiple) {
                    this.files.push(entry);
                } else {
                    this.reset();
                    this.files = [entry];
                }
            });

            this.syncInput();
        },
        validate(file) {
            if (this.maxSize && file.size > this.maxSize * 1024) {
                return `${file.name} is larger than ${this.humanSize(this.maxSize * 1024)}.`;
            }
            return null;
        },
        remove(id) {
            const entry = this.files.find((file) => file.id === id);
            if (entry?.previewUrl) { URL.revokeObjectURL(entry.previewUrl); }
            this.files = this.files.filter((file) => file.id !== id);
            this.syncInput();
        },
        reset() {
            this.files.forEach((file) => { if (file.previewUrl) { URL.revokeObjectURL(file.previewUrl); } });
            this.files = [];
            this.errors = [];
            this.syncInput();
        },
        syncInput() {
            const transfer = new DataTransfer();
            this.files.forEach((file) => transfer.items.add(file.raw));
            this.$refs.input.files = transfer.files;
        },
        submit(event) {
            if (! this.hasFiles || this.disabled) { return; }
            if (! this.simulate) { return; }

            event.preventDefault();
            this.uploading = true;
            this.files.forEach((file) => this.upload(file));
        },
        upload(file) {
            file.status = 'uploading';
            file.progress = 0;
            const timer = setInterval(() => {
                file.progress = Math.min(100, file.progress + Math.random() * 18);
                if (file.progress >= 100) {
                    file.progress = 100;
                    file.status = 'done';
                    clearInterval(timer);
                    this.uploading = this.files.some((item) => item.status === 'uploading');
                }
            }, 220);
        },
        iconFor(file) {
            const type = file.type || '';
            const ext = file.name.split('.').pop().toLowerCase();
            if (type.startsWith('image/')) { return 'image'; }
            if (type.startsWith('video/')) { return 'file-video'; }
            if (type.startsWith('audio/')) { return 'file-audio'; }
            if (type === 'application/pdf' || ext === 'pdf') { return 'file-pdf'; }
            if (['zip', 'rar', '7z', 'tar', 'gz'].includes(ext)) { return 'file-zip'; }
            if (['csv'].includes(ext)) { return 'file-csv'; }
            if (['xls', 'xlsx'].includes(ext)) { return 'file-xls'; }
            if (['doc', 'docx'].includes(ext)) { return 'file-doc'; }
            if (['js', 'ts', 'json', 'php', 'css', 'html', 'vue', 'py'].includes(ext)) { return 'file-code'; }
            if (['txt', 'md'].includes(ext)) { return 'file-text'; }
            return 'file';
        },
        humanSize(bytes) {
            if (bytes < 1024) { return bytes + ' B'; }
            if (bytes < 1048576) { return (bytes / 1024).toFixed(1) + ' KB'; }
            return (bytes / 1048576).toFixed(1) + ' MB';
        },
    }"
    @submit="submit($event)"
    {{ $attributes->merge(['class' => 'overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-sm dark:border-gray-800 dark:bg-gray-900']) }}
>
    @if (! in_array($method, ['GET', 'POST'], true))
        @csrf
        @method($method)
    @elseif ($formMethod === 'POST')
        @csrf
    @endif

    @if ($title || $description)
        <div class="border-b border-gray-200 px-5 py-4 dark:border-gray-800 sm:px-6">
            @if ($title)
                <h3 class="text-base font-semibold text-gray-900 dark:text-white">{{ $title }}</h3>
            @endif
            @if ($description)
                <p class="mt-1 text-sm text-gray-500 dark:text-gray-400">{{ $description }}</p>
            @endif
        </div>
    @endif

    <div class="space-y-4 p-5 sm:p-6">
        {{-- Dropzone --}}
        <label
            @dragover.prevent="if (! disabled) dragging = true"
            @dragleave.prevent="dragging = false"
            @drop.prevent="dragging = false; if (! disabled) { addFiles($event.dataTransfer.files); }"
            :class="dragging
                ? 'border-brand-500 bg-brand-50/60 dark:bg-brand-950/30'
                : 'border-gray-300 dark:border-gray-700'"
            class="flex flex-col items-center justify-center gap-3 rounded-2xl border-2 border-dashed bg-gray-50/60 px-6 py-10 text-center transition dark:bg-gray-900/40 {{ $disabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer hover:border-gray-400 dark:hover:border-gray-600' }}"
        >
            <span class="flex h-12 w-12 items-center justify-center rounded-2xl bg-white text-brand-500 shadow-sm dark:bg-gray-800 dark:text-brand-400">
                <x-ui.icon :name="$icon" size="2xl" />
            </span>
            <span class="text-sm text-gray-600 dark:text-gray-300">
                <span class="font-semibold text-brand-600 dark:text-brand-400">Click to upload</span> or drag and drop
            </span>
            @if ($hint)
                <span class="text-xs text-gray-400 dark:text-gray-500">{{ $hint }}</span>
            @endif

            <input
                type="file"
                x-ref="input"
                name="{{ $name }}"
                @if ($accept) accept="{{ $accept }}" @endif
                @if ($multiple) multiple @endif
                @disabled($disabled)
                @change="addFiles($event.target.files)"
                class="sr-only"
            >
        </label>

        {{-- Validation errors --}}
        <ul x-show="errors.length" x-cloak class="space-y-1 text-sm text-red-600 dark:text-red-400">
            <template x-for="error in errors" :key="error">
                <li class="flex items-center gap-1.5">
                    <x-ui.icon name="warning-circle" weight="fill" size="sm" />
                    <span x-text="error"></span>
                </li>
            </template>
        </ul>

        {{-- File queue --}}
        <div x-show="hasFiles" x-cloak class="space-y-3">
            <div class="flex items-center justify-between text-xs font-medium text-gray-500 dark:text-gray-400">
                <span>
                    <span x-text="files.length"></span>
                    <span x-text="files.length === 1 ? 'file' : 'files'"></span>
                    selected
                </span>
                <span x-text="humanSize(totalSize)" class="tabular-nums"></span>
            </div>

            <ul class="space-y-2">
                <template x-for="file in files" :key="file.id">
                    <li class="flex items-center gap-3 rounded-xl border border-gray-200 bg-white px-3 py-2.5 dark:border-gray-800 dark:bg-gray-900">
                        <template x-if="file.previewUrl">
                            <img :src="file.previewUrl" alt="" class="h-11 w-11 shrink-0 rounded-lg object-cover ring-1 ring-gray-200 dark:ring-gray-800">
                        </template>
                        <template x-if="! file.previewUrl">
                            <span class="flex h-11 w-11 shrink-0 items-center justify-center rounded-lg bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-400">
                                <i class="ph text-xl leading-none" :class="'ph-' + file.icon" aria-hidden="true"></i>
                            </span>
                        </template>

                        <div class="min-w-0 flex-1">
                            <div class="flex items-center justify-between gap-3">
                                <span class="truncate text-sm font-medium text-gray-700 dark:text-gray-200" x-text="file.name"></span>
                                <span class="shrink-0 text-xs text-gray-400 tabular-nums dark:text-gray-500" x-text="humanSize(file.size)"></span>
                            </div>

                            {{-- Per-file progress while uploading --}}
                            <div x-show="file.status === 'uploading'" class="mt-2 h-1.5 w-full overflow-hidden rounded-full bg-gray-200 dark:bg-gray-800">
                                <div class="h-full rounded-full bg-brand-600 transition-all duration-200" :style="`width: ${file.progress}%`"></div>
                            </div>

                            <div x-show="file.status === 'done'" x-cloak class="mt-1 flex items-center gap-1 text-xs font-medium text-green-600 dark:text-green-400">
                                <x-ui.icon name="check-circle" weight="fill" size="sm" />
                                <span>Uploaded</span>
                            </div>
                        </div>

                        <button
                            type="button"
                            x-show="file.status !== 'uploading'"
                            @click="remove(file.id)"
                            class="shrink-0 rounded-lg p-1.5 text-gray-400 transition hover:bg-gray-100 hover:text-red-600 dark:hover:bg-gray-800 dark:hover:text-red-400"
                            aria-label="Remove file"
                        >
                            <x-ui.icon name="x" weight="bold" size="sm" />
                        </button>
                    </li>
                </template>
            </ul>
        </div>
    </div>

    {{-- Footer actions --}}
    <div class="flex items-center justify-between gap-3 border-t border-gray-200 bg-gray-50/60 px-5 py-4 dark:border-gray-800 dark:bg-gray-900/40 sm:px-6">
        <button
            type="button"
            x-show="hasFiles"
            x-cloak
            @click="reset()"
            class="text-sm font-medium text-gray-500 transition hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
        >
            Clear all
        </button>
        <span x-show="! hasFiles" class="text-sm text-gray-400 dark:text-gray-500">No files selected</span>

        <div class="flex items-center gap-2">
            @if ($cancelHref)
                <x-ui.button :href="$cancelHref" variant="ghost" size="sm">{{ $cancelLabel }}</x-ui.button>
            @endif
            <x-ui.button
                type="submit"
                size="sm"
                icon="cloud-arrow-up"
                ::disabled="! hasFiles || disabled || uploading"
            >
                <span x-show="! uploading">{{ $submitLabel }}</span>
                <span x-show="uploading" x-cloak>Uploading…</span>
            </x-ui.button>
        </div>
    </div>
</form>