Skip to main content

Media

Media Upload code

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

resources/views/components/ui/media-upload.blade.php

@props([
    'name' => 'media',
    'accept' => 'video/*,audio/*',
    'multiple' => true,
    'hint' => null,
    'maxSize' => null,
    'maxFiles' => 8,
    'progress' => null,
    'disabled' => false,
    'invalid' => false,
    'title' => 'Upload media',
    'description' => null,
])

@php
    /*
    | Media-focused drag-and-drop upload zone for video and audio files.
    | Dispatches `media-selected` whenever the queue changes.
    |
    | Usage:
    |   <x-ui.media-upload name="clips[]" accept="video/*,audio/*" :max-size="51200" />
    */
    $hintText = $hint ?? 'MP4, WebM, MP3, WAV — drop files or browse';
    $ring = $invalid
        ? 'border-red-400 dark:border-red-500'
        : 'border-gray-300 dark:border-gray-700';
@endphp

<div
    x-data="{
        files: [],
        errors: [],
        dragging: false,
        disabled: @js((bool) $disabled),
        maxSize: @js($maxSize ? (int) $maxSize : null),
        maxFiles: @js(max(1, (int) $maxFiles)),
        humanSize(bytes) {
            if (bytes < 1024) return `${bytes} B`;
            if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`;
            return `${(bytes / 1048576).toFixed(1)} MB`;
        },
        isMedia(file) {
            return (file.type || '').startsWith('video/') || (file.type || '').startsWith('audio/');
        },
        emit() {
            this.$dispatch('media-selected', { files: this.files.map((file) => ({
                name: file.name,
                size: file.size,
                type: file.type,
            })) });
        },
        syncInput() {
            const transfer = new DataTransfer();
            this.files.forEach((file) => transfer.items.add(file.raw));
            this.$refs.input.files = transfer.files;
            this.emit();
        },
        handle(fileList) {
            if (this.disabled) { return; }
            this.errors = [];
            Array.from(fileList || []).forEach((file) => {
                if (this.files.length >= this.maxFiles) {
                    this.errors.push(`You can upload up to ${this.maxFiles} files.`);
                    return;
                }
                if (! this.isMedia(file)) {
                    this.errors.push(`${file.name} is not a video or audio file.`);
                    return;
                }
                if (this.maxSize && file.size > this.maxSize * 1024) {
                    this.errors.push(`${file.name} is larger than ${this.humanSize(this.maxSize * 1024)}.`);
                    return;
                }
                this.files.push({
                    id: `${file.name}-${file.size}-${file.lastModified}`,
                    name: file.name,
                    size: file.size,
                    type: file.type,
                    kind: file.type.startsWith('video/') ? 'video' : 'audio',
                    raw: file,
                });
            });
            this.syncInput();
        },
        remove(id) {
            this.files = this.files.filter((file) => file.id !== id);
            this.syncInput();
        },
    }"
    {{ $attributes->merge(['class' => 'min-w-0 max-w-full rounded-2xl border border-gray-200 bg-white p-3 shadow-sm dark:border-gray-800 dark:bg-gray-900 sm:p-4']) }}
>
    <div class="mb-3 min-w-0">
        <h3 class="text-sm font-semibold text-gray-900 dark:text-white">{{ $title }}</h3>
        <p class="mt-1 text-sm leading-5 text-gray-500 dark:text-gray-400">
            {{ $description ?? 'Drop clips onto the zone or browse from your device.' }}
        </p>
    </div>

    <label
        @dragover.prevent="if (! disabled) dragging = true"
        @dragleave.prevent="dragging = false"
        @drop.prevent="dragging = false; handle($event.dataTransfer.files)"
        :class="dragging ? 'border-brand-500 bg-brand-50/70 dark:border-brand-400 dark:bg-brand-950/40' : @js($ring)"
        class="flex cursor-pointer flex-col items-center justify-center gap-2 rounded-2xl border-2 border-dashed px-4 py-8 text-center transition sm:py-10"
    >
        <span class="flex h-12 w-12 items-center justify-center rounded-xl bg-brand-50 text-brand-600 dark:bg-brand-950/50 dark:text-brand-300">
            <x-ui.icon name="cloud-arrow-up" weight="duotone" size="xl" />
        </span>
        <span class="text-sm font-medium text-gray-900 dark:text-white">Drop media here</span>
        <span class="max-w-sm text-xs leading-5 text-gray-500 dark:text-gray-400">{{ $hintText }}</span>
        <span class="mt-1 inline-flex h-9 items-center rounded-lg border border-gray-200 bg-white px-3 text-xs font-semibold text-gray-700 shadow-sm dark:border-gray-800 dark:bg-gray-950 dark:text-gray-200">Browse files</span>
        <input
            x-ref="input"
            type="file"
            name="{{ $name }}"
            class="sr-only"
            accept="{{ $accept }}"
            @if ($multiple) multiple @endif
            @disabled($disabled)
            @change="handle($event.target.files)"
        >
    </label>

    <template x-if="errors.length">
        <ul class="mt-3 space-y-1 text-sm text-red-600 dark:text-red-400">
            <template x-for="error in errors" :key="error">
                <li x-text="error"></li>
            </template>
        </ul>
    </template>

    <template x-if="files.length">
        <ul class="mt-3 space-y-2">
            <template x-for="file in files" :key="file.id">
                <li class="flex min-w-0 items-center gap-3 rounded-xl border border-gray-200 bg-gray-50 px-3 py-2.5 dark:border-gray-800 dark:bg-gray-950/60">
                    <span class="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-white text-gray-500 ring-1 ring-gray-200 dark:bg-gray-900 dark:text-gray-400 dark:ring-gray-800">
                        <x-ui.icon x-show="file.kind === 'video'" name="film-strip" size="sm" />
                        <x-ui.icon x-show="file.kind === 'audio'" name="music-notes" size="sm" />
                    </span>
                    <div class="min-w-0 flex-1">
                        <p class="truncate text-sm font-medium text-gray-900 dark:text-white" x-text="file.name"></p>
                        <p class="text-xs tabular-nums text-gray-500 dark:text-gray-400" x-text="humanSize(file.size)"></p>
                    </div>
                    <button
                        type="button"
                        @click="remove(file.id)"
                        class="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-lg text-gray-400 transition hover:bg-white hover:text-gray-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 dark:hover:bg-gray-900 dark:hover:text-gray-200"
                        aria-label="Remove file"
                    >
                        <x-ui.icon name="x" weight="bold" size="sm" />
                    </button>
                </li>
            </template>
        </ul>
    </template>

    @if ($progress !== null)
        <div class="mt-3">
            <x-ui.progress :value="$progress" />
        </div>
    @endif
</div>