Files
ghassanandClaude Opus 4.7 4d9bc0f501 feat(engagement): video dislike + creator heart + creator pin
Three features that depend on the two additive migrations shipped in
the same commit (safe defaults, cascading foreign keys, no data loss
possible).

## Dislike button
- New video_dislikes table (migration 2026_10_21_000001) with the same
  shape as video_likes.
- Video model: dislikes() relation + isDislikedBy() / getDislikeCount
  Attribute() helpers mirroring the like pair.
- UserController@toggleDislike: endpoint behind POST
  /videos/{video}/toggle-dislike. Mutually exclusive with like — the
  toggle*Like / toggle*Dislike controllers detach the opposite state
  so a user is in exactly one of {liked, disliked, neither}. YouTube
  parity.
- video-actions component: a second thumbs-down button next to the
  existing like. Both reconcile in parallel on either response so the
  UI pair always reflects the authoritative server state.

## Creator heart + pin
- comments table gains hearted + pinned boolean columns (migration
  2026_10_21_000002, both default false, pinned is indexed).
- CommentController@heart + @pin: 403 for anyone but the video owner;
  pin() also rejects nested replies (422) and auto-unpins the
  previously-pinned top-level comment so the "at most one pinned"
  invariant is enforced at the DB level.
- Comment component:
    - Pinned comments render ahead of everything else; reorder()
      clears the comments() relation's default ->latest() so pinned
      DESC becomes the PRIMARY sort instead of a tiebreaker.
    - Creator sees heart + pin toggle buttons in the action bar.
    - Hearted comments carry a small red heart badge in the meta row.
    - Pinned comments carry a "Pinned by {creator}" header and a
      red left accent so they're visually distinct from the stream.

## Tests (9 new, 63 total)
- Dislike toggle adds/removes the row.
- Disliking clears an existing like and vice versa.
- Owner can heart; non-owner gets 403.
- Only one comment pinned at a time; replies can't be pinned (422);
  non-owner gets 403.
- Pinned comments render before newer unpinned ones.

## Deploy note
Running the migrations against the live DB needs:
    php artisan migrate

The two migration files are additive and reversible via `migrate:rollback`.

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

1828 lines
77 KiB
PHP
Raw Permalink 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);
// Pinned comments bubble to the top of the list regardless of recency,
// then everything else sorts newest-first. reorder() wipes the
// ->latest() default the comments() relation already applied, so
// pinned DESC becomes the PRIMARY sort instead of a tiebreaker.
$topComments = $video->comments()
->whereNull('parent_id')
->with('user', 'replies.user')
->reorder()
->orderByDesc('pinned')
->latest()
->get();
$topLevelCount = $topComments->count();
$replyCount = $topComments->sum(fn($c) => $c->replies->count());
// Permalink: ?lc=<comment-id> scrolls to and highlights a specific
// comment or reply so a shared link lands on the exact message. The
// id is validated against the current video's comment ids so the
// highlight only applies when the target really belongs here.
$permalinkId = (int) request('lc', 0);
$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">
{{-- Top-level count on the header; reply count surfaces inside each
thread's "N replies" expander. "1 Comment" singular when there's
exactly one — never "1 Comments". --}}
<span class="ytc-count" id="ytcCount">
{{ number_format($topLevelCount) }} {{ Str::plural('Comment', $topLevelCount) }}
@if($replyCount > 0)
<span class="ytc-count-replies" aria-label="reply count">
· {{ number_format($replyCount) }} {{ Str::plural('reply', $replyCount) }}
</span>
@endif
</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;
$isVideoOwner = $currentUserId === $video->user_id;
@endphp
<div class="ytc-comment{{ $comment->pinned ? ' ytc-pinned' : '' }}" id="comment-{{ $comment->id }}">
@if($comment->pinned)
<div class="ytc-pinned-badge" aria-label="Pinned by the creator">
<i class="bi bi-pin-fill" aria-hidden="true"></i> Pinned by {{ $video->user->name ?? 'the creator' }}
</div>
@endif
<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>
<a class="ytc-time ytc-permalink"
href="?lc={{ $comment->id }}#comment-{{ $comment->id }}"
title="Copy link to this comment"
aria-label="Permalink">{{ ytcTime($comment->created_at) }}</a>
@if($comment->hearted)
<span class="ytc-heart-badge"
aria-label="Hearted by the creator"
title="Hearted by {{ $video->user->name ?? 'the creator' }}">
<i class="bi bi-heart-fill" aria-hidden="true"></i>
</span>
@endif
</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
{{-- Creator-only heart + pin toggles. The server
rejects anyone else, but hiding the buttons
keeps the UI uncluttered for regular viewers. --}}
@if($isVideoOwner)
<button class="ytc-creator-btn ytc-heart-toggle{{ $comment->hearted ? ' active' : '' }}"
data-comment-id="{{ $comment->id }}"
aria-pressed="{{ $comment->hearted ? 'true' : 'false' }}"
title="{{ $comment->hearted ? 'Remove heart' : 'Heart this comment' }}">
<i class="bi {{ $comment->hearted ? 'bi-heart-fill' : 'bi-heart' }}" aria-hidden="true"></i>
</button>
<button class="ytc-creator-btn ytc-pin-toggle{{ $comment->pinned ? ' active' : '' }}"
data-comment-id="{{ $comment->id }}"
aria-pressed="{{ $comment->pinned ? 'true' : 'false' }}"
title="{{ $comment->pinned ? 'Unpin' : 'Pin to top' }}">
<i class="bi {{ $comment->pinned ? 'bi-pin-fill' : 'bi-pin' }}" aria-hidden="true"></i>
</button>
@endif
<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>
<a class="ytc-time ytc-permalink"
href="?lc={{ $reply->id }}#comment-{{ $reply->id }}"
title="Copy link to this reply"
aria-label="Permalink">{{ ytcTime($reply->created_at) }}</a>
</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>
@auth
{{-- Reply to a reply: opens the parent comment's reply form and
prefills "@AuthorName " so the responder's reader knows which
reply they're answering. The DB still stores it as a one-level
thread (parent_id = top-level), matching the existing model. --}}
<button class="ytc-reply-btn _comment-reply-nested-trigger"
data-parent-id="{{ $comment->id }}"
data-handle="{{ $reply->user->username ?? $reply->user->name ?? '' }}">Reply</button>
@endauth
@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-count-replies {
font-size: 13px;
color: var(--text-secondary);
margin-left: 4px;
}
/* ── Creator engagement (heart + pin) ── */
.ytc-pinned-badge {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
margin-bottom: 4px;
}
.ytc-pinned-badge i { color: #ef4444; }
.ytc-comment.ytc-pinned {
background: rgba(239, 68, 68, 0.03);
border-left: 2px solid rgba(239, 68, 68, 0.5);
padding-left: 10px;
margin-left: -12px;
}
.ytc-heart-badge {
display: inline-flex;
align-items: center;
margin-left: 4px;
color: #ef4444;
font-size: 13px;
}
.ytc-creator-btn {
background: none;
border: none;
color: var(--text-secondary);
font-size: 15px;
cursor: pointer;
padding: 4px;
border-radius: 50%;
width: 28px;
height: 28px;
display: inline-flex;
align-items: center;
justify-content: center;
transition: background .15s, color .15s;
}
.ytc-creator-btn:hover { background: rgba(255,255,255,.08); color: var(--text-primary); }
.ytc-creator-btn.active { color: #ef4444; }
.ytc-creator-btn.active:hover { background: rgba(239, 68, 68, 0.15); }
/* Comment permalink highlight — brief glow on the targeted row when a
?lc=<id> link lands on this page. Fades on its own after ~2.8s. */
.ytc-comment.ytc-highlight,
.ytc-reply.ytc-highlight {
animation: ytc-highlight-pulse 2.8s ease-out;
}
@keyframes ytc-highlight-pulse {
0% { background-color: rgba(239, 68, 68, 0.18); }
40% { background-color: rgba(239, 68, 68, 0.18); }
100% { background-color: transparent; }
}
.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);
text-decoration: none;
}
/* The time stamp is now an <a> permalink. Underline on hover so it's
discoverable but doesn't scream "link" at rest. */
.ytc-time.ytc-permalink:hover { text-decoration: underline; cursor: pointer; }
/* ── Comment text ── */
.ytc-text {
font-size: 14px;
line-height: 1.6;
color: var(--text-primary);
word-break: break-word;
white-space: pre-wrap;
}
/* ── Read more clamp ──
Long comments collapse to ~4 lines by default; the "Read more" button
under the body expands them. The clamp uses line-clamp (not a fixed
max-height) so the first few lines are always complete, never cut
mid-word, and the fade-mask eases into the trailing ellipsis.
*/
.ytc-text.ytc-clamp {
display: -webkit-box;
-webkit-line-clamp: 4;
-webkit-box-orient: vertical;
overflow: hidden;
position: relative;
}
.ytc-read-more {
background: none;
border: none;
color: var(--text-secondary);
font-size: 13px;
font-weight: 600;
cursor: pointer;
padding: 4px 0 0;
margin-top: 2px;
}
.ytc-read-more:hover { color: var(--text-primary); }
/* ── 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; }
// Replying to a reply — open the parent-level reply form and
// prefill it with the reply author's @handle so the thread stays
// readable even though the DB only stores one-level nesting.
const rtn = e.target.closest('._comment-reply-nested-trigger');
if (rtn) {
e.stopPropagation();
const parentId = rtn.dataset.parentId;
const handle = (rtn.dataset.handle || '').replace(/\s+/g, '');
toggleReplyForm(parentId);
const ta = document.getElementById('replyBody' + parentId);
if (ta && handle) {
const mention = '@' + handle + ' ';
if (!ta.value.startsWith(mention)) ta.value = mention + ta.value;
ta.focus();
ta.setSelectionRange(ta.value.length, ta.value.length);
autoResize(ta);
}
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; }
// Creator heart — owner-only, server rejects anyone else
const hb = e.target.closest('.ytc-heart-toggle');
if (hb) {
e.stopPropagation();
const id = hb.dataset.commentId;
fetch('/comments/' + id + '/heart', {
method: 'POST',
headers: { 'X-CSRF-TOKEN': YTC.csrf, 'Accept': 'application/json' },
}).then(r => r.json()).then(d => {
if (d.hearted !== undefined) {
hb.classList.toggle('active', d.hearted);
hb.setAttribute('aria-pressed', d.hearted ? 'true' : 'false');
hb.querySelector('i').className = 'bi ' + (d.hearted ? 'bi-heart-fill' : 'bi-heart');
// Also toggle the visible heart badge in the comment meta.
const meta = hb.closest('.ytc-comment').querySelector('.ytc-meta');
let badge = meta?.querySelector('.ytc-heart-badge');
if (d.hearted && !badge && meta) {
badge = document.createElement('span');
badge.className = 'ytc-heart-badge';
badge.innerHTML = '<i class="bi bi-heart-fill" aria-hidden="true"></i>';
meta.appendChild(badge);
} else if (!d.hearted && badge) {
badge.remove();
}
}
}).catch(() => {});
return;
}
// Creator pin — owner-only, pins top-level comments to the list head
const pb = e.target.closest('.ytc-pin-toggle');
if (pb) {
e.stopPropagation();
const id = pb.dataset.commentId;
fetch('/comments/' + id + '/pin', {
method: 'POST',
headers: { 'X-CSRF-TOKEN': YTC.csrf, 'Accept': 'application/json' },
}).then(r => r.json()).then(d => {
if (d.pinned !== undefined) {
// Reload to re-sort — pin changes the order of top-level
// comments and the DOM move is cleaner server-side than
// trying to shuffle the live list.
location.reload();
}
}).catch(() => {});
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 ─────────────────────
// ── Read-more clamp ──
// Flags long bodies (>4 lines once rendered) so a "Read more" button
// below the body expands them. Measured in computed line-height × 4.
function maybeClampBody(el) {
// Deferred one microtask so el.scrollHeight is correct even if the
// enclosing comment card just got inserted into the DOM.
requestAnimationFrame(() => {
const lh = parseFloat(getComputedStyle(el).lineHeight) || 22;
const limit = lh * 4;
if (el.scrollHeight <= limit + 1) return;
el.classList.add('ytc-clamp');
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'ytc-read-more';
btn.textContent = 'Read more';
btn.setAttribute('aria-expanded', 'false');
btn.addEventListener('click', () => {
const expanded = el.classList.toggle('ytc-clamp') === false;
btn.textContent = expanded ? 'Show less' : 'Read more';
btn.setAttribute('aria-expanded', expanded ? 'true' : 'false');
});
el.insertAdjacentElement('afterend', btn);
});
}
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);
});
});
// Add the Read more affordance after enhancing (so links and timestamps
// are rendered first and the measured height is final).
maybeClampBody(el);
}
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 });
// ── Comment permalink (?lc=<id>) ─────────────────────
// Scroll to #comment-<id> and briefly highlight the row so a shared link
// lands on the exact message. The reply row is nested inside a .ytc-replies
// expander by default — we auto-open the parent thread first so the target
// is visible before we scroll.
function openCommentPermalink(id) {
if (!id) return;
const target = document.getElementById('comment-' + id);
if (!target) return;
// Find and expand the parent replies section, if any.
const repliesSection = target.closest('.ytc-replies-section');
if (repliesSection) {
const toggle = repliesSection.querySelector('.ytc-replies-toggle');
if (toggle && toggle.dataset.open !== '1') toggle.click();
}
requestAnimationFrame(() => {
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
target.classList.add('ytc-highlight');
setTimeout(() => target.classList.remove('ytc-highlight'), 2800);
});
}
// ── Init ──────────────────────────────────────────────
function _ytcInit() {
enhanceAll();
initLikeStates();
@if($permalinkId)
openCommentPermalink({{ (int) $permalinkId }});
@endif
}
document.addEventListener('DOMContentLoaded', _ytcInit);
if (document.readyState !== 'loading') _ytcInit();
// ── 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