- 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>
1330 lines
54 KiB
PHP
1330 lines
54 KiB
PHP
@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>
|