Skip to main content

Media

Media Comments code

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

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

@props([
    'comments' => [],
    'title' => 'Comments',
    'player' => 'default',
    'placeholder' => 'Leave a timestamped comment…',
])

@php
    /*
    | Timestamped media comments. Clicking a time chip seeks the paired player
    | via `video-seek`. New comments dispatch `media-comment`.
    |
    | Usage:
    |   <x-ui.media-comments player="watch" :comments="[
    |       ['id' => 1, 'author' => 'Ada', 'body' => 'Nice cut', 'time' => 12],
    |   ]" />
    */
    $parseTime = function ($value): float {
        if (is_numeric($value)) {
            return (float) $value;
        }

        if (! is_string($value) || ! preg_match('/^(\d+):(\d{1,2})(?:\.(\d+))?$/', $value, $matches)) {
            return 0.0;
        }

        return ((int) $matches[1] * 60) + (int) $matches[2] + (isset($matches[3]) ? (float) ('0.'.$matches[3]) : 0);
    };

    $normalized = collect($comments)->map(function ($row, $index) use ($parseTime) {
        return [
            'id' => (string) ($row['id'] ?? $index),
            'author' => (string) ($row['author'] ?? 'Guest'),
            'avatar' => $row['avatar'] ?? null,
            'body' => (string) ($row['body'] ?? ''),
            'time' => $parseTime($row['time'] ?? 0),
            'createdAt' => $row['createdAt'] ?? ($row['created_at'] ?? null),
        ];
    })->values()->all();
@endphp

<section
    x-data="{
        player: @js((string) $player),
        comments: @js($normalized),
        current: 0,
        draft: '',
        formatTime(seconds) {
            const total = Math.max(0, Math.floor(Number(seconds) || 0));
            const m = Math.floor(total / 60);
            const s = String(total % 60).padStart(2, '0');
            return `${m}:${s}`;
        },
        seek(time) {
            this.$dispatch('video-seek', { player: this.player, time: Number(time) || 0 });
        },
        submit() {
            const body = this.draft.trim();
            if (! body) { return; }
            const comment = {
                id: `local-${Date.now()}`,
                author: 'You',
                avatar: null,
                body,
                time: this.current,
                createdAt: 'Just now',
            };
            this.comments = [comment, ...this.comments];
            this.draft = '';
            this.$dispatch('media-comment', { player: this.player, comment });
        },
        onTime(detail) {
            if (detail?.player && detail.player !== this.player) { return; }
            this.current = Number(detail?.time ?? this.current);
        },
    }"
    @video-time.window="onTime($event.detail)"
    {{ $attributes->merge(['class' => 'min-w-0 max-w-full rounded-2xl border border-gray-200 bg-white shadow-sm dark:border-gray-800 dark:bg-gray-900']) }}
    aria-label="{{ $title }}"
>
    <div class="border-b border-gray-200 px-3 py-3 sm:px-4 dark:border-gray-800">
        <h3 class="text-sm font-semibold text-gray-900 dark:text-white">{{ $title }}</h3>
        <p class="text-xs text-gray-500 dark:text-gray-400">
            <span x-text="comments.length"></span> comments · posting at
            <span class="font-mono tabular-nums text-brand-600 dark:text-brand-400" x-text="formatTime(current)"></span>
        </p>
    </div>

    <form class="border-b border-gray-200 p-3 sm:p-4 dark:border-gray-800" @submit.prevent="submit()">
        <label class="sr-only" for="media-comment-draft">{{ $placeholder }}</label>
        <textarea
            id="media-comment-draft"
            x-model="draft"
            rows="2"
            placeholder="{{ $placeholder }}"
            class="w-full rounded-xl border border-gray-200 bg-white px-3 py-2.5 text-base text-gray-900 shadow-sm placeholder:text-gray-400 focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-500/30 dark:border-gray-700 dark:bg-gray-950 dark:text-white dark:placeholder:text-gray-500 sm:text-sm"
        ></textarea>
        <div class="mt-2 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
            <p class="text-xs text-gray-500 dark:text-gray-400">Comment will be pinned to the current playhead.</p>
            <button
                type="submit"
                class="inline-flex h-10 w-full items-center justify-center rounded-lg bg-brand-600 px-3 text-sm font-semibold text-white shadow-sm transition hover:bg-brand-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 sm:h-9 sm:w-auto"
            >
                Post comment
            </button>
        </div>
    </form>

    <div class="max-h-[24rem] space-y-3 overflow-y-auto p-3 sm:max-h-[32rem] sm:p-4">
        <template x-for="comment in comments" :key="comment.id">
            <article class="flex min-w-0 gap-3">
                <div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-brand-100 text-xs font-semibold text-brand-700 dark:bg-brand-950 dark:text-brand-300" x-text="(comment.author || '?').slice(0, 2).toUpperCase()"></div>
                <div class="min-w-0 flex-1">
                    <div class="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
                        <p class="truncate text-sm font-semibold text-gray-900 dark:text-white" x-text="comment.author"></p>
                        <button
                            type="button"
                            class="inline-flex items-center rounded-md bg-brand-50 px-1.5 py-0.5 font-mono text-[11px] font-semibold tabular-nums text-brand-700 transition hover:bg-brand-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 dark:bg-brand-950/50 dark:text-brand-300 dark:hover:bg-brand-950"
                            @click="seek(comment.time)"
                            :aria-label="`Seek to ${formatTime(comment.time)}`"
                        >
                            <span x-text="formatTime(comment.time)"></span>
                        </button>
                        <span
                            x-show="comment.createdAt"
                            class="text-xs text-gray-400 dark:text-gray-500"
                            x-text="comment.createdAt"
                        ></span>
                    </div>
                    <p class="mt-1 text-sm leading-6 text-gray-600 dark:text-gray-300" x-text="comment.body"></p>
                </div>
            </article>
        </template>

        <template x-if="comments.length === 0">
            <p class="py-8 text-center text-sm text-gray-500 dark:text-gray-400">No comments yet. Be the first at this timestamp.</p>
        </template>
    </div>
</section>