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>