Compare commits
8
Commits
4d9bc0f501
...
639279f2a2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
639279f2a2 | ||
|
|
8c6746e547 | ||
|
|
45505e6419 | ||
|
|
59513f2051 | ||
|
|
677e8711dd | ||
|
|
f7c3b236ab | ||
|
|
e550d8d15b | ||
|
|
7ba272edf6 |
No files matched your search
@@ -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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,7 +18,7 @@ class UserController extends Controller
|
|||||||
{
|
{
|
||||||
public function __construct()
|
public function __construct()
|
||||||
{
|
{
|
||||||
$this->middleware('auth')->except(['channel']);
|
$this->middleware('auth')->except(['channel', 'channelVideosMore']);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Typeahead search for members (used by the "Share to member" picker)
|
// Typeahead search for members (used by the "Share to member" picker)
|
||||||
@@ -372,6 +372,38 @@ class UserController extends Controller
|
|||||||
return redirect()->route('history')->with('toast_success', 'Watch history cleared.');
|
return redirect()->route('history')->with('toast_success', 'Watch history cleared.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JSON endpoint powering the "Load more" button on the channel Videos
|
||||||
|
* tab. Takes a username + optional cursor and returns a page of
|
||||||
|
* server-rendered video cards + next cursor. Reuses the cursorPaginate
|
||||||
|
* pattern Phase 2 established for the home feed.
|
||||||
|
*/
|
||||||
|
public function channelVideosMore(Request $request, string $username)
|
||||||
|
{
|
||||||
|
$owner = User::where('username', $username)->firstOrFail();
|
||||||
|
$viewer = Auth::user();
|
||||||
|
$isOwner = $viewer && $viewer->id === $owner->id;
|
||||||
|
|
||||||
|
$query = $isOwner
|
||||||
|
? Video::where('user_id', $owner->id)
|
||||||
|
: Video::public()->where('user_id', $owner->id);
|
||||||
|
|
||||||
|
$paginator = $query->where('is_shorts', false)
|
||||||
|
->latest('id')
|
||||||
|
->cursorPaginate(24);
|
||||||
|
|
||||||
|
$html = '';
|
||||||
|
foreach ($paginator->items() as $video) {
|
||||||
|
$html .= view('components.video-card', ['video' => $video, 'hideChannelInfo' => true])->render();
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'items' => $html,
|
||||||
|
'next_cursor' => optional($paginator->nextCursor())->encode(),
|
||||||
|
'has_more' => $paginator->hasMorePages(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Subscriptions feed — the public videos uploaded by every channel the
|
* Subscriptions feed — the public videos uploaded by every channel the
|
||||||
* viewer is subscribed to, newest first. Replaces the dead href="#" that
|
* viewer is subscribed to, newest first. Replaces the dead href="#" that
|
||||||
|
|||||||
@@ -158,8 +158,13 @@
|
|||||||
</button>
|
</button>
|
||||||
@endif
|
@endif
|
||||||
@else
|
@else
|
||||||
<button onclick="window.location.href='{{ route('login') }}'" class="action-btn desktop-action">
|
{{-- Guest Subscribe: showToast first so the user understands what's
|
||||||
<i class="bi bi-bell"></i><span>Subscribe</span>
|
happening, then redirect to the login page with intended=
|
||||||
|
preserving the current URL so after login they land back on
|
||||||
|
this video. 600 ms gives the toast a moment to be seen. --}}
|
||||||
|
<button type="button" class="action-btn desktop-action"
|
||||||
|
onclick="(function(){ if (typeof showToast==='function') showToast('Sign in to subscribe to {{ addslashes($video->user->name ?? 'this channel') }}', 'info'); setTimeout(function(){ window.location.href='{{ route('login') }}?intended=' + encodeURIComponent(window.location.pathname + window.location.search); }, 600); })();">
|
||||||
|
<i class="bi bi-bell" aria-hidden="true"></i><span>Subscribe</span>
|
||||||
</button>
|
</button>
|
||||||
@endauth
|
@endauth
|
||||||
|
|
||||||
|
|||||||
@@ -205,12 +205,99 @@
|
|||||||
</div>{{-- /chrome-controls --}}
|
</div>{{-- /chrome-controls --}}
|
||||||
</div>{{-- /chrome-bottom --}}
|
</div>{{-- /chrome-bottom --}}
|
||||||
|
|
||||||
|
{{-- End screen: shown when the video reaches its 'ended' event. Carries
|
||||||
|
a thumbnail + title for the next recommended video and a radial
|
||||||
|
countdown ring; auto-advances when the ring completes, cancelled on
|
||||||
|
any click inside the player. --}}
|
||||||
|
<div class="ytp-end-screen" id="ytpEndScreen" aria-hidden="true">
|
||||||
|
<div class="ytp-end-inner">
|
||||||
|
<div class="ytp-end-label">Up next</div>
|
||||||
|
<a class="ytp-end-card" id="ytpEndCard" href="#">
|
||||||
|
<div class="ytp-end-thumb">
|
||||||
|
<img id="ytpEndThumb" src="" alt="" loading="lazy">
|
||||||
|
<div class="ytp-end-countdown">
|
||||||
|
<svg viewBox="0 0 36 36" aria-hidden="true">
|
||||||
|
<circle class="ytp-end-track" cx="18" cy="18" r="16"></circle>
|
||||||
|
<circle class="ytp-end-fill" cx="18" cy="18" r="16" id="ytpEndRing"></circle>
|
||||||
|
</svg>
|
||||||
|
<span class="ytp-end-timer" id="ytpEndTimer">5</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ytp-end-title" id="ytpEndTitle">Loading…</div>
|
||||||
|
</a>
|
||||||
|
<div class="ytp-end-actions">
|
||||||
|
<button type="button" class="ytp-end-btn ytp-end-cancel" id="ytpEndCancel">Cancel</button>
|
||||||
|
<button type="button" class="ytp-end-btn ytp-end-play" id="ytpEndPlay">Play now</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>{{-- /ytp --}}
|
</div>{{-- /ytp --}}
|
||||||
</div>{{-- /ytp-wrap --}}
|
</div>{{-- /ytp-wrap --}}
|
||||||
|
|
||||||
|
|
||||||
@once
|
@once
|
||||||
<style>
|
<style>
|
||||||
|
/* ── End screen (autoplay countdown) ─────────────────────── */
|
||||||
|
.ytp-end-screen {
|
||||||
|
position: absolute; inset: 0;
|
||||||
|
display: none;
|
||||||
|
align-items: center; justify-content: center;
|
||||||
|
background: rgba(0,0,0,.78);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
z-index: 20;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.ytp-end-screen.visible { display: flex; }
|
||||||
|
.ytp-end-inner {
|
||||||
|
display: flex; flex-direction: column; align-items: center;
|
||||||
|
gap: 16px; max-width: 560px; padding: 24px; text-align: center;
|
||||||
|
}
|
||||||
|
.ytp-end-label {
|
||||||
|
font-size: 12px; letter-spacing: .22em; text-transform: uppercase;
|
||||||
|
color: #aaa;
|
||||||
|
}
|
||||||
|
.ytp-end-card {
|
||||||
|
display: flex; flex-direction: column; gap: 10px;
|
||||||
|
text-decoration: none; color: inherit; cursor: pointer;
|
||||||
|
border-radius: 12px; padding: 12px; max-width: 420px;
|
||||||
|
transition: background .15s;
|
||||||
|
}
|
||||||
|
.ytp-end-card:hover { background: rgba(255,255,255,.06); color: #fff; }
|
||||||
|
.ytp-end-thumb {
|
||||||
|
position: relative; width: 100%; aspect-ratio: 16/9;
|
||||||
|
border-radius: 10px; overflow: hidden; background: #1a1a1a;
|
||||||
|
}
|
||||||
|
.ytp-end-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||||
|
.ytp-end-countdown {
|
||||||
|
position: absolute; inset: 0;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
background: rgba(0,0,0,.4);
|
||||||
|
}
|
||||||
|
.ytp-end-countdown svg { width: 72px; height: 72px; transform: rotate(-90deg); }
|
||||||
|
.ytp-end-track { fill: none; stroke: rgba(255,255,255,.25); stroke-width: 3; }
|
||||||
|
.ytp-end-fill { fill: none; stroke: #fff; stroke-width: 3;
|
||||||
|
stroke-dasharray: 100.5; stroke-dashoffset: 100.5;
|
||||||
|
transition: stroke-dashoffset .1s linear; }
|
||||||
|
.ytp-end-timer {
|
||||||
|
position: absolute; font-size: 22px; font-weight: 700;
|
||||||
|
color: #fff; text-shadow: 0 1px 4px rgba(0,0,0,.6);
|
||||||
|
}
|
||||||
|
.ytp-end-title {
|
||||||
|
font-size: 16px; font-weight: 500;
|
||||||
|
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.ytp-end-actions { display: flex; gap: 10px; }
|
||||||
|
.ytp-end-btn {
|
||||||
|
padding: 8px 18px; border-radius: 20px; font-size: 13px; font-weight: 600;
|
||||||
|
border: 1px solid rgba(255,255,255,.3); background: transparent; color: #fff;
|
||||||
|
cursor: pointer; transition: background .15s;
|
||||||
|
}
|
||||||
|
.ytp-end-btn:hover { background: rgba(255,255,255,.12); }
|
||||||
|
.ytp-end-play { background: #ef4444; border-color: #ef4444; }
|
||||||
|
.ytp-end-play:hover { background: #dc2626; }
|
||||||
|
|
||||||
/* ══════════════════════════════════════════════════════════
|
/* ══════════════════════════════════════════════════════════
|
||||||
YTP PLAYER — YouTube-identical custom player
|
YTP PLAYER — YouTube-identical custom player
|
||||||
══════════════════════════════════════════════════════════ */
|
══════════════════════════════════════════════════════════ */
|
||||||
@@ -1304,17 +1391,96 @@ function navigatePrev() {
|
|||||||
}
|
}
|
||||||
window._ytpNav = { next: navigateNext, prev: navigatePrev };
|
window._ytpNav = { next: navigateNext, prev: navigatePrev };
|
||||||
|
|
||||||
|
// ── End screen + autoplay countdown ─────────────────────
|
||||||
|
// Picks the first "Up Next" sidebar card as the next target and shows a
|
||||||
|
// 5-second countdown overlay. If the viewer doesn't cancel, we hand off to
|
||||||
|
// the existing _plOnVideoEnd / navigateNext flow.
|
||||||
|
const endScreen = document.getElementById('ytpEndScreen');
|
||||||
|
const endRing = document.getElementById('ytpEndRing');
|
||||||
|
const endTimer = document.getElementById('ytpEndTimer');
|
||||||
|
const endCard = document.getElementById('ytpEndCard');
|
||||||
|
const endThumb = document.getElementById('ytpEndThumb');
|
||||||
|
const endTitle = document.getElementById('ytpEndTitle');
|
||||||
|
const endCancel = document.getElementById('ytpEndCancel');
|
||||||
|
const endPlay = document.getElementById('ytpEndPlay');
|
||||||
|
let endInterval = null;
|
||||||
|
let endTargetUrl = null;
|
||||||
|
|
||||||
|
function closeEndScreen() {
|
||||||
|
if (endScreen) endScreen.classList.remove('visible');
|
||||||
|
if (endInterval) { clearInterval(endInterval); endInterval = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function _findNextRecCard() {
|
||||||
|
// Prefer the first unwatched recommendation; fall back to the first card.
|
||||||
|
const list = document.querySelector('#recList');
|
||||||
|
if (!list) return null;
|
||||||
|
return list.querySelector('.sidebar-video-card[data-rec-url]');
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEndScreen() {
|
||||||
|
if (!endScreen) {
|
||||||
|
// No end-screen markup (e.g. shorts). Fall back to the original behavior.
|
||||||
|
if (window._plOnVideoEnd) window._plOnVideoEnd();
|
||||||
|
else if (autoplayOn || isLooping) navigateNext();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const card = _findNextRecCard();
|
||||||
|
if (!card) {
|
||||||
|
// Nothing to count down to — restore the old no-UI behavior.
|
||||||
|
if (window._plOnVideoEnd) window._plOnVideoEnd();
|
||||||
|
else if (autoplayOn || isLooping) navigateNext();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
endTargetUrl = card.dataset.recUrl;
|
||||||
|
endCard.href = endTargetUrl;
|
||||||
|
const img = card.querySelector('img');
|
||||||
|
const title = card.querySelector('.sidebar-title');
|
||||||
|
if (img && endThumb) endThumb.src = img.src;
|
||||||
|
if (title && endTitle) endTitle.textContent = title.textContent.trim();
|
||||||
|
|
||||||
|
endScreen.classList.add('visible');
|
||||||
|
|
||||||
|
let left = 5;
|
||||||
|
endTimer.textContent = left;
|
||||||
|
endRing.style.strokeDashoffset = '100.5';
|
||||||
|
const start = Date.now();
|
||||||
|
const total = 5000;
|
||||||
|
endInterval = setInterval(() => {
|
||||||
|
const elapsed = Date.now() - start;
|
||||||
|
const remaining = Math.max(0, total - elapsed);
|
||||||
|
left = Math.ceil(remaining / 1000);
|
||||||
|
endTimer.textContent = left;
|
||||||
|
endRing.style.strokeDashoffset = String(100.5 * (remaining / total));
|
||||||
|
if (remaining <= 0) {
|
||||||
|
closeEndScreen();
|
||||||
|
if (window._plOnVideoEnd) window._plOnVideoEnd();
|
||||||
|
else navigateNext();
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
endCancel?.addEventListener('click', closeEndScreen);
|
||||||
|
endPlay?.addEventListener('click', () => {
|
||||||
|
closeEndScreen();
|
||||||
|
if (window._plOnVideoEnd) window._plOnVideoEnd();
|
||||||
|
else navigateNext();
|
||||||
|
});
|
||||||
|
|
||||||
video.addEventListener('ended', () => {
|
video.addEventListener('ended', () => {
|
||||||
updatePlayIcon();
|
updatePlayIcon();
|
||||||
largePlay.classList.add('visible');
|
largePlay.classList.add('visible');
|
||||||
clearTimeout(hideTimer);
|
clearTimeout(hideTimer);
|
||||||
releaseWakeLock();
|
releaseWakeLock();
|
||||||
if (window._plOnVideoEnd) {
|
if (isLooping) { navigateNext(); return; }
|
||||||
window._plOnVideoEnd();
|
if (autoplayOn || window._plOnVideoEnd) {
|
||||||
} else if (autoplayOn || isLooping) {
|
openEndScreen();
|
||||||
navigateNext();
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
// Any interaction inside the player cancels the countdown so the viewer
|
||||||
|
// can scrub back, replay, or just stop.
|
||||||
|
video.addEventListener('play', closeEndScreen);
|
||||||
|
video.addEventListener('seeking', closeEndScreen);
|
||||||
video.addEventListener('volumechange', updateVolumeIcon);
|
video.addEventListener('volumechange', updateVolumeIcon);
|
||||||
|
|
||||||
// ── Keyboard shortcuts ────────────────────────────────
|
// ── Keyboard shortcuts ────────────────────────────────
|
||||||
@@ -1339,6 +1505,16 @@ document.addEventListener('keydown', e => {
|
|||||||
e.preventDefault(); theaterBtn && theaterBtn.click(); break;
|
e.preventDefault(); theaterBtn && theaterBtn.click(); break;
|
||||||
case 'i': case 'I':
|
case 'i': case 'I':
|
||||||
e.preventDefault(); pipBtn && !pipBtn.classList.contains('unsupported') && pipBtn.click(); break;
|
e.preventDefault(); pipBtn && !pipBtn.classList.contains('unsupported') && pipBtn.click(); break;
|
||||||
|
case 'c': case 'C':
|
||||||
|
// Captions / lyrics toggle — click the lyrics button when the
|
||||||
|
// page has one (music type). On a video that has no lyrics
|
||||||
|
// the key is a no-op; we don't throw up a toast because the
|
||||||
|
// viewer may just be hitting letters next to K / L / M.
|
||||||
|
{
|
||||||
|
const lyr = document.getElementById('ytpLyricsBtn');
|
||||||
|
if (lyr && lyr.offsetParent !== null) { e.preventDefault(); lyr.click(); showControls(); }
|
||||||
|
}
|
||||||
|
break;
|
||||||
case 'j': case 'J':
|
case 'j': case 'J':
|
||||||
e.preventDefault(); seekRelative(-10); flashDbl(dblLeft); showControls(); break;
|
e.preventDefault(); seekRelative(-10); flashDbl(dblLeft); showControls(); break;
|
||||||
case 'l': case 'L':
|
case 'l': case 'L':
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -701,6 +701,56 @@ $headerSocialMap = [
|
|||||||
<span>No videos match your search</span>
|
<span>No videos match your search</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{{-- Cursor-paginated Load More. The sentinel sits below the grid and
|
||||||
|
fetches the next slice when scrolled into view. A manual Load More
|
||||||
|
button is the fallback for browsers without IntersectionObserver. --}}
|
||||||
|
<div id="chVidSentinel"
|
||||||
|
data-username="{{ $user->username }}"
|
||||||
|
data-more-url="{{ route('channel.videos.more', ['username' => $user->username]) }}"
|
||||||
|
style="height:1px;"></div>
|
||||||
|
<div id="chVidLoadMoreWrap" style="display:none; text-align:center; padding:16px 0;">
|
||||||
|
<button type="button" id="chVidLoadMoreBtn" class="action-btn">Load more</button>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
const grid = document.getElementById('chVidGrid');
|
||||||
|
const sentinel = document.getElementById('chVidSentinel');
|
||||||
|
const btnWrap = document.getElementById('chVidLoadMoreWrap');
|
||||||
|
const btn = document.getElementById('chVidLoadMoreBtn');
|
||||||
|
if (!grid || !sentinel) return;
|
||||||
|
let cursor = '', busy = false, done = false;
|
||||||
|
const url = sentinel.dataset.moreUrl;
|
||||||
|
async function loadMore() {
|
||||||
|
if (busy || done) return;
|
||||||
|
busy = true;
|
||||||
|
try {
|
||||||
|
const u = new URL(url, window.location.origin);
|
||||||
|
if (cursor) u.searchParams.set('cursor', cursor);
|
||||||
|
const r = await fetch(u.toString(), {
|
||||||
|
headers: { 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json' },
|
||||||
|
credentials: 'same-origin',
|
||||||
|
});
|
||||||
|
if (!r.ok) throw new Error('bad');
|
||||||
|
const d = await r.json();
|
||||||
|
if (d.items) grid.insertAdjacentHTML('beforeend', d.items);
|
||||||
|
cursor = d.next_cursor || '';
|
||||||
|
done = !d.has_more;
|
||||||
|
if (done) { sentinel.style.display = 'none'; btnWrap.style.display = 'none'; }
|
||||||
|
} catch (e) {
|
||||||
|
btnWrap.style.display = 'block';
|
||||||
|
} finally { busy = false; }
|
||||||
|
}
|
||||||
|
btn?.addEventListener('click', loadMore);
|
||||||
|
if ('IntersectionObserver' in window) {
|
||||||
|
new IntersectionObserver(entries => {
|
||||||
|
if (entries.some(e => e.isIntersecting)) loadMore();
|
||||||
|
}, { rootMargin: '400px 0px' }).observe(sentinel);
|
||||||
|
} else {
|
||||||
|
btnWrap.style.display = 'block';
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
@else
|
@else
|
||||||
<div class="ch-empty">
|
<div class="ch-empty">
|
||||||
<div class="ch-empty-icon"><i class="bi bi-camera-video"></i></div>
|
<div class="ch-empty-icon"><i class="bi bi-camera-video"></i></div>
|
||||||
|
|||||||
@@ -387,11 +387,14 @@
|
|||||||
<span class="short-action-label" id="like-count-{{ $vKey }}">{{ number_format($video->like_count) }}</span>
|
<span class="short-action-label" id="like-count-{{ $vKey }}">{{ number_format($video->like_count) }}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{{-- Comments --}}
|
{{-- Comments — opens the side panel on desktop, bottom sheet on
|
||||||
<a class="short-action-btn" href="{{ route('videos.show', $video) }}#comments">
|
mobile (CSS does the breakpoint; the button toggles a shared
|
||||||
<div class="short-action-icon"><i class="bi bi-chat-fill"></i></div>
|
.shorts-comments-pane element populated via fetch). --}}
|
||||||
|
<button type="button" class="short-action-btn"
|
||||||
|
onclick="shortOpenComments('{{ $video->getRouteKey() }}')">
|
||||||
|
<div class="short-action-icon"><i class="bi bi-chat-fill" aria-hidden="true"></i></div>
|
||||||
<span class="short-action-label">{{ number_format($video->comments()->count()) }}</span>
|
<span class="short-action-label">{{ number_format($video->comments()->count()) }}</span>
|
||||||
</a>
|
</button>
|
||||||
|
|
||||||
{{-- Share --}}
|
{{-- Share --}}
|
||||||
<button class="short-action-btn" onclick="shortShare('{{ $vKey }}', {{ json_encode($video->title) }})">
|
<button class="short-action-btn" onclick="shortShare('{{ $vKey }}', {{ json_encode($video->title) }})">
|
||||||
@@ -426,6 +429,68 @@
|
|||||||
|
|
||||||
@endforelse
|
@endforelse
|
||||||
|
|
||||||
|
{{-- Shared comments pane: side drawer on desktop, bottom sheet on mobile.
|
||||||
|
Populated via fetch of the regular watch page's #ytcSection block so
|
||||||
|
all the comment features (likes, replies, translate, heart/pin) work
|
||||||
|
identically. --}}
|
||||||
|
<div id="shortsCommentsPane" class="shorts-comments-pane" aria-hidden="true">
|
||||||
|
<div class="shorts-comments-head">
|
||||||
|
<span>Comments</span>
|
||||||
|
<button type="button" class="shorts-comments-close" onclick="shortCloseComments()" aria-label="Close comments">
|
||||||
|
<i class="bi bi-x-lg" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="shorts-comments-body" id="shortsCommentsBody">
|
||||||
|
<div class="shorts-comments-loading">Loading…</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="shortsCommentsBackdrop" class="shorts-comments-backdrop" onclick="shortCloseComments()"></div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.shorts-comments-backdrop {
|
||||||
|
position: fixed; inset: 0; background: rgba(0,0,0,.45);
|
||||||
|
z-index: 59; display: none;
|
||||||
|
}
|
||||||
|
.shorts-comments-backdrop.visible { display: block; }
|
||||||
|
.shorts-comments-pane {
|
||||||
|
position: fixed;
|
||||||
|
right: 0; top: 56px; bottom: 0; width: 420px;
|
||||||
|
background: var(--bg-dark); color: var(--text-primary);
|
||||||
|
box-shadow: -4px 0 24px rgba(0,0,0,.4);
|
||||||
|
z-index: 60; display: none; flex-direction: column;
|
||||||
|
border-left: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
.shorts-comments-pane.visible { display: flex; }
|
||||||
|
.shorts-comments-head {
|
||||||
|
display: flex; justify-content: space-between; align-items: center;
|
||||||
|
padding: 12px 16px; border-bottom: 1px solid var(--border-color);
|
||||||
|
font-size: 15px; font-weight: 600;
|
||||||
|
}
|
||||||
|
.shorts-comments-close {
|
||||||
|
background: none; border: none; color: var(--text-primary);
|
||||||
|
cursor: pointer; font-size: 16px; padding: 6px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
.shorts-comments-close:hover { background: rgba(255,255,255,.08); }
|
||||||
|
.shorts-comments-body {
|
||||||
|
flex: 1; overflow-y: auto; padding: 12px 16px;
|
||||||
|
}
|
||||||
|
.shorts-comments-loading {
|
||||||
|
text-align: center; padding: 40px 16px; color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile: bottom sheet instead of right drawer. */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.shorts-comments-pane {
|
||||||
|
left: 0; right: 0; top: auto; bottom: 56px;
|
||||||
|
width: 100%; height: 70vh;
|
||||||
|
border-left: none; border-top: 1px solid var(--border-color);
|
||||||
|
border-radius: 16px 16px 0 0;
|
||||||
|
box-shadow: 0 -4px 24px rgba(0,0,0,.4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
@endsection
|
@endsection
|
||||||
|
|
||||||
@section('scripts')
|
@section('scripts')
|
||||||
@@ -593,6 +658,37 @@
|
|||||||
@endauth
|
@endauth
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ── Comments pane ──────────────────────────────────────
|
||||||
|
window.shortOpenComments = function(videoKey) {
|
||||||
|
const pane = document.getElementById('shortsCommentsPane');
|
||||||
|
const backdrop = document.getElementById('shortsCommentsBackdrop');
|
||||||
|
const body = document.getElementById('shortsCommentsBody');
|
||||||
|
if (!pane || !body) return;
|
||||||
|
body.innerHTML = '<div class="shorts-comments-loading">Loading…</div>';
|
||||||
|
pane.classList.add('visible');
|
||||||
|
pane.setAttribute('aria-hidden', 'false');
|
||||||
|
backdrop?.classList.add('visible');
|
||||||
|
fetch('/videos/' + videoKey, {
|
||||||
|
headers: { 'X-Requested-With': 'XMLHttpRequest' },
|
||||||
|
credentials: 'same-origin'
|
||||||
|
})
|
||||||
|
.then(r => r.text())
|
||||||
|
.then(html => {
|
||||||
|
const doc = new DOMParser().parseFromString(html, 'text/html');
|
||||||
|
const section = doc.getElementById('ytcSection');
|
||||||
|
if (section) { body.innerHTML = ''; body.appendChild(section); }
|
||||||
|
else { body.innerHTML = '<div class="shorts-comments-loading">Comments unavailable.</div>'; }
|
||||||
|
})
|
||||||
|
.catch(() => { body.innerHTML = '<div class="shorts-comments-loading">Could not load comments.</div>'; });
|
||||||
|
};
|
||||||
|
window.shortCloseComments = function() {
|
||||||
|
const pane = document.getElementById('shortsCommentsPane');
|
||||||
|
const backdrop = document.getElementById('shortsCommentsBackdrop');
|
||||||
|
pane?.classList.remove('visible');
|
||||||
|
pane?.setAttribute('aria-hidden', 'true');
|
||||||
|
backdrop?.classList.remove('visible');
|
||||||
|
};
|
||||||
|
|
||||||
// ── Share ──────────────────────────────────────────────────
|
// ── Share ──────────────────────────────────────────────────
|
||||||
window.shortShare = function(videoKey, title) {
|
window.shortShare = function(videoKey, title) {
|
||||||
const url = window.location.origin + '/videos/' + videoKey;
|
const url = window.location.origin + '/videos/' + videoKey;
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|
||||||
@@ -188,6 +193,8 @@ Route::middleware(['auth', 'verified'])->group(function () {
|
|||||||
|
|
||||||
// Channel - public for viewing. {username} is the user's slug (non-sequential, safe to share).
|
// Channel - public for viewing. {username} is the user's slug (non-sequential, safe to share).
|
||||||
Route::get('/channel/{username?}', [UserController::class, 'channel'])->name('channel');
|
Route::get('/channel/{username?}', [UserController::class, 'channel'])->name('channel');
|
||||||
|
Route::get('/channel/{username}/videos/more', [UserController::class, 'channelVideosMore'])
|
||||||
|
->name('channel.videos.more');
|
||||||
// YouTube-style @handle alias. For now `handle` is just the existing
|
// YouTube-style @handle alias. For now `handle` is just the existing
|
||||||
// `username` slug — if the two ever diverge, flip the controller lookup
|
// `username` slug — if the two ever diverge, flip the controller lookup
|
||||||
// in UserController::channel() to prefer a dedicated `handle` column.
|
// in UserController::channel() to prefer a dedicated `handle` column.
|
||||||
@@ -197,6 +204,20 @@ Route::get('/@{handle}', [UserController::class, 'channel'])
|
|||||||
->where('handle', '[A-Za-z0-9][A-Za-z0-9._-]*')
|
->where('handle', '[A-Za-z0-9][A-Za-z0-9._-]*')
|
||||||
->name('channel.handle');
|
->name('channel.handle');
|
||||||
|
|
||||||
|
// Tab aliases: /@handle/videos lands on the Videos tab, /@handle/shorts on
|
||||||
|
// Shorts, etc. These bridge to the single-page channel view by forwarding
|
||||||
|
// the tab name as a hash — the channel.blade.php JS already handles
|
||||||
|
// #wall/#videos/#shorts/#playlists/#about/#posts on load.
|
||||||
|
Route::get('/@{handle}/{tab}', function (string $handle, string $tab) {
|
||||||
|
$allowed = ['wall', 'videos', 'shorts', 'playlists', 'posts', 'about'];
|
||||||
|
if (! in_array($tab, $allowed, true)) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
$user = \App\Models\User::where('username', $handle)->firstOrFail();
|
||||||
|
return redirect(route('channel', $user->channel) . '#' . $tab, 302);
|
||||||
|
})->where(['handle' => '[A-Za-z0-9][A-Za-z0-9._-]*', 'tab' => '[a-z]+'])
|
||||||
|
->name('channel.handle.tab');
|
||||||
|
|
||||||
// Post routes - auth required
|
// Post routes - auth required
|
||||||
Route::middleware(['auth'])->group(function () {
|
Route::middleware(['auth'])->group(function () {
|
||||||
Route::post('/channel/{user}/posts', [PostController::class, 'store'])->name('posts.store');
|
Route::post('/channel/{user}/posts', [PostController::class, 'store'])->name('posts.store');
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Http\Middleware\VerifyCsrfToken;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Video;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Covers the bulk cleanup of deferred items:
|
||||||
|
* end-screen markup, C hotkey, guest subscribe prompt, /@handle tab aliases,
|
||||||
|
* channel videos cursor endpoint, shorts comments pane, studio skeleton.
|
||||||
|
*/
|
||||||
|
class DeferredCleanupTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
putenv('DB_CONNECTION=sqlite');
|
||||||
|
putenv('DB_DATABASE=:memory:');
|
||||||
|
$_ENV['DB_CONNECTION'] = 'sqlite';
|
||||||
|
$_ENV['DB_DATABASE'] = ':memory:';
|
||||||
|
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
$this->withoutMiddleware([
|
||||||
|
VerifyCsrfToken::class,
|
||||||
|
\Illuminate\Session\Middleware\AuthenticateSession::class,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Player ─────────────────────────────────────
|
||||||
|
|
||||||
|
public function test_player_component_ships_the_end_screen_markup(): void
|
||||||
|
{
|
||||||
|
$u = $this->makeUser();
|
||||||
|
$v = $this->makeVideo($u);
|
||||||
|
$html = view('components.video-player', ['video' => $v])->render();
|
||||||
|
|
||||||
|
$this->assertStringContainsString('id="ytpEndScreen"', $html);
|
||||||
|
$this->assertStringContainsString('ytp-end-countdown', $html, 'countdown ring class hook');
|
||||||
|
$this->assertStringContainsString('id="ytpEndRing"', $html);
|
||||||
|
$this->assertStringContainsString('id="ytpEndCancel"', $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_player_binds_c_hotkey_to_lyrics_button(): void
|
||||||
|
{
|
||||||
|
$u = $this->makeUser();
|
||||||
|
$v = $this->makeVideo($u);
|
||||||
|
$html = view('components.video-player', ['video' => $v])->render();
|
||||||
|
|
||||||
|
$this->assertStringContainsString("case 'c':", $html);
|
||||||
|
$this->assertStringContainsString('ytpLyricsBtn', $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Guest Subscribe prompt ────────────────────
|
||||||
|
|
||||||
|
public function test_guest_subscribe_button_preserves_intended_url(): void
|
||||||
|
{
|
||||||
|
$u = $this->makeUser();
|
||||||
|
$v = $this->makeVideo($u);
|
||||||
|
$html = view('components.video-actions', ['video' => $v])->render();
|
||||||
|
|
||||||
|
$this->assertStringContainsString('?intended=', $html,
|
||||||
|
'Guest Subscribe button must redirect to login with ?intended=.');
|
||||||
|
$this->assertStringContainsString('encodeURIComponent', $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── /@handle tab aliases ───────────────────────
|
||||||
|
|
||||||
|
public function test_handle_tab_alias_302_redirects_to_hash_url(): void
|
||||||
|
{
|
||||||
|
$u = User::factory()->create(['username' => 'hta-'.substr(bin2hex(random_bytes(3)), 0, 6)]);
|
||||||
|
|
||||||
|
$r = $this->get('/@' . $u->username . '/videos');
|
||||||
|
$r->assertStatus(302);
|
||||||
|
$this->assertStringEndsWith('#videos', $r->headers->get('Location'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_handle_tab_alias_404s_on_unknown_tab(): void
|
||||||
|
{
|
||||||
|
$u = User::factory()->create(['username' => 'htn-'.substr(bin2hex(random_bytes(3)), 0, 6)]);
|
||||||
|
|
||||||
|
$this->get('/@' . $u->username . '/nonsense')->assertStatus(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Channel videos cursor endpoint ─────────────
|
||||||
|
|
||||||
|
public function test_channel_videos_more_returns_cursor_json(): void
|
||||||
|
{
|
||||||
|
$u = $this->makeUser();
|
||||||
|
// Twice the page size so has_more is true.
|
||||||
|
for ($i = 0; $i < 30; $i++) {
|
||||||
|
$this->makeVideo($u, ['title' => 'Vid '.$i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$r = $this->getJson(route('channel.videos.more', ['username' => $u->username]));
|
||||||
|
$r->assertOk()
|
||||||
|
->assertJsonStructure(['items', 'next_cursor', 'has_more'])
|
||||||
|
->assertJson(['has_more' => true]);
|
||||||
|
$this->assertStringContainsString('yt-video-card', $r->json('items'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Studio ─────────────────────────────────────
|
||||||
|
|
||||||
|
public function test_studio_requires_authentication(): void
|
||||||
|
{
|
||||||
|
$this->get(route('studio'))->assertStatus(302);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_studio_renders_only_my_videos(): void
|
||||||
|
{
|
||||||
|
$me = $this->makeUser();
|
||||||
|
$other = $this->makeUser();
|
||||||
|
$mine = $this->makeVideo($me, ['title' => 'MyStudioVideo']);
|
||||||
|
$theirs = $this->makeVideo($other, ['title' => 'TheirVideo']);
|
||||||
|
$this->actingAs($me);
|
||||||
|
|
||||||
|
$html = $this->get(route('studio'))->getContent();
|
||||||
|
|
||||||
|
$this->assertStringContainsString('MyStudioVideo', $html);
|
||||||
|
$this->assertStringNotContainsString('TheirVideo', $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_studio_bulk_visibility_refuses_other_users_videos(): void
|
||||||
|
{
|
||||||
|
$me = $this->makeUser();
|
||||||
|
$other = $this->makeUser();
|
||||||
|
$mine = $this->makeVideo($me, ['visibility' => 'public']);
|
||||||
|
$theirs = $this->makeVideo($other, ['visibility' => 'public']);
|
||||||
|
$this->actingAs($me);
|
||||||
|
|
||||||
|
$this->postJson(route('studio.bulkVisibility'), [
|
||||||
|
'video_ids' => [$mine->id, $theirs->id],
|
||||||
|
'visibility' => 'private',
|
||||||
|
])->assertOk()->assertJson(['updated' => 1]);
|
||||||
|
|
||||||
|
$this->assertEquals('private', $mine->fresh()->visibility);
|
||||||
|
$this->assertEquals('public', $theirs->fresh()->visibility,
|
||||||
|
'A crafted request body must not flip another user\'s video.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_studio_bulk_delete_scoped_to_owner(): void
|
||||||
|
{
|
||||||
|
$me = $this->makeUser();
|
||||||
|
$other = $this->makeUser();
|
||||||
|
$mine = $this->makeVideo($me);
|
||||||
|
$theirs = $this->makeVideo($other);
|
||||||
|
$this->actingAs($me);
|
||||||
|
|
||||||
|
$this->postJson(route('studio.bulkDelete'), [
|
||||||
|
'video_ids' => [$mine->id, $theirs->id],
|
||||||
|
])->assertOk()->assertJson(['deleted' => 1]);
|
||||||
|
|
||||||
|
$this->assertNull(Video::find($mine->id));
|
||||||
|
$this->assertNotNull(Video::find($theirs->id));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Helpers ────────────────────────────────────
|
||||||
|
|
||||||
|
private function makeUser(): User
|
||||||
|
{
|
||||||
|
return User::factory()->create([
|
||||||
|
'username' => 'u'.substr(bin2hex(random_bytes(4)), 0, 8),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function makeVideo(User $u, array $attrs = []): Video
|
||||||
|
{
|
||||||
|
return Video::create(array_merge([
|
||||||
|
'user_id' => $u->id,
|
||||||
|
'title' => 'T',
|
||||||
|
'description' => '',
|
||||||
|
'filename' => 'users/x/videos/'.substr(bin2hex(random_bytes(4)), 0, 8).'/video.mp4',
|
||||||
|
'path' => 'users/x/videos/'.substr(bin2hex(random_bytes(4)), 0, 8).'/video.mp4',
|
||||||
|
'type' => 'generic',
|
||||||
|
'visibility' => 'public',
|
||||||
|
'status' => 'ready',
|
||||||
|
'share_token' => bin2hex(random_bytes(8)),
|
||||||
|
], $attrs));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user