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:
1 parent
36061d1561
commit
4581870517
1 file changed
+79
-12
@@ -11,7 +11,14 @@
|
||||
$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());
|
||||
$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')))
|
||||
@@ -46,7 +53,17 @@
|
||||
|
||||
{{-- ── 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">
|
||||
<button class="ytc-sort-btn" id="ytcSortBtn">
|
||||
<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
|
||||
@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>
|
||||
<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 class="ytc-text _comment-body" data-_comment-enhanced="0">{{ $comment->body }}</div>
|
||||
@@ -184,7 +204,10 @@
|
||||
@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>
|
||||
<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)
|
||||
@@ -290,6 +313,23 @@
|
||||
font-weight: 400;
|
||||
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-btn {
|
||||
display: flex;
|
||||
@@ -460,7 +500,11 @@
|
||||
.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 {
|
||||
@@ -1506,15 +1550,38 @@ const listObserver = new MutationObserver(() => {
|
||||
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();
|
||||
// ── 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 = {
|
||||
|
||||
Reference in new issue
Block a user