From 8c6746e54734be4ac47bb7f627bc8658a18c9938 Mon Sep 17 00:00:00 2001 From: ghassan Date: Wed, 21 Oct 2026 12:35:47 +0300 Subject: [PATCH] feat(studio): /studio content table + bulk visibility / bulk delete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- app/Http/Controllers/StudioController.php | 98 ++++++++ .../views/layouts/partials/sidebar.blade.php | 4 + resources/views/studio/index.blade.php | 238 ++++++++++++++++++ routes/web.php | 5 + 4 files changed, 345 insertions(+) create mode 100644 app/Http/Controllers/StudioController.php create mode 100644 resources/views/studio/index.blade.php diff --git a/app/Http/Controllers/StudioController.php b/app/Http/Controllers/StudioController.php new file mode 100644 index 0000000..702629d --- /dev/null +++ b/app/Http/Controllers/StudioController.php @@ -0,0 +1,98 @@ +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]); + } +} diff --git a/resources/views/layouts/partials/sidebar.blade.php b/resources/views/layouts/partials/sidebar.blade.php index 872bdf4..05dd89b 100644 --- a/resources/views/layouts/partials/sidebar.blade.php +++ b/resources/views/layouts/partials/sidebar.blade.php @@ -57,6 +57,10 @@ Liked Videos + + + Studio + @endauth diff --git a/resources/views/studio/index.blade.php b/resources/views/studio/index.blade.php new file mode 100644 index 0000000..3bf4314 --- /dev/null +++ b/resources/views/studio/index.blade.php @@ -0,0 +1,238 @@ +@extends('layouts.app') + +@section('title', 'Studio | ' . config('app.name')) + +@section('content') + + +
+

Studio

+

Manage every video you've uploaded. Select rows for bulk visibility changes.

+ +
+
Total videos {{ number_format($totals['total']) }}
+
Public {{ number_format($totals['public']) }}
+
Unlisted {{ number_format($totals['unlisted']) }}
+
Private {{ number_format($totals['private']) }}
+
Processing {{ number_format($totals['processing']) }}
+
Failed {{ number_format($totals['failed']) }}
+
Total views {{ number_format($totals['total_views']) }}
+
+ + +
+ +
+ + + @if ($videos->isEmpty()) +
+ +

No videos in this filter

+

Switch filters above or upload a new video from the sidebar.

+
+ @else + + + + + + + + + + + + + + @foreach ($videos as $v) + + + + + + + + + + @endforeach + +
+ + VideoVisibilityViewsLikesCommentsUploaded
+
+ @if ($v->thumbnail) + + @else +
+ @endif +
+ +
{{ $v->title }}
+
+ @if ($v->description) + @php $plain = trim(preg_replace('/\s+/', ' ', strip_tags((string) $v->description))); @endphp +
{{ Str::limit($plain, 70) }}
+ @endif +
+
+
+ @switch($v->visibility) + @case('public') Public @break + @case('unlisted') Unlisted @break + @case('private') Private @break + @endswitch + @if ($v->status !== 'ready') +
{{ ucfirst($v->status) }}
+ @endif +
{{ number_format($v->view_count) }}{{ number_format($v->likes()->count()) }}{{ number_format($v->comments()->count()) }}{{ $v->created_at->format('M j, Y') }}
+ +
{{ $videos->links() }}
+ @endif +
+ + +@endsection diff --git a/routes/web.php b/routes/web.php index 6d75028..92d5164 100644 --- a/routes/web.php +++ b/routes/web.php @@ -177,6 +177,11 @@ Route::middleware(['auth', 'verified'])->group(function () { Route::get('/liked', [UserController::class, 'liked'])->name('liked'); 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 Route::post('/settings/logout-all-devices', [UserController::class, 'logoutAllDevices'])->name('settings.logoutAllDevices');