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>