WIP: storage-fix-local-nas work before playlist controls feature

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
This commit is contained in:
ghassanandClaude Sonnet 4.6 committed 2026-05-16 11:15:20 +03:00
1 parent 6b3ab5b65e
commit c160242dbc
46 files changed
+5365 -815

No files matched your search

+35 -2
View File
@@ -366,7 +366,7 @@
<td style="color:var(--text-secondary);font-weight:700;">{{ $i + 1 }}</td>
<td>
@if($v->thumbnail)
<img src="{{ asset('storage/thumbnails/'.$v->thumbnail) }}" class="top-thumb" alt="">
<img src="{{ route('media.thumbnail', $v->thumbnail) }}" class="top-thumb" alt="">
@else
<div class="top-thumb-placeholder"><i class="bi bi-play-fill"></i></div>
@endif
@@ -559,7 +559,7 @@
<tr class="clickable-row" onclick="window.open('{{ route('videos.show', $video) }}','_blank')">
<td>
@if($video->thumbnail)
<img src="{{ asset('storage/thumbnails/'.$video->thumbnail) }}" class="top-thumb" alt="">
<img src="{{ route('media.thumbnail', $video->thumbnail) }}" class="top-thumb" alt="">
@else
<div class="top-thumb-placeholder"><i class="bi bi-play-fill"></i></div>
@endif
@@ -619,6 +619,12 @@
<i class="bi bi-trash3-fill me-2"></i> Clean Orphaned Files
</button>
<div id="cleanupStatus" class="mt-2"></div>
@if(\App\Models\Setting::get('nas_sync_enabled', 'false') === 'true')
<button id="nasRepairBtn" class="btn btn-warning w-100 mt-2">
<i class="bi bi-arrow-repeat me-2"></i> Repair NAS Storage
</button>
<div id="nasRepairStatus" class="mt-2"></div>
@endif
</div>
</div>
<div class="col-lg-6">
@@ -1046,5 +1052,32 @@ document.getElementById('cleanupBtn')?.addEventListener('click', async function(
this.disabled = false;
this.innerHTML = '<i class="bi bi-trash3-fill me-2"></i> Clean Orphaned Files';
});
// ── NAS Repair ──────────────────────────────────────────────────
document.getElementById('nasRepairBtn')?.addEventListener('click', async function() {
this.disabled = true;
this.innerHTML = '<i class="bi bi-arrow-repeat me-2"></i> Scanning…';
const status = document.getElementById('nasRepairStatus');
status.innerHTML = '';
try {
const res = await fetch('{{ url("/admin/nas-repair") }}', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content, 'X-Requested-With': 'XMLHttpRequest' }
});
const data = await res.json();
const cls = data.success ? 'alert-success' : 'alert-warning';
const icon = data.success ? '✅' : '⚠️';
let html = `<div class="alert ${cls} mt-2 py-2 px-3" style="font-size:13px;">${icon} ${data.message}`;
if (data.details && data.details.length) {
html += `<ul class="mb-0 mt-1 ps-3" style="font-size:12px;">` + data.details.map(d => `<li>${d}</li>`).join('') + `</ul>`;
}
html += `</div>`;
status.innerHTML = html;
} catch(e) {
status.innerHTML = `<div class="alert alert-danger mt-2 py-2 px-3" style="font-size:13px;">❌ ${e.message}</div>`;
}
this.disabled = false;
this.innerHTML = '<i class="bi bi-arrow-repeat me-2"></i> Repair NAS Storage';
});
</script>
@endsection
+182 -8
View File
@@ -3,6 +3,11 @@
@section('title', 'Edit Video')
@section('page_title', 'Edit Video')
@php
$adminNeedsOtp = auth()->user()->two_factor_enabled &&
(! session('admin_2fa_verified_at') || now()->timestamp - session('admin_2fa_verified_at') >= 1800);
@endphp
@section('extra_styles')
<style>
.ef-grid {
@@ -77,6 +82,9 @@
background: rgba(248,113,113,.05);
}
.ef-danger-title { font-size: 12px; font-weight: 700; color: #f87171; margin-bottom: 10px; text-transform: uppercase; letter-spacing: .05em; }
.adm-btn-warning { color: #fb923c; border-color: rgba(251,146,60,.3); }
.adm-btn-warning:hover { background: rgba(251,146,60,.1); border-color: rgba(251,146,60,.5); color: #fdba74; }
.adm-btn-warning:disabled { opacity:.5; cursor:not-allowed; pointer-events:none; }
</style>
@endsection
@@ -203,7 +211,7 @@
<div class="adm-card">
<div class="adm-card-body">
@if($video->thumbnail)
<img src="{{ asset('storage/thumbnails/' . $video->thumbnail) }}"
<img src="{{ route('media.thumbnail', $video->thumbnail) }}"
alt="{{ $video->title }}" class="ef-thumb">
@else
<div class="ef-thumb-placeholder"><i class="bi bi-play-circle"></i></div>
@@ -259,6 +267,41 @@
</div>
</div>
{{-- Replace File --}}
<div class="ef-danger-zone" style="margin-bottom:12px;">
<div class="ef-danger-title" style="color:#fb923c;border-color:rgba(251,146,60,.2);">
<i class="bi bi-arrow-repeat me-1"></i> Replace Media File
</div>
<p style="font-size:12px;color:var(--text-2);margin:0 0 12px;line-height:1.5;">
Fix a corrupted or missing file. All stats are preserved.
</p>
<div id="adm-rfl-dropzone" onclick="document.getElementById('adm-rfl-input').click()"
style="border:2px dashed var(--border);border-radius:8px;padding:14px;text-align:center;cursor:pointer;transition:border-color .15s,background .15s;margin-bottom:8px;"
onmouseenter="this.style.borderColor='#ef4444';this.style.background='rgba(239,68,68,.05)'"
onmouseleave="this.style.borderColor='var(--border)';this.style.background='transparent'">
<i class="bi bi-cloud-upload" style="font-size:22px;color:var(--text-2);display:block;margin-bottom:4px;"></i>
<span id="adm-rfl-label" style="font-size:12px;color:var(--text-2);">Click to choose replacement file</span>
<input type="file" id="adm-rfl-input" accept="video/*,audio/*,.mp4,.webm,.ogg,.mov,.avi,.wmv,.flv,.mkv,.mp3,.m4a,.aac,.wav,.flac,.opus" style="display:none;" onchange="admRflSelected(this)">
</div>
<div id="adm-rfl-info" style="display:none;background:var(--bg-2);border:1px solid var(--border);border-radius:6px;padding:8px 12px;align-items:center;gap:8px;margin-bottom:8px;">
<i class="bi bi-file-earmark-play" style="font-size:18px;color:#ef4444;flex-shrink:0;"></i>
<div style="flex:1;min-width:0;">
<div id="adm-rfl-name" style="font-size:12px;font-weight:600;color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;"></div>
<div id="adm-rfl-size" style="font-size:11px;color:var(--text-2);margin-top:1px;"></div>
</div>
<button type="button" onclick="admRflClear()" style="background:none;border:none;color:var(--text-2);cursor:pointer;font-size:14px;flex-shrink:0;padding:0;"><i class="bi bi-x-lg"></i></button>
</div>
<div id="adm-rfl-status" style="display:none;margin-bottom:8px;font-size:12px;padding:7px 10px;border-radius:6px;"></div>
<button type="button" id="adm-rfl-btn" onclick="admRflSubmit()" disabled
class="adm-btn adm-btn-warning" style="width:100%;">
<i class="bi bi-arrow-repeat"></i> Replace File
</button>
</div>
{{-- Danger zone --}}
<div class="ef-danger-zone">
<div class="ef-danger-title"><i class="bi bi-exclamation-triangle-fill me-1"></i> Danger Zone</div>
@@ -289,15 +332,25 @@
<i class="bi bi-info-circle-fill" style="flex-shrink:0;margin-top:1px;"></i>
The video file, thumbnail, and all associated data will be removed. This cannot be undone.
</div>
@if($adminNeedsOtp)
<div style="margin-top:14px;">
<label style="font-size:13px;color:var(--text-2);display:flex;align-items:center;gap:6px;margin-bottom:6px;">
<i class="bi bi-shield-lock-fill" style="color:#a78bfa;"></i>
Enter your <strong style="color:var(--text);">2FA code</strong> to confirm
</label>
<input type="text" id="delDialogOtp" inputmode="numeric" pattern="[0-9]*"
maxlength="6" autocomplete="one-time-code"
placeholder="000000"
style="width:100%;background:var(--bg-card2);border:1px solid var(--border-light);border-radius:8px;padding:10px 14px;color:#fff;font-size:22px;letter-spacing:0.3em;text-align:center;">
</div>
@endif
<div id="delDialogError" style="display:none;margin-top:10px;padding:8px 12px;background:rgba(239,68,68,.12);border:1px solid rgba(239,68,68,.3);border-radius:6px;color:#f87171;font-size:13px;"></div>
</div>
<div class="adm-dialog-footer">
<button type="button" class="adm-btn" onclick="closeDelDialog()">Cancel</button>
<form method="POST" action="{{ route('admin.videos.delete', $video) }}">
@csrf @method('DELETE')
<button type="submit" class="adm-btn adm-btn-danger" style="height:36px;">
<i class="bi bi-trash3-fill"></i> Delete Permanently
</button>
</form>
<button type="button" class="adm-btn adm-btn-danger" id="delDialogConfirmBtn" onclick="confirmDelVideo()">
<i class="bi bi-trash3-fill"></i> Delete Permanently
</button>
</div>
</div>
</div>
@@ -306,9 +359,130 @@
@section('scripts')
<script>
function openDelDialog() { document.getElementById('delDialog').classList.add('open'); }
const _adminNeedsOtp = {{ $adminNeedsOtp ? 'true' : 'false' }};
const _editVideoId = '{{ $video->getRouteKey() }}';
function openDelDialog() {
document.getElementById('delDialogError').style.display = 'none';
if (_adminNeedsOtp) document.getElementById('delDialogOtp').value = '';
document.getElementById('delDialog').classList.add('open');
if (_adminNeedsOtp) setTimeout(() => document.getElementById('delDialogOtp').focus(), 100);
}
function closeDelDialog() { document.getElementById('delDialog').classList.remove('open'); }
function confirmDelVideo() {
const btn = document.getElementById('delDialogConfirmBtn');
const errEl = document.getElementById('delDialogError');
const otpCode = _adminNeedsOtp ? document.getElementById('delDialogOtp').value.replace(/\s/g,'') : '';
if (_adminNeedsOtp && otpCode.length !== 6) {
errEl.textContent = 'Please enter your 6-digit 2FA code.';
errEl.style.display = 'block';
document.getElementById('delDialogOtp').focus();
return;
}
btn.disabled = true;
btn.innerHTML = '<i class="bi bi-hourglass-split"></i> Deleting…';
errEl.style.display = 'none';
const body = new URLSearchParams({ '_token': '{{ csrf_token() }}', '_method': 'DELETE' });
if (_adminNeedsOtp) body.append('otp_code', otpCode);
fetch('/admin/videos/' + _editVideoId, {
method: 'POST',
headers: { 'Accept': 'application/json', 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString()
})
.then(r => r.json())
.then(data => {
if (data.success) {
window.location.href = '/admin/videos';
} else {
errEl.textContent = data.message || 'Delete failed.';
errEl.style.display = 'block';
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-trash3-fill"></i> Delete Permanently';
if (_adminNeedsOtp) { document.getElementById('delDialogOtp').value = ''; document.getElementById('delDialogOtp').focus(); }
}
})
.catch(() => {
errEl.textContent = 'An error occurred. Please try again.';
errEl.style.display = 'block';
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-trash3-fill"></i> Delete Permanently';
});
}
document.getElementById('delDialog').addEventListener('click', e => { if (e.target === document.getElementById('delDialog')) closeDelDialog(); });
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeDelDialog(); });
// ── Admin Replace File ────────────────────────────────────────────────────
let _admRflFile = null;
const _admRflFmtSize = b => b >= 1073741824 ? (b/1073741824).toFixed(1)+' GB' : b >= 1048576 ? (b/1048576).toFixed(1)+' MB' : Math.round(b/1024)+' KB';
function admRflSelected(input) {
_admRflFile = input.files[0] || null;
const info = document.getElementById('adm-rfl-info');
if (_admRflFile) {
document.getElementById('adm-rfl-name').textContent = _admRflFile.name;
document.getElementById('adm-rfl-size').textContent = _admRflFmtSize(_admRflFile.size);
document.getElementById('adm-rfl-label').textContent = _admRflFile.name;
info.style.display = 'flex';
} else {
info.style.display = 'none';
document.getElementById('adm-rfl-label').textContent = 'Click to choose replacement file';
}
document.getElementById('adm-rfl-btn').disabled = !_admRflFile;
document.getElementById('adm-rfl-status').style.display = 'none';
}
function admRflClear() {
_admRflFile = null;
document.getElementById('adm-rfl-input').value = '';
document.getElementById('adm-rfl-info').style.display = 'none';
document.getElementById('adm-rfl-label').textContent = 'Click to choose replacement file';
document.getElementById('adm-rfl-btn').disabled = true;
document.getElementById('adm-rfl-status').style.display = 'none';
}
function admRflSubmit() {
if (!_admRflFile) return;
const btn = document.getElementById('adm-rfl-btn');
const status = document.getElementById('adm-rfl-status');
btn.disabled = true;
btn.innerHTML = '<i class="bi bi-arrow-repeat"></i> Uploading…';
status.style.display = 'none';
const fd = new FormData();
fd.append('_token', '{{ csrf_token() }}');
fd.append('replacement_file', _admRflFile);
fetch('/videos/' + _editVideoId + '/replace-file', {
method: 'POST',
headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
body: fd
})
.then(r => r.json())
.then(data => {
if (data.success) {
status.style.cssText = 'display:block;background:rgba(74,222,128,.08);border:1px solid rgba(74,222,128,.25);color:#4ade80;';
status.innerHTML = '<i class="bi bi-check-circle-fill"></i> ' + data.message;
admRflClear();
setTimeout(() => location.reload(), 2200);
} else {
status.style.cssText = 'display:block;background:rgba(239,68,68,.08);border:1px solid rgba(239,68,68,.25);color:#f87171;';
status.textContent = data.message || 'Upload failed. Please try again.';
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-arrow-repeat"></i> Replace File';
}
})
.catch(() => {
status.style.cssText = 'display:block;background:rgba(239,68,68,.08);border:1px solid rgba(239,68,68,.25);color:#f87171;';
status.textContent = 'Upload failed. Please try again.';
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-arrow-repeat"></i> Replace File';
});
}
</script>
@endsection
+2 -2
View File
@@ -535,7 +535,7 @@
@auth
<button class="adm-user-btn" id="userMenuBtn" type="button">
@if(Auth::user()->avatar)
<img src="{{ asset('storage/avatars/' . Auth::user()->avatar) }}" class="adm-user-avatar" alt="">
<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="">
@endif
@@ -622,7 +622,7 @@
<div class="adm-sidebar-footer">
<a href="{{ route('profile') }}" class="adm-sidebar-user">
@if(Auth::user()->avatar)
<img src="{{ asset('storage/avatars/' . Auth::user()->avatar) }}" alt="">
<img src="{{ route('media.avatar', Auth::user()->avatar) }}" alt="">
@else
<img src="https://i.pravatar.cc/150?u={{ Auth::user()->id }}" alt="">
@endif
+334 -26
View File
@@ -336,26 +336,81 @@
<small>
When enabled, uploads go <strong>directly to the NAS</strong> — no permanent local copy is kept.
Files are stored at <code>users/{username}/videos/{title-slug}/</code> on the NAS share.
Thumbnails and video are fetched from NAS on demand for streaming and playback.
When disabled, files are stored in local storage using the same directory schema.
Requires the NAS connection to be configured under
<a href="{{ route('admin.nas-storage') }}" style="color:var(--brand)">NAS Storage</a>.
When disabled, all files are served from local disk using the same directory schema.
<strong>Disabling NAS will prompt you to migrate files or start fresh.</strong>
</small>
</div>
<div class="setting-control">
<label class="toggle-wrap">
<div class="toggle-switch">
<input type="checkbox" id="nasSyncInput" name="nas_sync_enabled_check"
{{ $settings['nas_sync_enabled'] === 'true' ? 'checked' : '' }}>
<div class="toggle-track"></div>
<div class="toggle-thumb"></div>
</div>
<span class="toggle-label" id="nasSyncLabel">
{{ $settings['nas_sync_enabled'] === 'true' ? 'Enabled' : 'Disabled' }}
</span>
</label>
<input type="hidden" name="nas_sync_enabled" id="nasSyncHidden"
value="{{ $settings['nas_sync_enabled'] }}">
@if($settings['nas_sync_enabled'] === 'true')
<button type="button" class="adm-btn adm-btn-danger" onclick="openNasDisableModal()">
<i class="bi bi-hdd-network"></i> Disable NAS
</button>
@else
<span style="font-size:13px;color:var(--text-2);">NAS is disabled. Enable it under <a href="{{ route('admin.nas-storage') }}" style="color:var(--brand)">NAS Storage</a>.</span>
@endif
</div>
</div>
@if($settings['nas_sync_enabled'] === 'true')
<div class="setting-row" style="border-top:1px solid var(--border-color);padding-top:18px;">
<div class="setting-label">
<strong>Repair stuck files</strong>
<small>
Scans for files that were saved locally but never reached the NAS (e.g. due to a
connection error during upload or edit). Uploads them to the NAS, then removes the
local copies. Safe to run at any time — nothing is deleted until the NAS confirms receipt.
</small>
</div>
<div class="setting-control" style="gap:10px;align-items:flex-start;">
<button type="button" id="nasRepairScanBtn" class="adm-btn" style="white-space:nowrap;">
<i class="bi bi-search" id="nasRepairScanIcon"></i> Scan
</button>
<button type="button" id="nasRepairFixBtn" class="adm-btn adm-btn-primary" style="white-space:nowrap;display:none;">
<i class="bi bi-arrow-repeat" id="nasRepairFixIcon"></i> Fix All
</button>
</div>
</div>
<div id="nasRepairResult" style="padding:0 22px 18px;display:none;">
<div id="nasRepairResultInner"></div>
</div>
@endif
</div>
</div>
{{-- ── Backup & Restore ──────────────────────────────────────────────────────── --}}
<div class="settings-section">
<div class="settings-section-header">
<i class="bi bi-archive"></i>
Backup & Restore
</div>
<div class="settings-section-body">
<div class="setting-row" style="padding-top:0;">
<div class="setting-label">
<strong>Export users &amp; settings</strong>
<small>Downloads a JSON file containing all user accounts and system settings. Does not include media files.</small>
</div>
<div class="setting-control">
<a href="{{ route('admin.backup.users-settings') }}" class="adm-btn">
<i class="bi bi-download"></i> Download Backup
</a>
</div>
</div>
<div class="setting-row">
<div class="setting-label">
<strong>Restore users &amp; settings</strong>
<small>Upload a previously exported backup JSON. Existing users are matched by email and updated; new users are created. Settings are merged.</small>
</div>
<div class="setting-control">
<form method="POST" action="{{ route('admin.backup.restore') }}" enctype="multipart/form-data" id="restoreForm">
@csrf
<input type="file" name="backup" id="restoreFile" accept=".json" style="display:none" onchange="document.getElementById('restoreForm').submit()">
<button type="button" class="adm-btn" onclick="document.getElementById('restoreFile').click()">
<i class="bi bi-upload"></i> Upload &amp; Restore
</button>
</form>
</div>
</div>
@@ -392,15 +447,6 @@ function selectEncoder(el) {
document.getElementById('gpuPresetSelect').value = isCpu ? 'fast' : 'p4';
}
// ── NAS sync toggle ───────────────────────────────────────────
const nasToggle = document.getElementById('nasSyncInput');
const nasHidden = document.getElementById('nasSyncHidden');
const nasLabel = document.getElementById('nasSyncLabel');
nasToggle.addEventListener('change', () => {
nasHidden.value = nasToggle.checked ? 'true' : 'false';
nasLabel.textContent = nasToggle.checked ? 'Enabled' : 'Disabled';
});
// ── GPU toggle ────────────────────────────────────────────────
const gpuToggle = document.getElementById('gpuEnabledInput');
const gpuHidden = document.getElementById('gpuEnabledHidden');
@@ -476,9 +522,271 @@ function buildGpuCards(gpus, selectedDevice) {
function escHtml(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// ── NAS Repair ────────────────────────────────────────────────
(function () {
const scanBtn = document.getElementById('nasRepairScanBtn');
const fixBtn = document.getElementById('nasRepairFixBtn');
const resultEl = document.getElementById('nasRepairResult');
const inner = document.getElementById('nasRepairResultInner');
const scanIcon = document.getElementById('nasRepairScanIcon');
const fixIcon = document.getElementById('nasRepairFixIcon');
if (! scanBtn) return;
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content;
let stuckCount = 0;
function showResult(html, type = 'info') {
const colours = { success: '#22c55e', warning: '#f59e0b', danger: '#ef4444', info: 'var(--text-secondary)' };
inner.innerHTML = `<div style="font-size:13px;color:${colours[type] ?? colours.info};padding:12px 0;">${html}</div>`;
resultEl.style.display = 'block';
}
scanBtn.addEventListener('click', async function () {
scanBtn.disabled = true;
fixBtn.style.display = 'none';
scanIcon.className = 'bi bi-arrow-repeat spin';
showResult('Scanning…', 'info');
try {
const res = await fetch('{{ url("/admin/nas-repair") }}?scan=1', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrfToken, 'X-Requested-With': 'XMLHttpRequest' },
body: JSON.stringify({ scan_only: true }),
});
const data = await res.json();
stuckCount = data.stuck ?? 0;
if (stuckCount === 0) {
showResult('✅ All clear — no stuck local files found.', 'success');
} else {
let html = `<strong style="color:var(--brand);">⚠ ${stuckCount} video(s) have files stuck locally.</strong>`;
if (data.details && data.details.length) {
html += `<ul style="margin:8px 0 0;padding-left:18px;">` +
data.details.map(d => `<li>${escHtml(d)}</li>`).join('') + `</ul>`;
}
html += `<p style="margin-top:10px;color:var(--text-secondary);font-size:12px;">Click <strong>Fix All</strong> to upload them to the NAS and remove local copies.</p>`;
showResult(html, 'warning');
fixBtn.style.display = '';
}
} catch (e) {
showResult('❌ Scan failed: ' + escHtml(e.message), 'danger');
}
scanIcon.className = 'bi bi-search';
scanBtn.disabled = false;
});
fixBtn.addEventListener('click', async function () {
if (! confirm(`Upload ${stuckCount} stuck file(s) to NAS and remove local copies?`)) return;
fixBtn.disabled = true;
scanBtn.disabled = true;
fixIcon.className = 'bi bi-arrow-repeat spin';
showResult('Uploading to NAS…', 'info');
try {
const res = await fetch('{{ url("/admin/nas-repair") }}', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrfToken, 'X-Requested-With': 'XMLHttpRequest' },
});
const data = await res.json();
const type = data.failed > 0 ? 'warning' : 'success';
let html = `${data.success ? '✅' : '⚠️'} ${escHtml(data.message)}`;
if (data.details && data.details.length) {
html += `<ul style="margin:8px 0 0;padding-left:18px;">` +
data.details.map(d => `<li>${escHtml(d)}</li>`).join('') + `</ul>`;
}
showResult(html, type);
fixBtn.style.display = 'none';
} catch (e) {
showResult('❌ Repair failed: ' + escHtml(e.message), 'danger');
}
fixIcon.className = 'bi bi-arrow-repeat';
fixBtn.disabled = false;
scanBtn.disabled = false;
});
})();
// ── NAS Disable Modal ────────────────────────────────────────
let _nasOpt = null;
let _nasPollTimer = null;
function openNasDisableModal() {
_nasOpt = null;
document.getElementById('nasDisableModal').style.display = 'flex';
document.getElementById('nasDisableStep1').style.display = 'block';
document.getElementById('nasDisableStep2Migrate').style.display = 'none';
document.getElementById('nasDisableStep2Fresh').style.display = 'none';
document.getElementById('nasDisableNextBtn').disabled = true;
['optMigrate','optFresh'].forEach(id => {
document.getElementById(id).style.borderColor = 'var(--border)';
});
}
function closeNasDisableModal() {
if (_nasPollTimer) clearInterval(_nasPollTimer);
document.getElementById('nasDisableModal').style.display = 'none';
}
function selectNasOpt(opt) {
_nasOpt = opt;
document.getElementById('optMigrate').style.borderColor = opt === 'migrate' ? 'var(--brand)' : 'var(--border)';
document.getElementById('optFresh').style.borderColor = opt === 'fresh' ? '#e74c3c' : 'var(--border)';
document.getElementById('nasDisableNextBtn').disabled = false;
}
function nasDisableNext() {
document.getElementById('nasDisableStep1').style.display = 'none';
if (_nasOpt === 'migrate') {
document.getElementById('nasDisableStep2Migrate').style.display = 'block';
nasStartMigration();
} else {
document.getElementById('nasDisableStep2Fresh').style.display = 'block';
}
}
async function nasStartMigration() {
try {
await fetch('{{ route("admin.nas.disable") }}', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': '{{ csrf_token() }}' },
body: JSON.stringify({ mode: 'migrate' }),
});
} catch(e) {
document.getElementById('nasDisableError').textContent = 'Failed to start migration: ' + e.message;
document.getElementById('nasDisableError').style.display = 'block';
return;
}
_nasPollTimer = setInterval(nasPollProgress, 2000);
}
async function nasPollProgress() {
try {
const r = await fetch('{{ route("admin.nas.migrate-progress") }}');
const d = await r.json();
const pct = d.total > 0 ? Math.round((d.current / d.total) * 100) : 0;
document.getElementById('nasDisableBar').style.width = pct + '%';
document.getElementById('nasDisableCount').textContent = d.current + ' / ' + d.total;
document.getElementById('nasDisablePhase').textContent = d.phase || '';
if (d.error) {
clearInterval(_nasPollTimer);
document.getElementById('nasDisableError').textContent = 'Error: ' + d.error;
document.getElementById('nasDisableError').style.display = 'block';
}
if (d.done) {
clearInterval(_nasPollTimer);
document.getElementById('nasDisableBar').style.width = '100%';
document.getElementById('nasDisableCount').textContent = d.total + ' / ' + d.total;
document.getElementById('nasDisableDone').style.display = 'block';
}
} catch(e) { /* network blip, keep polling */ }
}
async function nasDisableFreshConfirm() {
const val = document.getElementById('nasDeleteConfirmInput').value.trim();
const errEl = document.getElementById('nasDeleteConfirmError');
if (val !== 'DELETE') {
errEl.textContent = 'Type DELETE (all caps) to confirm.';
errEl.style.display = 'block';
return;
}
errEl.style.display = 'none';
try {
const r = await fetch('{{ route("admin.nas.disable") }}', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': '{{ csrf_token() }}' },
body: JSON.stringify({ mode: 'fresh' }),
});
const d = await r.json();
if (d.ok) {
closeNasDisableModal();
location.reload();
} else {
errEl.textContent = d.message || 'An error occurred.';
errEl.style.display = 'block';
}
} catch(e) {
errEl.textContent = 'Failed: ' + e.message;
errEl.style.display = 'block';
}
}
</script>
<style>
@keyframes spin { to { transform: rotate(360deg); } }
.spin { display: inline-block; animation: spin .6s linear infinite; }
.adm-input {
background: var(--bg-input, #1e1e1e);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text);
font-size: 13px;
padding: 8px 12px;
outline: none;
}
.adm-input:focus { border-color: var(--brand); }
</style>
{{-- ── NAS Disable Modal ─────────────────────────────────────── --}}
<div id="nasDisableModal" style="display:none;position:fixed;inset:0;z-index:9999;background:rgba(0,0,0,.7);align-items:center;justify-content:center;">
<div style="background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius);width:min(520px,94vw);padding:28px;max-height:90vh;overflow-y:auto;">
{{-- Step 1: Choose action --}}
<div id="nasDisableStep1">
<h3 style="margin:0 0 8px;font-size:17px;">Disable NAS Storage</h3>
<p style="margin:0 0 20px;font-size:13px;color:var(--text-2);">All your files currently live on the NAS. Choose what to do before disabling:</p>
<div style="display:grid;gap:12px;margin-bottom:24px;">
<div id="optMigrate" onclick="selectNasOpt('migrate')" style="border:2px solid var(--border);border-radius:8px;padding:16px;cursor:pointer;transition:border-color .15s;">
<div style="display:flex;align-items:center;gap:10px;margin-bottom:6px;">
<i class="bi bi-arrow-down-circle" style="color:var(--brand);font-size:18px;"></i>
<strong style="font-size:14px;">Copy all files to local disk</strong>
</div>
<p style="margin:0;font-size:12px;color:var(--text-2);">Downloads every video, thumbnail, avatar, and banner from the NAS to <code>storage/app/users/…</code>. Same directory structure — everything keeps working. May take a while.</p>
</div>
<div id="optFresh" onclick="selectNasOpt('fresh')" style="border:2px solid var(--border);border-radius:8px;padding:16px;cursor:pointer;transition:border-color .15s;">
<div style="display:flex;align-items:center;gap:10px;margin-bottom:6px;">
<i class="bi bi-trash3" style="color:#e74c3c;font-size:18px;"></i>
<strong style="font-size:14px;color:#e74c3c;">Delete all media, start fresh</strong>
</div>
<p style="margin:0;font-size:12px;color:var(--text-2);">Removes all videos, thumbnails, playlists, comments, and posts. <strong>User accounts are kept.</strong> Nothing is downloaded.</p>
</div>
</div>
<div style="display:flex;gap:10px;justify-content:flex-end;">
<button type="button" class="adm-btn" onclick="closeNasDisableModal()">Cancel</button>
<button type="button" id="nasDisableNextBtn" class="adm-btn adm-btn-danger" disabled onclick="nasDisableNext()">Continue &rarr;</button>
</div>
</div>
{{-- Step 2a: Migration progress --}}
<div id="nasDisableStep2Migrate" style="display:none;">
<h3 style="margin:0 0 8px;font-size:17px;">Migrating files to local disk&hellip;</h3>
<p id="nasDisablePhase" style="margin:0 0 16px;font-size:13px;color:var(--text-2);">Starting&hellip;</p>
<div style="background:var(--border);border-radius:4px;height:8px;margin-bottom:8px;overflow:hidden;">
<div id="nasDisableBar" style="height:100%;background:var(--brand);border-radius:4px;width:0%;transition:width .3s;"></div>
</div>
<div id="nasDisableCount" style="font-size:12px;color:var(--text-2);margin-bottom:20px;">0 / 0</div>
<div id="nasDisableDone" style="display:none;">
<div style="color:#27ae60;font-size:13px;margin-bottom:16px;"><i class="bi bi-check-circle-fill"></i> Migration complete! NAS has been disabled. Reload the page to continue.</div>
<button type="button" class="adm-btn adm-btn-primary" onclick="location.reload()">Reload Page</button>
</div>
<div id="nasDisableError" style="display:none;color:#e74c3c;font-size:13px;margin-bottom:16px;"></div>
</div>
{{-- Step 2b: Fresh start confirmation --}}
<div id="nasDisableStep2Fresh" style="display:none;">
<h3 style="margin:0 0 8px;font-size:17px;color:#e74c3c;">Delete all media?</h3>
<p style="margin:0 0 16px;font-size:13px;color:var(--text-2);">This will permanently delete all videos, playlists, comments, and posts. User accounts will remain. <strong>This cannot be undone.</strong></p>
<p style="margin:0 0 8px;font-size:13px;">Type <strong>DELETE</strong> to confirm:</p>
<input type="text" id="nasDeleteConfirmInput" placeholder="DELETE" class="adm-input" style="width:100%;margin-bottom:8px;box-sizing:border-box;">
<div id="nasDeleteConfirmError" style="display:none;color:#e74c3c;font-size:12px;margin-bottom:12px;"></div>
<div style="display:flex;gap:10px;justify-content:flex-end;">
<button type="button" class="adm-btn" onclick="closeNasDisableModal()">Cancel</button>
<button type="button" class="adm-btn adm-btn-danger" onclick="nasDisableFreshConfirm()">Delete &amp; Disable NAS</button>
</div>
</div>
</div>
</div>
@endsection
+504 -126
View File
@@ -2,14 +2,229 @@
@section('title', 'Users')
@php
$adminNeedsOtp = auth()->user()->two_factor_enabled &&
(! session('admin_2fa_verified_at') || now()->timestamp - session('admin_2fa_verified_at') >= 1800);
$totalUsers = \App\Models\User::count();
$totalAdmins = \App\Models\User::whereIn('role', ['admin','super_admin'])->count();
$newThisWeek = \App\Models\User::where('created_at', '>=', now()->subDays(7))->count();
$unverified = \App\Models\User::whereNull('email_verified_at')->count();
@endphp
@section('extra_styles')
<style>
/* ═══ STAT CARDS ═══════════════════════════════════════════════════ */
.u-stats {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
margin-bottom: 24px;
}
.u-stat {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 14px;
padding: 20px 22px;
position: relative;
overflow: hidden;
transition: border-color .2s, transform .2s;
}
.u-stat:hover { border-color: #3a3a3a; transform: translateY(-1px); }
.u-stat-top { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 14px; }
.u-stat-icon {
width: 40px; height: 40px; border-radius: 10px;
display: flex; align-items: center; justify-content: center;
font-size: 17px; flex-shrink: 0;
}
.u-stat-icon.c-blue { background: rgba(96,165,250,.14); color: #60a5fa; }
.u-stat-icon.c-red { background: rgba(248,113,113,.14); color: #f87171; }
.u-stat-icon.c-green { background: rgba(52,211,153,.14); color: #34d399; }
.u-stat-icon.c-amber { background: rgba(251,191,36,.14); color: #fbbf24; }
.u-stat-accent {
position: absolute; top: 0; left: 0; right: 0; height: 2px;
border-radius: 14px 14px 0 0;
}
.u-stat-val { font-size: 32px; font-weight: 700; line-height: 1; color: var(--text-primary); letter-spacing: -1px; }
.u-stat-lbl { font-size: 13px; color: var(--text-secondary); margin-top: 4px; font-weight: 500; }
/* ═══ FILTER BAR ══════════════════════════════════════════════════ */
.u-filter {
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
padding: 14px 18px;
}
.u-search {
position: relative; flex: 1; min-width: 200px;
}
.u-search i {
position: absolute; left: 12px; top: 50%; transform: translateY(-50%);
font-size: 13px; color: var(--text-secondary); pointer-events: none;
}
.u-search input {
width: 100%; height: 38px;
background: var(--bg-card2, #1a1a1a);
border: 1px solid var(--border-color); border-radius: 9px;
padding: 0 14px 0 36px; color: var(--text-primary); font-size: 13px;
transition: border-color .15s;
}
.u-search input:focus { outline: none; border-color: var(--brand-red); }
.u-search input::placeholder { color: #444; }
/* ═══ TABLE ════════════════════════════════════════════════════════ */
.u-table-wrap { overflow-x: auto; }
.u-table {
width: 100%; border-collapse: collapse; min-width: 640px;
}
.u-table thead tr {
border-bottom: 1px solid var(--border-color);
}
.u-table thead th {
padding: 11px 18px;
font-size: 11px; font-weight: 600; letter-spacing: .7px;
text-transform: uppercase; color: var(--text-secondary);
white-space: nowrap; text-align: left;
}
.u-table thead th.right { text-align: right; }
.u-table tbody tr {
border-bottom: 1px solid rgba(255,255,255,.04);
cursor: pointer;
transition: background .1s;
}
.u-table tbody tr:last-child { border-bottom: none; }
.u-table tbody tr:hover { background: rgba(255,255,255,.03); }
.u-table tbody td {
padding: 0 18px; height: 68px;
vertical-align: middle; font-size: 13px;
}
/* Identity cell */
.u-identity { display: flex; align-items: center; gap: 13px; }
.u-avatar-wrap { position: relative; flex-shrink: 0; }
.u-avatar {
width: 40px; height: 40px; border-radius: 50%;
object-fit: cover; display: block;
border: 2px solid var(--border-color);
transition: border-color .15s;
}
.u-table tbody tr:hover .u-avatar { border-color: var(--brand-red); }
.u-name {
font-size: 14px; font-weight: 600; color: var(--text-primary);
white-space: nowrap; display: flex; align-items: center; gap: 7px;
line-height: 1;
}
.u-email { font-size: 12px; color: #555; margin-top: 3px; }
.u-you {
font-size: 9px; font-weight: 700; letter-spacing: .5px;
padding: 2px 6px; border-radius: 4px;
background: rgba(230,30,30,.15); color: var(--brand-red);
text-transform: uppercase; flex-shrink: 0;
}
/* Role cell */
.u-role { display: flex; align-items: center; gap: 7px; font-size: 13px; font-weight: 500; white-space: nowrap; }
.u-role-dot {
width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0;
}
.u-role-dot.super { background: #f87171; box-shadow: 0 0 6px rgba(248,113,113,.5); }
.u-role-dot.admin { background: #fbbf24; box-shadow: 0 0 6px rgba(251,191,36,.4); }
.u-role-dot.user { background: #555; }
/* Status cell */
.u-status { display: inline-flex; align-items: center; gap: 5px; font-size: 12px; font-weight: 500; white-space: nowrap; }
.u-status-dot { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; }
.u-status.verified { color: #34d399; }
.u-status.verified .u-status-dot { background: #34d399; box-shadow: 0 0 5px rgba(52,211,153,.5); }
.u-status.unverified { color: #fbbf24; }
.u-status.unverified .u-status-dot { background: #fbbf24; }
/* Videos cell */
.u-vcount { font-size: 15px; font-weight: 700; color: var(--text-primary); }
.u-vcount-lbl { font-size: 11px; color: #444; }
/* Joined cell */
.u-joined-date { font-size: 13px; font-weight: 500; color: var(--text-primary); }
.u-joined-ago { font-size: 11px; color: #444; margin-top: 2px; }
/* Row click hint */
.u-table tbody tr:hover td:last-child::after {
content: '⋯';
float: right;
color: #444;
font-size: 18px;
line-height: 1;
}
/* Row dropdown */
#uRowMenu {
display: none;
position: fixed;
z-index: 9999;
min-width: 190px;
background: #1c1c1c;
border: 1px solid #2e2e2e;
border-radius: 10px;
padding: 5px 0;
box-shadow: 0 12px 40px rgba(0,0,0,.7);
animation: uMenuIn .1s ease;
}
@keyframes uMenuIn {
from { opacity:0; transform:translateY(-4px); }
to { opacity:1; transform:translateY(0); }
}
#uRowMenu .u-menu-item {
display: flex; align-items: center; gap: 10px;
width: 100%; padding: 9px 14px;
background: none; border: none;
color: var(--text-primary); font-size: 13px;
cursor: pointer; text-align: left; text-decoration: none;
transition: background .1s;
white-space: nowrap;
}
#uRowMenu .u-menu-item:hover { background: rgba(255,255,255,.06); }
#uRowMenu .u-menu-item.danger { color: #f87171; }
#uRowMenu .u-menu-item.danger:hover { background: rgba(248,113,113,.1); }
#uRowMenu .u-menu-sep {
height: 1px; background: #2a2a2a; margin: 4px 0;
}
/* Empty state */
.u-empty { padding: 80px 20px; text-align: center; }
.u-empty-circle {
width: 72px; height: 72px; border-radius: 50%;
background: rgba(255,255,255,.03); border: 1px solid var(--border-color);
margin: 0 auto 18px;
display: flex; align-items: center; justify-content: center;
font-size: 30px; color: #333;
}
.u-empty h3 { font-size: 16px; font-weight: 600; color: var(--text-secondary); margin: 0 0 6px; }
.u-empty p { font-size: 13px; color: #444; margin: 0; }
/* ═══ RESPONSIVE ════════════════════════════════════════════════════ */
@media (max-width: 960px) {
.u-stats { grid-template-columns: repeat(2, 1fr); }
.u-hide-md { display: none !important; }
}
@media (max-width: 600px) {
.u-stats { grid-template-columns: repeat(2, 1fr); }
.u-hide-sm { display: none !important; }
}
</style>
@endsection
@section('content')
{{-- ── Page header ──────────────────────────────────────────────────── --}}
<div class="adm-page-header">
<h1 class="adm-page-title"><i class="bi bi-people"></i> Users</h1>
<a href="{{ route('admin.dashboard') }}" class="adm-btn" style="font-size:12px;">
<i class="bi bi-arrow-left"></i> Dashboard
</a>
</div>
{{-- ── Alerts ───────────────────────────────────────────────────────── --}}
{{-- Alerts --}}
@if(session('success'))
<div class="adm-alert adm-alert-success">
<i class="bi bi-check-circle-fill"></i>
@@ -25,181 +240,220 @@
</div>
@endif
{{-- ── Filter card ──────────────────────────────────────────────────── --}}
<div class="adm-card">
<div class="adm-card-body" style="padding:16px 20px;">
<form method="GET" action="{{ route('admin.users') }}" class="adm-filter-form">
<div class="adm-filter-search">
<i class="bi bi-search"></i>
<input type="text" name="search" class="adm-input"
placeholder="Search name or email…"
value="{{ request('search') }}" autocomplete="off">
{{-- Stats --}}
<div class="u-stats">
<div class="u-stat">
<div class="u-stat-accent" style="background: linear-gradient(90deg,#60a5fa,#3b82f6);"></div>
<div class="u-stat-top">
<div>
<div class="u-stat-val">{{ number_format($totalUsers) }}</div>
<div class="u-stat-lbl">Total users</div>
</div>
<select name="role" class="adm-select">
<option value="">All Roles</option>
<option value="user" {{ request('role') === 'user' ? 'selected' : '' }}>User</option>
<option value="admin" {{ request('role') === 'admin' ? 'selected' : '' }}>Admin</option>
<option value="super_admin" {{ request('role') === 'super_admin' ? 'selected' : '' }}>Super Admin</option>
</select>
<select name="sort" class="adm-select">
<option value="latest" {{ request('sort', 'latest') === 'latest' ? 'selected' : '' }}>Newest first</option>
<option value="oldest" {{ request('sort') === 'oldest' ? 'selected' : '' }}>Oldest first</option>
<option value="name_asc" {{ request('sort') === 'name_asc' ? 'selected' : '' }}>Name A–Z</option>
<option value="name_desc" {{ request('sort') === 'name_desc' ? 'selected' : '' }}>Name Z–A</option>
</select>
<button type="submit" class="adm-btn adm-btn-primary">
<i class="bi bi-funnel"></i> Filter
</button>
@if(request()->hasAny(['search','role','sort']))
<a href="{{ route('admin.users') }}" class="adm-btn">
<i class="bi bi-x-lg"></i> Clear
</a>
@endif
</form>
<div class="u-stat-icon c-blue"><i class="bi bi-people-fill"></i></div>
</div>
</div>
<div class="u-stat">
<div class="u-stat-accent" style="background: linear-gradient(90deg,#f87171,#e61e1e);"></div>
<div class="u-stat-top">
<div>
<div class="u-stat-val">{{ $totalAdmins }}</div>
<div class="u-stat-lbl">Admins</div>
</div>
<div class="u-stat-icon c-red"><i class="bi bi-shield-fill"></i></div>
</div>
</div>
<div class="u-stat">
<div class="u-stat-accent" style="background: linear-gradient(90deg,#34d399,#059669);"></div>
<div class="u-stat-top">
<div>
<div class="u-stat-val">{{ $newThisWeek }}</div>
<div class="u-stat-lbl">New this week</div>
</div>
<div class="u-stat-icon c-green"><i class="bi bi-person-plus-fill"></i></div>
</div>
</div>
<div class="u-stat">
<div class="u-stat-accent" style="background: linear-gradient(90deg,#fbbf24,#d97706);"></div>
<div class="u-stat-top">
<div>
<div class="u-stat-val">{{ $unverified }}</div>
<div class="u-stat-lbl">Unverified</div>
</div>
<div class="u-stat-icon c-amber"><i class="bi bi-envelope-exclamation-fill"></i></div>
</div>
</div>
</div>
{{-- ── Users table ──────────────────────────────────────────────────── --}}
{{-- Filter --}}
<div class="adm-card" style="margin-bottom:16px;">
<form method="GET" action="{{ route('admin.users') }}" class="u-filter">
<div class="u-search">
<i class="bi bi-search"></i>
<input type="text" name="search" placeholder="Search by name or email…"
value="{{ request('search') }}" autocomplete="off">
</div>
<select name="role" class="adm-select">
<option value="">All Roles</option>
<option value="user" {{ request('role') === 'user' ? 'selected' : '' }}>Users</option>
<option value="admin" {{ request('role') === 'admin' ? 'selected' : '' }}>Admins</option>
<option value="super_admin" {{ request('role') === 'super_admin' ? 'selected' : '' }}>Super Admins</option>
</select>
<select name="sort" class="adm-select">
<option value="latest" {{ request('sort','latest') === 'latest' ? 'selected' : '' }}>Newest first</option>
<option value="oldest" {{ request('sort') === 'oldest' ? 'selected' : '' }}>Oldest first</option>
<option value="name_asc" {{ request('sort') === 'name_asc' ? 'selected' : '' }}>Name A–Z</option>
<option value="name_desc" {{ request('sort') === 'name_desc' ? 'selected' : '' }}>Name Z–A</option>
</select>
<button type="submit" class="adm-btn adm-btn-primary"><i class="bi bi-funnel-fill"></i> Filter</button>
@if(request()->hasAny(['search','role','sort']))
<a href="{{ route('admin.users') }}" class="adm-btn"><i class="bi bi-x-lg"></i> Clear</a>
@endif
</form>
</div>
{{-- Table --}}
<div class="adm-card">
<div class="adm-card-header">
<div class="adm-card-title">
<i class="bi bi-people"></i>
All Users
<i class="bi bi-people"></i> Members
<span class="adm-badge adm-badge-user">{{ $users->total() ?? $users->count() }}</span>
</div>
<span style="font-size:12px; color:#444; display:flex; align-items:center; gap:5px;">
<i class="bi bi-hand-index" style="font-size:11px;"></i>
Click a row for actions
</span>
</div>
<div class="adm-table-wrap">
<table class="adm-table">
<div class="u-table-wrap">
<table class="u-table">
<thead>
<tr>
<th>User</th>
<th>Role</th>
<th>Verified</th>
<th>Videos</th>
<th>Joined</th>
<th style="width:80px; text-align:right;">Actions</th>
<th class="u-hide-sm">Role</th>
<th class="u-hide-sm">Status</th>
<th class="u-hide-md">Videos</th>
<th class="u-hide-md">Likes</th>
<th class="u-hide-md">Shares</th>
<th class="u-hide-md">Joined</th>
</tr>
</thead>
<tbody>
@forelse($users as $user)
<tr>
{{-- User cell --}}
@forelse($users as $user)
<tr class="u-clickable-row"
data-channel="{{ route('channel', $user->channel) }}"
data-user-id="{{ $user->id }}"
data-user-name="{{ addslashes($user->name) }}"
data-verified="{{ $user->email_verified_at ? '1' : '0' }}"
data-is-self="{{ $user->id === auth()->id() ? '1' : '0' }}"
data-is-super="{{ $user->isSuperAdmin() ? '1' : '0' }}"
data-edit-url="{{ route('admin.users.edit', $user->id) }}"
data-verify-url="{{ route('admin.users.verify', $user->id) }}"
data-impersonate-url="{{ route('admin.users.impersonate', $user->id) }}"
data-delete-id="{{ $user->id }}">
{{-- Identity --}}
<td>
<div class="adm-user-cell">
<img src="{{ $user->avatar_url }}" alt="{{ $user->name }}">
<div class="u-identity">
<div class="u-avatar-wrap">
<img class="u-avatar" src="{{ $user->avatar_url }}" alt="{{ $user->name }}">
</div>
<div>
<div style="display:flex;align-items:center;gap:4px;">
<span class="adm-user-cell-name">{{ $user->name }}</span>
<div class="u-name">
{{ $user->name }}
@if($user->id === auth()->id())
<span class="adm-user-cell-you">you</span>
<span class="u-you">you</span>
@endif
</div>
<div class="adm-user-cell-email">{{ $user->email }}</div>
<div class="u-email">{{ $user->email }}</div>
</div>
</div>
</td>
{{-- Role --}}
<td>
<td class="u-hide-sm">
@if($user->role === 'super_admin')
<span class="adm-badge adm-badge-superadmin"><i class="bi bi-shield-fill"></i> Super Admin</span>
<div class="u-role"><span class="u-role-dot super"></span> Super Admin</div>
@elseif($user->role === 'admin')
<span class="adm-badge adm-badge-admin"><i class="bi bi-person-badge"></i> Admin</span>
<div class="u-role"><span class="u-role-dot admin"></span> Admin</div>
@else
<span class="adm-badge adm-badge-user"><i class="bi bi-person"></i> User</span>
<div class="u-role" style="color:#666;"><span class="u-role-dot user"></span> User</div>
@endif
</td>
{{-- Verified --}}
<td>
{{-- Status --}}
<td class="u-hide-sm">
@if($user->email_verified_at)
<span class="adm-badge adm-badge-verified"><i class="bi bi-check-circle-fill"></i> Verified</span>
<div class="u-status verified">
<span class="u-status-dot"></span> Verified
</div>
@else
<span class="adm-badge adm-badge-unverified"><i class="bi bi-clock"></i> Pending</span>
<div class="u-status unverified">
<span class="u-status-dot"></span> Pending
</div>
@endif
</td>
{{-- Videos --}}
<td>
<a href="{{ route('channel', $user->channel) }}" target="_blank"
class="text-dim" style="text-decoration:none; font-size:13px;">
{{ $user->videos->count() }}
<i class="bi bi-box-arrow-up-right" style="font-size:10px; opacity:.5; margin-left:2px;"></i>
</a>
<td class="u-hide-md">
<div class="u-vcount">{{ $user->videos->count() }}</div>
<div class="u-vcount-lbl">videos</div>
</td>
{{-- Likes --}}
<td class="u-hide-md">
@php $videoIds = $user->videos->pluck('id'); @endphp
<div class="u-vcount">{{ number_format(\DB::table('video_likes')->whereIn('video_id', $videoIds)->count()) }}</div>
<div class="u-vcount-lbl">likes</div>
</td>
{{-- Shares --}}
<td class="u-hide-md">
<div class="u-vcount">{{ number_format(\DB::table('video_shares')->whereIn('video_id', $videoIds)->count()) }}</div>
<div class="u-vcount-lbl">shares</div>
</td>
{{-- Joined --}}
<td class="text-muted-sm">{{ $user->created_at->format('M d, Y') }}</td>
<td class="u-hide-md">
<div class="u-joined-date">{{ $user->created_at->format('M d, Y') }}</div>
<div class="u-joined-ago">{{ $user->created_at->diffForHumans() }}</div>
</td>
{{-- Actions --}}
<td>
<div class="adm-row-actions" style="justify-content:flex-end;">
@if(!$user->email_verified_at)
<form method="POST" action="{{ route('admin.users.verify', $user->id) }}" style="display:inline;">
@csrf
<button type="submit" class="adm-btn adm-btn-sm adm-btn-verify" title="Manually verify account">
<i class="bi bi-patch-check-fill"></i>
</button>
</form>
@endif
@if($user->id !== auth()->id() && !$user->isSuperAdmin())
<form method="POST" action="{{ route('admin.users.impersonate', $user->id) }}" style="display:inline;">
@csrf
<button type="submit" class="adm-btn adm-btn-sm adm-btn-impersonate" title="Impersonate user">
<i class="bi bi-person-fill-gear"></i>
</button>
</form>
@endif
<a href="{{ route('admin.users.edit', $user->id) }}"
class="adm-btn adm-btn-sm" title="Edit user">
<i class="bi bi-pencil"></i>
</a>
@if($user->id !== auth()->id())
<button type="button"
class="adm-btn adm-btn-sm adm-btn-danger"
title="Delete user"
onclick="openDeleteDialog({{ $user->id }}, '{{ addslashes($user->name) }}')">
<i class="bi bi-trash"></i>
</button>
@endif
</div>
</td>
</tr>
@empty
@empty
<tr>
<td colspan="6">
<div class="empty-state">
<i class="bi bi-people"></i>
<p>No users found</p>
<td colspan="7" style="border:none;">
<div class="u-empty">
<div class="u-empty-circle"><i class="bi bi-people"></i></div>
<h3>No users found</h3>
<p>Try adjusting your search or filter criteria</p>
</div>
</td>
</tr>
@endforelse
@endforelse
</tbody>
</table>
</div>
{{-- Pagination --}}
@if($users instanceof \Illuminate\Pagination\LengthAwarePaginator && $users->hasPages())
<div style="padding:16px 20px; border-top:1px solid var(--border);">
<div style="padding:14px 20px; border-top:1px solid var(--border-color);">
{{ $users->onEachSide(1)->links() }}
</div>
@endif
</div>
{{-- ── Delete confirmation dialog ───────────────────────────────────── --}}
{{-- Row action dropdown --}}
<div id="uRowMenu"></div>
{{-- Delete dialog --}}
<div class="adm-dialog-overlay" id="deleteDialog">
<div class="adm-dialog">
<div class="adm-dialog-header">
<div class="adm-dialog-title">
<i class="bi bi-exclamation-triangle-fill"></i>
Delete User
<i class="bi bi-exclamation-triangle-fill"></i> Delete User
</div>
<button type="button" class="adm-btn adm-btn-sm" onclick="closeDeleteDialog()" style="border:none;background:none;color:var(--text-2);">
<button type="button" class="adm-btn adm-btn-sm" onclick="closeDeleteDialog()"
style="border:none;background:none;color:var(--text-secondary);">
<i class="bi bi-x-lg"></i>
</button>
</div>
@@ -209,15 +463,26 @@
<i class="bi bi-info-circle-fill" style="flex-shrink:0;margin-top:1px;"></i>
All videos uploaded by this user will also be deleted. This cannot be undone.
</div>
@if($adminNeedsOtp)
<div style="margin-top:16px;">
<label style="font-size:13px;color:var(--text-secondary);display:flex;align-items:center;gap:6px;margin-bottom:8px;">
<i class="bi bi-shield-lock-fill" style="color:#a78bfa;"></i>
Enter your <strong style="color:var(--text-primary);">2FA code</strong> to confirm
</label>
<input type="text" id="dlgUserOtp" inputmode="numeric" pattern="[0-9]*"
maxlength="6" autocomplete="one-time-code" placeholder="000 000"
style="width:100%;background:#111;border:1px solid var(--border-color);border-radius:8px;padding:12px 16px;color:#fff;font-size:26px;letter-spacing:.25em;text-align:center;transition:border-color .15s;"
onfocus="this.style.borderColor='#a78bfa'" onblur="this.style.borderColor='var(--border-color)'">
</div>
@endif
<div id="dlgUserError" style="display:none;margin-top:10px;padding:10px 14px;background:rgba(248,113,113,.1);border:1px solid rgba(248,113,113,.25);border-radius:8px;color:#f87171;font-size:13px;"></div>
</div>
<div class="adm-dialog-footer">
<button type="button" class="adm-btn" onclick="closeDeleteDialog()">Cancel</button>
<form id="deleteForm" method="POST">
@csrf @method('DELETE')
<button type="submit" class="adm-btn adm-btn-danger" style="height:36px;">
<i class="bi bi-trash"></i> Delete User
</button>
</form>
<button type="button" class="adm-btn adm-btn-danger" id="dlgUserConfirmBtn" onclick="confirmDeleteUser()"
style="height:36px;">
<i class="bi bi-trash"></i> Delete permanently
</button>
</div>
</div>
</div>
@@ -226,19 +491,132 @@
@section('scripts')
<script>
const _adminNeedsOtp = {{ $adminNeedsOtp ? 'true' : 'false' }};
let _deleteUserId = null;
/* ── Row dropdown ─────────────────────────────────────────────── */
const _menu = document.getElementById('uRowMenu');
function _closeMenu() { _menu.style.display = 'none'; }
document.querySelectorAll('.u-clickable-row').forEach(function(row) {
row.addEventListener('click', function(e) {
e.stopPropagation();
const d = row.dataset;
const isSelf = d.isSelf === '1';
const isSuper = d.isSuper === '1';
const verified = d.verified === '1';
let html = '';
// Open channel
html += `<a class="u-menu-item" href="${d.channel}" target="_blank"><i class="bi bi-box-arrow-up-right"></i> Open Channel</a>`;
html += `<a class="u-menu-item" href="${d.editUrl}"><i class="bi bi-pencil"></i> Edit User</a>`;
if (!verified) {
html += `<button class="u-menu-item" onclick="_verifyUser('${d.verifyUrl}')"><i class="bi bi-patch-check-fill" style="color:#34d399;"></i> Verify Account</button>`;
}
if (!isSelf && !isSuper) {
html += `<button class="u-menu-item" onclick="_impersonateUser('${d.impersonateUrl}')"><i class="bi bi-person-fill-gear" style="color:#60a5fa;"></i> Impersonate</button>`;
}
if (!isSelf) {
html += `<div class="u-menu-sep"></div>`;
html += `<button class="u-menu-item danger" onclick="_closeMenu(); openDeleteDialog(${d.deleteId}, '${d.userName}')"><i class="bi bi-trash"></i> Delete User</button>`;
}
_menu.innerHTML = html;
// Position near click, keep within viewport
const vw = window.innerWidth, vh = window.innerHeight;
const mw = 200, mh = _menu.childElementCount * 40;
let x = e.clientX, y = e.clientY + 6;
if (x + mw > vw - 12) x = vw - mw - 12;
if (y + mh > vh - 12) y = e.clientY - mh - 6;
_menu.style.left = x + 'px';
_menu.style.top = y + 'px';
_menu.style.display = 'block';
});
});
document.addEventListener('click', _closeMenu);
document.addEventListener('keydown', e => { if (e.key === 'Escape') { _closeMenu(); closeDeleteDialog(); } });
function _verifyUser(url) {
_closeMenu();
const f = document.createElement('form');
f.method = 'POST'; f.action = url;
f.innerHTML = '<input type="hidden" name="_token" value="{{ csrf_token() }}">';
document.body.appendChild(f); f.submit();
}
function _impersonateUser(url) {
_closeMenu();
const f = document.createElement('form');
f.method = 'POST'; f.action = url;
f.innerHTML = '<input type="hidden" name="_token" value="{{ csrf_token() }}">';
document.body.appendChild(f); f.submit();
}
/* ── Delete dialog ────────────────────────────────────────────── */
function openDeleteDialog(userId, userName) {
_deleteUserId = userId;
document.getElementById('dlgUserName').textContent = userName;
document.getElementById('deleteForm').action = '/admin/users/' + userId;
document.getElementById('dlgUserError').style.display = 'none';
if (_adminNeedsOtp) document.getElementById('dlgUserOtp').value = '';
document.getElementById('deleteDialog').classList.add('open');
if (_adminNeedsOtp) setTimeout(() => document.getElementById('dlgUserOtp').focus(), 120);
}
function closeDeleteDialog() {
document.getElementById('deleteDialog').classList.remove('open');
_deleteUserId = null;
}
document.getElementById('deleteDialog').addEventListener('click', function(e) {
if (e.target === this) closeDeleteDialog();
});
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') closeDeleteDialog();
function confirmDeleteUser() {
if (!_deleteUserId) return;
const btn = document.getElementById('dlgUserConfirmBtn');
const errEl = document.getElementById('dlgUserError');
const otp = _adminNeedsOtp ? document.getElementById('dlgUserOtp').value.replace(/\s/g,'') : '';
if (_adminNeedsOtp && otp.length !== 6) {
errEl.textContent = 'Please enter your 6-digit 2FA code.';
errEl.style.display = 'block';
document.getElementById('dlgUserOtp').focus();
return;
}
btn.disabled = true;
btn.innerHTML = '<i class="bi bi-hourglass-split"></i> Deleting…';
errEl.style.display = 'none';
const body = new URLSearchParams({ _token: '{{ csrf_token() }}', _method: 'DELETE' });
if (_adminNeedsOtp) body.append('otp_code', otp);
fetch('/admin/users/' + _deleteUserId, {
method: 'POST',
headers: { Accept: 'application/json', 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString()
})
.then(r => r.json())
.then(data => {
if (data.success) { closeDeleteDialog(); window.location.reload(); }
else {
errEl.textContent = data.message || 'Delete failed.';
errEl.style.display = 'block';
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-trash"></i> Delete permanently';
if (_adminNeedsOtp) { document.getElementById('dlgUserOtp').value = ''; document.getElementById('dlgUserOtp').focus(); }
}
})
.catch(() => {
errEl.textContent = 'An unexpected error occurred.';
errEl.style.display = 'block';
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-trash"></i> Delete permanently';
});
}
document.getElementById('deleteDialog').addEventListener('click', e => {
if (e.target === document.getElementById('deleteDialog')) closeDeleteDialog();
});
</script>
@endsection
@@ -195,7 +195,7 @@ function flagEmoji(string $code): string {
<div class="video-info-card">
<div class="video-info-thumb">
@if($video->thumbnail)
<img src="{{ asset('storage/thumbnails/' . $video->thumbnail) }}" alt="{{ $video->title }}">
<img src="{{ route('media.thumbnail', $video->thumbnail) }}" alt="{{ $video->title }}">
@else
<i class="bi bi-play-circle"></i>
@endif
@@ -414,7 +414,7 @@ function flagEmoji(string $code): string {
<div style="display:flex; align-items:center; gap:10px;">
<div class="viewer-avatar">
@if($view->viewer_avatar)
<img src="{{ asset('storage/avatars/' . $view->viewer_avatar) }}" alt="">
<img src="{{ route('media.avatar', $view->viewer_avatar) }}" alt="">
@else
<i class="bi bi-person"></i>
@endif
+85 -12
View File
@@ -2,6 +2,11 @@
@section('title', 'Videos')
@php
$adminNeedsOtp = auth()->user()->two_factor_enabled &&
(! session('admin_2fa_verified_at') || now()->timestamp - session('admin_2fa_verified_at') >= 1800);
@endphp
@section('content')
{{-- ── Page header ──────────────────────────────────────────────────── --}}
@@ -100,18 +105,19 @@
<th>Type</th>
<th>Views</th>
<th>Likes</th>
<th>Shares</th>
<th>Uploaded</th>
<th style="width:110px; text-align:right;">Actions</th>
</tr>
</thead>
<tbody>
@forelse($videos as $video)
<tr>
<tr style="cursor:pointer;" onclick="window.open('{{ route('videos.show', $video) }}', '_blank')">
{{-- Thumbnail + title --}}
<td>
<div style="display:flex; align-items:center; gap:12px;">
@if($video->thumbnail)
<img src="{{ asset('storage/thumbnails/' . $video->thumbnail) }}"
<img src="{{ route('media.thumbnail', $video->thumbnail) }}"
alt="" style="width:72px;height:44px;object-fit:cover;border-radius:6px;flex-shrink:0;border:1px solid var(--border);">
@else
<div style="width:72px;height:44px;border-radius:6px;background:var(--bg-card2);border:1px solid var(--border);display:flex;align-items:center;justify-content:center;flex-shrink:0;">
@@ -132,7 +138,7 @@
</td>
{{-- Owner --}}
<td>
<td onclick="event.stopPropagation()">
<a href="{{ route('channel', $video->user->channel) }}" target="_blank"
style="font-size:13px;color:var(--text);text-decoration:none;display:flex;align-items:center;gap:4px;">
{{ $video->user->name }}
@@ -193,11 +199,16 @@
{{ number_format(\DB::table('video_likes')->where('video_id',$video->id)->count()) }}
</td>
{{-- Shares --}}
<td style="font-size:13px;color:var(--text-2);">
{{ number_format(\DB::table('video_shares')->where('video_id',$video->id)->count()) }}
</td>
{{-- Date --}}
<td class="text-muted-sm">{{ $video->created_at->format('M d, Y') }}</td>
{{-- Actions --}}
<td>
<td onclick="event.stopPropagation()">
<div class="adm-row-actions" style="justify-content:flex-end;">
<a href="{{ route('videos.show', $video) }}" target="_blank"
class="adm-btn adm-btn-sm" title="Watch">
@@ -218,7 +229,7 @@
</tr>
@empty
<tr>
<td colspan="9">
<td colspan="10">
<div class="empty-state">
<i class="bi bi-play-circle"></i>
<p>No videos found</p>
@@ -256,15 +267,25 @@
<i class="bi bi-info-circle-fill" style="flex-shrink:0;margin-top:1px;"></i>
All views, likes, comments, and HLS files for this video will also be deleted.
</div>
@if($adminNeedsOtp)
<div style="margin-top:14px;">
<label style="font-size:13px;color:var(--text-2);display:flex;align-items:center;gap:6px;margin-bottom:6px;">
<i class="bi bi-shield-lock-fill" style="color:#a78bfa;"></i>
Enter your <strong style="color:var(--text);">2FA code</strong> to confirm
</label>
<input type="text" id="dlgVideoOtp" inputmode="numeric" pattern="[0-9]*"
maxlength="6" autocomplete="one-time-code"
placeholder="000000"
style="width:100%;background:var(--bg-card2);border:1px solid var(--border-light);border-radius:8px;padding:10px 14px;color:#fff;font-size:22px;letter-spacing:0.3em;text-align:center;">
</div>
@endif
<div id="dlgVideoError" style="display:none;margin-top:10px;padding:8px 12px;background:rgba(239,68,68,.12);border:1px solid rgba(239,68,68,.3);border-radius:6px;color:#f87171;font-size:13px;"></div>
</div>
<div class="adm-dialog-footer">
<button type="button" class="adm-btn" onclick="closeDeleteDialog()">Cancel</button>
<form id="deleteForm" method="POST">
@csrf @method('DELETE')
<button type="submit" class="adm-btn adm-btn-danger" style="height:36px;">
<i class="bi bi-trash"></i> Delete Video
</button>
</form>
<button type="button" class="adm-btn adm-btn-danger" id="dlgVideoConfirmBtn" onclick="confirmDeleteVideo()">
<i class="bi bi-trash"></i> Delete Video
</button>
</div>
</div>
</div>
@@ -273,13 +294,65 @@
@section('scripts')
<script>
const _adminNeedsOtp = {{ $adminNeedsOtp ? 'true' : 'false' }};
let _deleteVideoId = null;
function openDeleteDialog(videoId, videoTitle) {
_deleteVideoId = videoId;
document.getElementById('dlgVideoTitle').textContent = videoTitle;
document.getElementById('deleteForm').action = '/admin/videos/' + videoId;
document.getElementById('dlgVideoError').style.display = 'none';
if (_adminNeedsOtp) document.getElementById('dlgVideoOtp').value = '';
document.getElementById('deleteDialog').classList.add('open');
if (_adminNeedsOtp) setTimeout(() => document.getElementById('dlgVideoOtp').focus(), 100);
}
function closeDeleteDialog() {
document.getElementById('deleteDialog').classList.remove('open');
_deleteVideoId = null;
}
function confirmDeleteVideo() {
if (!_deleteVideoId) return;
const btn = document.getElementById('dlgVideoConfirmBtn');
const errEl = document.getElementById('dlgVideoError');
const otpCode = _adminNeedsOtp ? document.getElementById('dlgVideoOtp').value.replace(/\s/g,'') : '';
if (_adminNeedsOtp && otpCode.length !== 6) {
errEl.textContent = 'Please enter your 6-digit 2FA code.';
errEl.style.display = 'block';
document.getElementById('dlgVideoOtp').focus();
return;
}
btn.disabled = true;
btn.innerHTML = '<i class="bi bi-hourglass-split"></i> Deleting…';
errEl.style.display = 'none';
const body = new URLSearchParams({ '_token': '{{ csrf_token() }}', '_method': 'DELETE' });
if (_adminNeedsOtp) body.append('otp_code', otpCode);
fetch('/admin/videos/' + _deleteVideoId, {
method: 'POST',
headers: { 'Accept': 'application/json', 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString()
})
.then(r => r.json())
.then(data => {
if (data.success) {
closeDeleteDialog();
window.location.reload();
} else {
errEl.textContent = data.message || 'Delete failed.';
errEl.style.display = 'block';
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-trash"></i> Delete Video';
if (_adminNeedsOtp) { document.getElementById('dlgVideoOtp').value = ''; document.getElementById('dlgVideoOtp').focus(); }
}
})
.catch(() => {
errEl.textContent = 'An error occurred. Please try again.';
errEl.style.display = 'block';
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-trash"></i> Delete Video';
});
}
document.getElementById('deleteDialog').addEventListener('click', function(e) {
if (e.target === this) closeDeleteDialog();
@@ -1,7 +1,24 @@
@props(['playlist', 'showTypeBadge' => false])
@props(['playlist'])
@once
<style>
.pl-count-badge {
position: absolute;
inset: 0 0 0 auto;
width: 72px;
background: rgba(0,0,0,.78);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
font-size: 12px;
font-weight: 600;
color: #fff;
pointer-events: none;
z-index: 3;
}
.pl-count-badge i { font-size: 20px; }
.pl-visibility-badge {
position: absolute;
bottom: 8px;
@@ -18,6 +35,26 @@
text-transform: uppercase;
letter-spacing: .4px;
pointer-events: none;
z-index: 3;
}
.pl-type-badge {
position: absolute;
top: 8px;
left: 8px;
background: rgba(0,0,0,.82);
color: #a78bfa;
font-size: 11px;
font-weight: 700;
padding: 3px 7px;
border-radius: 4px;
display: flex;
align-items: center;
gap: 4px;
text-transform: uppercase;
letter-spacing: .4px;
pointer-events: none;
white-space: nowrap;
z-index: 3;
}
</style>
@endonce
@@ -27,16 +64,14 @@
<div class="yt-video-card">
<a href="{{ route('playlists.show', $pl->id) }}">
<div class="yt-video-thumb">
<img src="{{ $pl->thumbnail_url }}" alt="{{ $pl->name }}" loading="lazy">
<img src="{{ $pl->thumbnail_url }}" alt="{{ $pl->name }}" loading="lazy" decoding="async" onload="this.classList.add('loaded');this.closest('.yt-video-thumb').classList.add('loaded')">
<div class="pl-count-badge">
<i class="bi bi-collection-play-fill"></i>
{{ $pl->videos_count }}
</div>
@if($showTypeBadge)
<span class="feed-pl-type-badge">
<i class="bi bi-collection-play-fill"></i> PLAYLIST
</span>
@endif
<span class="pl-type-badge">
<i class="bi bi-collection-play-fill"></i> Playlist
</span>
@if($pl->visibility === 'private')
<span class="pl-visibility-badge"><i class="bi bi-lock-fill"></i> Private</span>
@elseif($pl->visibility === 'unlisted')
@@ -4,18 +4,33 @@
.action-btn {
border: none;
border-radius: 8px;
padding: 8px 14px;
padding: 0 14px;
height: 36px;
font-size: 0.82rem;
font-weight: 500;
cursor: pointer;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
color: var(--text-primary);
display: flex;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
transition: all 0.2s ease;
text-decoration: none;
white-space: nowrap;
line-height: 1;
box-sizing: border-box;
}
.action-btn i,
.action-btn svg {
font-size: 14px;
width: 14px;
height: 14px;
flex-shrink: 0;
display: inline-flex;
align-items: center;
}
.action-btn:hover {
@@ -113,6 +128,11 @@
<i class="bi bi-pencil"></i>
<span>Edit</span>
</button>
<button class="action-btn desktop-action" onclick="showDeleteModal('{{ $video->getRouteKey() }}', {{ json_encode($video->title) }})"
style="color:#ef4444;border-color:rgba(239,68,68,.35);">
<i class="bi bi-trash"></i>
<span>Delete</span>
</button>
@elseif (Auth::id() !== $video->user_id)
@php $isSubscribed = Auth::user()->isSubscribedTo($video->user); @endphp
<button type="button"
@@ -149,16 +169,13 @@
@if ($video->isShareable())
<button class="action-btn desktop-action"
onclick="openShareModal('{{ $video->share_url }}', '{{ addslashes($video->title) }}', '{{ route('videos.recordShare', $video) }}')">
<i class="bi bi-share"></i> Share
<i class="bi bi-share"></i><span>Share</span>
</button>
@endif
<!-- Save to Playlist Button -->
<button class="action-btn desktop-action" onclick="openAddToPlaylistModal({{ $video->id }})">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"></path>
</svg>
<i class="bi bi-bookmark"></i>
<span>Save</span>
</button>
@@ -590,4 +607,110 @@ if (!window._slideshowDlInit) {
});
};
}
// ── Video delete dialog ──────────────────────────────────────────────
@auth
@if(Auth::id() === $video->user_id)
(function () {
var _videoDeleteUrl = '{{ route('videos.destroy', $video) }}';
var _videoCsrf = '{{ csrf_token() }}';
var _owner2fa = {{ (Auth::user()->two_factor_enabled && Auth::user()->two_factor_secret) ? 'true' : 'false' }};
var _dlgId = 'vaDlg_{{ $video->id }}';
var _dlgHtml = '<div id="' + _dlgId + '" style="display:none;position:fixed;inset:0;z-index:9999;background:rgba(0,0,0,.75);backdrop-filter:blur(4px);align-items:center;justify-content:center;padding:16px;" onclick="if(event.target===this)_closeVaDlg()">'
+ '<div style="background:var(--bg-secondary);border:1px solid var(--border-color);border-radius:16px;width:100%;max-width:420px;overflow:hidden;box-shadow:0 24px 60px rgba(0,0,0,.6);">'
+ '<div style="padding:20px 24px 16px;border-bottom:1px solid var(--border-color);display:flex;align-items:center;justify-content:space-between;">'
+ '<div style="display:flex;align-items:center;gap:10px;font-size:16px;font-weight:600;color:#ef4444;"><i class="bi bi-trash"></i> Delete Video</div>'
+ '<button onclick="_closeVaDlg()" style="background:none;border:none;color:var(--text-secondary);cursor:pointer;font-size:18px;line-height:1;padding:4px;"><i class="bi bi-x-lg"></i></button>'
+ '</div>'
+ '<div style="padding:20px 24px;">'
+ '<p id="' + _dlgId + '_title" style="margin:0 0 12px;font-size:14px;color:var(--text-primary);"></p>'
+ '<div style="background:rgba(239,68,68,.1);border:1px solid rgba(239,68,68,.25);border-radius:8px;padding:10px 14px;font-size:13px;color:#fca5a5;display:flex;gap:8px;align-items:flex-start;">'
+ '<i class="bi bi-exclamation-triangle-fill" style="flex-shrink:0;margin-top:1px;"></i>'
+ '<span>All views, likes, comments and HLS files will be deleted. This cannot be undone.</span>'
+ '</div>'
+ (_owner2fa ? '<div style="margin-top:16px;"><label style="font-size:13px;color:var(--text-secondary);display:flex;align-items:center;gap:6px;margin-bottom:8px;"><i class="bi bi-shield-lock-fill" style="color:#a78bfa;"></i>Enter your <strong style=\'color:var(--text-primary);\'>2FA code</strong> to confirm</label>'
+ '<input type="text" id="' + _dlgId + '_otp" inputmode="numeric" pattern="[0-9]*" maxlength="6" autocomplete="one-time-code" placeholder="000000"'
+ ' style="width:100%;background:var(--bg-primary);border:1px solid var(--border-color);border-radius:8px;padding:10px 14px;color:#fff;font-size:22px;letter-spacing:.3em;text-align:center;box-sizing:border-box;"></div>' : '')
+ '<div id="' + _dlgId + '_err" style="display:none;margin-top:12px;padding:8px 12px;background:rgba(239,68,68,.12);border:1px solid rgba(239,68,68,.3);border-radius:6px;color:#f87171;font-size:13px;"></div>'
+ '</div>'
+ '<div style="padding:12px 24px 20px;display:flex;gap:10px;justify-content:flex-end;">'
+ '<button onclick="_closeVaDlg()" class="action-btn">Cancel</button>'
+ '<button id="' + _dlgId + '_btn" onclick="_confirmVaDlg()" class="action-btn" style="background:#ef4444;color:#fff;border-color:#ef4444;"><i class="bi bi-trash"></i> Delete</button>'
+ '</div>'
+ '</div></div>';
var _dlgInserted = false;
function _ensureDlg() {
if (_dlgInserted) return;
document.body.insertAdjacentHTML('beforeend', _dlgHtml);
_dlgInserted = true;
}
window.showDeleteModal = function (routeKey, title) {
_ensureDlg();
var dlg = document.getElementById(_dlgId);
document.getElementById(_dlgId + '_title').textContent = 'You are about to permanently delete "' + title + '".';
document.getElementById(_dlgId + '_err').style.display = 'none';
if (_owner2fa) { document.getElementById(_dlgId + '_otp').value = ''; }
dlg.style.display = 'flex';
dlg.style.alignItems = 'center';
dlg.style.justifyContent = 'center';
if (_owner2fa) setTimeout(function () { document.getElementById(_dlgId + '_otp').focus(); }, 100);
document.addEventListener('keydown', _escHandler);
};
window._closeVaDlg = function () {
var dlg = document.getElementById(_dlgId);
if (dlg) dlg.style.display = 'none';
document.removeEventListener('keydown', _escHandler);
};
function _escHandler(e) { if (e.key === 'Escape') _closeVaDlg(); }
window._confirmVaDlg = function () {
var btn = document.getElementById(_dlgId + '_btn');
var errEl = document.getElementById(_dlgId + '_err');
var otp = _owner2fa ? document.getElementById(_dlgId + '_otp').value.replace(/\s/g, '') : '';
if (_owner2fa && otp.length !== 6) {
errEl.textContent = 'Please enter your 6-digit 2FA code.';
errEl.style.display = 'block';
document.getElementById(_dlgId + '_otp').focus();
return;
}
btn.disabled = true;
btn.innerHTML = '<i class="bi bi-hourglass-split"></i> Deleting…';
errEl.style.display = 'none';
var body = new URLSearchParams({ '_token': _videoCsrf, '_method': 'DELETE' });
if (_owner2fa) body.append('otp_code', otp);
fetch(_videoDeleteUrl, {
method: 'POST',
headers: { 'Accept': 'application/json', 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString()
})
.then(function (r) { return r.json(); })
.then(function (data) {
if (data.success) {
window.location.href = '/';
} else {
errEl.textContent = data.message || 'Delete failed.';
errEl.style.display = 'block';
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-trash"></i> Delete';
if (_owner2fa) { document.getElementById(_dlgId + '_otp').value = ''; document.getElementById(_dlgId + '_otp').focus(); }
}
})
.catch(function () {
errEl.textContent = 'An error occurred. Please try again.';
errEl.style.display = 'block';
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-trash"></i> Delete';
});
};
})();
@endif
@endauth
</script>
@@ -3,7 +3,7 @@
@php
$videoUrl = $video ? asset('storage/videos/' . $video->filename) : null;
$thumbnailUrl = $video && $video->thumbnail
? asset('storage/thumbnails/' . $video->thumbnail)
? route('media.thumbnail', $video->thumbnail)
: ($video ? 'https://picsum.photos/seed/' . $video->id . '/640/360' : 'https://picsum.photos/seed/random/640/360');
$typeIcon = $video ? match($video->type) {
@@ -29,7 +29,7 @@ $sizeClasses = match($size) {
<a href="{{ $video ? route('videos.show', $video) : '#' }}">
<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' }}">
<img src="{{ $thumbnailUrl }}" alt="{{ $video->title ?? 'Video' }}" loading="lazy" decoding="async" onload="this.classList.add('loaded');this.closest('.yt-video-thumb').classList.add('loaded')">
@if($videoUrl)
<video preload="none">
<source src="{{ $videoUrl }}" type="{{ $video->mime_type ?? 'video/mp4' }}">
@@ -64,7 +64,7 @@ $sizeClasses = match($size) {
<a href="{{ $video && $video->user ? route('channel', $video->user->channel) : '#' }}"
class="yt-channel-icon" onclick="event.stopPropagation();">
@if($video && $video->user && $video->user->avatar_url)
<img src="{{ $video->user->avatar_url }}" alt="{{ $video->user->name }}" style="width: 100%; height: 100%; object-fit: cover; border-radius: 50%;">
<img src="{{ $video->user->avatar_url }}" alt="{{ $video->user->name }}" loading="lazy" decoding="async" onload="this.classList.add('loaded')" style="width: 100%; height: 100%; object-fit: cover; border-radius: 50%; opacity: 0; transition: opacity 0.25s ease;">
@endif
</a>
<div class="yt-video-details">
@@ -275,6 +275,28 @@ $sizeClasses = match($size) {
background: #1a1a1a;
}
/* Skeleton shimmer while thumbnail loads */
.yt-video-card .yt-video-thumb::before {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(90deg, #1a1a1a 25%, #2a2a2a 50%, #1a1a1a 75%);
background-size: 200% 100%;
animation: thumb-shimmer 1.4s ease infinite;
z-index: 0;
border-radius: inherit;
}
@keyframes thumb-shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
.yt-video-card .yt-video-thumb.loaded::before {
animation: none;
opacity: 0;
}
.yt-video-card .yt-video-thumb img {
width: 100%;
height: 100%;
@@ -282,9 +304,16 @@ $sizeClasses = match($size) {
position: absolute;
top: 0;
left: 0;
transition: transform 0.2s ease;
opacity: 0;
transition: opacity 0.3s ease, transform 0.2s ease;
z-index: 1;
}
.yt-video-card:hover .yt-video-thumb img {
.yt-video-card .yt-video-thumb img.loaded {
opacity: 1;
}
.yt-video-card:hover .yt-video-thumb img.loaded {
transform: scale(1.03);
}
@@ -298,6 +327,7 @@ $sizeClasses = match($size) {
opacity: 0;
transition: opacity 0.3s ease;
background: #000;
z-index: 2;
}
.yt-video-card .yt-video-thumb video.active {
@@ -315,6 +345,7 @@ $sizeClasses = match($size) {
opacity: 0;
transition: opacity 0.3s ease;
pointer-events: none;
z-index: 3;
}
.yt-video-thumb.audio-playing .audio-preview-overlay {
@@ -360,6 +391,7 @@ $sizeClasses = match($size) {
border-radius: 4px;
font-size: 12px;
font-weight: 500;
z-index: 3;
}
.yt-video-card .yt-shorts-badge {
@@ -377,6 +409,7 @@ $sizeClasses = match($size) {
gap: 4px;
text-transform: uppercase;
letter-spacing: 0.5px;
z-index: 3;
}
.yt-video-card .yt-shorts-badge i {
@@ -395,6 +428,7 @@ $sizeClasses = match($size) {
align-items: center;
gap: 4px;
pointer-events: none;
z-index: 3;
}
.yt-video-card .yt-visibility-private {
@@ -423,6 +457,10 @@ $sizeClasses = match($size) {
overflow: hidden;
}
.yt-video-card .yt-channel-icon img.loaded {
opacity: 1 !important;
}
.yt-video-card .yt-video-details {
flex: 1;
min-width: 0;
@@ -79,6 +79,18 @@
.ins-demo-pct { font-size:12px; font-weight:700; color:var(--text-secondary); min-width:34px; text-align:right; }
.ins-demo-cnt { font-size:11px; color:var(--text-secondary); min-width:30px; text-align:right; }
/* ── Two-column grid (collapses to 1 col on mobile) ─── */
.ins-two-col { display:grid; grid-template-columns:1fr 1fr; gap:20px; }
@media(max-width:600px){ .ins-two-col { grid-template-columns:1fr; } }
/* ── Who Liked rows ──────────────────────────────────── */
.ins-liker-row { display:flex; align-items:center; gap:10px; padding:7px 6px; border-bottom:1px solid rgba(255,255,255,.04); cursor:pointer; border-radius:8px; transition:background .12s; }
.ins-liker-row:last-child { border-bottom:none; }
.ins-liker-row:hover { background:rgba(255,255,255,.05); }
.ins-liker-avatar { width:34px; height:34px; border-radius:50%; object-fit:cover; flex-shrink:0; background:rgba(255,255,255,.08); }
.ins-liker-name { flex:1; font-size:13px; font-weight:600; color:var(--text-primary); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.ins-liker-time { font-size:11px; color:var(--text-secondary); flex-shrink:0; }
/* ── Skeleton ─────────────────────────────────────────── */
.ins-skeleton { display:flex; flex-direction:column; gap:12px; padding:4px 0; }
.ins-skel-row { height:14px; border-radius:6px; background:rgba(255,255,255,.06); animation:skelPulse 1.4s ease-in-out infinite; }
@@ -367,7 +379,7 @@ function renderInsights(d) {
<div class="ins-dl-header">
<div class="ins-section-title" style="margin:0;"><i class="bi bi-people-fill"></i> Viewers — ${_fmt(d.unique_viewers)} registered · ${_fmt(d.guest_views||0)} guest</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:20px;">
<div class="ins-two-col">
<div>
<div style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--text-secondary);margin-bottom:8px;">Top Viewers</div>
${topViewerRows || '<p style="font-size:13px;color:var(--text-secondary);margin:0;">No registered viewers yet.</p>'}
@@ -379,6 +391,30 @@ function renderInsights(d) {
</div>
</div>`;
// ── Who Liked section ───────────────────────────────
let likersHtml = '';
if (d.likes > 0 && d.likers && d.likers.length) {
const likerRows = d.likers.map(u => `
<div class="ins-liker-row" onclick="window.location.href='/channel/${u.channel||u.id}'" title="View ${u.name}'s profile">
<img src="${u.avatar}" alt="${u.name}" class="ins-liker-avatar">
<div class="ins-liker-name">${u.name}</div>
<div class="ins-liker-time">${_ago(u.liked_at)}</div>
<i class="bi bi-chevron-right" style="font-size:10px;color:var(--text-secondary);flex-shrink:0;margin-left:4px;"></i>
</div>`).join('');
likersHtml = `
<div style="margin-top:20px;border-top:1px solid var(--border-color);padding-top:18px;">
<div class="ins-section-title"><i class="bi bi-heart-fill" style="color:#ef4444;"></i> Liked by — ${_fmt(d.likes)} ${d.likes===1?'person':'people'}</div>
${likerRows}
</div>`;
} else if (d.likes === 0) {
likersHtml = `
<div style="margin-top:20px;border-top:1px solid var(--border-color);padding-top:18px;">
<div class="ins-section-title"><i class="bi bi-heart" style="color:var(--text-secondary);"></i> Likes</div>
<p style="font-size:13px;color:var(--text-secondary);margin:0;">No likes yet.</p>
</div>`;
}
// ── Downloads section ───────────────────────────────
let dlHtml = '';
if (d.downloads===0) {
@@ -422,7 +458,7 @@ function renderInsights(d) {
<div class="ins-section-title" style="margin:0;"><i class="bi bi-download"></i> Downloads — ${_fmt(d.downloads)} total</div>
<div class="ins-dl-type-pills">${pills}</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:20px;">
<div class="ins-two-col">
<div>
<div style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--text-secondary);margin-bottom:8px;">
Top Downloaders <span style="font-weight:400;opacity:.5;">(tap to see history)</span>
@@ -523,7 +559,7 @@ function renderInsights(d) {
<div>
<div class="ins-section-title"><i class="bi bi-globe2"></i> Audience by Country <span style="font-size:10px;opacity:.5;font-weight:400;text-transform:none;">(tap for viewer breakdown)</span></div>
${countriesHtml}
</div></div>` + viewersHtml + dlHtml + shareHtml + demographicsHtml;
</div></div>` + viewersHtml + likersHtml + dlHtml + shareHtml + demographicsHtml;
}
// ══════════════════════════════════════════════════════
@@ -10,7 +10,7 @@
<div class="email-thumb-wrap">
@if($video->thumbnail)
<a href="{{ route('videos.show', $video) }}">
<img src="{{ asset('storage/thumbnails/' . $video->thumbnail) }}" alt="{{ $video->title }}">
<img src="{{ route('media.thumbnail', $video->thumbnail) }}" alt="{{ $video->title }}">
</a>
@else
<div class="email-thumb-placeholder">
@@ -16,7 +16,7 @@
<div class="email-thumb-wrap">
@if($video->thumbnail)
<a href="{{ route('videos.show', $video) }}">
<img src="{{ asset('storage/thumbnails/' . $video->thumbnail) }}" alt="{{ $video->title }}">
<img src="{{ route('media.thumbnail', $video->thumbnail) }}" alt="{{ $video->title }}">
</a>
@else
<div class="email-thumb-placeholder">
@@ -277,6 +277,42 @@
</div>
</div>
</form>
{{-- ── Replace File panel (outside main form, separate POST) ── --}}
<div id="replace-file-panel" style="margin-top:24px;padding-top:20px;border-top:1px solid #3a3a3a;">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;">
<i class="bi bi-arrow-repeat" style="color:#f87171;font-size:16px;"></i>
<span style="font-size:13px;font-weight:700;color:#f87171;text-transform:uppercase;letter-spacing:.05em;">Replace Media File</span>
</div>
<p style="font-size:12px;color:#888;margin:0 0 14px;line-height:1.5;">
Upload a new file to fix a corrupted or missing video/audio. All views, likes, and comments are preserved.
</p>
<div id="rfl-dropzone" onclick="document.getElementById('rfl-input').click()"
style="border:2px dashed #444;border-radius:10px;padding:20px;text-align:center;cursor:pointer;transition:border-color .15s,background .15s;"
onmouseenter="this.style.borderColor='#ef4444';this.style.background='rgba(239,68,68,.05)'"
onmouseleave="this.style.borderColor='#444';this.style.background='transparent'">
<i class="bi bi-cloud-upload" style="font-size:28px;color:#666;display:block;margin-bottom:8px;"></i>
<span id="rfl-drop-label" style="font-size:13px;color:#888;">Click to choose replacement file</span>
<input type="file" id="rfl-input" accept="video/*,audio/*,.mp4,.webm,.ogg,.mov,.avi,.wmv,.flv,.mkv,.mp3,.m4a,.aac,.wav,.flac,.opus" style="display:none;" onchange="rflFileSelected(this)">
</div>
<div id="rfl-file-info" style="display:none;margin-top:10px;background:#1a1a1a;border:1px solid #333;border-radius:8px;padding:10px 14px;display:none;align-items:center;gap:10px;">
<i class="bi bi-file-earmark-play" style="font-size:22px;color:#ef4444;flex-shrink:0;"></i>
<div style="flex:1;min-width:0;">
<div id="rfl-file-name" style="font-size:13px;font-weight:600;color:#e5e5e5;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;"></div>
<div id="rfl-file-size" style="font-size:11px;color:#888;margin-top:2px;"></div>
</div>
<button type="button" onclick="rflClearFile()" style="background:none;border:none;color:#888;cursor:pointer;font-size:16px;flex-shrink:0;padding:0;"><i class="bi bi-x-lg"></i></button>
</div>
<div id="rfl-status" style="display:none;margin-top:10px;font-size:12px;padding:8px 12px;border-radius:6px;"></div>
<button type="button" id="rfl-submit-btn" onclick="rflSubmit()" disabled
class="action-btn action-btn-danger" style="width:100%;justify-content:center;margin-top:12px;">
<i class="bi bi-arrow-repeat"></i> <span>Replace File</span>
</button>
</div>
</div>
</div>
</div>
@@ -1403,6 +1439,75 @@
submitBtn.innerHTML = '<i class="bi bi-check-lg"></i> Save Changes';
});
});
// ── Replace File ──────────────────────────────────────────────────────
let _rflFile = null;
const _rflFmtSize = b => b >= 1073741824 ? (b/1073741824).toFixed(1)+' GB' : b >= 1048576 ? (b/1048576).toFixed(1)+' MB' : Math.round(b/1024)+' KB';
function rflFileSelected(input) {
_rflFile = input.files[0] || null;
const info = document.getElementById('rfl-file-info');
if (_rflFile) {
document.getElementById('rfl-file-name').textContent = _rflFile.name;
document.getElementById('rfl-file-size').textContent = _rflFmtSize(_rflFile.size);
document.getElementById('rfl-drop-label').textContent = _rflFile.name;
info.style.display = 'flex';
} else {
info.style.display = 'none';
document.getElementById('rfl-drop-label').textContent = 'Click to choose replacement file';
}
document.getElementById('rfl-submit-btn').disabled = !_rflFile;
document.getElementById('rfl-status').style.display = 'none';
}
function rflClearFile() {
_rflFile = null;
document.getElementById('rfl-input').value = '';
document.getElementById('rfl-file-info').style.display = 'none';
document.getElementById('rfl-drop-label').textContent = 'Click to choose replacement file';
document.getElementById('rfl-submit-btn').disabled = true;
document.getElementById('rfl-status').style.display = 'none';
}
function rflSubmit() {
if (!_rflFile || !window.currentVideoId) return;
const btn = document.getElementById('rfl-submit-btn');
const status = document.getElementById('rfl-status');
btn.disabled = true;
btn.innerHTML = '<i class="bi bi-arrow-repeat"></i> Uploading…';
status.style.display = 'none';
const fd = new FormData();
fd.append('_token', '{{ csrf_token() }}');
fd.append('replacement_file', _rflFile);
fetch(`/videos/${window.currentVideoId}/replace-file`, {
method: 'POST',
headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
body: fd
})
.then(r => r.json())
.then(data => {
if (data.success) {
status.style.cssText = 'display:block;background:rgba(74,222,128,.1);border:1px solid rgba(74,222,128,.25);color:#4ade80;';
status.innerHTML = '<i class="bi bi-check-circle-fill"></i> ' + data.message;
rflClearFile();
setTimeout(() => location.reload(), 2000);
} else {
status.style.cssText = 'display:block;background:rgba(239,68,68,.1);border:1px solid rgba(239,68,68,.25);color:#f87171;';
status.textContent = data.message || 'Upload failed. Please try again.';
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-arrow-repeat"></i> <span>Replace File</span>';
}
})
.catch(() => {
status.style.cssText = 'display:block;background:rgba(239,68,68,.1);border:1px solid rgba(239,68,68,.25);color:#f87171;';
status.textContent = 'Upload failed. Please try again.';
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-arrow-repeat"></i> <span>Replace File</span>';
});
}
</script>
<x-image-cropper
+20 -9
View File
@@ -318,19 +318,29 @@ const thumbnailDefault = document.getElementById('playlist-thumbnail-default');
const thumbnailPreview = document.getElementById('playlist-thumbnail-preview');
const thumbnailImg = document.getElementById('playlist-thumbnail-img');
// Click to upload
// Click to upload — open file picker; cropper will open after file is chosen
thumbnailDropzone.addEventListener('click', function(e) {
if (e.target.closest('button')) return;
if (typeof window.openCropperModal_thumb_pl_create === 'function') {
window.openCropperModal_thumb_pl_create();
} else {
thumbnailInput.click();
}
thumbnailInput.click();
});
// File input change
// File input change — if cropper is open (result came from cropper), just show preview;
// otherwise preload the file into the cropper and open it.
thumbnailInput.addEventListener('change', function() {
handleThumbnailSelect(this);
if (!this.files || !this.files[0]) return;
const cropperOpen = document.getElementById('tcOverlay_thumb_pl_create')?.classList.contains('open');
if (cropperOpen) {
handleThumbnailSelect(this);
return;
}
const file = this.files[0];
if (typeof window.tcPreload_thumb_pl_create === 'function' &&
typeof window.openCropperModal_thumb_pl_create === 'function') {
window.tcPreload_thumb_pl_create(file);
window.openCropperModal_thumb_pl_create();
} else {
handleThumbnailSelect(this);
}
});
// Drag and drop
@@ -353,7 +363,8 @@ thumbnailDropzone.addEventListener('drop', function(e) {
if (e.dataTransfer.files.length) {
const droppedFile = e.dataTransfer.files[0];
if (typeof window.tcPreload_thumb_pl_create === 'function') {
if (typeof window.tcPreload_thumb_pl_create === 'function' &&
typeof window.openCropperModal_thumb_pl_create === 'function') {
window.tcPreload_thumb_pl_create(droppedFile);
window.openCropperModal_thumb_pl_create();
} else {
+4 -4
View File
@@ -1590,7 +1590,7 @@ $headerSocialMap = [
@foreach($allVids as $v)
<a href="{{ route('videos.show', $v) }}" class="ch-post-video-card" style="{{ !$loop->first ? 'margin-top:8px;' : '' }}">
<div class="ch-post-video-thumb-wrap">
<img src="{{ $v->thumbnail ? asset('storage/thumbnails/'.$v->thumbnail) : '' }}"
<img src="{{ $v->thumbnail ? route('media.thumbnail', $v->thumbnail) : '' }}"
alt="" onerror="this.style.display='none'">
<div class="ch-post-video-play"><i class="bi bi-play-circle-fill"></i></div>
</div>
@@ -1774,7 +1774,7 @@ $headerSocialMap = [
@foreach($shorts as $short)
<a href="{{ route('videos.show', $short) }}" class="ch-short-card">
<div class="ch-short-thumb">
<img src="{{ $short->thumbnail ? asset('storage/thumbnails/' . $short->thumbnail) : 'https://picsum.photos/seed/' . $short->id . '/360/640' }}"
<img src="{{ $short->thumbnail ? route('media.thumbnail', $short->thumbnail) : 'https://picsum.photos/seed/' . $short->id . '/360/640' }}"
alt="{{ $short->title }}" loading="lazy">
@if($short->duration)
<span class="ch-short-duration">{{ gmdate('i:s', $short->duration) }}</span>
@@ -2454,11 +2454,11 @@ if ($oldLinks) {
<div class="ch-vp-card"
data-id="{{ $v->id }}"
data-title="{{ $v->title }}"
data-thumb="{{ $v->thumbnail ? asset('storage/thumbnails/'.$v->thumbnail) : '' }}"
data-thumb="{{ $v->thumbnail ? route('media.thumbnail', $v->thumbnail) : '' }}"
onclick="togglePickerCard(this)">
<div class="ch-vp-card-thumb">
@if($v->thumbnail)
<img src="{{ asset('storage/thumbnails/'.$v->thumbnail) }}" alt="" loading="lazy">
<img src="{{ route('media.thumbnail', $v->thumbnail) }}" alt="" loading="lazy">
@else
<div style="width:100%;height:100%;display:flex;align-items:center;justify-content:center;color:rgba(255,255,255,.2);font-size:22px;"><i class="bi bi-film"></i></div>
@endif
+3 -3
View File
@@ -328,7 +328,7 @@ if ($oldLinks) {
<div class="profile-content">
<div class="profile-avatar-wrap">
@if($user->avatar)
<img src="{{ asset('storage/avatars/' . $user->avatar) }}" alt="{{ $user->name }}" class="profile-avatar" id="pageAvatar">
<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">
@endif
@@ -416,7 +416,7 @@ if ($oldLinks) {
<div class="video-card">
<a href="{{ route('videos.show', $video) }}">
<div class="video-thumbnail">
<img src="{{ $video->thumbnail ? asset('storage/thumbnails/' . $video->thumbnail) : 'https://i.ytimg.com/vi/default.jpg' }}" alt="{{ $video->title }}">
<img src="{{ $video->thumbnail ? route('media.thumbnail', $video->thumbnail) : 'https://i.ytimg.com/vi/default.jpg' }}" alt="{{ $video->title }}">
<span class="video-duration">{{ $video->duration ?? '0:00' }}</span>
</div>
</a>
@@ -525,7 +525,7 @@ if ($oldLinks) {
<div class="avatar-upload-area">
<div class="avatar-preview-wrap">
@if($user->avatar)
<img src="{{ asset('storage/avatars/' . $user->avatar) }}" class="avatar-preview" id="avatarPreview" alt="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">
@endif
File diff suppressed because it is too large. Load diff
+105 -3
View File
@@ -179,7 +179,7 @@
<div class="form-group">
<label class="form-label"><i class="bi bi-image"></i> Current Thumbnail</label>
<div id="current-thumb-wrap" class="{{ $video->thumbnail ? 'has-thumb' : '' }}">
<img src="{{ $video->thumbnail ? asset('storage/thumbnails/'.$video->thumbnail) : '' }}" alt="Thumbnail">
<img src="{{ $video->thumbnail ? route('media.thumbnail', $video->thumbnail) : '' }}" alt="Thumbnail">
<div id="current-thumb-placeholder">
<i class="bi bi-card-image"></i>
<span>No thumbnail set</span>
@@ -305,8 +305,41 @@
</button>
</form>
{{-- Replace File --}}
<div class="danger-zone" style="margin-top:20px;">
<p style="color:#f87171;"><i class="bi bi-arrow-repeat" style="margin-right:6px;"></i>Replace Media File</p>
<p style="font-size:12px;color:#888;margin:-8px 0 14px;line-height:1.5;">
Fix a corrupted or missing file without losing any views, likes, or comments.
</p>
<div id="ep-rfl-dropzone" onclick="document.getElementById('ep-rfl-input').click()"
style="border:2px dashed #444;border-radius:10px;padding:18px;text-align:center;cursor:pointer;transition:border-color .15s,background .15s;margin-bottom:10px;"
onmouseenter="this.style.borderColor='#ef4444';this.style.background='rgba(239,68,68,.05)'"
onmouseleave="this.style.borderColor='#444';this.style.background='transparent'">
<i class="bi bi-cloud-upload" style="font-size:26px;color:#666;display:block;margin-bottom:6px;"></i>
<span id="ep-rfl-label" style="font-size:13px;color:#888;">Tap to choose replacement file</span>
<input type="file" id="ep-rfl-input" accept="video/*,audio/*,.mp4,.webm,.ogg,.mov,.avi,.wmv,.flv,.mkv,.mp3,.m4a,.aac,.wav,.flac,.opus" style="display:none;" onchange="epRflSelected(this)">
</div>
<div id="ep-rfl-info" style="display:none;background:#1a1a1a;border:1px solid #333;border-radius:8px;padding:10px 14px;align-items:center;gap:10px;margin-bottom:10px;">
<i class="bi bi-file-earmark-play" style="font-size:20px;color:#ef4444;flex-shrink:0;"></i>
<div style="flex:1;min-width:0;">
<div id="ep-rfl-name" style="font-size:13px;font-weight:600;color:#e5e5e5;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;"></div>
<div id="ep-rfl-size" style="font-size:11px;color:#888;margin-top:2px;"></div>
</div>
<button type="button" onclick="epRflClear()" style="background:none;border:none;color:#888;cursor:pointer;font-size:16px;flex-shrink:0;padding:0;"><i class="bi bi-x-lg"></i></button>
</div>
<div id="ep-rfl-status" style="display:none;margin-bottom:10px;font-size:12px;padding:8px 12px;border-radius:6px;"></div>
<button type="button" id="ep-rfl-btn" onclick="epRflSubmit()" disabled
class="action-btn action-btn-danger" style="width:100%;justify-content:center;">
<i class="bi bi-arrow-repeat"></i> <span>Replace File</span>
</button>
</div>
{{-- Danger zone --}}
<div class="danger-zone">
<div class="danger-zone" style="margin-top:12px;">
<p>Danger Zone</p>
<form action="{{ route('videos.destroy', $video) }}" method="POST" id="delete-edit-form">
@csrf
@@ -380,7 +413,7 @@
}
@else
// ── Slides manager (audio tracks only) ────────────────────────
let epSlidesData = @json($video->slides->map(fn($s) => ['id' => $s->id, 'url' => asset('storage/thumbnails/'.$s->filename)])->values());
let epSlidesData = @json($video->slides->map(fn($s) => ['id' => $s->id, 'url' => route('media.thumbnail', $s->filename)])->values());
let epDragSrc = null;
function _epSyncOrder() {
@@ -536,6 +569,75 @@
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-check-lg"></i> <span>Save Changes</span>';
}
// ── Replace File ──────────────────────────────────────────────────────
let _epRflFile = null;
const _epRflFmtSize = b => b >= 1073741824 ? (b/1073741824).toFixed(1)+' GB' : b >= 1048576 ? (b/1048576).toFixed(1)+' MB' : Math.round(b/1024)+' KB';
function epRflSelected(input) {
_epRflFile = input.files[0] || null;
const info = document.getElementById('ep-rfl-info');
if (_epRflFile) {
document.getElementById('ep-rfl-name').textContent = _epRflFile.name;
document.getElementById('ep-rfl-size').textContent = _epRflFmtSize(_epRflFile.size);
document.getElementById('ep-rfl-label').textContent = _epRflFile.name;
info.style.display = 'flex';
} else {
info.style.display = 'none';
document.getElementById('ep-rfl-label').textContent = 'Tap to choose replacement file';
}
document.getElementById('ep-rfl-btn').disabled = !_epRflFile;
document.getElementById('ep-rfl-status').style.display = 'none';
}
function epRflClear() {
_epRflFile = null;
document.getElementById('ep-rfl-input').value = '';
document.getElementById('ep-rfl-info').style.display = 'none';
document.getElementById('ep-rfl-label').textContent = 'Tap to choose replacement file';
document.getElementById('ep-rfl-btn').disabled = true;
document.getElementById('ep-rfl-status').style.display = 'none';
}
function epRflSubmit() {
if (!_epRflFile) return;
const btn = document.getElementById('ep-rfl-btn');
const status = document.getElementById('ep-rfl-status');
btn.disabled = true;
btn.innerHTML = '<i class="bi bi-arrow-repeat"></i> Uploading…';
status.style.display = 'none';
const fd = new FormData();
fd.append('_token', '{{ csrf_token() }}');
fd.append('replacement_file', _epRflFile);
fetch('{{ route("videos.replaceFile", $video) }}', {
method: 'POST',
headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
body: fd
})
.then(r => r.json())
.then(data => {
if (data.success) {
status.style.cssText = 'display:block;background:rgba(74,222,128,.1);border:1px solid rgba(74,222,128,.25);color:#4ade80;';
status.innerHTML = '<i class="bi bi-check-circle-fill"></i> ' + data.message;
epRflClear();
setTimeout(() => location.reload(), 2000);
} else {
status.style.cssText = 'display:block;background:rgba(239,68,68,.1);border:1px solid rgba(239,68,68,.25);color:#f87171;';
status.textContent = data.message || 'Upload failed. Please try again.';
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-arrow-repeat"></i> <span>Replace File</span>';
}
})
.catch(() => {
status.style.cssText = 'display:block;background:rgba(239,68,68,.1);border:1px solid rgba(239,68,68,.25);color:#f87171;';
status.textContent = 'Upload failed. Please try again.';
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-arrow-repeat"></i> <span>Replace File</span>';
});
}
</script>
<x-image-cropper
id="thumb_edit_mobile"
+2 -22
View File
@@ -14,28 +14,8 @@
@media (max-width: 576px) { .yt-video-grid { grid-template-columns: 1fr; gap: 14px; } }
/* ── Playlist count badge (right strip on thumbnail) ── */
.pl-count-badge {
position: absolute; inset: 0 0 0 auto;
width: 72px;
background: rgba(0,0,0,.78);
display: flex; flex-direction: column;
align-items: center; justify-content: center;
gap: 4px; font-size: 12px; font-weight: 600; color: #fff;
pointer-events: none;
}
.pl-count-badge i { font-size: 20px; }
/* ── Thumbnail orientation fix ── */
/* ── Playlist type badge (top-left on thumbnail) ── */
.feed-pl-type-badge {
position: absolute; top: 8px; left: 8px;
background: rgba(0,0,0,.75); color: #fff;
font-size: 11px; font-weight: 700;
padding: 3px 8px; border-radius: 4px;
display: flex; align-items: center; gap: 4px;
text-transform: uppercase; letter-spacing: .4px;
pointer-events: none;
}
/* ── Thumbnail orientation fix ── */
.yt-video-card .yt-video-thumb img { object-fit: cover; }
@@ -167,7 +147,7 @@
@include('components.video-card', ['video' => $entry['item']])
@else
@php $pl = $entry['item']; @endphp
@include('components.playlist-card', ['playlist' => $pl, 'showTypeBadge' => true])
@include('components.playlist-card', ['playlist' => $pl])
@endif
@endforeach
</div>
@@ -1,10 +1,10 @@
@php
$audioUrl = route('videos.stream', $video);
$coverUrl = $video->thumbnail ? asset('storage/thumbnails/' . $video->thumbnail) : asset('storage/images/logo.png');
$coverUrl = $video->thumbnail ? route('media.thumbnail', $video->thumbnail) : asset('storage/images/logo.png');
$nextUrl = isset($nextVideo, $playlist) ? route('videos.show', $nextVideo).'?playlist='.$playlist->share_token : null;
$prevUrl = isset($previousVideo, $playlist) ? route('videos.show', $previousVideo).'?playlist='.$playlist->share_token : null;
$slideUrls = $video->slides->count() > 1
? $video->slides->map(fn($s) => asset('storage/thumbnails/' . $s->filename))->values()->all()
? $video->slides->map(fn($s) => route('media.thumbnail', $s->filename))->values()->all()
: [];
@endphp
+120 -4
View File
@@ -478,11 +478,15 @@
</button>
</form>
<!-- Edit Button - Only for video owner -->
<!-- Edit / Delete Buttons - Only for video owner -->
@if (Auth::id() === $video->user_id)
<button class="yt-action-btn" onclick="openEditVideoModal('{{ $video->getRouteKey() }}')">
<i class="bi bi-pencil"></i> Edit
</button>
<button class="yt-action-btn" onclick="openVideoDeleteDialog()"
style="color:#ef4444;">
<i class="bi bi-trash"></i> Delete
</button>
@endif
@else
<a href="{{ route('login') }}" class="yt-action-btn">
@@ -696,7 +700,7 @@
style="{{ $isCurrent ? 'cursor:default;' : 'cursor:pointer;' }}">
<div class="sidebar-thumb" style="position: relative;">
@if ($playlistVideo->thumbnail)
<img src="{{ asset('storage/thumbnails/' . $playlistVideo->thumbnail) }}"
<img src="{{ route('media.thumbnail', $playlistVideo->thumbnail) }}"
alt="{{ $playlistVideo->title }}">
@else
<img src="https://picsum.photos/seed/{{ $playlistVideo->id }}/320/180"
@@ -751,7 +755,7 @@
onclick="window.location.href='{{ route('videos.show', $recVideo) }}'">
<div class="sidebar-thumb" style="position: relative;">
@if ($recVideo->thumbnail)
<img src="{{ asset('storage/thumbnails/' . $recVideo->thumbnail) }}"
<img src="{{ route('media.thumbnail', $recVideo->thumbnail) }}"
alt="{{ $recVideo->title }}">
@else
<img src="https://picsum.photos/seed/{{ $recVideo->id }}/320/180"
@@ -822,7 +826,12 @@
</button>
@auth
@if (Auth::id() !== $video->user_id)
@if (Auth::id() === $video->user_id)
<button class="yt-action-btn" onclick="openVideoDeleteDialog()"
style="flex:1;color:#ef4444;">
<i class="bi bi-trash"></i><span>Delete</span>
</button>
@else
<button class="yt-action-btn" style="flex:1;background:var(--brand-red);color:white;">
<i class="bi bi-bell"></i><span>Subscribe</span>
</button>
@@ -886,6 +895,113 @@
}
});
</script>
@auth
@if(Auth::id() === $video->user_id)
@php $owner2fa = Auth::user()->two_factor_enabled && Auth::user()->two_factor_secret; @endphp
{{-- Delete confirmation dialog --}}
<div id="videoDeleteDialog" style="display:none;position:fixed;inset:0;z-index:9999;background:rgba(0,0,0,.7);backdrop-filter:blur(4px);align-items:center;justify-content:center;padding:16px;" onclick="if(event.target===this)closeVideoDeleteDialog()">
<div style="background:var(--bg-secondary);border:1px solid var(--border-color);border-radius:16px;width:100%;max-width:420px;overflow:hidden;box-shadow:0 24px 60px rgba(0,0,0,.6);">
<div style="padding:20px 24px 16px;border-bottom:1px solid var(--border-color);display:flex;align-items:center;justify-content:space-between;">
<div style="display:flex;align-items:center;gap:10px;font-size:16px;font-weight:600;color:#ef4444;">
<i class="bi bi-trash"></i> Delete Video
</div>
<button onclick="closeVideoDeleteDialog()" style="background:none;border:none;color:var(--text-secondary);cursor:pointer;font-size:18px;line-height:1;padding:4px;"><i class="bi bi-x-lg"></i></button>
</div>
<div style="padding:20px 24px;">
<p style="margin:0 0 12px;font-size:14px;color:var(--text-primary);">
You are about to permanently delete <strong>{{ $video->title }}</strong>.
</p>
<div style="background:rgba(239,68,68,.1);border:1px solid rgba(239,68,68,.25);border-radius:8px;padding:10px 14px;font-size:13px;color:#fca5a5;display:flex;gap:8px;align-items:flex-start;">
<i class="bi bi-exclamation-triangle-fill" style="flex-shrink:0;margin-top:1px;"></i>
<span>All views, likes, comments, and HLS files will also be deleted. This cannot be undone.</span>
</div>
@if($owner2fa)
<div style="margin-top:16px;">
<label style="font-size:13px;color:var(--text-secondary);display:flex;align-items:center;gap:6px;margin-bottom:8px;">
<i class="bi bi-shield-lock-fill" style="color:#a78bfa;"></i>
Enter your <strong style="color:var(--text-primary);">2FA code</strong> to confirm
</label>
<input type="text" id="delVideoOtp" inputmode="numeric" pattern="[0-9]*"
maxlength="6" autocomplete="one-time-code" placeholder="000000"
style="width:100%;background:var(--bg-primary);border:1px solid var(--border-color);border-radius:8px;padding:10px 14px;color:#fff;font-size:22px;letter-spacing:.3em;text-align:center;box-sizing:border-box;">
</div>
@endif
<div id="delVideoError" style="display:none;margin-top:12px;padding:8px 12px;background:rgba(239,68,68,.12);border:1px solid rgba(239,68,68,.3);border-radius:6px;color:#f87171;font-size:13px;"></div>
</div>
<div style="padding:12px 24px 20px;display:flex;gap:10px;justify-content:flex-end;">
<button onclick="closeVideoDeleteDialog()" class="yt-action-btn">Cancel</button>
<button id="delVideoConfirmBtn" onclick="confirmVideoDelete()" class="yt-action-btn" style="background:#ef4444;color:#fff;border-color:#ef4444;">
<i class="bi bi-trash"></i> Delete
</button>
</div>
</div>
</div>
<script>
const _owner2fa = {{ $owner2fa ? 'true' : 'false' }};
const _videoDeleteUrl = '{{ route('videos.destroy', $video) }}';
const _videoCsrf = '{{ csrf_token() }}';
function openVideoDeleteDialog() {
const dlg = document.getElementById('videoDeleteDialog');
dlg.style.display = 'flex';
dlg.style.alignItems = 'center';
dlg.style.justifyContent = 'center';
document.getElementById('delVideoError').style.display = 'none';
if (_owner2fa) { document.getElementById('delVideoOtp').value = ''; setTimeout(() => document.getElementById('delVideoOtp').focus(), 100); }
}
function closeVideoDeleteDialog() {
document.getElementById('videoDeleteDialog').style.display = 'none';
}
function confirmVideoDelete() {
const btn = document.getElementById('delVideoConfirmBtn');
const errEl = document.getElementById('delVideoError');
const otpCode = _owner2fa ? document.getElementById('delVideoOtp').value.replace(/\s/g,'') : '';
if (_owner2fa && otpCode.length !== 6) {
errEl.textContent = 'Please enter your 6-digit 2FA code.';
errEl.style.display = 'block';
document.getElementById('delVideoOtp').focus();
return;
}
btn.disabled = true;
btn.innerHTML = '<i class="bi bi-hourglass-split"></i> Deleting…';
errEl.style.display = 'none';
const body = new URLSearchParams({ '_token': _videoCsrf, '_method': 'DELETE' });
if (_owner2fa) body.append('otp_code', otpCode);
fetch(_videoDeleteUrl, {
method: 'POST',
headers: { 'Accept': 'application/json', 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString()
})
.then(r => r.json())
.then(data => {
if (data.success) {
window.location.href = '/';
} else {
errEl.textContent = data.message || 'Delete failed.';
errEl.style.display = 'block';
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-trash"></i> Delete';
if (_owner2fa) { document.getElementById('delVideoOtp').value = ''; document.getElementById('delVideoOtp').focus(); }
}
})
.catch(() => {
errEl.textContent = 'An error occurred. Please try again.';
errEl.style.display = 'block';
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-trash"></i> Delete';
});
}
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeVideoDeleteDialog(); });
</script>
@endif
@endauth
@endsection
@@ -455,7 +455,7 @@
onclick="window.location.href='{{ route('videos.show', $playlistVideo) }}?playlist={{ $playlist->share_token }}'">
<div class="sidebar-thumb" style="position: relative;">
@if ($playlistVideo->thumbnail)
<img src="{{ asset('storage/thumbnails/' . $playlistVideo->thumbnail) }}"
<img src="{{ route('media.thumbnail', $playlistVideo->thumbnail) }}"
alt="{{ $playlistVideo->title }}">
@else
<img src="https://picsum.photos/seed/{{ $playlistVideo->id }}/320/180"
@@ -507,7 +507,7 @@
onclick="window.location.href='{{ route('videos.show', $recVideo) }}'">
<div class="sidebar-thumb" style="position: relative;">
@if ($recVideo->thumbnail)
<img src="{{ asset('storage/thumbnails/' . $recVideo->thumbnail) }}"
<img src="{{ route('media.thumbnail', $recVideo->thumbnail) }}"
alt="{{ $recVideo->title }}">
@else
<img src="https://picsum.photos/seed/{{ $recVideo->id }}/320/180"
+2 -2
View File
@@ -2543,7 +2543,7 @@
onclick="window.location.href='{{ route('videos.show', $playlistVideo) }}?playlist={{ $playlist->share_token }}'">
<div class="sidebar-thumb" style="position: relative;">
@if ($playlistVideo->thumbnail)
<img src="{{ asset('storage/thumbnails/' . $playlistVideo->thumbnail) }}"
<img src="{{ route('media.thumbnail', $playlistVideo->thumbnail) }}"
alt="{{ $playlistVideo->title }}">
@else
<img src="https://picsum.photos/seed/{{ $playlistVideo->id }}/320/180"
@@ -2595,7 +2595,7 @@
onclick="window.location.href='{{ route('videos.show', $recVideo) }}'">
<div class="sidebar-thumb" style="position: relative;">
@if ($recVideo->thumbnail)
<img src="{{ asset('storage/thumbnails/' . $recVideo->thumbnail) }}"
<img src="{{ route('media.thumbnail', $recVideo->thumbnail) }}"
alt="{{ $recVideo->title }}">
@else
<img src="https://picsum.photos/seed/{{ $recVideo->id }}/320/180"
+2 -2
View File
@@ -483,7 +483,7 @@
onclick="window.location.href='{{ route('videos.show', $playlistVideo) }}?playlist={{ $playlist->share_token }}'">
<div class="sidebar-thumb" style="position: relative;">
@if ($playlistVideo->thumbnail)
<img src="{{ asset('storage/thumbnails/' . $playlistVideo->thumbnail) }}"
<img src="{{ route('media.thumbnail', $playlistVideo->thumbnail) }}"
alt="{{ $playlistVideo->title }}">
@else
<img src="https://picsum.photos/seed/{{ $playlistVideo->id }}/320/180"
@@ -535,7 +535,7 @@
onclick="window.location.href='{{ route('videos.show', $recVideo) }}'">
<div class="sidebar-thumb" style="position: relative;">
@if ($recVideo->thumbnail)
<img src="{{ asset('storage/thumbnails/' . $recVideo->thumbnail) }}"
<img src="{{ route('media.thumbnail', $recVideo->thumbnail) }}"
alt="{{ $recVideo->title }}">
@else
<img src="https://picsum.photos/seed/{{ $recVideo->id }}/320/180"