WIP: storage-fix-local-nas work before playlist controls feature
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
This commit is contained in:
1 parent
6b3ab5b65e
commit
c160242dbc
46 files changed
+5365
-815
No files matched your search
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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 & 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 & 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 & 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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
// ── 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 →</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…</h3>
|
||||
<p id="nasDisablePhase" style="margin:0 0 16px;font-size:13px;color:var(--text-2);">Starting…</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 & Disable NAS</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+1272
-449
File diff suppressed because it is too large.
Load diff
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user