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 }}">
|
||||
<div class="yt-video-thumb" onmouseenter="playVideo(this)" onmouseleave="stopVideo(this)"
|
||||
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)
|
||||
@include('components.partials.vs-mini', ['video' => $video])
|
||||
@endif
|
||||
@@ -177,9 +184,9 @@ $sizeClasses = match($size) {
|
||||
<ul class="dropdown-menu dropdown-menu-end dropdown-menu-dark">
|
||||
@if($isOwner)
|
||||
<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
|
||||
</a>
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<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>
|
||||
@endif
|
||||
<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
|
||||
</a>
|
||||
</button>
|
||||
</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
|
||||
</a>
|
||||
</button>
|
||||
</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
|
||||
</x-save-to-playlist-button>
|
||||
</li>
|
||||
@@ -212,133 +219,45 @@ $sizeClasses = match($size) {
|
||||
@endif
|
||||
@if($video->isShareable())
|
||||
<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
|
||||
</x-share-button>
|
||||
</li>
|
||||
@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>
|
||||
<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
|
||||
</a>
|
||||
</button>
|
||||
</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
|
||||
</a>
|
||||
</button>
|
||||
</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
|
||||
</a>
|
||||
</button>
|
||||
</li>
|
||||
@endunless
|
||||
</ul>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@php $vk = $video->getRouteKey() ?? ''; @endphp
|
||||
<!-- Cute Edit Video Modal -->
|
||||
<div class="modal fade" id="editVideoModal{{ $vk }}" tabindex="-1" aria-labelledby="editVideoModalLabel{{ $vk }}" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered cute-edit-modal">
|
||||
<div class="modal-content cute-edit-content">
|
||||
<div class="cute-edit-header">
|
||||
<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>
|
||||
{{--
|
||||
The per-card edit modal that used to live here (97 lines of markup, 100+ lines of JS)
|
||||
was dead code — the singleton at resources/views/layouts/partials/edit-video-modal.blade.php
|
||||
defines a global window.openEditVideoModal that always wins the last-definition race,
|
||||
so clicking "Edit" on a card has always been going through the singleton. Removing the
|
||||
per-card copy drops ~6000 lines of HTML from a 60-card feed page.
|
||||
--}}
|
||||
|
||||
@include('components.partials.card-styles')
|
||||
|
||||
@@ -393,155 +312,9 @@ function stopVideo(element) {
|
||||
element.classList.remove('audio-playing');
|
||||
}
|
||||
|
||||
// Edit Modal Functions
|
||||
let currentEditVideoId = null;
|
||||
|
||||
function openEditVideoModal(videoId) {
|
||||
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
|
||||
// openEditVideoModal + the type/privacy/thumbnail/submit handlers that used
|
||||
// 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
|
||||
// handles its own event bindings.
|
||||
</script>
|
||||
@endonce
|
||||
Reference in new issue
Block a user