Files
takeone-youtube-clone/resources/views/components/video-comments.blade.php
T
ghassanandClaude Opus 4.7 8aa08e705a perf(comments): lazy-render top-level reply forms
The reply form used to render ~13 lines of hidden markup under every
top-level comment, so a 50-comment video shipped roughly 650 lines of
inert DOM to every signed-in viewer. Replaced with a single
<template id="ytc-reply-form-tpl"> plus a per-comment slot div; the
first click on Reply clones the template, wires the IDs
(replyForm{id}, replyBody{id}, data-parent-id, data-video-id) and
appends it, so toggle behavior stays identical from the user's side.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-10-21 00:52:13 +03:00

1546 lines
64 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
@props(['video'])
@php
use App\Models\CommentLike;
$currentUserId = Auth::id() ?? 0;
$llmEnabled = app(\App\Services\LlmLyricsService::class)->isEnabled();
// Flag per commenter: most-frequent access country, falling back to the
// user's nationality. Batched so the comments list stays a single query.
$commenterIds = $video->comments()->pluck('user_id')->filter()->unique()->values()->all();
$commenterCountry = \App\Models\User::topCountriesFor($commenterIds);
$topComments = $video->comments()->whereNull('parent_id')->with('user', 'replies.user')->latest()->get();
$commentCount = $topComments->sum(fn($c) => 1 + $c->replies->count());
$allCommentIds = $topComments->pluck('id')
->merge($topComments->flatMap(fn($c) => $c->replies->pluck('id')))
->all();
$likeCounts = CommentLike::whereIn('comment_id', $allCommentIds)
->selectRaw('comment_id, COUNT(*) as cnt')
->groupBy('comment_id')
->pluck('cnt', 'comment_id');
$userLikedIds = $currentUserId
? CommentLike::where('user_id', $currentUserId)
->whereIn('comment_id', $allCommentIds)
->pluck('comment_id')->flip()->all()
: [];
if (!function_exists('ytcAvatar')) {
function ytcAvatar($user) {
if (!$user) return asset('images/default-avatar.svg');
return $user->avatar_url ?? asset('images/default-avatar.svg');
}
}
if (!function_exists('ytcTime')) {
function ytcTime($dt) {
if (!$dt) return '';
return $dt->diffForHumans();
}
}
@endphp
<div class="ytc" id="ytcSection">
{{-- ── Header ── --}}
<div class="ytc-header">
<span class="ytc-count" id="ytcCount">{{ number_format($commentCount) }} Comments</span>
<div class="ytc-sort-wrap" id="ytcSortWrap">
<button class="ytc-sort-btn" id="ytcSortBtn">
<svg viewBox="0 0 24 24"><path d="M3 18h6v-2H3v2zM3 6v2h18V6H3zm0 7h12v-2H3v2z"/></svg>
Sort by
</button>
<div class="ytc-sort-menu" id="ytcSortMenu">
<div class="ytc-sort-opt active" data-sort="newest">
<svg viewBox="0 0 24 24"><path d="M9 16.17 4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/></svg>
Top comments
</div>
<div class="ytc-sort-opt" data-sort="top">
<svg viewBox="0 0 24 24"><path d="M9 16.17 4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" opacity="0"/></svg>
Newest first
</div>
</div>
</div>
</div>
{{-- ── New comment form ── --}}
@auth
<div class="ytc-new-form" id="ytcNewForm">
<img src="{{ Auth::user()->avatar_url }}" class="ytc-avatar" alt="{{ Auth::user()->name }}">
<div class="ytc-input-wrap">
<textarea class="ytc-textarea" id="ytcTextarea"
placeholder="Add a comment..." rows="1"></textarea>
<div class="ytc-form-actions" id="ytcFormActions">
<button class="ytc-btn ytc-btn-cancel" id="ytcCancelBtn">Cancel</button>
<button class="ytc-btn ytc-btn-submit" id="ytcSubmitBtn">Comment</button>
</div>
</div>
</div>
@else
<div class="ytc-login-prompt">
<svg viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 3c1.66 0 3 1.34 3 3s-1.34 3-3 3-3-1.34-3-3 1.34-3 3-3zm0 14.2c-2.5 0-4.71-1.28-6-3.22.03-1.99 4-3.08 6-3.08 1.99 0 5.97 1.09 6 3.08-1.29 1.94-3.5 3.22-6 3.22z"/></svg>
<a href="{{ route('login') }}">Sign in</a> to leave a comment
</div>
@endauth
{{-- ── Comments list ── --}}
<div id="ytcList">
@forelse($topComments as $comment)
@php $isOwn = $comment->user_id === $currentUserId; @endphp
<div class="ytc-comment" id="comment-{{ $comment->id }}">
<a href="{{ $comment->user ? route('channel', $comment->user->channel) : '#' }}" class="ytc-avatar-link">
<img src="{{ ytcAvatar($comment->user) }}" class="ytc-avatar" alt="{{ $comment->user->name ?? 'User' }}">
</a>
<div class="ytc-body-wrap">
<div class="ytc-meta">
@php $cFlag = $commenterCountry[$comment->user_id] ?? null; @endphp
@if($cFlag)<span class="ytc-flag fi fi-{{ strtolower($cFlag) }}" title="{{ $cFlag }}"></span>@endif
<a href="{{ $comment->user ? route('channel', $comment->user->channel) : '#' }}" class="ytc-author">{{ $comment->user->name ?? 'User' }}</a>
<span class="ytc-time">{{ ytcTime($comment->created_at) }}</span>
</div>
<div class="ytc-text _comment-body" data-_comment-enhanced="0">{{ $comment->body }}</div>
@if($llmEnabled)
<div class="ytc-translation" id="translation-{{ $comment->id }}" style="display:none"></div>
@endif
{{-- Edit form --}}
@if($isOwn)
<div class="ytc-edit-form" id="commentEditWrap{{ $comment->id }}" style="display:none">
<textarea class="ytc-edit-textarea" id="commentEditInput{{ $comment->id }}" rows="2">{{ $comment->body }}</textarea>
<div class="ytc-edit-actions">
<span class="ytc-edit-hint">Press Esc to cancel</span>
<button class="ytc-btn ytc-btn-cancel _comment-cancel-edit-trigger" data-comment-id="{{ $comment->id }}">Cancel</button>
<button class="ytc-btn ytc-btn-submit _comment-save-edit-trigger" data-comment-id="{{ $comment->id }}">Save</button>
</div>
</div>
@endif
{{-- Action bar --}}
<div class="ytc-actions">
@php $cLikes = $likeCounts->get($comment->id, 0); $cLiked = isset($userLikedIds[$comment->id]); @endphp
<button class="ytc-like-btn{{ $cLiked ? ' liked' : '' }}" data-id="{{ $comment->id }}" data-count="{{ $cLikes }}" title="Like">
<svg viewBox="0 0 24 24"><path d="M1 21h4V9H1v12zm22-11c0-1.1-.9-2-2-2h-6.31l.95-4.57.03-.32c0-.41-.17-.79-.44-1.06L14.17 1 7.59 7.59C7.22 7.95 7 8.45 7 9v10c0 1.1.9 2 2 2h9c.83 0 1.54-.5 1.84-1.22l3.02-7.05c.09-.23.14-.47.14-.73v-2z"/></svg>
<span class="ytc-like-count" data-id="{{ $comment->id }}">{{ $cLikes ?: '' }}</span>
</button>
<button class="ytc-dislike-btn" data-id="{{ $comment->id }}" title="Dislike">
<svg viewBox="0 0 24 24"><path d="M15 3H6c-.83 0-1.54.5-1.84 1.22l-3.02 7.05c-.09.23-.14.47-.14.73v2c0 1.1.9 2 2 2h6.31l-.95 4.57-.03.32c0 .41.17.79.44 1.06L10.83 23l6.59-6.59c.36-.36.58-.86.58-1.41V5c0-1.1-.9-2-2-2zm4 0v12h4V3h-4z"/></svg>
</button>
@auth
<button class="ytc-reply-btn _comment-reply-trigger" data-comment-id="{{ $comment->id }}">Reply</button>
@endauth
@if($llmEnabled)
<button class="ytc-translate-btn _comment-translate-trigger" data-comment-id="{{ $comment->id }}" title="Translate this comment">
<svg viewBox="0 0 24 24"><path d="M12.87 15.07l-2.54-2.51.03-.03c1.74-1.94 2.98-4.17 3.71-6.53H17V4h-7V2H8v2H1v1.99h11.17C11.5 7.92 10.44 9.75 9 11.35 8.07 10.32 7.3 9.19 6.69 8h-2c.73 1.63 1.73 3.17 2.98 4.56l-5.09 5.02L4 19l5-5 3.11 3.11.76-2.04zM18.5 10h-2L12 22h2l1.12-3h4.75L21 22h2l-4.5-12zm-2.62 7l1.62-4.33L19.12 17h-3.24z"/></svg>
<span class="ytc-translate-label">Translate</span>
</button>
@endif
@if($isOwn)
<div class="ytc-more-wrap">
<button class="ytc-more-btn" title="More actions">
<svg viewBox="0 0 24 24"><path d="M12 8c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"/></svg>
</button>
<div class="ytc-more-menu">
<button class="ytc-more-item _comment-edit-trigger" data-comment-id="{{ $comment->id }}">
<svg viewBox="0 0 24 24"><path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></svg>
Edit
</button>
<button class="ytc-more-item ytc-more-delete _comment-delete-trigger" data-comment-id="{{ $comment->id }}">
<svg viewBox="0 0 24 24"><path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></svg>
Delete
</button>
</div>
</div>
@endif
</div>
{{-- Reply form is lazy-cloned from #ytc-reply-form-tpl on
first click; the ~13-line markup used to render for
every top-level comment (so a 50-comment video shipped
~650 lines of hidden form markup to every viewer). --}}
@auth
<div class="ytc-reply-slot" data-comment-id="{{ $comment->id }}" data-video-id="{{ $video->getRouteKey() }}"></div>
@endauth
{{-- Replies --}}
@if($comment->replies && $comment->replies->count() > 0)
<div class="ytc-replies-section">
<button class="ytc-replies-toggle" data-open="0"
onclick="ytcToggleReplies(this, {{ $comment->id }})">
<svg class="ytc-chevron" viewBox="0 0 24 24"><path d="M7.41 8.59 12 13.17l4.59-4.58L18 10l-6 6-6-6 1.41-1.41z"/></svg>
{{ $comment->replies->count() }} {{ Str::plural('reply', $comment->replies->count()) }}
</button>
<div class="ytc-replies-list" id="replies-{{ $comment->id }}" style="display:none">
@foreach($comment->replies as $reply)
@php $replyIsOwn = $reply->user_id === $currentUserId; @endphp
<div class="ytc-comment ytc-reply" id="comment-{{ $reply->id }}">
<a href="{{ $reply->user ? route('channel', $reply->user->channel) : '#' }}" class="ytc-avatar-link">
<img src="{{ ytcAvatar($reply->user) }}" class="ytc-avatar ytc-avatar-sm" alt="{{ $reply->user->name ?? 'User' }}">
</a>
<div class="ytc-body-wrap">
<div class="ytc-meta">
@php $rFlag = $commenterCountry[$reply->user_id] ?? null; @endphp
@if($rFlag)<span class="ytc-flag fi fi-{{ strtolower($rFlag) }}" title="{{ $rFlag }}"></span>@endif
<a href="{{ $reply->user ? route('channel', $reply->user->channel) : '#' }}" class="ytc-author">{{ $reply->user->name ?? 'User' }}</a>
<span class="ytc-time">{{ ytcTime($reply->created_at) }}</span>
</div>
<div class="ytc-text _comment-body" data-_comment-enhanced="0">{{ $reply->body }}</div>
@if($llmEnabled)
<div class="ytc-translation" id="translation-{{ $reply->id }}" style="display:none"></div>
@endif
@if($replyIsOwn)
<div class="ytc-edit-form" id="commentEditWrap{{ $reply->id }}" style="display:none">
<textarea class="ytc-edit-textarea" id="commentEditInput{{ $reply->id }}" rows="2">{{ $reply->body }}</textarea>
<div class="ytc-edit-actions">
<span class="ytc-edit-hint">Press Esc to cancel</span>
<button class="ytc-btn ytc-btn-cancel _comment-cancel-edit-trigger" data-comment-id="{{ $reply->id }}">Cancel</button>
<button class="ytc-btn ytc-btn-submit _comment-save-edit-trigger" data-comment-id="{{ $reply->id }}">Save</button>
</div>
</div>
@endif
<div class="ytc-actions">
@php $rLikes = $likeCounts->get($reply->id, 0); $rLiked = isset($userLikedIds[$reply->id]); @endphp
<button class="ytc-like-btn{{ $rLiked ? ' liked' : '' }}" data-id="{{ $reply->id }}" data-count="{{ $rLikes }}" title="Like">
<svg viewBox="0 0 24 24"><path d="M1 21h4V9H1v12zm22-11c0-1.1-.9-2-2-2h-6.31l.95-4.57.03-.32c0-.41-.17-.79-.44-1.06L14.17 1 7.59 7.59C7.22 7.95 7 8.45 7 9v10c0 1.1.9 2 2 2h9c.83 0 1.54-.5 1.84-1.22l3.02-7.05c.09-.23.14-.47.14-.73v-2z"/></svg>
<span class="ytc-like-count" data-id="{{ $reply->id }}">{{ $rLikes ?: '' }}</span>
</button>
<button class="ytc-dislike-btn" data-id="{{ $reply->id }}" title="Dislike">
<svg viewBox="0 0 24 24"><path d="M15 3H6c-.83 0-1.54.5-1.84 1.22l-3.02 7.05c-.09.23-.14.47-.14.73v2c0 1.1.9 2 2 2h6.31l-.95 4.57-.03.32c0 .41.17.79.44 1.06L10.83 23l6.59-6.59c.36-.36.58-.86.58-1.41V5c0-1.1-.9-2-2-2zm4 0v12h4V3h-4z"/></svg>
</button>
@if($llmEnabled)
<button class="ytc-translate-btn _comment-translate-trigger" data-comment-id="{{ $reply->id }}" title="Translate this comment">
<svg viewBox="0 0 24 24"><path d="M12.87 15.07l-2.54-2.51.03-.03c1.74-1.94 2.98-4.17 3.71-6.53H17V4h-7V2H8v2H1v1.99h11.17C11.5 7.92 10.44 9.75 9 11.35 8.07 10.32 7.3 9.19 6.69 8h-2c.73 1.63 1.73 3.17 2.98 4.56l-5.09 5.02L4 19l5-5 3.11 3.11.76-2.04zM18.5 10h-2L12 22h2l1.12-3h4.75L21 22h2l-4.5-12zm-2.62 7l1.62-4.33L19.12 17h-3.24z"/></svg>
<span class="ytc-translate-label">Translate</span>
</button>
@endif
@if($replyIsOwn)
<div class="ytc-more-wrap">
<button class="ytc-more-btn">
<svg viewBox="0 0 24 24"><path d="M12 8c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"/></svg>
</button>
<div class="ytc-more-menu">
<button class="ytc-more-item _comment-edit-trigger" data-comment-id="{{ $reply->id }}">
<svg viewBox="0 0 24 24"><path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></svg>
Edit
</button>
<button class="ytc-more-item ytc-more-delete _comment-delete-trigger" data-comment-id="{{ $reply->id }}">
<svg viewBox="0 0 24 24"><path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></svg>
Delete
</button>
</div>
</div>
@endif
</div>
</div>
</div>
@endforeach
</div>
</div>
@endif
</div>
</div>
@empty
<div class="ytc-empty" id="ytcEmpty">
<svg viewBox="0 0 24 24"><path d="M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-2 12H6v-2h12v2zm0-3H6V9h12v2zm0-3H6V6h12v2z"/></svg>
<p>No comments yet. Be the first!</p>
</div>
@endforelse
</div>
</div>
{{-- ── Delete confirm modal ── --}}
<div class="ytc-modal" id="ytcDeleteModal">
<div class="ytc-modal-box">
<h4 class="ytc-modal-title">Delete comment?</h4>
<p class="ytc-modal-msg">This will permanently delete your comment.</p>
<div class="ytc-modal-actions">
<button class="ytc-btn ytc-btn-cancel" id="ytcModalCancel">Cancel</button>
<button class="ytc-btn ytc-btn-danger" id="ytcModalConfirm">Delete</button>
</div>
</div>
</div>
<style>
/* ══════════════════════════════════════════════════
YTC — YouTube-style comments
══════════════════════════════════════════════════ */
.ytc {
margin-top: 24px;
padding-top: 24px;
border-top: 1px solid var(--border-color);
font-family: Roboto, Arial, sans-serif;
}
/* ── Header ── */
.ytc-header {
display: flex;
align-items: center;
gap: 24px;
margin-bottom: 24px;
position: relative;
}
.ytc-count {
font-size: 16px;
font-weight: 400;
color: var(--text-primary);
}
.ytc-sort-wrap { position: relative; }
.ytc-sort-btn {
display: flex;
align-items: center;
gap: 8px;
background: none;
border: none;
color: var(--text-primary);
font-size: 14px;
font-weight: 500;
cursor: pointer;
padding: 6px 12px;
border-radius: 18px;
transition: background .15s;
}
.ytc-sort-btn:hover { background: rgba(255,255,255,.1); }
.ytc-sort-btn svg { width: 18px; height: 18px; fill: currentColor; }
.ytc-sort-menu {
display: none;
position: absolute;
top: calc(100% + 4px);
left: 0;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 12px;
min-width: 180px;
z-index: 200;
overflow: hidden;
box-shadow: 0 4px 20px rgba(0,0,0,.4);
}
.ytc-sort-menu.open { display: block; }
.ytc-sort-opt {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
font-size: 14px;
color: var(--text-primary);
cursor: pointer;
transition: background .15s;
}
.ytc-sort-opt:hover { background: rgba(255,255,255,.08); }
.ytc-sort-opt svg { width: 18px; height: 18px; fill: currentColor; flex-shrink: 0; }
/* ── Login prompt ── */
.ytc-login-prompt {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 24px;
font-size: 14px;
color: var(--text-secondary);
padding: 16px 0;
border-bottom: 1px solid var(--border-color);
}
.ytc-login-prompt svg { width: 24px; height: 24px; fill: var(--text-secondary); }
.ytc-login-prompt a { color: #3ea6ff; text-decoration: none; font-weight: 500; }
/* ── Avatar ── */
.ytc-avatar-link { flex-shrink: 0; display: block; }
.ytc-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
object-fit: cover;
flex-shrink: 0;
}
.ytc-avatar-sm { width: 24px; height: 24px; }
/* ── New comment form ── */
.ytc-new-form {
display: flex;
gap: 16px;
align-items: flex-start;
margin-bottom: 32px;
}
.ytc-input-wrap { flex: 1; }
.ytc-textarea {
width: 100%;
background: transparent;
border: none;
border-bottom: 1px solid var(--border-color);
color: var(--text-primary);
font-size: 14px;
font-family: inherit;
padding: 6px 0;
resize: none;
outline: none;
transition: border-color .2s;
line-height: 1.5;
overflow: hidden;
min-height: 32px;
box-sizing: border-box;
display: block;
}
.ytc-textarea:focus { border-bottom-color: var(--text-primary); }
.ytc-form-actions {
display: none;
justify-content: flex-end;
gap: 8px;
margin-top: 12px;
}
.ytc-form-actions.visible { display: flex; }
/* Reply form shown state */
.ytc-reply-form {
display: none;
gap: 12px;
align-items: flex-start;
margin-top: 16px;
}
.ytc-reply-form.open { display: flex; }
.ytc-reply-form .ytc-form-actions { display: flex; }
/* ── Buttons ── */
.ytc-btn {
border: none;
border-radius: 18px;
padding: 8px 16px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: background .15s;
white-space: nowrap;
font-family: inherit;
}
.ytc-btn-cancel {
background: transparent;
color: var(--text-primary);
}
.ytc-btn-cancel:hover { background: rgba(255,255,255,.1); }
.ytc-btn-submit {
background: #3ea6ff;
color: #0d0d0d;
}
.ytc-btn-submit:hover { background: #65b8ff; }
.ytc-btn-submit:disabled { opacity: .5; cursor: not-allowed; }
.ytc-btn-danger {
background: #cc0000;
color: #fff;
}
.ytc-btn-danger:hover { background: #aa0000; }
/* ── Comment item ── */
.ytc-comment {
display: flex;
gap: 16px;
margin-bottom: 24px;
}
.ytc-reply { margin-left: 56px; margin-bottom: 16px; }
.ytc-body-wrap { flex: 1; min-width: 0; }
/* ── Meta row ── */
.ytc-meta {
display: flex;
align-items: baseline;
gap: 8px;
margin-bottom: 4px;
flex-wrap: wrap;
}
.ytc-author {
font-size: 13px;
font-weight: 500;
color: var(--text-primary);
text-decoration: none;
}
.ytc-author:hover { text-decoration: underline; }
.ytc-time {
font-size: 12px;
color: var(--text-secondary);
}
/* ── Comment text ── */
.ytc-text {
font-size: 14px;
line-height: 1.6;
color: var(--text-primary);
word-break: break-word;
white-space: pre-wrap;
}
/* ── Actions ── */
.ytc-actions {
display: flex;
align-items: center;
gap: 4px;
margin-top: 8px;
}
.ytc-like-btn, .ytc-dislike-btn {
display: flex;
align-items: center;
gap: 6px;
background: none;
border: none;
color: var(--text-secondary);
font-size: 13px;
cursor: pointer;
padding: 6px 8px;
border-radius: 18px;
transition: background .15s, color .15s;
}
.ytc-like-btn:hover, .ytc-dislike-btn:hover {
background: rgba(255,255,255,.1);
color: var(--text-primary);
}
.ytc-like-btn.liked { color: #3ea6ff; }
.ytc-dislike-btn.disliked { color: #aaa; }
.ytc-like-btn svg, .ytc-dislike-btn svg {
width: 18px;
height: 18px;
fill: currentColor;
}
.ytc-like-count:empty { display: none; }
.ytc-reply-btn {
background: none;
border: none;
color: var(--text-primary);
font-size: 13px;
font-weight: 500;
cursor: pointer;
padding: 6px 12px;
border-radius: 18px;
margin-left: 4px;
transition: background .15s;
font-family: inherit;
}
.ytc-reply-btn:hover { background: rgba(255,255,255,.1); }
/* ── Translate ── */
.ytc-translate-btn {
display: inline-flex;
align-items: center;
gap: 6px;
background: none;
border: none;
color: var(--text-secondary);
font-size: 13px;
font-weight: 500;
cursor: pointer;
padding: 6px 10px;
border-radius: 18px;
transition: background .15s, color .15s;
font-family: inherit;
}
.ytc-translate-btn:hover { background: rgba(255,255,255,.1); color: var(--text-primary); }
.ytc-translate-btn svg { width: 16px; height: 16px; fill: currentColor; }
.ytc-translate-btn.loading { opacity: .6; cursor: wait; }
.ytc-translate-btn.active { color: #3ea6ff; }
.ytc-flag {
display: inline-block;
width: 18px;
height: 13px;
border-radius: 2px;
box-shadow: 0 0 0 1px rgba(255,255,255,.08);
vertical-align: middle;
transform: translateY(-1px);
flex-shrink: 0;
}
.ytc-translation {
margin-top: 6px;
padding: 10px 12px;
background: rgba(62,166,255,.08);
border-left: 2px solid #3ea6ff;
border-radius: 4px;
font-size: 14px;
line-height: 1.6;
color: var(--text-primary);
white-space: pre-wrap;
word-break: break-word;
}
.ytc-translation-meta {
display: block;
font-size: 11px;
color: var(--text-secondary);
margin-bottom: 4px;
text-transform: uppercase;
letter-spacing: .4px;
}
/* ── Three-dot menu ── */
.ytc-more-wrap {
position: relative;
margin-left: auto;
}
.ytc-more-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 50%;
background: none;
border: none;
color: var(--text-secondary);
cursor: pointer;
transition: background .15s;
opacity: 0;
transition: opacity .15s, background .15s;
}
.ytc-comment:hover .ytc-more-btn { opacity: 1; }
.ytc-more-btn:focus, .ytc-more-btn[aria-expanded="true"] { opacity: 1; background: rgba(255,255,255,.1); }
.ytc-more-btn:hover { background: rgba(255,255,255,.1); opacity: 1; }
.ytc-more-btn svg { width: 20px; height: 20px; fill: currentColor; }
.ytc-more-menu {
display: none;
position: absolute;
right: 0;
top: calc(100% + 4px);
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 12px;
min-width: 160px;
z-index: 200;
overflow: hidden;
box-shadow: 0 4px 20px rgba(0,0,0,.4);
}
.ytc-more-menu.open { display: block; }
.ytc-more-item {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
padding: 12px 16px;
background: none;
border: none;
color: var(--text-primary);
font-size: 14px;
cursor: pointer;
text-align: left;
transition: background .15s;
font-family: inherit;
}
.ytc-more-item:hover { background: rgba(255,255,255,.08); }
.ytc-more-item svg { width: 18px; height: 18px; fill: currentColor; flex-shrink: 0; }
.ytc-more-delete { color: #f28b82; }
.ytc-more-delete svg { fill: #f28b82; }
/* ── Edit form ── */
.ytc-edit-form { margin-top: 8px; }
.ytc-edit-textarea {
width: 100%;
background: transparent;
border: none;
border-bottom: 2px solid #3ea6ff;
color: var(--text-primary);
font-size: 14px;
font-family: inherit;
padding: 6px 0;
resize: none;
outline: none;
line-height: 1.5;
box-sizing: border-box;
}
.ytc-edit-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
margin-top: 10px;
}
.ytc-edit-hint {
font-size: 12px;
color: var(--text-secondary);
margin-right: auto;
}
/* ── Replies section ── */
.ytc-replies-section { margin-top: 12px; }
.ytc-replies-toggle {
display: flex;
align-items: center;
gap: 6px;
background: none;
border: none;
color: #3ea6ff;
font-size: 14px;
font-weight: 500;
cursor: pointer;
padding: 8px 12px;
border-radius: 18px;
transition: background .15s;
font-family: inherit;
}
.ytc-replies-toggle:hover { background: rgba(62,166,255,.1); }
.ytc-chevron {
width: 18px;
height: 18px;
fill: #3ea6ff;
transition: transform .2s;
}
.ytc-replies-toggle[data-open="1"] .ytc-chevron { transform: rotate(180deg); }
.ytc-replies-list { padding-top: 8px; }
/* ── Timestamp badge ── */
._comment-time-badge {
display: inline-flex !important;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 14px;
background: rgba(62,166,255,.15);
border: 1px solid rgba(62,166,255,.3);
color: #3ea6ff;
font-weight: 500;
font-size: 12px;
cursor: pointer;
text-decoration: none;
margin: 0 2px;
transition: background .15s;
white-space: nowrap;
}
._comment-time-badge:hover { background: rgba(62,166,255,.25); }
._comment-time-badge i, ._comment-time-badge svg { font-size: 11px; }
/* @mention styling */
.ytc-mention { color: #3ea6ff; font-weight: 500; cursor: pointer; }
/* ── Empty state ── */
.ytc-empty {
text-align: center;
padding: 48px 24px;
color: var(--text-secondary);
}
.ytc-empty svg { width: 48px; height: 48px; fill: var(--text-secondary); margin-bottom: 12px; display: block; margin-inline: auto; }
.ytc-empty p { font-size: 14px; }
/* ── Delete modal ── */
.ytc-modal {
display: none;
position: fixed;
inset: 0;
background: rgba(0,0,0,.7);
z-index: 9999;
align-items: center;
justify-content: center;
}
.ytc-modal.open { display: flex; }
.ytc-modal-box {
background: var(--bg-secondary);
border-radius: 12px;
padding: 28px 32px;
width: 90%;
max-width: 380px;
box-shadow: 0 8px 40px rgba(0,0,0,.5);
}
.ytc-modal-title {
font-size: 18px;
font-weight: 600;
margin: 0 0 8px;
color: var(--text-primary);
}
.ytc-modal-msg {
font-size: 14px;
color: var(--text-secondary);
margin: 0 0 24px;
line-height: 1.5;
}
.ytc-modal-actions { display: flex; justify-content: flex-end; gap: 8px; }
/* ── Mobile ── */
@media (max-width: 576px) {
.ytc-comment { gap: 10px; }
.ytc-reply { margin-left: 34px; }
.ytc-avatar { width: 32px; height: 32px; }
.ytc-more-btn { opacity: 1; }
}
</style>
<script>
(function () {
'use strict';
const YTC = {
videoId: '{{ $video->getRouteKey() }}',
csrf: '{{ csrf_token() }}',
userId: {{ $currentUserId }},
llmEnabled: {{ $llmEnabled ? 'true' : 'false' }},
meCountry: @json($currentUserId && isset($commenterCountry[$currentUserId]) ? strtolower($commenterCountry[$currentUserId]) : null),
deleteId: null,
toastTimer: null,
};
const YTC_TRANSLATE_SVG = '<svg viewBox="0 0 24 24"><path d="M12.87 15.07l-2.54-2.51.03-.03c1.74-1.94 2.98-4.17 3.71-6.53H17V4h-7V2H8v2H1v1.99h11.17C11.5 7.92 10.44 9.75 9 11.35 8.07 10.32 7.3 9.19 6.69 8h-2c.73 1.63 1.73 3.17 2.98 4.56l-5.09 5.02L4 19l5-5 3.11 3.11.76-2.04zM18.5 10h-2L12 22h2l1.12-3h4.75L21 22h2l-4.5-12zm-2.62 7l1.62-4.33L19.12 17h-3.24z"/></svg>';
function targetLang() {
const l = (navigator.language || 'en').toLowerCase().split('-')[0];
return l || 'en';
}
// ── Helpers ──────────────────────────────────────────
function esc(s) {
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
function fmt(s) { return s ?? ''; }
function avatarUrl(user) {
if (!user) return '{{ asset('images/default-avatar.svg') }}';
return user.avatar_url || '{{ asset('images/default-avatar.svg') }}';
}
// ── Toast (use global if available, else local) ───────
function toast(msg, type) {
if (window.showToast) { window.showToast(msg, type || 'info'); return; }
console.log('[YTC]', msg);
}
// ── Auto-resize textareas ────────────────────────────
function autoResize(el) {
el.style.height = 'auto';
el.style.height = el.scrollHeight + 'px';
}
document.querySelectorAll('.ytc-textarea, .ytc-edit-textarea').forEach(ta => {
ta.addEventListener('input', () => autoResize(ta));
});
// ── New comment form focus expand ────────────────────
const newTextarea = document.getElementById('ytcTextarea');
const formActions = document.getElementById('ytcFormActions');
const cancelBtn = document.getElementById('ytcCancelBtn');
const submitBtn = document.getElementById('ytcSubmitBtn');
if (newTextarea) {
newTextarea.addEventListener('focus', () => formActions.classList.add('visible'));
}
if (cancelBtn) {
cancelBtn.addEventListener('click', () => {
newTextarea.value = '';
autoResize(newTextarea);
formActions.classList.remove('visible');
newTextarea.blur();
});
}
if (submitBtn) {
submitBtn.addEventListener('click', () => postComment());
}
// ── Sort ─────────────────────────────────────────────
const sortBtn = document.getElementById('ytcSortBtn');
const sortMenu = document.getElementById('ytcSortMenu');
if (sortBtn) {
sortBtn.addEventListener('click', e => {
e.stopPropagation();
sortMenu.classList.toggle('open');
});
document.querySelectorAll('.ytc-sort-opt').forEach(opt => {
opt.addEventListener('click', () => {
document.querySelectorAll('.ytc-sort-opt').forEach(o => o.classList.remove('active'));
opt.classList.add('active');
sortMenu.classList.remove('open');
sortComments(opt.dataset.sort);
});
});
}
document.addEventListener('click', e => {
if (!document.getElementById('ytcSortWrap')?.contains(e.target))
sortMenu?.classList.remove('open');
});
function sortComments(dir) {
const list = document.getElementById('ytcList');
if (!list) return;
const items = Array.from(list.querySelectorAll(':scope > .ytc-comment'));
if (dir === 'newest') {
items.sort((a, b) => {
const ta = a.querySelector('.ytc-time')?.textContent || '';
const tb = b.querySelector('.ytc-time')?.textContent || '';
return ta.localeCompare(tb);
});
}
items.forEach(el => list.appendChild(el));
}
// ── Reply toggle ─────────────────────────────────────
window.ytcToggleReplies = function(btn, commentId) {
const list = document.getElementById('replies-' + commentId);
if (!list) return;
const open = btn.dataset.open === '1';
if (open) {
list.style.display = 'none';
btn.dataset.open = '0';
} else {
list.style.display = 'block';
btn.dataset.open = '1';
}
};
// ── Three-dot menus ───────────────────────────────────
document.addEventListener('click', e => {
// Toggle own menu
const moreBtn = e.target.closest('.ytc-more-btn');
if (moreBtn) {
e.stopPropagation();
const menu = moreBtn.nextElementSibling;
const isOpen = menu.classList.contains('open');
document.querySelectorAll('.ytc-more-menu.open').forEach(m => m.classList.remove('open'));
if (!isOpen) menu.classList.add('open');
return;
}
// Close all
if (!e.target.closest('.ytc-more-wrap')) {
document.querySelectorAll('.ytc-more-menu.open').forEach(m => m.classList.remove('open'));
}
});
// ── Delete modal ─────────────────────────────────────
const modal = document.getElementById('ytcDeleteModal');
const modalCancel = document.getElementById('ytcModalCancel');
const modalConfirm = document.getElementById('ytcModalConfirm');
function openDeleteModal(id) {
YTC.deleteId = id;
modal.classList.add('open');
document.body.style.overflow = 'hidden';
}
function closeDeleteModal() {
modal.classList.remove('open');
YTC.deleteId = null;
document.body.style.overflow = '';
}
if (modalCancel) modalCancel.addEventListener('click', closeDeleteModal);
modal?.addEventListener('click', e => { if (e.target === modal) closeDeleteModal(); });
document.addEventListener('keydown', e => {
if (e.key === 'Escape' && modal.classList.contains('open')) closeDeleteModal();
});
if (modalConfirm) {
modalConfirm.addEventListener('click', async () => {
if (!YTC.deleteId) return;
const id = YTC.deleteId;
modalConfirm.disabled = true;
modalConfirm.textContent = 'Deleting…';
try {
const r = await fetch('/comments/' + id, {
method: 'DELETE',
headers: { 'X-CSRF-TOKEN': YTC.csrf, 'Content-Type': 'application/json' }
});
const d = await r.json();
if (!r.ok && !d.success && !d.deleted) throw new Error(d.error || 'Failed');
const el = document.getElementById('comment-' + id);
if (el) {
el.style.transition = 'opacity .2s';
el.style.opacity = '0';
setTimeout(() => el.remove(), 200);
}
updateCount(-1);
toast('Comment deleted', 'success');
closeDeleteModal();
} catch (err) {
toast(err.message || 'Failed to delete', 'error');
} finally {
modalConfirm.disabled = false;
modalConfirm.textContent = 'Delete';
}
});
}
// ── Event delegation ─────────────────────────────────
document.getElementById('ytcList')?.addEventListener('click', e => {
// Delete trigger
const dt = e.target.closest('._comment-delete-trigger');
if (dt) { e.stopPropagation(); openDeleteModal(parseInt(dt.dataset.commentId)); return; }
// Reply trigger
const rt = e.target.closest('._comment-reply-trigger');
if (rt) { e.stopPropagation(); toggleReplyForm(rt.dataset.commentId); return; }
// Edit trigger
const et = e.target.closest('._comment-edit-trigger');
if (et) { e.stopPropagation(); startEdit(et.dataset.commentId); return; }
// Cancel edit
const ce = e.target.closest('._comment-cancel-edit-trigger');
if (ce) { e.stopPropagation(); cancelEdit(ce.dataset.commentId); return; }
// Save edit
const se = e.target.closest('._comment-save-edit-trigger');
if (se) { e.stopPropagation(); saveEdit(se.dataset.commentId); return; }
// Cancel reply
const cr = e.target.closest('._comment-cancel-reply-trigger');
if (cr) { e.stopPropagation(); toggleReplyForm(cr.dataset.commentId); return; }
// Submit reply
const sr = e.target.closest('._comment-submit-reply-trigger');
if (sr) { e.stopPropagation(); postReply(sr.dataset.videoId, sr.dataset.parentId); return; }
// Like btn
const lb = e.target.closest('.ytc-like-btn');
if (lb) { e.stopPropagation(); toggleLike(lb); return; }
// Dislike btn
const db = e.target.closest('.ytc-dislike-btn');
if (db) { e.stopPropagation(); toggleDislike(db); return; }
});
// Translate — bound at `document` level once, dispatches to the LATEST
// IIFE's handler via window.__ytcToggleTranslate. SPA video-to-video swaps
// replace #ytcSection and re-run this script; the new IIFE overwrites
// window.__ytcToggleTranslate so it keeps using fresh state (YTC.csrf etc.).
window.__ytcToggleTranslate = function (btn) { toggleTranslate(btn); };
if (!window.__ytcTranslateDelegated) {
window.__ytcTranslateDelegated = true;
document.addEventListener('click', e => {
const tb = e.target.closest('._comment-translate-trigger');
if (!tb) return;
e.stopPropagation();
if (typeof window.__ytcToggleTranslate === 'function') window.__ytcToggleTranslate(tb);
});
}
// ── Translate ─────────────────────────────────────────
async function toggleTranslate(btn) {
const id = btn.dataset.commentId;
const panel = document.getElementById('translation-' + id);
const label = btn.querySelector('.ytc-translate-label');
if (!panel) return;
// Toggle off if already shown.
if (panel.style.display !== 'none' && panel.dataset.loaded === '1') {
panel.style.display = 'none';
btn.classList.remove('active');
if (label) label.textContent = 'Translate';
return;
}
// Already loaded — just reveal.
if (panel.dataset.loaded === '1') {
panel.style.display = 'block';
btn.classList.add('active');
if (label) label.textContent = 'Show original';
return;
}
btn.classList.add('loading');
if (label) label.textContent = 'Translating…';
try {
const r = await fetch('/comments/' + id + '/translate', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': YTC.csrf, 'Accept': 'application/json' },
body: JSON.stringify({ target: targetLang() })
});
const d = await r.json();
if (!r.ok || !d.success) throw new Error(d.error || 'Translation failed');
panel.innerHTML = '';
const meta = document.createElement('span');
meta.className = 'ytc-translation-meta';
meta.textContent = 'Translated → ' + (d.target || targetLang()).toUpperCase();
const body = document.createElement('div');
body.textContent = d.translated;
panel.appendChild(meta);
panel.appendChild(body);
panel.dataset.loaded = '1';
panel.style.display = 'block';
btn.classList.add('active');
if (label) label.textContent = 'Show original';
} catch (err) {
toast(err.message || 'Translation failed', 'error');
if (label) label.textContent = 'Translate';
} finally {
btn.classList.remove('loading');
}
}
// ── Reply form (lazy-rendered from a <template>) ──────
// Replaces the per-comment inline markup that used to live in the DOM
// pre-hidden. First click on "Reply" clones the template into the comment's
// .ytc-reply-slot and wires its IDs. Subsequent toggles just show/hide.
function toggleReplyForm(commentId) {
let form = document.getElementById('replyForm' + commentId);
if (!form) {
const slot = document.querySelector('.ytc-reply-slot[data-comment-id="' + commentId + '"]');
const tpl = document.getElementById('ytc-reply-form-tpl');
if (!slot || !tpl) return;
const frag = tpl.content.cloneNode(true);
const root = frag.querySelector('.ytc-reply-form');
const ta = frag.querySelector('.ytc-reply-textarea');
const cancel = frag.querySelector('._comment-cancel-reply-trigger');
const submit = frag.querySelector('._comment-submit-reply-trigger');
root.id = 'replyForm' + commentId;
ta.id = 'replyBody' + commentId;
cancel.dataset.commentId = commentId;
submit.dataset.parentId = commentId;
submit.dataset.videoId = slot.dataset.videoId || YTC.videoId;
slot.appendChild(frag);
form = root;
}
const isOpen = form.classList.contains('open');
document.querySelectorAll('.ytc-reply-form.open').forEach(f => f.classList.remove('open'));
if (!isOpen) {
form.classList.add('open');
const ta = document.getElementById('replyBody' + commentId);
if (ta) { ta.focus(); autoResize(ta); }
}
}
// ── Edit ──────────────────────────────────────────────
function startEdit(commentId) {
const body = document.querySelector('#comment-' + commentId + ' .ytc-text');
const wrap = document.getElementById('commentEditWrap' + commentId);
const input = document.getElementById('commentEditInput' + commentId);
if (!body || !wrap || !input) return;
body.style.display = 'none';
wrap.style.display = 'block';
autoResize(input);
input.focus();
document.querySelectorAll('.ytc-more-menu.open').forEach(m => m.classList.remove('open'));
}
function cancelEdit(commentId) {
const body = document.querySelector('#comment-' + commentId + ' .ytc-text');
const wrap = document.getElementById('commentEditWrap' + commentId);
if (body) body.style.display = '';
if (wrap) wrap.style.display = 'none';
}
async function saveEdit(commentId) {
const input = document.getElementById('commentEditInput' + commentId);
const body = document.querySelector('#comment-' + commentId + ' .ytc-text');
const wrap = document.getElementById('commentEditWrap' + commentId);
if (!input || !body || !wrap) return;
const text = input.value.trim();
if (!text) { toast('Comment cannot be empty', 'error'); return; }
const btn = wrap.querySelector('._comment-save-edit-trigger');
if (btn) { btn.disabled = true; btn.textContent = 'Saving…'; }
try {
const r = await fetch('/comments/' + commentId, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': YTC.csrf },
body: JSON.stringify({ body: text })
});
const d = await r.json();
if (r.ok && (d.success || d.body)) {
body.textContent = d.body || text;
body.dataset._commentEnhanced = '0';
enhanceBody(body);
wrap.style.display = 'none';
body.style.display = '';
toast('Comment updated', 'success');
} else throw new Error(d.error || 'Failed');
} catch (err) {
toast(err.message, 'error');
} finally {
if (btn) { btn.disabled = false; btn.textContent = 'Save'; }
}
}
// ── Post comment ──────────────────────────────────────
async function postComment() {
if (postComment._busy) return; // guard against double-submit (fast dbl-click, Enter+click, duplicate listeners after SPA swap)
const ta = document.getElementById('ytcTextarea');
if (!ta) return;
const text = ta.value.trim();
if (!text) { toast('Write something first', 'error'); return; }
postComment._busy = true;
submitBtn.disabled = true;
submitBtn.textContent = 'Posting…';
try {
// Idempotency key: random per attempt. On retry the client reuses the
// same key so the server returns the original comment instead of
// writing a second one.
const idemKey = (window.crypto?.randomUUID?.() || (Date.now() + '-' + Math.random().toString(36).slice(2)));
const r = await fetch('/videos/' + YTC.videoId + '/comments', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': YTC.csrf,
'X-Idempotency-Key': idemKey,
},
body: JSON.stringify({ body: text })
});
if (!(r.headers.get('content-type') || '').includes('application/json')) {
if (r.status === 419) throw new Error('Session expired — please refresh the page');
if (r.status === 401) throw new Error('Please sign in to comment');
throw new Error('Something went wrong — please refresh and try again');
}
const d = await r.json();
if (r.ok && d.success) {
ta.value = '';
autoResize(ta);
formActions?.classList.remove('visible');
ta.blur();
prependComment(d.comment);
updateCount(1);
toast('Comment posted', 'success');
} else throw new Error(d.error || 'Failed');
} catch (err) {
toast(err.message, 'error');
} finally {
postComment._busy = false;
submitBtn.disabled = false;
submitBtn.textContent = 'Comment';
}
}
// ── Post reply ────────────────────────────────────────
async function postReply(vid, parentId) {
const input = document.getElementById('replyBody' + parentId);
if (!input) return;
const text = input.value.trim();
if (!text) { toast('Write something first', 'error'); return; }
const btn = input.closest('.ytc-reply-form')?.querySelector('._comment-submit-reply-trigger');
if (btn) {
if (btn.dataset.busy === '1') return; // guard against double-submit
btn.dataset.busy = '1';
btn.disabled = true;
btn.textContent = 'Posting…';
}
try {
const idemKey = (window.crypto?.randomUUID?.() || (Date.now() + '-' + Math.random().toString(36).slice(2)));
const r = await fetch('/videos/' + vid + '/comments', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': YTC.csrf,
'X-Idempotency-Key': idemKey,
},
body: JSON.stringify({ body: text, parent_id: parentId })
});
if (!(r.headers.get('content-type') || '').includes('application/json')) {
if (r.status === 419) throw new Error('Session expired — please refresh the page');
if (r.status === 401) throw new Error('Please sign in to comment');
throw new Error('Something went wrong — please refresh and try again');
}
const d = await r.json();
if (r.ok && d.success) {
input.value = '';
toggleReplyForm(parentId);
appendReply(parentId, d.comment);
updateCount(1);
toast('Reply posted', 'success');
} else throw new Error(d.error || 'Failed');
} catch (err) {
toast(err.message, 'error');
} finally {
if (btn) { btn.dataset.busy = ''; btn.disabled = false; btn.textContent = 'Reply'; }
}
}
// ── Render new comment (optimistic prepend) ───────────
function prependComment(c) {
const list = document.getElementById('ytcList');
const empty = document.getElementById('ytcEmpty');
if (empty) empty.remove();
const el = buildCommentEl(c, false);
list.insertBefore(el, list.firstChild);
enhanceBody(el.querySelector('.ytc-text'));
el.querySelectorAll('.ytc-textarea, .ytc-edit-textarea').forEach(ta =>
ta.addEventListener('input', () => autoResize(ta)));
}
// ── Append a new reply under a parent ────────────────
function appendReply(parentId, c) {
const parent = document.getElementById('comment-' + parentId);
if (!parent) return;
let section = parent.querySelector('.ytc-replies-section');
if (!section) {
section = document.createElement('div');
section.className = 'ytc-replies-section';
section.innerHTML = `<button class="ytc-replies-toggle" data-open="1"
onclick="ytcToggleReplies(this, ${parentId})">
<svg class="ytc-chevron" viewBox="0 0 24 24"><path d="M7.41 8.59 12 13.17l4.59-4.58L18 10l-6 6-6-6 1.41-1.41z"/></svg>
<span class="ytc-reply-count">1 reply</span></button>
<div class="ytc-replies-list" id="replies-${parentId}" style="display:block;padding-top:8px;"></div>`;
parent.querySelector('.ytc-body-wrap').appendChild(section);
}
const list = document.getElementById('replies-' + parentId);
const toggl = section.querySelector('.ytc-replies-toggle');
const el = buildCommentEl(c, true);
list.appendChild(el);
if (list.style.display === 'none') {
list.style.display = 'block';
if (toggl) toggl.dataset.open = '1';
}
// Update count label
const existing = list.querySelectorAll('.ytc-comment').length;
if (toggl) {
const label = toggl.querySelector('.ytc-reply-count') || toggl;
label.textContent = existing + (existing === 1 ? ' reply' : ' replies');
}
enhanceBody(el.querySelector('.ytc-text'));
el.querySelectorAll('.ytc-textarea, .ytc-edit-textarea').forEach(ta =>
ta.addEventListener('input', () => autoResize(ta)));
}
function buildCommentEl(c, isReply) {
const isOwn = c.user_id === YTC.userId;
const avatar = avatarUrl(c.user);
const name = esc(c.user?.name || 'User');
const time = c.created_at ? 'just now' : '';
const body = esc(c.body || '');
const id = c.id;
const moreMenu = isOwn ? `
<div class="ytc-more-wrap">
<button class="ytc-more-btn">
<svg viewBox="0 0 24 24"><path d="M12 8c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"/></svg>
</button>
<div class="ytc-more-menu">
<button class="ytc-more-item _comment-edit-trigger" data-comment-id="${id}">
<svg viewBox="0 0 24 24"><path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></svg>
Edit
</button>
<button class="ytc-more-item ytc-more-delete _comment-delete-trigger" data-comment-id="${id}">
<svg viewBox="0 0 24 24"><path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></svg>
Delete
</button>
</div>
</div>` : '';
const editForm = isOwn ? `
<div class="ytc-edit-form" id="commentEditWrap${id}" style="display:none">
<textarea class="ytc-edit-textarea" id="commentEditInput${id}" rows="2">${body}</textarea>
<div class="ytc-edit-actions">
<span class="ytc-edit-hint">Press Esc to cancel</span>
<button class="ytc-btn ytc-btn-cancel _comment-cancel-edit-trigger" data-comment-id="${id}">Cancel</button>
<button class="ytc-btn ytc-btn-submit _comment-save-edit-trigger" data-comment-id="${id}">Save</button>
</div>
</div>` : '';
const replyForm = YTC.userId ? `
<div class="ytc-reply-form" id="replyForm${id}" style="display:none">
<img src="{{ Auth::user()->avatar_url ?? '' }}" class="ytc-avatar ytc-avatar-sm" alt="">
<div class="ytc-input-wrap">
<textarea class="ytc-textarea ytc-reply-textarea" id="replyBody${id}" placeholder="Add a reply..." rows="1"></textarea>
<div class="ytc-form-actions">
<button class="ytc-btn ytc-btn-cancel _comment-cancel-reply-trigger" data-comment-id="${id}">Cancel</button>
<button class="ytc-btn ytc-btn-submit _comment-submit-reply-trigger" data-video-id="${YTC.videoId}" data-parent-id="${id}">Reply</button>
</div>
</div>
</div>` : '';
const replyBtn = YTC.userId ? `<button class="ytc-reply-btn _comment-reply-trigger" data-comment-id="${id}">Reply</button>` : '';
const translateBtn = YTC.llmEnabled ? `
<button class="ytc-translate-btn _comment-translate-trigger" data-comment-id="${id}" title="Translate this comment">
${YTC_TRANSLATE_SVG}<span class="ytc-translate-label">Translate</span>
</button>` : '';
const translationPanel = YTC.llmEnabled ? `<div class="ytc-translation" id="translation-${id}" style="display:none"></div>` : '';
const flagEl = YTC.meCountry ? `<span class="ytc-flag fi fi-${YTC.meCountry}"></span>` : '';
const div = document.createElement('div');
div.className = 'ytc-comment' + (isReply ? ' ytc-reply' : '');
div.id = 'comment-' + id;
div.innerHTML = `
<a class="ytc-avatar-link">
<img src="${avatar}" class="ytc-avatar${isReply ? ' ytc-avatar-sm' : ''}" alt="${name}">
</a>
<div class="ytc-body-wrap">
<div class="ytc-meta">
${flagEl}
<span class="ytc-author">${name}</span>
<span class="ytc-time">${time}</span>
</div>
<div class="ytc-text _comment-body" data-_comment-enhanced="0">${body}</div>
${translationPanel}
${editForm}
<div class="ytc-actions">
<button class="ytc-like-btn" data-id="${id}" title="Like">
<svg viewBox="0 0 24 24"><path d="M1 21h4V9H1v12zm22-11c0-1.1-.9-2-2-2h-6.31l.95-4.57.03-.32c0-.41-.17-.79-.44-1.06L14.17 1 7.59 7.59C7.22 7.95 7 8.45 7 9v10c0 1.1.9 2 2 2h9c.83 0 1.54-.5 1.84-1.22l3.02-7.05c.09-.23.14-.47.14-.73v-2z"/></svg>
<span class="ytc-like-count" data-id="${id}"></span>
</button>
<button class="ytc-dislike-btn" data-id="${id}" title="Dislike">
<svg viewBox="0 0 24 24"><path d="M15 3H6c-.83 0-1.54.5-1.84 1.22l-3.02 7.05c-.09.23-.14.47-.14.73v2c0 1.1.9 2 2 2h6.31l-.95 4.57-.03.32c0 .41.17.79.44 1.06L10.83 23l6.59-6.59c.36-.36.58-.86.58-1.41V5c0-1.1-.9-2-2-2zm4 0v12h4V3h-4z"/></svg>
</button>
${replyBtn}
${translateBtn}
${moreMenu}
</div>
${replyForm}
</div>`;
return div;
}
// ── Count ─────────────────────────────────────────────
function updateCount(delta) {
const el = document.getElementById('ytcCount');
if (!el) return;
const n = parseInt(el.textContent) || 0;
const v = Math.max(0, n + delta);
el.textContent = v.toLocaleString() + ' Comment' + (v !== 1 ? 's' : '');
}
// ── Like (backend) / Dislike (UI-only) ───────────────
async function toggleLike(btn) {
if (!YTC.userId) { toast('Sign in to like comments', 'info'); return; }
const id = btn.dataset.id;
const wasLiked = btn.classList.contains('liked');
const countEl = document.querySelector(`.ytc-like-count[data-id="${id}"]`);
const prev = parseInt(btn.dataset.count || '0');
// Optimistic update
const next = wasLiked ? Math.max(0, prev - 1) : prev + 1;
btn.dataset.count = next;
btn.classList.toggle('liked', !wasLiked);
if (countEl) countEl.textContent = next > 0 ? next : '';
if (!wasLiked) {
const db = document.querySelector(`.ytc-dislike-btn[data-id="${id}"]`);
if (db) db.classList.remove('disliked');
}
try {
const r = await fetch('/comments/' + id + '/like', {
method: 'POST',
headers: { 'X-CSRF-TOKEN': YTC.csrf, 'Accept': 'application/json' },
credentials: 'same-origin',
});
const d = await r.json();
if (r.ok) {
btn.dataset.count = d.count;
btn.classList.toggle('liked', d.liked);
if (countEl) countEl.textContent = d.count > 0 ? d.count : '';
} else {
// Revert on server error
btn.dataset.count = prev;
btn.classList.toggle('liked', wasLiked);
if (countEl) countEl.textContent = prev > 0 ? prev : '';
}
} catch (e) {
btn.dataset.count = prev;
btn.classList.toggle('liked', wasLiked);
if (countEl) countEl.textContent = prev > 0 ? prev : '';
}
}
function toggleDislike(btn) {
const id = btn.dataset.id;
if (btn.classList.toggle('disliked')) {
const lb = document.querySelector(`.ytc-like-btn[data-id="${id}"]`);
if (lb && lb.classList.contains('liked')) lb.click(); // un-like via backend
}
}
function initLikeStates() {} // server-rendered state is already in the HTML
// ── Timestamp & @mention parsing ─────────────────────
function enhanceBody(el) {
if (!el || el.dataset._commentEnhanced === '1') return;
let text = el.textContent || '';
// @mm:ss, @mm.ss, @mm:ss-mm:ss, @mm.ss-mm.ss timestamps (colon or dot separator)
text = text.replace(/@(\d{1,2})[.:](\d{2})(?:-(\d{1,2})[.:](\d{2}))?/g, (m, sM, sS, eM, eS) => {
const sm = parseInt(sM), ss = parseInt(sS);
if (isNaN(sm) || isNaN(ss) || ss > 59) return m;
const start = sm * 60 + ss;
const startFmt = String(sm).padStart(2,'0') + ':' + String(ss).padStart(2,'0');
if (eM && eS) {
const em = parseInt(eM), es = parseInt(eS);
if (isNaN(em) || isNaN(es) || es > 59) return m;
const end = em * 60 + es;
const endFmt = String(em).padStart(2,'0') + ':' + String(es).padStart(2,'0');
return `<span class="_comment-time-badge" data-start="${start}" data-end="${end}" title="Play ${startFmt}–${endFmt}"><i class="bi bi-play-fill" style="font-size:11px"></i> ${startFmt}–${endFmt}</span>`;
}
return `<span class="_comment-time-badge" data-start="${start}" title="Jump to ${startFmt}"><i class="bi bi-play-fill" style="font-size:11px"></i> ${startFmt}</span>`;
});
// @username mentions (only letters/digits/underscore — won't match already-replaced badge spans)
text = text.replace(/@([a-zA-Z][a-zA-Z0-9_]*)/g, '<span class="ytc-mention">@$1</span>');
el.innerHTML = text;
el.dataset._commentEnhanced = '1';
// Attach timestamp click
el.querySelectorAll('._comment-time-badge').forEach(badge => {
badge.addEventListener('click', e => {
e.stopPropagation();
playTimeRange(parseFloat(badge.dataset.start), badge.dataset.end ? parseFloat(badge.dataset.end) : null);
});
});
}
function enhanceAll() {
document.querySelectorAll('._comment-body:not([data-_comment-enhanced="1"])').forEach(enhanceBody);
}
// ── Video seek ────────────────────────────────────────
let rangeHandler = null;
function playTimeRange(start, end) {
const v = document.getElementById('videoPlayer') || document.getElementById('audioEl');
if (!v) { toast('Player not found', 'error'); return; }
if (rangeHandler) v.removeEventListener('timeupdate', rangeHandler);
const playerEl = document.getElementById('ytpWrap') || document.getElementById('videoContainer') || v;
playerEl.scrollIntoView({ behavior: 'smooth', block: 'start' });
setTimeout(() => {
v.currentTime = Math.max(0, start);
v.play().catch(() => {});
if (end) {
rangeHandler = () => { if (v.currentTime >= end) { v.pause(); v.removeEventListener('timeupdate', rangeHandler); rangeHandler = null; } };
v.addEventListener('timeupdate', rangeHandler);
}
}, 500);
}
// ── Edit textarea Esc key ─────────────────────────────
document.addEventListener('keydown', e => {
if (e.key === 'Escape') {
const open = document.querySelector('.ytc-edit-form[style*="block"]');
if (open) {
const id = open.id.replace('commentEditWrap', '');
cancelEdit(id);
}
const replyOpen = document.querySelector('.ytc-reply-form.open');
if (replyOpen) {
const id = replyOpen.id.replace('replyForm', '');
toggleReplyForm(id);
}
}
});
// ── Auto-resize for dynamically added textareas ───────
const listObserver = new MutationObserver(() => {
document.querySelectorAll('.ytc-textarea, .ytc-edit-textarea').forEach(ta => {
if (!ta._ytcResizeAttached) {
ta.addEventListener('input', () => autoResize(ta));
ta._ytcResizeAttached = true;
}
});
enhanceAll();
});
const list = document.getElementById('ytcList');
if (list) listObserver.observe(list, { childList: true, subtree: true });
// ── Init ──────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', () => {
enhanceAll();
initLikeStates();
});
if (document.readyState !== 'loading') {
enhanceAll();
initLikeStates();
}
// ── Public API (for backward compat) ─────────────────
window._comment = {
deleteComment: openDeleteModal,
playTimeRange: playTimeRange,
};
})();
</script>
@auth
{{-- Reply-form template. Cloned into .ytc-reply-slot the first time a user
clicks Reply on a given comment. Keeping it here (not in the layout)
means the markup travels with the comments component — the only place
that uses it. --}}
<template id="ytc-reply-form-tpl">
<div class="ytc-reply-form">
<img src="{{ Auth::user()->avatar_url }}" class="ytc-avatar ytc-avatar-sm" alt="{{ Auth::user()->name }}">
<div class="ytc-input-wrap">
<textarea class="ytc-textarea ytc-reply-textarea" placeholder="Add a reply..." rows="1"></textarea>
<div class="ytc-form-actions">
<button class="ytc-btn ytc-btn-cancel _comment-cancel-reply-trigger">Cancel</button>
<button class="ytc-btn ytc-btn-submit _comment-submit-reply-trigger">Reply</button>
</div>
</div>
</div>
</template>
@endauth