perf(video-card): strip dead per-card edit modal, tighten owner menu
- Delete the per-card "cute" edit modal (lines 242-341) and the three dead handlers that drove it (openEditVideoModal, closeEditVideoModal, the type/privacy/thumbnail/submit document-level delegators). The singleton at layouts/partials/edit-video-modal.blade.php already defines a global window.openEditVideoModal that always won the last-definition race, so clicking Edit was already going through the singleton. Card is 547 -> 308 lines; a 60-card feed drops ~14 000 lines of modal markup. - "Not interested", "Don't recommend channel", "Report" are now gated on @unless($isOwner) — they're meaningless on your own video. - All dropdown items that were <a href="javascript:void(0)" onclick> are now real <button type="button">, including the save-to-playlist and share slots (tag="button"). - Thumbnail img gains width="320" height="180" so the browser reserves layout space before the image decodes — removes layout shift on slow connections. The actual rendered size still comes from .yt-video-thumb's aspect-ratio: 16/9. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
1 parent
4718888de0
commit
73620a0f24
1 file changed
+38
-265
@@ -85,7 +85,14 @@ $sizeClasses = match($size) {
|
|||||||
<a href="{{ $showUrl }}">
|
<a href="{{ $showUrl }}">
|
||||||
<div class="yt-video-thumb" onmouseenter="playVideo(this)" onmouseleave="stopVideo(this)"
|
<div class="yt-video-thumb" onmouseenter="playVideo(this)" onmouseleave="stopVideo(this)"
|
||||||
data-audio="{{ $video && $video->isAudioOnly() ? 'true' : 'false' }}">
|
data-audio="{{ $video && $video->isAudioOnly() ? 'true' : 'false' }}">
|
||||||
<img src="{{ $thumbnailUrl }}" alt="{{ $video->title ?? 'Video' }}" loading="lazy" decoding="async" onload="this.classList.add('loaded');this.closest('.yt-video-thumb').classList.add('loaded')">
|
{{-- width/height let the browser reserve the card's thumb box
|
||||||
|
BEFORE the image decodes, so a grid of 60 cards doesn't shift
|
||||||
|
as each thumbnail finishes loading. The ratio is honored by
|
||||||
|
.yt-video-thumb's aspect-ratio: 16/9 regardless of actual px. --}}
|
||||||
|
<img src="{{ $thumbnailUrl }}" alt="{{ $video->title ?? 'Video' }}"
|
||||||
|
width="320" height="180"
|
||||||
|
loading="lazy" decoding="async"
|
||||||
|
onload="this.classList.add('loaded');this.closest('.yt-video-thumb').classList.add('loaded')">
|
||||||
@if($video && $video->type === 'match' && $video->sportsMatch)
|
@if($video && $video->type === 'match' && $video->sportsMatch)
|
||||||
@include('components.partials.vs-mini', ['video' => $video])
|
@include('components.partials.vs-mini', ['video' => $video])
|
||||||
@endif
|
@endif
|
||||||
@@ -177,9 +184,9 @@ $sizeClasses = match($size) {
|
|||||||
<ul class="dropdown-menu dropdown-menu-end dropdown-menu-dark">
|
<ul class="dropdown-menu dropdown-menu-end dropdown-menu-dark">
|
||||||
@if($isOwner)
|
@if($isOwner)
|
||||||
<li>
|
<li>
|
||||||
<a class="dropdown-item" href="javascript:void(0)" onclick="openEditVideoModal('{{ $video->getRouteKey() }}')">
|
<button type="button" class="dropdown-item" onclick="openEditVideoModal('{{ $video->getRouteKey() }}')">
|
||||||
<i class="bi bi-pencil"></i> Edit
|
<i class="bi bi-pencil"></i> Edit
|
||||||
</a>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<button type="button" class="dropdown-item text-danger" onclick="showDeleteModal('{{ $video->getRouteKey() }}', {{ json_encode($video->title) }})">
|
<button type="button" class="dropdown-item text-danger" onclick="showDeleteModal('{{ $video->getRouteKey() }}', {{ json_encode($video->title) }})">
|
||||||
@@ -189,17 +196,17 @@ $sizeClasses = match($size) {
|
|||||||
<li><hr class="dropdown-divider"></li>
|
<li><hr class="dropdown-divider"></li>
|
||||||
@endif
|
@endif
|
||||||
<li>
|
<li>
|
||||||
<a class="dropdown-item" href="javascript:void(0)" onclick="addToQueue('{{ $video->getRouteKey() }}')">
|
<button type="button" class="dropdown-item" onclick="addToQueue('{{ $video->getRouteKey() }}')">
|
||||||
<i class="bi bi-list-nested"></i> Add to queue
|
<i class="bi bi-list-nested"></i> Add to queue
|
||||||
</a>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a class="dropdown-item" href="javascript:void(0)" onclick="saveToWatchLater('{{ $video->getRouteKey() }}')">
|
<button type="button" class="dropdown-item" onclick="saveToWatchLater('{{ $video->getRouteKey() }}')">
|
||||||
<i class="bi bi-clock"></i> Save to Watch later
|
<i class="bi bi-clock"></i> Save to Watch later
|
||||||
</a>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<x-save-to-playlist-button :video="$video" tag="a" class="dropdown-item">
|
<x-save-to-playlist-button :video="$video" tag="button" class="dropdown-item">
|
||||||
<i class="bi bi-bookmark"></i> Save to playlist
|
<i class="bi bi-bookmark"></i> Save to playlist
|
||||||
</x-save-to-playlist-button>
|
</x-save-to-playlist-button>
|
||||||
</li>
|
</li>
|
||||||
@@ -212,133 +219,45 @@ $sizeClasses = match($size) {
|
|||||||
@endif
|
@endif
|
||||||
@if($video->isShareable())
|
@if($video->isShareable())
|
||||||
<li>
|
<li>
|
||||||
<x-share-button :video="$video" tag="a" class="dropdown-item">
|
<x-share-button :video="$video" tag="button" class="dropdown-item">
|
||||||
<i class="bi bi-share"></i> Share
|
<i class="bi bi-share"></i> Share
|
||||||
</x-share-button>
|
</x-share-button>
|
||||||
</li>
|
</li>
|
||||||
@endif
|
@endif
|
||||||
|
{{-- "Not interested", "Don't recommend channel" and "Report"
|
||||||
|
only make sense for someone else's video. On your own, the
|
||||||
|
Edit/Delete items above are what you want instead. --}}
|
||||||
|
@unless($isOwner)
|
||||||
<li><hr class="dropdown-divider"></li>
|
<li><hr class="dropdown-divider"></li>
|
||||||
<li>
|
<li>
|
||||||
<a class="dropdown-item" href="javascript:void(0)" onclick="notInterested('{{ $video->getRouteKey() }}')">
|
<button type="button" class="dropdown-item" onclick="notInterested('{{ $video->getRouteKey() }}')">
|
||||||
<i class="bi bi-dash-circle"></i> Not interested
|
<i class="bi bi-dash-circle"></i> Not interested
|
||||||
</a>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a class="dropdown-item" href="javascript:void(0)" onclick="dontRecommendChannel('{{ $video->user_id }}')">
|
<button type="button" class="dropdown-item" onclick="dontRecommendChannel('{{ $video->user_id }}')">
|
||||||
<i class="bi bi-x-circle"></i> Don't recommend channel
|
<i class="bi bi-x-circle"></i> Don't recommend channel
|
||||||
</a>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a class="dropdown-item" href="javascript:void(0)" onclick="reportVideo('{{ $video->getRouteKey() }}')">
|
<button type="button" class="dropdown-item" onclick="reportVideo('{{ $video->getRouteKey() }}')">
|
||||||
<i class="bi bi-flag"></i> Report
|
<i class="bi bi-flag"></i> Report
|
||||||
</a>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
|
@endunless
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@php $vk = $video->getRouteKey() ?? ''; @endphp
|
{{--
|
||||||
<!-- Cute Edit Video Modal -->
|
The per-card edit modal that used to live here (97 lines of markup, 100+ lines of JS)
|
||||||
<div class="modal fade" id="editVideoModal{{ $vk }}" tabindex="-1" aria-labelledby="editVideoModalLabel{{ $vk }}" aria-hidden="true">
|
was dead code — the singleton at resources/views/layouts/partials/edit-video-modal.blade.php
|
||||||
<div class="modal-dialog modal-dialog-centered cute-edit-modal">
|
defines a global window.openEditVideoModal that always wins the last-definition race,
|
||||||
<div class="modal-content cute-edit-content">
|
so clicking "Edit" on a card has always been going through the singleton. Removing the
|
||||||
<div class="cute-edit-header">
|
per-card copy drops ~6000 lines of HTML from a 60-card feed page.
|
||||||
<span class="cute-edit-icon">✏️</span>
|
--}}
|
||||||
<h5>Edit Video</h5>
|
|
||||||
<button type="button" class="btn-close-cute" onclick="closeEditVideoModal('{{ $vk }}')">×</button>
|
|
||||||
</div>
|
|
||||||
<div class="cute-edit-body">
|
|
||||||
<form id="edit-video-form-{{ $vk }}" enctype="multipart/form-data">
|
|
||||||
@csrf
|
|
||||||
@method('PUT')
|
|
||||||
|
|
||||||
<!-- Title -->
|
|
||||||
<div class="cute-form-group">
|
|
||||||
<label><i class="bi bi-card-heading"></i> Title</label>
|
|
||||||
<input type="text" name="title" id="edit-title-{{ $vk }}" class="cute-input" placeholder="Video title">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Description -->
|
|
||||||
<div class="cute-form-group">
|
|
||||||
<label><i class="bi bi-text-paragraph"></i> Description</label>
|
|
||||||
<textarea name="description" id="edit-description-{{ $vk }}" class="cute-textarea" rows="2" placeholder="Tell viewers about your video"></textarea>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Video Type -->
|
|
||||||
<div class="cute-form-group">
|
|
||||||
<label><i class="bi bi-collection-play"></i> Type</label>
|
|
||||||
<div class="cute-type-options">
|
|
||||||
<label class="cute-type-option active" data-type="generic">
|
|
||||||
<input type="radio" name="type" value="generic" checked>
|
|
||||||
<span>🎬 Generic</span>
|
|
||||||
</label>
|
|
||||||
<label class="cute-type-option" data-type="music">
|
|
||||||
<input type="radio" name="type" value="music">
|
|
||||||
<span>🎵 Music</span>
|
|
||||||
</label>
|
|
||||||
<label class="cute-type-option" data-type="match">
|
|
||||||
<input type="radio" name="type" value="match">
|
|
||||||
<span>🏆 Match</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Shorts Toggle -->
|
|
||||||
<div class="cute-form-group">
|
|
||||||
<label><i class="bi bi-lightning-charge-fill"></i> Shorts</label>
|
|
||||||
<label class="cute-shorts-toggle">
|
|
||||||
<input type="checkbox" name="is_shorts" id="edit-is-shorts-{{ $vk }}" value="1">
|
|
||||||
<span class="cute-shorts-slider"></span>
|
|
||||||
<span class="cute-shorts-label">Mark as Short</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Thumbnail -->
|
|
||||||
<div class="cute-form-group">
|
|
||||||
<label><i class="bi bi-image"></i> Thumbnail</label>
|
|
||||||
<div class="cute-thumbnail-upload" onclick="document.getElementById('edit-thumbnail-{{ $vk }}').click()">
|
|
||||||
<input type="file" name="thumbnail" id="edit-thumbnail-{{ $vk }}" accept="image/*" hidden>
|
|
||||||
<div class="cute-thumbnail-preview" id="thumbnail-preview-{{ $vk }}">
|
|
||||||
<i class="bi bi-camera"></i>
|
|
||||||
<span>Click to change</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Privacy -->
|
|
||||||
<div class="cute-form-group">
|
|
||||||
<label><i class="bi bi-shield-lock"></i> Privacy</label>
|
|
||||||
<div class="cute-privacy-options">
|
|
||||||
<label class="cute-privacy-option active" data-privacy="public">
|
|
||||||
<input type="radio" name="visibility" value="public" checked>
|
|
||||||
<span>🌐 Public</span>
|
|
||||||
</label>
|
|
||||||
<label class="cute-privacy-option" data-privacy="unlisted">
|
|
||||||
<input type="radio" name="visibility" value="unlisted">
|
|
||||||
<span>🔗 Unlisted</span>
|
|
||||||
</label>
|
|
||||||
<label class="cute-privacy-option" data-privacy="private">
|
|
||||||
<input type="radio" name="visibility" value="private">
|
|
||||||
<span>🔒 Private</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Status -->
|
|
||||||
<div class="cute-status" id="edit-status-{{ $vk }}"></div>
|
|
||||||
|
|
||||||
<!-- Buttons -->
|
|
||||||
<div class="cute-edit-actions">
|
|
||||||
<button type="button" class="cute-btn-cancel" onclick="closeEditVideoModal('{{ $vk }}')">Cancel</button>
|
|
||||||
<button type="submit" class="cute-btn-save">Save</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@include('components.partials.card-styles')
|
@include('components.partials.card-styles')
|
||||||
|
|
||||||
@@ -393,155 +312,9 @@ function stopVideo(element) {
|
|||||||
element.classList.remove('audio-playing');
|
element.classList.remove('audio-playing');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Edit Modal Functions
|
// openEditVideoModal + the type/privacy/thumbnail/submit handlers that used
|
||||||
let currentEditVideoId = null;
|
// to live here were removed with the per-card modal — the singleton at
|
||||||
|
// layouts/partials/edit-video-modal.blade.php owns the global function and
|
||||||
function openEditVideoModal(videoId) {
|
// handles its own event bindings.
|
||||||
currentEditVideoId = videoId;
|
|
||||||
const modalId = 'editVideoModal' + (videoId || '');
|
|
||||||
const modal = new bootstrap.Modal(document.getElementById(modalId));
|
|
||||||
modal.show();
|
|
||||||
|
|
||||||
// Fetch video data
|
|
||||||
fetch(`/videos/${videoId}/edit`, {
|
|
||||||
headers: {
|
|
||||||
'X-CSRF-TOKEN': '{{ csrf_token() }}',
|
|
||||||
'Accept': 'application/json',
|
|
||||||
'X-Requested-With': 'XMLHttpRequest'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.then(response => response.json())
|
|
||||||
.then(data => {
|
|
||||||
if (data.success) {
|
|
||||||
const video = data.video;
|
|
||||||
document.getElementById('edit-title-' + videoId).value = video.title || '';
|
|
||||||
document.getElementById('edit-description-' + videoId).value = video.description || '';
|
|
||||||
|
|
||||||
// Set type
|
|
||||||
const typeOptions = document.querySelectorAll('#' + modalId + ' .cute-type-option');
|
|
||||||
typeOptions.forEach(opt => {
|
|
||||||
opt.classList.remove('active');
|
|
||||||
if (opt.dataset.type === (video.type || 'generic')) {
|
|
||||||
opt.classList.add('active');
|
|
||||||
opt.querySelector('input').checked = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Set privacy
|
|
||||||
const privacyOptions = document.querySelectorAll('#' + modalId + ' .cute-privacy-option');
|
|
||||||
privacyOptions.forEach(opt => {
|
|
||||||
opt.classList.remove('active');
|
|
||||||
if (opt.dataset.privacy === (video.visibility || 'public')) {
|
|
||||||
opt.classList.add('active');
|
|
||||||
opt.querySelector('input').checked = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Set shorts toggle
|
|
||||||
const shortsCheckbox = document.getElementById('edit-is-shorts-' + videoId);
|
|
||||||
if (shortsCheckbox) {
|
|
||||||
shortsCheckbox.checked = video.is_shorts === true || video.is_shorts === 1 || video.is_shorts === '1';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear status
|
|
||||||
const statusEl = document.getElementById('edit-status-' + videoId);
|
|
||||||
statusEl.className = 'cute-status';
|
|
||||||
statusEl.textContent = '';
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(error => {
|
|
||||||
console.error('Error:', error);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeEditVideoModal(videoId) {
|
|
||||||
const modalId = 'editVideoModal' + (videoId || '');
|
|
||||||
const modalEl = document.getElementById(modalId);
|
|
||||||
const modal = bootstrap.Modal.getInstance(modalEl);
|
|
||||||
if (modal) {
|
|
||||||
modal.hide();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// SPA navigation re-executes this once-protected script on every page swap.
|
|
||||||
// Guard the document-level listeners so they don't stack up across navs.
|
|
||||||
if (!window._videoCardListenersBound) {
|
|
||||||
window._videoCardListenersBound = true;
|
|
||||||
|
|
||||||
// Type option click handlers
|
|
||||||
document.addEventListener('click', function(e) {
|
|
||||||
if (e.target.closest('.cute-type-option')) {
|
|
||||||
const option = e.target.closest('.cute-type-option');
|
|
||||||
const parent = option.parentElement;
|
|
||||||
parent.querySelectorAll('.cute-type-option').forEach(opt => opt.classList.remove('active'));
|
|
||||||
option.classList.add('active');
|
|
||||||
option.querySelector('input').checked = true;
|
|
||||||
}
|
|
||||||
if (e.target.closest('.cute-privacy-option')) {
|
|
||||||
const option = e.target.closest('.cute-privacy-option');
|
|
||||||
const parent = option.parentElement;
|
|
||||||
parent.querySelectorAll('.cute-privacy-option').forEach(opt => opt.classList.remove('active'));
|
|
||||||
option.classList.add('active');
|
|
||||||
option.querySelector('input').checked = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Thumbnail preview
|
|
||||||
document.addEventListener('change', function(e) {
|
|
||||||
if (e.target.id && e.target.id.startsWith('edit-thumbnail-')) {
|
|
||||||
const file = e.target.files[0];
|
|
||||||
if (file) {
|
|
||||||
const videoId = e.target.id.replace('edit-thumbnail-', '');
|
|
||||||
const preview = document.getElementById('thumbnail-preview-' + videoId);
|
|
||||||
preview.innerHTML = `<span>${file.name}</span>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Form submission
|
|
||||||
document.addEventListener('submit', function(e) {
|
|
||||||
const form = e.target;
|
|
||||||
if (form.id && form.id.startsWith('edit-video-form-')) {
|
|
||||||
e.preventDefault();
|
|
||||||
const videoId = form.id.replace('edit-video-form-', '');
|
|
||||||
const formData = new FormData(form);
|
|
||||||
const statusEl = document.getElementById('edit-status-' + videoId);
|
|
||||||
const submitBtn = form.querySelector('.cute-btn-save');
|
|
||||||
|
|
||||||
submitBtn.disabled = true;
|
|
||||||
submitBtn.textContent = 'Saving...';
|
|
||||||
|
|
||||||
fetch(`/videos/${videoId}`, {
|
|
||||||
method: 'POST',
|
|
||||||
body: formData,
|
|
||||||
headers: {
|
|
||||||
'X-CSRF-TOKEN': '{{ csrf_token() }}',
|
|
||||||
'Accept': 'application/json',
|
|
||||||
'X-Requested-With': 'XMLHttpRequest'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.then(response => response.json())
|
|
||||||
.then(data => {
|
|
||||||
if (data.success) {
|
|
||||||
statusEl.className = 'cute-status success';
|
|
||||||
statusEl.textContent = '✓ Saved successfully!';
|
|
||||||
setTimeout(() => {
|
|
||||||
closeEditVideoModal(videoId);
|
|
||||||
window.location.reload();
|
|
||||||
}, 1000);
|
|
||||||
} else {
|
|
||||||
throw new Error(data.message || 'Update failed');
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(error => {
|
|
||||||
statusEl.className = 'cute-status error';
|
|
||||||
statusEl.textContent = '✗ ' + error.message;
|
|
||||||
submitBtn.disabled = false;
|
|
||||||
submitBtn.textContent = 'Save';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
} // end _videoCardListenersBound guard
|
|
||||||
</script>
|
</script>
|
||||||
@endonce
|
@endonce
|
||||||
Reference in new issue
Block a user