Skip to main content

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>