885 lines
36 KiB
PHP
885 lines
36 KiB
PHP
{{-- ✅ DEFINE HELPER FUNCTION FIRST (before any HTML) --}}
|
|
@php
|
|
if (!function_exists('_renderComment')) {
|
|
function _renderComment($comment, $video, $depth = 0)
|
|
{
|
|
$avatar =
|
|
$comment->user->avatar_url ??
|
|
'https://ui-avatars.com/api/?name=' .
|
|
urlencode($comment->user->name ?? 'User') .
|
|
'&background=ef4444&color=fff';
|
|
$isOwn = $comment->user_id === (auth()->id() ?? 0);
|
|
$videoId = $video->id ?? 0;
|
|
|
|
$html = '<div class="_comment-item" id="comment-' . $comment->id . '">';
|
|
$html .=
|
|
'<img src="' .
|
|
e($avatar) .
|
|
'" class="_comment-avatar" style="width:36px;height:36px;" alt="' .
|
|
e($comment->user->name ?? 'User') .
|
|
'">';
|
|
$html .= '<div style="flex:1">';
|
|
$html .= '<div style="display:flex;align-items:center;gap:8px;margin-bottom:4px">';
|
|
$html .= '<span style="font-weight:600;font-size:14px">' . e($comment->user->name ?? 'User') . '</span>';
|
|
$html .=
|
|
'<span style="color:var(--text-secondary);font-size:12px">' .
|
|
($comment->created_at ? $comment->created_at->diffForHumans() : '') .
|
|
'</span>';
|
|
$html .= '</div>';
|
|
$html .=
|
|
'<div class="_comment-body" data-_comment-enhanced="0" style="font-size:14px;line-height:1.5">' .
|
|
e($comment->body) .
|
|
'</div>';
|
|
|
|
// Edit form (only for owner)
|
|
if ($isOwn) {
|
|
$html .=
|
|
'<div id="commentEditWrap' . $comment->id . '" class="_comment-edit-wrap" style="display:none;">';
|
|
$html .=
|
|
'<textarea id="commentEditInput' .
|
|
$comment->id .
|
|
'" class="_comment-edit-textarea" rows="3">' .
|
|
e($comment->body) .
|
|
'</textarea>';
|
|
$html .= '<div style="display:flex;gap:8px;justify-content:flex-end;margin-top:8px;">';
|
|
$html .=
|
|
'<button type="button" class="_comment-btn _comment-btn-primary _comment-save-edit-trigger" data-comment-id="' .
|
|
$comment->id .
|
|
'"><i class="bi bi-chat-dots"></i><span>Send</span></button>';
|
|
$html .=
|
|
'<button type="button" class="_comment-btn _comment-btn-secondary _comment-cancel-edit-trigger" data-comment-id="' .
|
|
$comment->id .
|
|
'">Cancel</button>';
|
|
$html .= '</div></div>';
|
|
}
|
|
|
|
// Action buttons
|
|
$html .= '<div style="display:flex;gap:12px;margin-top:8px;">';
|
|
$html .=
|
|
'<button type="button" class="_comment-btn _comment-btn-link _comment-reply-trigger" data-comment-id="' .
|
|
$comment->id .
|
|
'">Reply</button>';
|
|
if ($isOwn) {
|
|
$html .=
|
|
'<button type="button" class="_comment-btn _comment-btn-link _comment-edit-trigger" data-comment-id="' .
|
|
$comment->id .
|
|
'">Edit</button>';
|
|
$html .=
|
|
'<button type="button" class="_comment-btn _comment-btn-link _comment-delete-trigger" data-comment-id="' .
|
|
$comment->id .
|
|
'">Delete</button>';
|
|
}
|
|
$html .= '</div>';
|
|
|
|
// Reply form
|
|
$html .=
|
|
'<div id="replyForm' .
|
|
$comment->id .
|
|
'" class="_comment-reply-form" style="display:none;margin-top:12px;">';
|
|
$html .=
|
|
'<textarea id="replyBody' .
|
|
$comment->id .
|
|
'" class="_comment-reply-textarea" placeholder="Write a reply..." rows="2" style="margin-bottom:8px;"></textarea>';
|
|
$html .= '<div style="display:flex;gap:8px;justify-content:flex-end;">';
|
|
$html .=
|
|
'<button type="button" class="_comment-btn _comment-btn-primary _comment-submit-reply-trigger" data-video-id="' .
|
|
$videoId .
|
|
'" data-parent-id="' .
|
|
$comment->id .
|
|
'"><i class="bi bi-chat-dots"></i><span>Send</span></button>';
|
|
$html .=
|
|
'<button type="button" class="_comment-btn _comment-btn-secondary _comment-cancel-reply-trigger" data-comment-id="' .
|
|
$comment->id .
|
|
'">Cancel</button>';
|
|
$html .= '</div></div>';
|
|
|
|
// Nested replies (max depth 3)
|
|
if ($comment->replies && $comment->replies->count() > 0 && $depth < 3) {
|
|
$html .= '<div class="_comment-reply-wrapper" style="margin-left:24px;margin-top:12px;">';
|
|
foreach ($comment->replies as $reply) {
|
|
$html .= _renderComment($reply, $video, $depth + 1);
|
|
}
|
|
$html .= '</div>';
|
|
}
|
|
|
|
$html .= '</div></div>';
|
|
return $html;
|
|
}
|
|
}
|
|
@endphp
|
|
|
|
{{-- ✅ MAIN COMMENTS SECTION --}}
|
|
<div class="_comment-section"
|
|
style="margin-top: 24px; padding-top: 16px; border-top: 1px solid var(--border-color, #e5e7eb);">
|
|
<h3 style="font-size: 18px; font-weight: 600; margin-bottom: 16px; color: var(--text-primary, #1f2937);">
|
|
Comments <span style="color: var(--text-secondary, #6b7280); font-weight: 400;"
|
|
id="_commentCount">({{ $video->comment_count }})</span>
|
|
</h3>
|
|
|
|
@auth
|
|
<div class="_comment-form" style="display: flex; gap: 12px; margin-bottom: 24px;">
|
|
<img src="{{ Auth::user()->avatar_url }}" class="_comment-avatar"
|
|
style="width: 40px; height: 40px; border-radius: 50%; object-fit: cover;" alt="{{ Auth::user()->name }}">
|
|
<div style="flex: 1; display: flex; align-items: center; gap: 8px;">
|
|
<textarea id="_commentBody" class="_comment-textarea"
|
|
placeholder="Add a comment... Use @ to mention someone or @mm.ss for timestamps" rows="1"
|
|
style="background: transparent; border: none; border-bottom: 2px solid var(--border-color, #e5e7eb); color: var(--text-primary, #1f2937); padding: 12px 0 8px 0; flex: 1; margin: 0; height: 40px; font-size: 14px; outline: none; overflow: hidden; resize: none; font-family: inherit;"></textarea>
|
|
<button type="button" id="_commentSubmitBtn" class="_comment-btn _comment-btn-primary"
|
|
style="flex-shrink: 0;">
|
|
<i class="bi bi-chat-dots"></i>
|
|
<span>Send</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
@else
|
|
<div
|
|
style="margin-bottom: 24px; padding: 16px; background: var(--bg-secondary, #f9fafb); border-radius: 8px; text-align: center;">
|
|
<a href="{{ route('login') }}" style="color: var(--brand-red, #ef4444);">Sign in</a> to comment
|
|
</div>
|
|
@endauth
|
|
|
|
<div id="_commentsList">
|
|
@forelse($video->comments()->whereNull('parent_id')->with('user', 'replies.user')->latest()->get() as $comment)
|
|
{!! _renderComment($comment, $video, 0) !!}
|
|
@empty
|
|
<p style="color: var(--text-secondary, #6b7280); text-align: center; padding: 20px;">No comments yet. Be the
|
|
first to comment!</p>
|
|
@endforelse
|
|
</div>
|
|
</div>
|
|
|
|
{{-- ✅ DELETE MODAL --}}
|
|
<div id="_commentDeleteModal" class="_comment-modal"
|
|
style="display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); z-index: 9999; align-items: center; justify-content: center; opacity: 0; transition: opacity 0.2s ease;">
|
|
<div class="_comment-modal-content"
|
|
style="background: var(--bg-secondary, #f9fafb); border-radius: 12px; padding: 24px; width: 90%; max-width: 340px; box-shadow: 0 10px 40px rgba(0,0,0,0.2); transform: translateY(-10px); transition: transform 0.2s ease;">
|
|
<div style="display: flex; align-items: flex-start; gap: 12px; margin-bottom: 16px;">
|
|
<div class="_comment-modal-icon"
|
|
style="width: 40px; height: 40px; border-radius: 50%; background: #fef2f2; display: flex; align-items: center; justify-content: center; flex-shrink: 0;">
|
|
<i class="bi bi-exclamation-triangle" style="color: #dc2626; font-size: 20px;"></i>
|
|
</div>
|
|
<div style="flex: 1;">
|
|
<h4 class="_comment-modal-title"
|
|
style="font-weight: 600; font-size: 16px; margin: 0 0 6px 0; color: var(--text-primary, #1f2937);">
|
|
Delete Comment?</h4>
|
|
<p class="_comment-modal-message"
|
|
style="font-size: 14px; color: var(--text-secondary, #6b7280); margin: 0; line-height: 1.5;">This
|
|
action cannot be undone. The comment and its replies will be permanently removed.</p>
|
|
</div>
|
|
</div>
|
|
<div class="_comment-modal-actions" style="display: flex; gap: 10px; justify-content: flex-end;">
|
|
<button id="_commentModalCancel" class="_comment-btn _comment-btn-secondary"
|
|
style="background: transparent; color: var(--text-secondary, #6b7280); border: 1px solid var(--border-color, #e5e7eb); padding: 8px 16px; border-radius: 8px; font-size: 13px; font-weight: 500; cursor: pointer;">Cancel</button>
|
|
<button id="_commentModalConfirm" class="_comment-btn _comment-btn-danger"
|
|
style="background: #dc2626; color: white; border: none; padding: 8px 16px; border-radius: 8px; font-size: 13px; font-weight: 500; cursor: pointer; display: flex; align-items: center; gap: 6px;">
|
|
<i class="bi bi-trash" style="font-size: 12px;"></i> Delete
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{{-- ✅ TOAST NOTIFICATION --}}
|
|
<div id="_commentToast" class="_comment-toast"
|
|
style="display: none; position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%) translateY(20px); background: #1f2937; color: white; padding: 12px 20px; border-radius: 8px; font-size: 14px; font-weight: 500; z-index: 10000; box-shadow: 0 4px 12px rgba(0,0,0,0.3); opacity: 0; transition: all 0.3s ease; max-width: 90%; text-align: center; pointer-events: none;">
|
|
<span id="_commentToastMessage"></span>
|
|
</div>
|
|
|
|
{{-- ✅ PREFIXED CSS --}}
|
|
<style>
|
|
._comment-section {
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
|
}
|
|
|
|
._comment-textarea,
|
|
._comment-reply-textarea,
|
|
._comment-edit-textarea {
|
|
background: var(--bg-secondary, #f9fafb);
|
|
border: 1px solid var(--border-color, #e5e7eb);
|
|
color: var(--text-primary, #1f2937);
|
|
border-radius: 8px;
|
|
padding: 10px 12px;
|
|
font-size: 14px;
|
|
font-family: inherit;
|
|
resize: vertical;
|
|
outline: none;
|
|
transition: border-color 0.15s ease;
|
|
width: 100%;
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
._comment-textarea:focus,
|
|
._comment-reply-textarea:focus,
|
|
._comment-edit-textarea:focus {
|
|
border-color: var(--brand-red, #ef4444);
|
|
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.1);
|
|
}
|
|
|
|
._comment-form ._comment-textarea {
|
|
background: transparent;
|
|
border: none;
|
|
border-bottom: 2px solid var(--border-color, #e5e7eb);
|
|
border-radius: 0;
|
|
padding: 12px 0 8px 0;
|
|
}
|
|
|
|
._comment-btn {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 6px;
|
|
padding: 8px 16px;
|
|
border-radius: 8px;
|
|
font-size: 13px;
|
|
font-weight: 500;
|
|
cursor: pointer;
|
|
transition: all 0.15s ease;
|
|
border: none;
|
|
text-decoration: none;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
._comment-btn-primary {
|
|
background: var(--brand-red, #ef4444);
|
|
color: white;
|
|
}
|
|
|
|
._comment-btn-primary:hover {
|
|
background: #dc2626;
|
|
}
|
|
|
|
._comment-btn-primary:disabled {
|
|
opacity: 0.6;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
._comment-btn-secondary {
|
|
background: transparent;
|
|
color: var(--text-secondary, #6b7280);
|
|
border: 1px solid var(--border-color, #e5e7eb);
|
|
}
|
|
|
|
._comment-btn-secondary:hover {
|
|
background: var(--bg-secondary, #f9fafb);
|
|
color: var(--text-primary, #1f2937);
|
|
}
|
|
|
|
._comment-btn-danger {
|
|
background: #dc2626;
|
|
color: white;
|
|
}
|
|
|
|
._comment-btn-danger:hover {
|
|
background: #b91c1c;
|
|
}
|
|
|
|
._comment-btn-link {
|
|
background: none;
|
|
border: none;
|
|
color: var(--text-secondary, #6b7280);
|
|
font-size: 12px;
|
|
font-weight: 600;
|
|
padding: 0;
|
|
cursor: pointer;
|
|
}
|
|
|
|
._comment-btn-link:hover {
|
|
color: #dc2626;
|
|
}
|
|
|
|
._comment-item {
|
|
display: flex;
|
|
gap: 12px;
|
|
margin-bottom: 16px;
|
|
}
|
|
|
|
._comment-avatar {
|
|
flex-shrink: 0;
|
|
border-radius: 50%;
|
|
object-fit: cover;
|
|
}
|
|
|
|
._comment-body {
|
|
font-size: 14px;
|
|
line-height: 1.5;
|
|
word-wrap: break-word;
|
|
color: var(--text-primary, #1f2937);
|
|
}
|
|
|
|
._comment-reply-wrapper {
|
|
padding-left: 48px;
|
|
margin-bottom: 12px;
|
|
position: relative;
|
|
}
|
|
|
|
._comment-reply-wrapper::before {
|
|
content: '';
|
|
position: absolute;
|
|
left: 0;
|
|
top: 0;
|
|
bottom: 0;
|
|
width: 2px;
|
|
background: var(--border-color, #e5e7eb);
|
|
}
|
|
|
|
._comment-time-badge {
|
|
display: inline-flex !important;
|
|
align-items: center;
|
|
gap: 4px;
|
|
padding: 4px 8px;
|
|
border-radius: 6px;
|
|
background: var(--brand-red, #ef4444);
|
|
border: 1px solid #dc2626;
|
|
color: white;
|
|
font-weight: 600;
|
|
font-size: 12px;
|
|
line-height: 1;
|
|
cursor: pointer;
|
|
text-decoration: none;
|
|
user-select: none;
|
|
margin: 0 2px;
|
|
transition: all 0.2s ease;
|
|
box-shadow: 0 2px 4px rgba(220, 38, 38, 0.3);
|
|
white-space: nowrap;
|
|
}
|
|
|
|
._comment-time-badge:hover {
|
|
background: #dc2626 !important;
|
|
border-color: #b91c1c !important;
|
|
transform: translateY(-1px);
|
|
box-shadow: 0 4px 8px rgba(220, 38, 38, 0.4) !important;
|
|
}
|
|
|
|
._comment-time-badge:active {
|
|
transform: translateY(0);
|
|
}
|
|
|
|
._comment-time-badge i {
|
|
font-size: 11px;
|
|
line-height: 1;
|
|
display: inline-block;
|
|
}
|
|
|
|
._comment-modal._comment-modal-show {
|
|
opacity: 1;
|
|
}
|
|
|
|
._comment-modal._comment-modal-show ._comment-modal-content {
|
|
transform: translateY(0);
|
|
}
|
|
|
|
._comment-toast._comment-toast-show {
|
|
opacity: 1;
|
|
transform: translateX(-50%) translateY(0);
|
|
pointer-events: auto;
|
|
}
|
|
|
|
._comment-toast._comment-toast-success {
|
|
background: #059669;
|
|
}
|
|
|
|
._comment-toast._comment-toast-error {
|
|
background: #dc2626;
|
|
}
|
|
|
|
._comment-edit-wrap,
|
|
._comment-reply-form {
|
|
margin-top: 12px;
|
|
}
|
|
|
|
@media (max-width: 768px) {
|
|
._comment-form {
|
|
gap: 8px;
|
|
padding: 0 8px;
|
|
}
|
|
|
|
._comment-form ._comment-avatar {
|
|
width: 32px;
|
|
height: 32px;
|
|
}
|
|
|
|
._comment-form ._comment-textarea {
|
|
font-size: 16px;
|
|
height: 44px;
|
|
}
|
|
|
|
._comment-btn-primary {
|
|
min-width: 52px;
|
|
padding: 8px 12px;
|
|
font-size: 14px;
|
|
}
|
|
|
|
._comment-section {
|
|
padding-left: 8px;
|
|
padding-right: 8px;
|
|
}
|
|
|
|
._comment-modal-content {
|
|
max-width: 280px;
|
|
padding: 20px;
|
|
}
|
|
}
|
|
|
|
@media (max-width: 480px) {
|
|
._comment-form ._comment-avatar {
|
|
display: none;
|
|
}
|
|
|
|
._comment-form>div {
|
|
gap: 4px;
|
|
}
|
|
}
|
|
</style>
|
|
|
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css">
|
|
|
|
{{-- ✅ JAVASCRIPT --}}
|
|
<script>
|
|
(function() {
|
|
'use strict';
|
|
const _comment = {
|
|
videoId: {{ $video->id }},
|
|
csrfToken: '{{ csrf_token() }}',
|
|
userId: {{ Auth::id() ?? 0 }},
|
|
elements: {},
|
|
state: {
|
|
deleteCommentId: null,
|
|
toastTimeout: null,
|
|
playbackHandler: null
|
|
}
|
|
};
|
|
|
|
function initElements() {
|
|
_comment.elements.list = document.getElementById('_commentsList');
|
|
_comment.elements.count = document.getElementById('_commentCount');
|
|
_comment.elements.modal = document.getElementById('_commentDeleteModal');
|
|
_comment.elements.modalConfirm = document.getElementById('_commentModalConfirm');
|
|
_comment.elements.modalCancel = document.getElementById('_commentModalCancel');
|
|
_comment.elements.toast = document.getElementById('_commentToast');
|
|
_comment.elements.toastMessage = document.getElementById('_commentToastMessage');
|
|
_comment.elements.submitBtn = document.getElementById('_commentSubmitBtn');
|
|
}
|
|
|
|
function showToast(message, type = 'info') {
|
|
if (!_comment.elements.toast || !_comment.elements.toastMessage) return;
|
|
if (_comment.state.toastTimeout) clearTimeout(_comment.state.toastTimeout);
|
|
_comment.elements.toastMessage.textContent = message;
|
|
_comment.elements.toast.className = '_comment-toast _comment-toast-' + type;
|
|
_comment.elements.toast.style.display = 'block';
|
|
requestAnimationFrame(() => _comment.elements.toast.classList.add('_comment-toast-show'));
|
|
_comment.state.toastTimeout = setTimeout(() => {
|
|
_comment.elements.toast.classList.remove('_comment-toast-show');
|
|
setTimeout(() => _comment.elements.toast.style.display = 'none', 300);
|
|
}, 3000);
|
|
}
|
|
|
|
function openDeleteModal(commentId) {
|
|
if (!_comment.elements.modal) return;
|
|
_comment.state.deleteCommentId = commentId;
|
|
_comment.elements.modal.style.display = 'flex';
|
|
requestAnimationFrame(() => _comment.elements.modal.classList.add('_comment-modal-show'));
|
|
document.body.style.overflow = 'hidden';
|
|
}
|
|
|
|
function closeDeleteModal() {
|
|
if (!_comment.elements.modal) return;
|
|
_comment.elements.modal.classList.remove('_comment-modal-show');
|
|
setTimeout(() => {
|
|
_comment.elements.modal.style.display = 'none';
|
|
_comment.state.deleteCommentId = null;
|
|
document.body.style.overflow = '';
|
|
}, 200);
|
|
}
|
|
|
|
function initDeleteModal() {
|
|
if (!_comment.elements.modal) return;
|
|
_comment.elements.modalCancel?.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
closeDeleteModal();
|
|
});
|
|
_comment.elements.modal.addEventListener('click', (e) => {
|
|
if (e.target === _comment.elements.modal) closeDeleteModal();
|
|
});
|
|
document.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Escape' && _comment.elements.modal.style.display === 'flex')
|
|
closeDeleteModal();
|
|
});
|
|
_comment.elements.modalConfirm?.addEventListener('click', async () => {
|
|
if (!_comment.state.deleteCommentId) return;
|
|
const btn = _comment.elements.modalConfirm;
|
|
btn.disabled = true;
|
|
btn.innerHTML = '<i class="bi bi-hourglass-split"></i> Deleting...';
|
|
try {
|
|
const res = await fetch(`/comments/${_comment.state.deleteCommentId}`, {
|
|
method: 'DELETE',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRF-TOKEN': _comment.csrfToken
|
|
}
|
|
});
|
|
const data = await res.json();
|
|
if (!res.ok || !(data.success || data.deleted)) throw new Error(data.error ||
|
|
'Failed to delete');
|
|
const commentEl = document.getElementById('comment-' + _comment.state
|
|
.deleteCommentId);
|
|
if (commentEl) {
|
|
commentEl.style.transition = 'opacity 0.2s, transform 0.2s';
|
|
commentEl.style.opacity = '0';
|
|
commentEl.style.transform = 'translateX(-20px)';
|
|
setTimeout(() => commentEl.remove(), 200);
|
|
}
|
|
if (_comment.elements.count) {
|
|
const current = parseInt(_comment.elements.count.textContent.match(/\d+/)?.[
|
|
0] || 0);
|
|
_comment.elements.count.textContent = `(${Math.max(0, current - 1)})`;
|
|
}
|
|
showToast('Comment deleted', 'success');
|
|
closeDeleteModal();
|
|
} catch (e) {
|
|
showToast(e.message || 'Failed to delete', 'error');
|
|
btn.disabled = false;
|
|
btn.innerHTML = '<i class="bi bi-trash"></i> Delete';
|
|
}
|
|
});
|
|
}
|
|
|
|
function initEventDelegation() {
|
|
if (!_comment.elements.list) return;
|
|
_comment.elements.list.addEventListener('click', (e) => {
|
|
const db = e.target.closest('._comment-delete-trigger');
|
|
if (db) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
const id = db.dataset.commentId;
|
|
if (id) openDeleteModal(parseInt(id));
|
|
return;
|
|
}
|
|
const rb = e.target.closest('._comment-reply-trigger');
|
|
if (rb) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
const id = rb.dataset.commentId;
|
|
if (id) toggleReplyForm(id);
|
|
return;
|
|
}
|
|
const eb = e.target.closest('._comment-edit-trigger');
|
|
if (eb) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
const id = eb.dataset.commentId;
|
|
if (id) startEditComment(id);
|
|
return;
|
|
}
|
|
const ceb = e.target.closest('._comment-cancel-edit-trigger');
|
|
if (ceb) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
const id = ceb.dataset.commentId;
|
|
if (id) cancelEditComment(id);
|
|
return;
|
|
}
|
|
const seb = e.target.closest('._comment-save-edit-trigger');
|
|
if (seb) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
const id = seb.dataset.commentId;
|
|
if (id) saveEditComment(id);
|
|
return;
|
|
}
|
|
const crb = e.target.closest('._comment-cancel-reply-trigger');
|
|
if (crb) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
const id = crb.dataset.commentId;
|
|
if (id) toggleReplyForm(id);
|
|
return;
|
|
}
|
|
const srb = e.target.closest('._comment-submit-reply-trigger');
|
|
if (srb) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
const vid = srb.dataset.videoId,
|
|
pid = srb.dataset.parentId;
|
|
if (vid && pid) submitReply(vid, pid);
|
|
return;
|
|
}
|
|
});
|
|
}
|
|
|
|
function enhanceCommentBody(bodyEl) {
|
|
if (!bodyEl || bodyEl.dataset._commentEnhanced === '1') return;
|
|
let text = bodyEl.textContent || '';
|
|
const timeRegex = /@(\d{1,2})\.(\d{2})(?:-(\d{1,2})\.(\d{2}))?/g;
|
|
text = text.replace(timeRegex, (match, sM, sS, eM, eS) => {
|
|
const startMin = parseInt(sM, 10),
|
|
startSec = parseInt(sS, 10);
|
|
if (isNaN(startMin) || isNaN(startSec) || startSec > 59) return match;
|
|
const startDisplay =
|
|
`${String(startMin).padStart(2,'0')}:${String(startSec).padStart(2,'0')}`,
|
|
startSeconds = startMin * 60 + startSec;
|
|
if (eM && eS) {
|
|
const endMin = parseInt(eM, 10),
|
|
endSec = parseInt(eS, 10);
|
|
if (isNaN(endMin) || isNaN(endSec) || endSec > 59) return match;
|
|
const endDisplay =
|
|
`${String(endMin).padStart(2,'0')}:${String(endSec).padStart(2,'0')}`,
|
|
endSeconds = endMin * 60 + endSec;
|
|
return `<span class="_comment-time-badge" data-start="${startSeconds}" data-end="${endSeconds}" title="Play ${startDisplay} to ${endDisplay}"><i class="bi bi-clock"></i>${startDisplay}-${endDisplay}</span>`;
|
|
}
|
|
return `<span class="_comment-time-badge" data-start="${startSeconds}" title="Jump to ${startDisplay}"><i class="bi bi-play-fill"></i>${startDisplay}</span>`;
|
|
});
|
|
text = text.replace(/@([a-zA-Z0-9_]+)/g, '<span style="color:#3ea6ff;font-weight:500;">@$1</span>');
|
|
bodyEl.innerHTML = text;
|
|
bodyEl.dataset._commentEnhanced = '1';
|
|
bodyEl.querySelectorAll('._comment-time-badge').forEach(badge => {
|
|
badge.onclick = (e) => {
|
|
e.stopPropagation();
|
|
playTimeRange(parseFloat(badge.dataset.start), badge.dataset.end ? parseFloat(badge
|
|
.dataset.end) : null);
|
|
};
|
|
});
|
|
}
|
|
|
|
function enhanceAllComments() {
|
|
document.querySelectorAll(
|
|
'._comment-body[data-_comment-enhanced="0"], ._comment-body:not([data-_comment-enhanced])')
|
|
.forEach(enhanceCommentBody);
|
|
}
|
|
|
|
function clearPlaybackHandler(video) {
|
|
if (video && _comment.state.playbackHandler) video.removeEventListener('timeupdate', _comment.state
|
|
.playbackHandler);
|
|
_comment.state.playbackHandler = null;
|
|
}
|
|
|
|
function playTimeRange(startSec, endSec = null) {
|
|
const video = document.getElementById('videoPlayer');
|
|
if (!video) {
|
|
showToast('Video player not found', 'error');
|
|
return;
|
|
}
|
|
clearPlaybackHandler(video);
|
|
video.currentTime = Math.max(0, startSec - 1);
|
|
video.play().catch(() => {});
|
|
if (endSec) {
|
|
_comment.state.playbackHandler = () => {
|
|
if (video.currentTime >= endSec) {
|
|
video.pause();
|
|
clearPlaybackHandler(video);
|
|
}
|
|
};
|
|
video.addEventListener('timeupdate', _comment.state.playbackHandler);
|
|
}
|
|
}
|
|
|
|
function toggleReplyForm(commentId) {
|
|
const form = document.getElementById('replyForm' + commentId);
|
|
if (!form) return;
|
|
form.style.display = form.style.display === 'none' ? 'block' : 'none';
|
|
if (form.style.display === 'block') {
|
|
const input = document.getElementById('replyBody' + commentId);
|
|
if (input) input.focus();
|
|
}
|
|
}
|
|
|
|
function startEditComment(commentId) {
|
|
const body = document.querySelector(`#comment-${commentId} ._comment-body`),
|
|
wrap = document.getElementById('commentEditWrap' + commentId),
|
|
input = document.getElementById('commentEditInput' + commentId);
|
|
if (!body || !wrap || !input) return;
|
|
input.value = body.textContent.trim();
|
|
body.style.display = 'none';
|
|
wrap.style.display = 'block';
|
|
input.focus();
|
|
}
|
|
|
|
function cancelEditComment(commentId) {
|
|
const body = document.querySelector(`#comment-${commentId} ._comment-body`),
|
|
wrap = document.getElementById('commentEditWrap' + commentId);
|
|
if (body) body.style.display = 'block';
|
|
if (wrap) wrap.style.display = 'none';
|
|
}
|
|
|
|
async function saveEditComment(commentId) {
|
|
const input = document.getElementById('commentEditInput' + commentId),
|
|
bodyEl = document.querySelector(`#comment-${commentId} ._comment-body`),
|
|
wrap = document.getElementById('commentEditWrap' + commentId);
|
|
if (!input || !bodyEl || !wrap) return;
|
|
const body = input.value.trim();
|
|
if (!body) {
|
|
showToast('Comment cannot be empty', 'error');
|
|
return;
|
|
}
|
|
const btn = wrap?.querySelector('._comment-btn-primary');
|
|
if (btn) {
|
|
btn.disabled = true;
|
|
btn.innerHTML = '<i class="bi bi-hourglass-split"></i> Sending...';
|
|
}
|
|
try {
|
|
const res = await fetch(`/comments/${commentId}`, {
|
|
method: 'PUT',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRF-TOKEN': _comment.csrfToken
|
|
},
|
|
body: JSON.stringify({
|
|
body
|
|
})
|
|
});
|
|
const data = await res.json();
|
|
if (res.ok && (data.success || data.body)) {
|
|
bodyEl.textContent = data.body || body;
|
|
bodyEl.dataset._commentEnhanced = '0';
|
|
enhanceCommentBody(bodyEl);
|
|
wrap.style.display = 'none';
|
|
bodyEl.style.display = 'block';
|
|
showToast('Comment updated', 'success');
|
|
} else {
|
|
throw new Error(data.error || 'Failed to update');
|
|
}
|
|
} catch (e) {
|
|
showToast(e.message, 'error');
|
|
} finally {
|
|
if (btn) {
|
|
btn.disabled = false;
|
|
btn.innerHTML = '<i class="bi bi-chat-dots"></i> <span>Send</span>';
|
|
}
|
|
}
|
|
}
|
|
|
|
async function submitReply(vid, parentId) {
|
|
const input = document.getElementById('replyBody' + parentId);
|
|
if (!input) return;
|
|
const body = input.value.trim();
|
|
if (!body) {
|
|
showToast('Reply cannot be empty', 'error');
|
|
return;
|
|
}
|
|
const btn = input.parentElement?.querySelector('._comment-btn-primary');
|
|
if (btn) {
|
|
btn.disabled = true;
|
|
btn.innerHTML = '<i class="bi bi-hourglass-split"></i> Sending...';
|
|
}
|
|
try {
|
|
const res = await fetch(`/videos/${vid}/comments`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRF-TOKEN': _comment.csrfToken
|
|
},
|
|
body: JSON.stringify({
|
|
body,
|
|
parent_id: parentId
|
|
})
|
|
});
|
|
const data = await res.json();
|
|
if (res.ok && data.success) {
|
|
input.value = '';
|
|
toggleReplyForm(parentId);
|
|
refreshComments();
|
|
showToast('Reply posted', 'success');
|
|
} else {
|
|
throw new Error(data.error || 'Failed to post reply');
|
|
}
|
|
} catch (e) {
|
|
showToast(e.message, 'error');
|
|
} finally {
|
|
if (btn) {
|
|
btn.disabled = false;
|
|
btn.innerHTML = '<i class="bi bi-chat-dots"></i> <span>Send</span>';
|
|
}
|
|
}
|
|
}
|
|
|
|
async function submitComment(vid) {
|
|
const input = document.getElementById('_commentBody');
|
|
if (!input) return;
|
|
const body = input.value.trim();
|
|
if (!body) {
|
|
showToast('Comment cannot be empty', 'error');
|
|
return;
|
|
}
|
|
if (_comment.elements.submitBtn) {
|
|
_comment.elements.submitBtn.disabled = true;
|
|
_comment.elements.submitBtn.innerHTML = '<i class="bi bi-hourglass-split"></i> Sending...';
|
|
}
|
|
try {
|
|
const res = await fetch(`/videos/${vid}/comments`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRF-TOKEN': _comment.csrfToken
|
|
},
|
|
body: JSON.stringify({
|
|
body
|
|
})
|
|
});
|
|
const data = await res.json();
|
|
if (res.ok && data.success) {
|
|
input.value = '';
|
|
refreshComments();
|
|
showToast('Comment posted', 'success');
|
|
} else {
|
|
throw new Error(data.error || 'Failed to post comment');
|
|
}
|
|
} catch (e) {
|
|
showToast(e.message, 'error');
|
|
} finally {
|
|
if (_comment.elements.submitBtn) {
|
|
_comment.elements.submitBtn.disabled = false;
|
|
_comment.elements.submitBtn.innerHTML = '<i class="bi bi-chat-dots"></i> <span>Send</span>';
|
|
}
|
|
}
|
|
}
|
|
|
|
async function refreshComments() {
|
|
try {
|
|
const res = await fetch(`/videos/${_comment.videoId}/comments`),
|
|
comments = await res.json(),
|
|
list = _comment.elements.list;
|
|
if (!list) return;
|
|
if (Array.isArray(comments) && comments.length) {
|
|
list.innerHTML = comments.map(c => {
|
|
const avatar = c.user?.avatar_url ||
|
|
`https://ui-avatars.com/api/?name=${encodeURIComponent(c.user?.name||'User')}&background=ef4444&color=fff`;
|
|
return `<div class="_comment-item" id="comment-${c.id}"><img src="${avatar}" class="_comment-avatar" style="width:36px;height:36px;"><div style="flex:1"><div style="display:flex;align-items:center;gap:8px;margin-bottom:4px"><span style="font-weight:600;font-size:14px">${c.user?.name||'User'}</span><span style="color:var(--text-secondary);font-size:12px">${c.created_at||''}</span></div><div class="_comment-body" data-_comment-enhanced="0" style="font-size:14px;line-height:1.5">${c.body||''}</div></div></div>`;
|
|
}).join('');
|
|
} else {
|
|
list.innerHTML =
|
|
'<p style="color:var(--text-secondary);text-align:center;padding:20px">No comments yet. Be the first to comment!</p>';
|
|
}
|
|
if (_comment.elements.count) _comment.elements.count.textContent =
|
|
`(${Array.isArray(comments) ? comments.length : 0})`;
|
|
setTimeout(enhanceAllComments, 50);
|
|
} catch (e) {
|
|
showToast('Failed to load comments', 'error');
|
|
}
|
|
}
|
|
|
|
function init() {
|
|
initElements();
|
|
initDeleteModal();
|
|
initEventDelegation();
|
|
enhanceAllComments();
|
|
_comment.elements.submitBtn?.addEventListener('click', () => submitComment(_comment.videoId));
|
|
if (_comment.elements.list && !_comment.elements.list._observerAttached) {
|
|
const observer = new MutationObserver(() => enhanceAllComments());
|
|
observer.observe(_comment.elements.list, {
|
|
childList: true,
|
|
subtree: true
|
|
});
|
|
_comment.elements.list._observerAttached = true;
|
|
}
|
|
}
|
|
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', init);
|
|
} else {
|
|
init();
|
|
}
|
|
window._comment = {
|
|
deleteComment: openDeleteModal,
|
|
playTimeRange: playTimeRange
|
|
};
|
|
})();
|
|
</script>
|