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
+80
-13
@@ -10,8 +10,15 @@
|
|||||||
// user's nationality. Batched so the comments list stays a single query.
|
// user's nationality. Batched so the comments list stays a single query.
|
||||||
$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,
|
||||||
|
|||||||
Reference in new issue
Block a user