feat(comments): ?lc= permalink + top-level vs reply counts

- Header count now reads "N Comments · M replies" with correct
  pluralisation ("1 Comment", "1 reply" — never "1 Comments"). The
  old "N comments" number silently folded replies into the main
  figure, overstating conversation depth.
- Every timestamp is a permalink. href="?lc=<id>#comment-<id>" lands
  on the exact comment or reply when opened — the init handler
  auto-expands the parent thread for nested replies, scrolls the
  target into view, and pulses a soft red highlight for 2.8s so the
  reader sees where they landed.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
ghassanandClaude Opus 4.7 committed 2026-10-21 10:20:10 +03:00
1 parent 36061d1561
commit 4581870517
1 file changed
+79 -12
@@ -11,7 +11,14 @@
$commenterIds = $video->comments()->pluck('user_id')->filter()->unique()->values()->all(); $commenterIds = $video->comments()->pluck('user_id')->filter()->unique()->values()->all();
$commenterCountry = \App\Models\User::topCountriesFor($commenterIds); $commenterCountry = \App\Models\User::topCountriesFor($commenterIds);
$topComments = $video->comments()->whereNull('parent_id')->with('user', 'replies.user')->latest()->get(); $topComments = $video->comments()->whereNull('parent_id')->with('user', 'replies.user')->latest()->get();
$commentCount = $topComments->sum(fn($c) => 1 + $c->replies->count()); $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') $allCommentIds = $topComments->pluck('id')
->merge($topComments->flatMap(fn($c) => $c->replies->pluck('id'))) ->merge($topComments->flatMap(fn($c) => $c->replies->pluck('id')))
@@ -46,7 +53,17 @@
{{-- ── Header ── --}} {{-- ── Header ── --}}
<div class="ytc-header"> <div class="ytc-header">
<span class="ytc-count" id="ytcCount">{{ number_format($commentCount) }} Comments</span> {{-- 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"> <div class="ytc-sort-wrap" id="ytcSortWrap">
<button class="ytc-sort-btn" id="ytcSortBtn"> <button class="ytc-sort-btn" id="ytcSortBtn">
<svg viewBox="0 0 24 24"><path d="M3 18h6v-2H3v2zM3 6v2h18V6H3zm0 7h12v-2H3v2z"/></svg> <svg viewBox="0 0 24 24"><path d="M3 18h6v-2H3v2zM3 6v2h18V6H3zm0 7h12v-2H3v2z"/></svg>
@@ -98,7 +115,10 @@
@php $cFlag = $commenterCountry[$comment->user_id] ?? null; @endphp @php $cFlag = $commenterCountry[$comment->user_id] ?? null; @endphp
@if($cFlag)<span class="ytc-flag fi fi-{{ strtolower($cFlag) }}" title="{{ $cFlag }}"></span>@endif @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 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> <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>
</div> </div>
<div class="ytc-text _comment-body" data-_comment-enhanced="0">{{ $comment->body }}</div> <div class="ytc-text _comment-body" data-_comment-enhanced="0">{{ $comment->body }}</div>
@@ -184,7 +204,10 @@
@php $rFlag = $commenterCountry[$reply->user_id] ?? null; @endphp @php $rFlag = $commenterCountry[$reply->user_id] ?? null; @endphp
@if($rFlag)<span class="ytc-flag fi fi-{{ strtolower($rFlag) }}" title="{{ $rFlag }}"></span>@endif @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 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> <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>
<div class="ytc-text _comment-body" data-_comment-enhanced="0">{{ $reply->body }}</div> <div class="ytc-text _comment-body" data-_comment-enhanced="0">{{ $reply->body }}</div>
@if($llmEnabled) @if($llmEnabled)
@@ -290,6 +313,23 @@
font-weight: 400; font-weight: 400;
color: var(--text-primary); color: var(--text-primary);
} }
.ytc-count-replies {
font-size: 13px;
color: var(--text-secondary);
margin-left: 4px;
}
/* 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-wrap { position: relative; }
.ytc-sort-btn { .ytc-sort-btn {
display: flex; display: flex;
@@ -460,7 +500,11 @@
.ytc-time { .ytc-time {
font-size: 12px; font-size: 12px;
color: var(--text-secondary); 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 ── */ /* ── Comment text ── */
.ytc-text { .ytc-text {
@@ -1506,16 +1550,39 @@ const listObserver = new MutationObserver(() => {
const list = document.getElementById('ytcList'); const list = document.getElementById('ytcList');
if (list) listObserver.observe(list, { childList: true, subtree: true }); if (list) listObserver.observe(list, { childList: true, subtree: true });
// ── Init ────────────────────────────────────────────── // ── Comment permalink (?lc=<id>) ─────────────────────
document.addEventListener('DOMContentLoaded', () => { // Scroll to #comment-<id> and briefly highlight the row so a shared link
enhanceAll(); // lands on the exact message. The reply row is nested inside a .ytc-replies
initLikeStates(); // expander by default — we auto-open the parent thread first so the target
}); // is visible before we scroll.
if (document.readyState !== 'loading') { function openCommentPermalink(id) {
enhanceAll(); if (!id) return;
initLikeStates(); 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) ───────────────── // ── Public API (for backward compat) ─────────────────
window._comment = { window._comment = {
deleteComment: openDeleteModal, deleteComment: openDeleteModal,