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:
ghassanandClaude Opus 4.7 committed 2026-10-21 00:49:33 +03:00
1 parent 4718888de0
commit 73620a0f24
1 file changed
+38 -265
+38 -265
View File
@@ -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