takeone-youtube-clone/resources/views/layouts/partials/merge-video-modal.blade.php
ghassan 8d27520bdb Video merge feature, default avatar, video-redirects table, misc UI tweaks
Bundled pre-existing changes on the branch:
- Video merge: controller, service, admin merge modal, and a repair
  console command; new video_redirects table (with source_folder)
  so merged/moved videos keep resolving under their old keys.
- Default avatar SVG for users without a profile picture.
- Assorted view tweaks across profile, video actions/comments/insights,
  admin layout, video-details, and the app layout.
- Small touch-ups in SuperAdminController, User model, NasSyncService.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-07-31 02:42:43 +03:00

176 lines
9.3 KiB
PHP

{{-- Music-video merge modal. Opened via openMergeModal(targetId).
Target = the video currently being viewed (survives).
Source = the picked video (folded in, then deleted, then 302s to target). --}}
<div class="modal fade" id="mergeVideoModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-lg">
<div class="modal-content" style="background:#181818; color:#eee; border:1px solid #2a2a2a;">
<div class="modal-header" style="border-bottom:1px solid #2a2a2a;">
<h5 class="modal-title"><i class="bi bi-signpost-split"></i> Merge songs</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p style="color:#aaa; font-size:14px;">
The song you pick below will be <strong>merged into this song</strong>.
Its audio tracks, slides, comments, views, likes, downloads and shares
all move here. The old link will redirect to this song.
</p>
<p style="color:#f5b544; font-size:13px;"><i class="bi bi-exclamation-triangle"></i> This cannot be undone.</p>
<div class="mb-3">
<input id="mergeSearchInput" type="text" class="form-control"
placeholder="Search your music songs…"
style="background:#0f0f0f; color:#eee; border:1px solid #2a2a2a;">
</div>
<div id="mergeCandidateList" style="max-height:320px; overflow-y:auto; border:1px solid #2a2a2a; border-radius:8px;">
<div class="text-center text-secondary p-4">Loading…</div>
</div>
<div id="mergePreviewBox" style="display:none; margin-top:16px; padding:12px; background:#0f0f0f; border:1px solid #2a2a2a; border-radius:8px;">
<div style="display:flex; gap:12px; align-items:center; margin-bottom:12px;">
<img id="mergePreviewThumb" src="" style="width:80px; height:45px; object-fit:cover; border-radius:4px;">
<div>
<div style="font-size:13px; color:#aaa;">Merging in:</div>
<div id="mergePreviewTitle" style="font-weight:600;"></div>
</div>
</div>
<div id="mergePreviewCounts" style="font-size:13px; color:#ccc; line-height:1.8;"></div>
</div>
</div>
<div class="modal-footer" style="border-top:1px solid #2a2a2a;">
<button type="button" class="action-btn" data-bs-dismiss="modal">Cancel</button>
<button type="button" id="mergeConfirmBtn" class="action-btn action-btn-primary" disabled onclick="doMerge()">
<i class="bi bi-signpost-split"></i> <span>Merge into this song</span>
</button>
</div>
</div>
</div>
</div>
<script>
(function () {
let targetId = null;
let sourceId = null;
let searchTimer = null;
window.openMergeModal = function (id) {
targetId = id;
sourceId = null;
document.getElementById('mergePreviewBox').style.display = 'none';
document.getElementById('mergeConfirmBtn').disabled = true;
document.getElementById('mergeSearchInput').value = '';
loadCandidates('');
const modal = bootstrap.Modal.getOrCreateInstance(document.getElementById('mergeVideoModal'));
modal.show();
};
document.getElementById('mergeSearchInput').addEventListener('input', function (e) {
clearTimeout(searchTimer);
const q = e.target.value;
searchTimer = setTimeout(() => loadCandidates(q), 250);
});
function loadCandidates(q) {
const list = document.getElementById('mergeCandidateList');
list.innerHTML = '<div class="text-center text-secondary p-4">Loading…</div>';
const url = '/videos/' + encodeURIComponent(targetId) + '/merge/candidates?q=' + encodeURIComponent(q);
fetch(url, { headers: { 'Accept': 'application/json' }})
.then(r => r.json())
.then(d => {
if (!d.items || d.items.length === 0) {
list.innerHTML = '<div class="text-center text-secondary p-4">No other music songs to merge.</div>';
return;
}
list.innerHTML = d.items.map(it => `
<div class="merge-cand" data-id="${it.id}"
style="display:flex; gap:10px; align-items:center; padding:8px 10px; cursor:pointer; border-bottom:1px solid #1e1e1e;">
<img src="${it.thumbnail}" style="width:64px; height:36px; object-fit:cover; border-radius:4px; flex:none;">
<div style="flex:1; min-width:0;">
<div style="font-size:14px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">${escapeHtml(it.title)}</div>
<div style="font-size:12px; color:#888;">${it.created || ''}</div>
</div>
</div>
`).join('');
list.querySelectorAll('.merge-cand').forEach(el => {
el.addEventListener('click', () => pickSource(el.dataset.id));
el.addEventListener('mouseenter', () => el.style.background = '#242424');
el.addEventListener('mouseleave', () => el.style.background = '');
});
})
.catch(() => { list.innerHTML = '<div class="text-center text-danger p-4">Failed to load.</div>'; });
}
function pickSource(id) {
sourceId = id;
const box = document.getElementById('mergePreviewBox');
const counts = document.getElementById('mergePreviewCounts');
counts.innerHTML = '<div class="text-secondary">Calculating…</div>';
box.style.display = 'block';
document.getElementById('mergeConfirmBtn').disabled = true;
const url = '/videos/' + encodeURIComponent(targetId) + '/merge/' + encodeURIComponent(sourceId) + '/preview';
fetch(url, { headers: { 'Accept': 'application/json' }})
.then(r => r.json())
.then(d => {
if (d.error) {
counts.innerHTML = '<div class="text-danger">' + escapeHtml(d.error) + '</div>';
return;
}
document.getElementById('mergePreviewThumb').src = d.source.thumb;
document.getElementById('mergePreviewTitle').textContent = d.source.title;
const c = d.counts;
counts.innerHTML = `
<div><i class="bi bi-music-note-list"></i> ${c.tracks} audio track(s) will be added</div>
<div><i class="bi bi-images"></i> ${c.slides} slide(s) will move</div>
<div><i class="bi bi-chat-left-text"></i> ${c.comments} comment(s) will move</div>
<div><i class="bi bi-eye"></i> ${c.views} view(s) will merge</div>
<div><i class="bi bi-hand-thumbs-up"></i> ${c.likes_source} like(s) → ${c.likes_after_dedupe} new (deduped by user)</div>
<div><i class="bi bi-download"></i> ${c.downloads} download(s) will merge</div>
<div><i class="bi bi-share"></i> ${c.shares} share(s) will merge</div>
<div><i class="bi bi-collection-play"></i> ${c.playlists_merged} playlist(s) will re-point (dedupe)</div>
`;
document.getElementById('mergeConfirmBtn').disabled = false;
})
.catch(() => { counts.innerHTML = '<div class="text-danger">Preview failed.</div>'; });
}
window.doMerge = function () {
if (!sourceId || !targetId) return;
const btn = document.getElementById('mergeConfirmBtn');
btn.disabled = true;
btn.innerHTML = '<i class="bi bi-hourglass-split"></i> <span>Merging…</span>';
const url = '/videos/' + encodeURIComponent(targetId) + '/merge/' + encodeURIComponent(sourceId);
fetch(url, {
method: 'POST',
headers: {
'Accept': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
},
})
.then(r => r.json())
.then(d => {
if (d.ok) {
if (window.showToast) window.showToast('Songs merged successfully.', 'success');
setTimeout(() => window.location.href = d.redirect, 400);
} else {
if (window.showToast) window.showToast(d.error || 'Merge failed.', 'error');
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-signpost-split"></i> <span>Merge into this song</span>';
}
})
.catch(() => {
if (window.showToast) window.showToast('Merge failed.', 'error');
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-signpost-split"></i> <span>Merge into this song</span>';
});
};
function escapeHtml(s) {
return String(s || '').replace(/[&<>"']/g, c => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
})[c]);
}
})();
</script>