ghassan 0b2e95ea65 Add NAS file manager integration and all pending platform changes
- Installed p7h/nas-file-manager package via private VCS repo
- Published config/nas-file-manager.php with super_admin middleware restriction
- Added NAS env vars to .env.example
- Created admin/nas-storage page with connection info panel and file browser widget
- Added NAS Storage link to admin sidebar (super_admin only)
- Added SuperAdminController@nasStorage method and admin.nas-storage route
- Includes all accumulated branch changes: profile wall, 2FA, audit logs,
  settings panel, country/phone/timezone components, posts, slideshow,
  playlist shares, video downloads/shares, comment likes, notifications,
  social links, and more

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 13:24:32 +03:00

1330 lines
54 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;
$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 'https://ui-avatars.com/api/?name=User&background=333&color=fff';
return $user->avatar_url ?? ('https://ui-avatars.com/api/?name='.urlencode($user->name ?? 'User').'&background=333&color=fff');
}
}
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">
<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>
{{-- 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($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 --}}
@auth
<div class="ytc-reply-form" id="replyForm{{ $comment->id }}">
<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" id="replyBody{{ $comment->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="{{ $comment->id }}">Cancel</button>
<button class="ytc-btn ytc-btn-submit _comment-submit-reply-trigger"
data-video-id="{{ $video->getRouteKey() }}" data-parent-id="{{ $comment->id }}">Reply</button>
</div>
</div>
</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">
<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($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($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); }
/* ── 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 }},
deleteId: null,
toastTimer: null,
};
// ── 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 'https://ui-avatars.com/api/?name=User&background=333&color=fff';
return user.avatar_url || ('https://ui-avatars.com/api/?name=' + encodeURIComponent(user.name || 'User') + '&background=333&color=fff');
}
// ── 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; }
});
// ── Reply form ────────────────────────────────────────
function toggleReplyForm(commentId) {
const form = document.getElementById('replyForm' + commentId);
if (!form) return;
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() {
const ta = document.getElementById('ytcTextarea');
if (!ta) return;
const text = ta.value.trim();
if (!text) { toast('Write something first', 'error'); return; }
submitBtn.disabled = true;
submitBtn.textContent = 'Posting…';
try {
const r = await fetch('/videos/' + YTC.videoId + '/comments', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': YTC.csrf },
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 {
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) { btn.disabled = true; btn.textContent = 'Posting…'; }
try {
const r = await fetch('/videos/' + vid + '/comments', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': YTC.csrf },
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.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 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">
<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>
${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}
${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>