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) <[email protected]>
This commit is contained in:
1 parent
ba8ab11ce4
commit
8d27520bdb
17 files changed
+736
-13
No files matched your search
@@ -538,7 +538,7 @@
|
||||
@if(Auth::user()->avatar)
|
||||
<img src="{{ route('media.avatar', Auth::user()->avatar) }}" class="adm-user-avatar" alt="">
|
||||
@else
|
||||
<img src="https://i.pravatar.cc/150?u={{ Auth::user()->id }}" class="adm-user-avatar" alt="">
|
||||
<img src="{{ Auth::user()->avatar_url }}" class="adm-user-avatar" alt="">
|
||||
@endif
|
||||
<div class="adm-user-info">
|
||||
<div class="adm-user-name">{{ Str::limit(Auth::user()->name, 18) }}</div>
|
||||
@@ -637,7 +637,7 @@
|
||||
@if(Auth::user()->avatar)
|
||||
<img src="{{ route('media.avatar', Auth::user()->avatar) }}" alt="">
|
||||
@else
|
||||
<img src="https://i.pravatar.cc/150?u={{ Auth::user()->id }}" alt="">
|
||||
<img src="{{ Auth::user()->avatar_url }}" alt="">
|
||||
@endif
|
||||
<div class="adm-sidebar-user-info">
|
||||
<div class="adm-sidebar-user-name">{{ Str::limit(Auth::user()->name, 20) }}</div>
|
||||
|
||||
@@ -128,6 +128,12 @@
|
||||
<i class="bi bi-pencil"></i>
|
||||
<span>Edit</span>
|
||||
</button>
|
||||
@if($video->type === 'music')
|
||||
<button class="action-btn desktop-action" onclick="openMergeModal('{{ $video->getRouteKey() }}')">
|
||||
<i class="bi bi-signpost-split"></i>
|
||||
<span>Merge</span>
|
||||
</button>
|
||||
@endif
|
||||
{{-- Lyrics generate/regenerate + edit now live inside the player's gear menu
|
||||
so they're always reachable on both mobile and desktop. --}}
|
||||
<button class="action-btn desktop-action" onclick="showDeleteModal('{{ $video->getRouteKey() }}', {{ json_encode($video->title) }})"
|
||||
@@ -250,6 +256,11 @@
|
||||
<button type="button" class="dropdown-item" onclick="openEditVideoModal('{{ $video->getRouteKey() }}')">
|
||||
<i class="bi bi-pencil"></i> Edit
|
||||
</button>
|
||||
@if($video->type === 'music')
|
||||
<button type="button" class="dropdown-item" onclick="openMergeModal('{{ $video->getRouteKey() }}')">
|
||||
<i class="bi bi-signpost-split"></i> Merge
|
||||
</button>
|
||||
@endif
|
||||
@endif
|
||||
@else
|
||||
<button class="dropdown-item" onclick="window.location.href='{{ route('login') }}'">
|
||||
|
||||
@@ -24,8 +24,8 @@
|
||||
|
||||
if (!function_exists('ytcAvatar')) {
|
||||
function ytcAvatar($user) {
|
||||
if (!$user) return 'https://ui-avatars.com/api/?name=User&background=333&color=fff';
|
||||
return $user->avatar_url ?? ('https://ui-avatars.com/api/?name='.urlencode($user->name ?? 'User').'&background=333&color=fff');
|
||||
if (!$user) return asset('images/default-avatar.svg');
|
||||
return $user->avatar_url ?? asset('images/default-avatar.svg');
|
||||
}
|
||||
}
|
||||
if (!function_exists('ytcTime')) {
|
||||
@@ -707,8 +707,8 @@ function esc(s) {
|
||||
}
|
||||
function fmt(s) { return s ?? ''; }
|
||||
function avatarUrl(user) {
|
||||
if (!user) return 'https://ui-avatars.com/api/?name=User&background=333&color=fff';
|
||||
return user.avatar_url || ('https://ui-avatars.com/api/?name=' + encodeURIComponent(user.name || 'User') + '&background=333&color=fff');
|
||||
if (!user) return '{{ asset('images/default-avatar.svg') }}';
|
||||
return user.avatar_url || '{{ asset('images/default-avatar.svg') }}';
|
||||
}
|
||||
|
||||
// ── Toast (use global if available, else local) ───────
|
||||
|
||||
@@ -468,7 +468,7 @@ function renderInsights(d) {
|
||||
</div>`).join('');
|
||||
|
||||
const recentViewerRows = (d.recent_viewers || []).map(r => {
|
||||
const avatarSrc = r.user_avatar || `https://i.pravatar.cc/150?u=guest-${r.country||'unknown'}`;
|
||||
const avatarSrc = r.user_avatar || '{{ asset('images/default-avatar.svg') }}';
|
||||
const title = `Tap for full activity — ${r.user_name}`;
|
||||
const safeName = (r.user_name || 'Viewer').replace(/'/g, "\\'");
|
||||
return `<div class="ins-dl-user-row" title="${title}"
|
||||
@@ -554,7 +554,7 @@ function renderInsights(d) {
|
||||
: '';
|
||||
|
||||
const recentRows = (d.dl_recent||[]).map(r => {
|
||||
const avatarSrc = r.user_avatar || `https://i.pravatar.cc/150?u=guest-${r.country||'unknown'}`;
|
||||
const avatarSrc = r.user_avatar || '{{ asset('images/default-avatar.svg') }}';
|
||||
const safeName = (r.user_name || 'Downloader').replace(/'/g, "\\'");
|
||||
const clickAttr = r.user_id
|
||||
? `onclick="openDownloaderHistory(${r.user_id},'${safeName}','${avatarSrc}')" style="cursor:pointer;" title="See full download history"`
|
||||
|
||||
@@ -1230,6 +1230,7 @@
|
||||
@include('layouts.partials.upload-modal')
|
||||
@include('layouts.partials.edit-video-modal')
|
||||
@include('layouts.partials.sports-match-modal')
|
||||
@include('layouts.partials.merge-video-modal')
|
||||
@endauth
|
||||
|
||||
<!-- Add to Playlist Modal - Available for all users (shows login prompt if not authenticated) -->
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
{{-- 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 => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
||||
})[c]);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
@@ -330,7 +330,7 @@ if ($oldLinks) {
|
||||
@if($user->avatar)
|
||||
<img src="{{ route('media.avatar', $user->avatar) }}" alt="{{ $user->name }}" class="profile-avatar" id="pageAvatar">
|
||||
@else
|
||||
<img src="https://i.pravatar.cc/150?u={{ $user->id }}" alt="{{ $user->name }}" class="profile-avatar" id="pageAvatar">
|
||||
<img src="{{ $user->avatar_url }}" alt="{{ $user->name }}" class="profile-avatar" id="pageAvatar">
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@@ -527,7 +527,7 @@ if ($oldLinks) {
|
||||
@if($user->avatar)
|
||||
<img src="{{ route('media.avatar', $user->avatar) }}" class="avatar-preview" id="avatarPreview" alt="Avatar">
|
||||
@else
|
||||
<img src="https://i.pravatar.cc/150?u={{ $user->id }}" class="avatar-preview" id="avatarPreview" alt="Avatar">
|
||||
<img src="{{ $user->avatar_url }}" class="avatar-preview" id="avatarPreview" alt="Avatar">
|
||||
@endif
|
||||
<button type="button" class="avatar-edit-btn" id="avatarEditBtn" title="Change photo">
|
||||
<i class="bi bi-camera-fill"></i>
|
||||
|
||||
@@ -62,6 +62,11 @@
|
||||
<button class="action-btn" onclick="openEditVideoModal('{{ $video->getRouteKey() }}')">
|
||||
<i class="bi bi-pencil"></i> <span>Edit</span>
|
||||
</button>
|
||||
@if($video->type === 'music')
|
||||
<button class="action-btn" onclick="openMergeModal('{{ $video->getRouteKey() }}')">
|
||||
<i class="bi bi-signpost-split"></i> <span>Merge</span>
|
||||
</button>
|
||||
@endif
|
||||
@endif
|
||||
@else
|
||||
<a href="{{ route('login') }}" class="action-btn">
|
||||
|
||||
Reference in new issue
Block a user