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:
ghassanandClaude Opus 4.7 committed 2026-10-21 12:35:47 +03:00
1 parent 45505e6419
commit 8c6746e547
4 files changed
+345

No files matched your search

+98
View File
@@ -0,0 +1,98 @@
<?php
namespace App\Http\Controllers;
use App\Models\Video;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
/**
* Creator studio — the content management surface YouTube calls "YouTube
* Studio". A skeleton for now: a filterable table of the signed-in user's
* videos plus a bulk visibility change endpoint. Analytics, stepped upload
* and schedule are future work.
*/
class StudioController extends Controller
{
public function __construct()
{
$this->middleware('auth');
}
/**
* The studio dashboard — defaults to the content tab.
*/
public function index(Request $request)
{
$user = Auth::user();
$filter = $request->get('filter', 'all');
$query = Video::where('user_id', $user->id)->latest();
switch ($filter) {
case 'public': $query->where('visibility', 'public'); break;
case 'unlisted': $query->where('visibility', 'unlisted'); break;
case 'private': $query->where('visibility', 'private'); break;
case 'shorts': $query->where('is_shorts', true); break;
case 'draft': $query->whereIn('status', ['pending', 'processing', 'failed']); break;
}
$videos = $query->paginate(24)->withQueryString();
// Headline numbers for the top bar.
$totals = [
'total' => Video::where('user_id', $user->id)->count(),
'public' => Video::where('user_id', $user->id)->where('visibility', 'public')->count(),
'unlisted' => Video::where('user_id', $user->id)->where('visibility', 'unlisted')->count(),
'private' => Video::where('user_id', $user->id)->where('visibility', 'private')->count(),
'processing' => Video::where('user_id', $user->id)->whereIn('status', ['pending', 'processing'])->count(),
'failed' => Video::where('user_id', $user->id)->where('status', 'failed')->count(),
'total_views' => \DB::table('video_views')
->whereIn('video_id', Video::where('user_id', $user->id)->pluck('id'))
->count(),
];
return view('studio.index', [
'videos' => $videos,
'filter' => $filter,
'totals' => $totals,
]);
}
/**
* Bulk visibility change. POSTs { video_ids: [...], visibility: '...' }.
* Guards against setting visibility on videos the user doesn't own, so a
* crafted request can't flip anyone else's content.
*/
public function bulkVisibility(Request $request)
{
$request->validate([
'video_ids' => ['required', 'array', 'min:1'],
'video_ids.*' => ['integer'],
'visibility' => ['required', 'in:public,unlisted,private'],
]);
$updated = Video::whereIn('id', $request->video_ids)
->where('user_id', Auth::id())
->update(['visibility' => $request->visibility]);
return response()->json(['updated' => $updated]);
}
/**
* Bulk delete. Same owner-only guard as above.
*/
public function bulkDelete(Request $request)
{
$request->validate([
'video_ids' => ['required', 'array', 'min:1'],
'video_ids.*' => ['integer'],
]);
$deleted = Video::whereIn('id', $request->video_ids)
->where('user_id', Auth::id())
->delete();
return response()->json(['deleted' => $deleted]);
}
}
@@ -57,6 +57,10 @@
<i class="bi bi-hand-thumbs-up-fill"></i> <i class="bi bi-hand-thumbs-up-fill"></i>
<span>Liked Videos</span> <span>Liked Videos</span>
</a> </a>
<a href="{{ route('studio') }}" class="yt-sidebar-link {{ request()->is('studio*') ? 'active' : '' }}">
<i class="bi bi-sliders"></i>
<span>Studio</span>
</a>
</div> </div>
@endauth @endauth
+238
View File
@@ -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
+5
View File
@@ -177,6 +177,11 @@ Route::middleware(['auth', 'verified'])->group(function () {
Route::get('/liked', [UserController::class, 'liked'])->name('liked'); Route::get('/liked', [UserController::class, 'liked'])->name('liked');
Route::get('/feed/subscriptions', [UserController::class, 'subscriptionsFeed'])->name('subscriptions.feed'); Route::get('/feed/subscriptions', [UserController::class, 'subscriptionsFeed'])->name('subscriptions.feed');
// Creator studio
Route::get('/studio', [\App\Http\Controllers\StudioController::class, 'index'])->name('studio');
Route::post('/studio/bulk/visibility', [\App\Http\Controllers\StudioController::class, 'bulkVisibility'])->name('studio.bulkVisibility');
Route::post('/studio/bulk/delete', [\App\Http\Controllers\StudioController::class, 'bulkDelete'])->name('studio.bulkDelete');
// Logout all other devices // Logout all other devices
Route::post('/settings/logout-all-devices', [UserController::class, 'logoutAllDevices'])->name('settings.logoutAllDevices'); Route::post('/settings/logout-all-devices', [UserController::class, 'logoutAllDevices'])->name('settings.logoutAllDevices');