feat(studio): /studio content table + bulk visibility / bulk delete
Skeleton for the creator studio surface deferred since Phase 6. The
/studio route lands on a filterable table of the signed-in user's
videos — All / Public / Unlisted / Private / Shorts / Processing-or-
Failed — with headline totals (public / unlisted / private counts,
processing + failed, total views) in a stat strip above.
Each row shows thumbnail + title + stripped description preview +
visibility badge + per-video totals. Row-level checkboxes raise a
toolbar when any row is selected; the toolbar supports:
- Change visibility across the selection (any of the three visibility
enums).
- Delete the selection with a confirm prompt.
Server-side the bulk endpoints owner-filter by Auth::id() so a crafted
request body can't flip or delete anyone else's videos. Response is
JSON { updated | deleted : N } so the page can reload / toast cleanly.
Sidebar now carries a persistent "Studio" link under the You section
alongside History / Liked Videos.
Analytics, stepped upload (Details → Visibility), and schedule remain
future work — this ships the content management surface, nothing else.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
1 parent
45505e6419
commit
8c6746e547
4 files changed
+345
No files matched your search
@@ -57,6 +57,10 @@
|
||||
<i class="bi bi-hand-thumbs-up-fill"></i>
|
||||
<span>Liked Videos</span>
|
||||
</a>
|
||||
<a href="{{ route('studio') }}" class="yt-sidebar-link {{ request()->is('studio*') ? 'active' : '' }}">
|
||||
<i class="bi bi-sliders"></i>
|
||||
<span>Studio</span>
|
||||
</a>
|
||||
</div>
|
||||
@endauth
|
||||
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', 'Studio | ' . config('app.name'))
|
||||
|
||||
@section('content')
|
||||
<style>
|
||||
.st-head { padding: 24px 32px 8px; }
|
||||
.st-head h1 { font-size: 24px; font-weight: 700; margin: 0 0 4px; display: flex; align-items: center; gap: 10px; }
|
||||
.st-head h1 i { color: #ef4444; }
|
||||
.st-head p { margin: 0 0 14px; color: var(--text-secondary); font-size: 13px; }
|
||||
|
||||
.st-stat-cards {
|
||||
display: grid; grid-template-columns: repeat(auto-fill, minmax(170px, 1fr));
|
||||
gap: 10px; margin: 12px 0 20px;
|
||||
}
|
||||
.st-stat-card {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 10px; padding: 12px 14px;
|
||||
display: flex; flex-direction: column; gap: 2px;
|
||||
}
|
||||
.st-stat-card .k { font-size: 11px; letter-spacing: .14em; text-transform: uppercase; color: var(--text-secondary); }
|
||||
.st-stat-card .v { font-size: 22px; font-weight: 700; color: var(--text-primary); }
|
||||
|
||||
.st-filter-bar {
|
||||
display: flex; gap: 8px; overflow-x: auto;
|
||||
padding: 4px 0; scrollbar-width: none;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.st-filter-bar::-webkit-scrollbar { display: none; }
|
||||
.st-filter-pill {
|
||||
padding: 6px 12px; border-radius: 999px;
|
||||
background: var(--bg-secondary); color: var(--text-primary);
|
||||
font-size: 12px; font-weight: 600; text-decoration: none;
|
||||
white-space: nowrap; border: 1px solid transparent;
|
||||
}
|
||||
.st-filter-pill:hover { background: rgba(255,255,255,.08); color: var(--text-primary); }
|
||||
.st-filter-pill.active { background: #ef4444; color: #fff; }
|
||||
|
||||
.st-toolbar {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 10px 14px; margin-bottom: 8px;
|
||||
background: var(--bg-secondary); border-radius: 10px;
|
||||
}
|
||||
.st-toolbar.hidden { display: none; }
|
||||
.st-toolbar-count { font-size: 13px; color: var(--text-secondary); }
|
||||
.st-toolbar-actions { display: flex; gap: 8px; align-items: center; }
|
||||
.st-visibility-select {
|
||||
background: var(--bg-dark); color: var(--text-primary);
|
||||
border: 1px solid var(--border-color); border-radius: 6px;
|
||||
padding: 6px 10px; font-size: 13px;
|
||||
}
|
||||
|
||||
.st-table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
.st-table th, .st-table td {
|
||||
padding: 10px 12px; border-bottom: 1px solid var(--border-color);
|
||||
text-align: left;
|
||||
}
|
||||
.st-table th { font-size: 11px; letter-spacing: .14em; text-transform: uppercase; color: var(--text-secondary); font-weight: 600; }
|
||||
.st-table tbody tr:hover { background: rgba(255,255,255,.03); }
|
||||
.st-table .st-check { width: 32px; }
|
||||
.st-table .st-thumb { width: 76px; height: 44px; object-fit: cover; border-radius: 6px; }
|
||||
.st-table .st-title-cell { display: flex; gap: 10px; align-items: flex-start; max-width: 320px; }
|
||||
.st-table .st-title-name {
|
||||
font-weight: 500; color: var(--text-primary);
|
||||
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.st-table .st-title-desc {
|
||||
font-size: 11px; color: var(--text-secondary); margin-top: 2px;
|
||||
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.st-badge {
|
||||
display: inline-block; padding: 2px 8px; border-radius: 999px;
|
||||
font-size: 11px; font-weight: 600;
|
||||
}
|
||||
.st-badge.pub { background: rgba(34,197,94,.15); color: #4ade80; }
|
||||
.st-badge.unl { background: rgba(234,179,8,.15); color: #fde047; }
|
||||
.st-badge.priv { background: rgba(100,116,139,.2); color: #cbd5e1; }
|
||||
.st-status { font-size: 11px; color: var(--text-secondary); }
|
||||
.st-row-link { color: var(--text-primary); text-decoration: none; }
|
||||
.st-row-link:hover { color: #ef4444; }
|
||||
</style>
|
||||
|
||||
<div class="st-head">
|
||||
<h1><i class="bi bi-sliders" aria-hidden="true"></i> Studio</h1>
|
||||
<p>Manage every video you've uploaded. Select rows for bulk visibility changes.</p>
|
||||
|
||||
<div class="st-stat-cards">
|
||||
<div class="st-stat-card"><span class="k">Total videos</span> <span class="v">{{ number_format($totals['total']) }}</span></div>
|
||||
<div class="st-stat-card"><span class="k">Public</span> <span class="v">{{ number_format($totals['public']) }}</span></div>
|
||||
<div class="st-stat-card"><span class="k">Unlisted</span> <span class="v">{{ number_format($totals['unlisted']) }}</span></div>
|
||||
<div class="st-stat-card"><span class="k">Private</span> <span class="v">{{ number_format($totals['private']) }}</span></div>
|
||||
<div class="st-stat-card"><span class="k">Processing</span> <span class="v">{{ number_format($totals['processing']) }}</span></div>
|
||||
<div class="st-stat-card"><span class="k">Failed</span> <span class="v">{{ number_format($totals['failed']) }}</span></div>
|
||||
<div class="st-stat-card"><span class="k">Total views</span> <span class="v">{{ number_format($totals['total_views']) }}</span></div>
|
||||
</div>
|
||||
|
||||
<div class="st-filter-bar" role="navigation" aria-label="Filter videos">
|
||||
@foreach (['all'=>'All', 'public'=>'Public', 'unlisted'=>'Unlisted', 'private'=>'Private', 'shorts'=>'Shorts', 'draft'=>'Processing / Failed'] as $k => $label)
|
||||
<a href="{{ route('studio', ['filter' => $k]) }}"
|
||||
class="st-filter-pill {{ $filter === $k ? 'active' : '' }}"
|
||||
@if($filter === $k) aria-current="page" @endif>{{ $label }}</a>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="padding: 0 32px 32px;">
|
||||
<div class="st-toolbar hidden" id="stToolbar" aria-live="polite">
|
||||
<span class="st-toolbar-count"><span id="stSelectedCount">0</span> selected</span>
|
||||
<div class="st-toolbar-actions">
|
||||
<select class="st-visibility-select" id="stBulkVisibility">
|
||||
<option value="">Change visibility…</option>
|
||||
<option value="public">Public</option>
|
||||
<option value="unlisted">Unlisted</option>
|
||||
<option value="private">Private</option>
|
||||
</select>
|
||||
<button type="button" class="action-btn" onclick="stApplyBulkVisibility()">Apply</button>
|
||||
<button type="button" class="action-btn action-btn-danger" onclick="stApplyBulkDelete()">Delete selected</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if ($videos->isEmpty())
|
||||
<div class="yt-empty" style="text-align:center; padding:60px 20px;">
|
||||
<i class="bi bi-camera-video" style="font-size:48px;color:var(--text-secondary);display:block;margin-bottom:12px;" aria-hidden="true"></i>
|
||||
<h2>No videos in this filter</h2>
|
||||
<p style="color:var(--text-secondary);">Switch filters above or upload a new video from the sidebar.</p>
|
||||
</div>
|
||||
@else
|
||||
<table class="st-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="st-check">
|
||||
<input type="checkbox" id="stSelectAll" onclick="stToggleAll(this)" aria-label="Select all rows">
|
||||
</th>
|
||||
<th>Video</th>
|
||||
<th>Visibility</th>
|
||||
<th>Views</th>
|
||||
<th>Likes</th>
|
||||
<th>Comments</th>
|
||||
<th>Uploaded</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach ($videos as $v)
|
||||
<tr>
|
||||
<td class="st-check"><input type="checkbox" class="st-row-check" value="{{ $v->id }}" onclick="stUpdateToolbar()" aria-label="Select {{ $v->title }}"></td>
|
||||
<td>
|
||||
<div class="st-title-cell">
|
||||
@if ($v->thumbnail)
|
||||
<img src="{{ route('media.thumbnail', $v->thumbnail) }}" class="st-thumb" alt="" loading="lazy">
|
||||
@else
|
||||
<div class="st-thumb" style="background:#1a1a1a;"></div>
|
||||
@endif
|
||||
<div style="min-width:0;">
|
||||
<a class="st-row-link" href="{{ route('videos.show', $v) }}" target="_blank">
|
||||
<div class="st-title-name" title="{{ $v->title }}">{{ $v->title }}</div>
|
||||
</a>
|
||||
@if ($v->description)
|
||||
@php $plain = trim(preg_replace('/\s+/', ' ', strip_tags((string) $v->description))); @endphp
|
||||
<div class="st-title-desc" title="{{ $plain }}">{{ Str::limit($plain, 70) }}</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
@switch($v->visibility)
|
||||
@case('public') <span class="st-badge pub">Public</span> @break
|
||||
@case('unlisted') <span class="st-badge unl">Unlisted</span> @break
|
||||
@case('private') <span class="st-badge priv">Private</span> @break
|
||||
@endswitch
|
||||
@if ($v->status !== 'ready')
|
||||
<div class="st-status">{{ ucfirst($v->status) }}</div>
|
||||
@endif
|
||||
</td>
|
||||
<td>{{ number_format($v->view_count) }}</td>
|
||||
<td>{{ number_format($v->likes()->count()) }}</td>
|
||||
<td>{{ number_format($v->comments()->count()) }}</td>
|
||||
<td>{{ $v->created_at->format('M j, Y') }}</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div style="margin-top: 16px;">{{ $videos->links() }}</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function stCheckedIds() {
|
||||
return Array.from(document.querySelectorAll('.st-row-check:checked')).map(c => parseInt(c.value, 10));
|
||||
}
|
||||
function stUpdateToolbar() {
|
||||
const n = stCheckedIds().length;
|
||||
const t = document.getElementById('stToolbar');
|
||||
document.getElementById('stSelectedCount').textContent = n;
|
||||
t.classList.toggle('hidden', n === 0);
|
||||
}
|
||||
function stToggleAll(cb) {
|
||||
document.querySelectorAll('.st-row-check').forEach(c => { c.checked = cb.checked; });
|
||||
stUpdateToolbar();
|
||||
}
|
||||
async function stApplyBulkVisibility() {
|
||||
const ids = stCheckedIds();
|
||||
const sel = document.getElementById('stBulkVisibility');
|
||||
if (!ids.length || !sel.value) return;
|
||||
const csrf = document.querySelector('meta[name="csrf-token"]')?.content || '';
|
||||
try {
|
||||
const r = await fetch('{{ route('studio.bulkVisibility') }}', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrf, 'Accept': 'application/json' },
|
||||
body: JSON.stringify({ video_ids: ids, visibility: sel.value }),
|
||||
});
|
||||
if (!r.ok) throw new Error('bad');
|
||||
const d = await r.json();
|
||||
if (typeof showToast === 'function') showToast('Updated ' + d.updated + ' video(s).', 'success');
|
||||
setTimeout(() => location.reload(), 400);
|
||||
} catch { if (typeof showToast === 'function') showToast('Could not update videos.', 'error'); }
|
||||
}
|
||||
async function stApplyBulkDelete() {
|
||||
const ids = stCheckedIds();
|
||||
if (!ids.length) return;
|
||||
if (!window.confirm('Delete ' + ids.length + ' video(s)? This cannot be undone.')) return;
|
||||
const csrf = document.querySelector('meta[name="csrf-token"]')?.content || '';
|
||||
try {
|
||||
const r = await fetch('{{ route('studio.bulkDelete') }}', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrf, 'Accept': 'application/json' },
|
||||
body: JSON.stringify({ video_ids: ids }),
|
||||
});
|
||||
if (!r.ok) throw new Error('bad');
|
||||
const d = await r.json();
|
||||
if (typeof showToast === 'function') showToast('Deleted ' + d.deleted + ' video(s).', 'success');
|
||||
setTimeout(() => location.reload(), 400);
|
||||
} catch { if (typeof showToast === 'function') showToast('Could not delete videos.', 'error'); }
|
||||
}
|
||||
</script>
|
||||
@endsection
|
||||
Reference in new issue
Block a user