Media
Video Grid code
Copy the implementation or inspect every file that belongs to this component unit.
resources/views/components/ui/video-grid.blade.php
@props([
'items' => [],
'title' => 'Library',
'description' => null,
'player' => 'default',
'searchable' => true,
'emptyTitle' => 'No videos yet',
'emptyDescription' => 'Upload a video or adjust your filters.',
])
@php
/*
| Responsive media library / video grid with optional client-side search.
| Each item mirrors <x-ui.video-card> fields. Clicking a card with `src`
| dispatches `video-source` for the paired player.
|
| Usage:
| <x-ui.video-grid title="Library" :items="[...]" player="watch" />
*/
$normalized = collect($items)->map(function ($item, $index) {
return [
'id' => (string) ($item['id'] ?? $index),
'title' => $item['title'] ?? 'Untitled',
'src' => $item['src'] ?? null,
'href' => $item['href'] ?? null,
'poster' => $item['poster'] ?? null,
'duration' => $item['duration'] ?? null,
'uploader' => $item['uploader'] ?? null,
'uploaderAvatar' => $item['uploaderAvatar'] ?? ($item['uploader_avatar'] ?? null),
'views' => $item['views'] ?? null,
'uploadedAt' => $item['uploadedAt'] ?? ($item['uploaded_at'] ?? null),
'tags' => array_values($item['tags'] ?? []),
];
})->values()->all();
@endphp
<section
x-data="{
player: @js((string) $player),
items: @js($normalized),
query: '',
get filtered() {
const q = this.query.trim().toLowerCase();
if (! q) { return this.items; }
return this.items.filter((item) => {
const haystack = [item.title, item.uploader, ...(item.tags || [])].join(' ').toLowerCase();
return haystack.includes(q);
});
},
open(item) {
if (item.href) {
window.location.href = item.href;
return;
}
if (! item.src) { return; }
this.$dispatch('video-source', {
player: this.player,
src: item.src,
poster: item.poster,
title: item.title,
play: true,
});
},
}"
{{ $attributes->merge(['class' => 'space-y-4']) }}
aria-label="{{ $title }}"
>
<div class="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div class="min-w-0">
<h3 class="text-base font-semibold tracking-tight text-gray-900 dark:text-white">{{ $title }}</h3>
@if (filled($description))
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">{{ $description }}</p>
@endif
<p class="mt-1 text-xs text-gray-400 dark:text-gray-500">
<span x-text="filtered.length"></span> of <span x-text="items.length"></span> videos
</p>
</div>
@if ($searchable)
<div class="relative w-full sm:max-w-xs">
<x-ui.icon name="magnifying-glass" size="sm" class="pointer-events-none absolute start-3 top-1/2 -translate-y-1/2 text-gray-400" />
<input
type="search"
x-model="query"
placeholder="Search videos"
class="w-full rounded-xl border border-gray-200 bg-white py-2.5 ps-9 pe-3 text-sm text-gray-900 shadow-sm placeholder:text-gray-400 focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-500/20 dark:border-gray-800 dark:bg-gray-950 dark:text-white dark:placeholder:text-gray-500"
>
</div>
@endif
</div>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
<template x-for="item in filtered" :key="item.id">
<button
type="button"
class="group flex w-full flex-col overflow-hidden rounded-2xl border border-gray-200 bg-white text-start shadow-sm transition hover:border-brand-300 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 dark:border-gray-800 dark:bg-gray-900 dark:hover:border-brand-700"
@click="open(item)"
>
<div class="relative aspect-video overflow-hidden bg-gray-100 dark:bg-gray-800">
<template x-if="item.poster">
<img :src="item.poster" alt="" class="h-full w-full object-cover transition duration-300 group-hover:scale-[1.02]">
</template>
<template x-if="! item.poster">
<div class="flex h-full w-full items-center justify-center text-gray-400">
<i class="ph-fill ph-play text-2xl" aria-hidden="true"></i>
</div>
</template>
<span
x-show="item.duration"
x-cloak
class="absolute bottom-2 end-2 rounded-md bg-black/75 px-1.5 py-0.5 font-mono text-[11px] font-medium text-white"
x-text="item.duration"
></span>
</div>
<div class="p-3 sm:p-4">
<p class="line-clamp-2 text-sm font-semibold text-gray-900 dark:text-white" x-text="item.title"></p>
<p class="mt-1.5 truncate text-xs text-gray-500 dark:text-gray-400">
<span x-show="item.uploader" x-text="item.uploader"></span>
<span x-show="item.uploader && item.views"> · </span>
<span x-show="item.views" x-text="item.views + ' views'"></span>
</p>
</div>
</button>
</template>
</div>
<div x-show="filtered.length === 0" x-cloak>
<x-ui.video-empty
status="empty"
:title="$emptyTitle"
:description="$emptyDescription"
/>
</div>
</section>