latest update
This commit is contained in:
parent
64eadfaf56
commit
2a562b99f1
44
TODO.md
44
TODO.md
@ -1,40 +1,6 @@
|
|||||||
# TODO - Topbar Standardization - COMPLETED
|
# Mobile Upload Icon Change to +
|
||||||
|
|
||||||
## Task: Use same topbar across all pages
|
## Steps:
|
||||||
|
1. [x] Edit resources/views/layouts/app.blade.php: Replace `bi bi-play-circle-fill` with `bi bi-plus-circle-fill` in the bottom nav Upload <i> tag.
|
||||||
### Summary:
|
2. [x] Verify in browser mobile view (refresh page, resize to <768px).
|
||||||
- Topbar is in a separate file: `resources/views/layouts/partials/header.blade.php`
|
3. [x] Task complete - icon updated successfully.
|
||||||
- All layouts now include this header partial
|
|
||||||
|
|
||||||
### Layouts and their pages:
|
|
||||||
|
|
||||||
1. **layouts/app.blade.php** (includes header + sidebar)
|
|
||||||
- videos/index.blade.php
|
|
||||||
- videos/trending.blade.php
|
|
||||||
- videos/show.blade.php
|
|
||||||
- videos/create.blade.php
|
|
||||||
- videos/edit.blade.php
|
|
||||||
- videos/types/*.blade.php
|
|
||||||
- user/profile.blade.php
|
|
||||||
- user/channel.blade.php
|
|
||||||
- user/history.blade.php
|
|
||||||
- user/liked.blade.php
|
|
||||||
- user/settings.blade.php
|
|
||||||
- welcome.blade.php
|
|
||||||
|
|
||||||
2. **layouts/plain.blade.php** (includes header, no sidebar)
|
|
||||||
- auth/login.blade.php
|
|
||||||
- auth/register.blade.php
|
|
||||||
|
|
||||||
3. **admin/layout.blade.php** (includes header, admin sidebar)
|
|
||||||
- admin/dashboard.blade.php
|
|
||||||
- admin/users.blade.php
|
|
||||||
- admin/videos.blade.php
|
|
||||||
- admin/edit-user.blade.php
|
|
||||||
- admin/edit-video.blade.php
|
|
||||||
|
|
||||||
### Changes Made:
|
|
||||||
- [x] 1. Analyzed current structure
|
|
||||||
- [x] 2. Updated welcome.blade.php to use layouts.app
|
|
||||||
- [x] 3. Verified plain.blade.php includes header (already had it)
|
|
||||||
- [x] 4. Verified admin layout uses header (already had it)
|
|
||||||
|
|||||||
7
TODO_delete_video_dropdown.md
Normal file
7
TODO_delete_video_dropdown.md
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
# Add Delete Dropdown to Video Show Page ✅
|
||||||
|
|
||||||
|
## Steps:
|
||||||
|
1. [x] Edit resources/views/components/video-actions.blade.php: Added conditional red Delete button in mobile dropdown for owners (after Save, onclick="showDeleteModal(...)").
|
||||||
|
2. [x] Edit resources/views/layouts/app.blade.php: Updated confirmDeleteVideo() success → redirect to {{ route('videos.index') }} instead of reload.
|
||||||
|
3. [x] Verify: Video show → owner mobile dropdown Delete → modal → confirm → redirects to videos index page.
|
||||||
|
4. [x] Task complete - delete now redirects to home videos list.
|
||||||
41
TODO_gpu_acceleration.md
Normal file
41
TODO_gpu_acceleration.md
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
# GPU Acceleration Implementation Steps
|
||||||
|
|
||||||
|
## Status: In Progress ✅ Started
|
||||||
|
|
||||||
|
**Hardware Confirmed:**
|
||||||
|
- 2x NVIDIA RTX 3060 (12GB each)
|
||||||
|
- NVIDIA Driver 580.76.05, CUDA 13.0
|
||||||
|
- FFmpeg 4.4.2 with NVENC support (h264_nvenc, hevc_nvenc)
|
||||||
|
- hwaccels: cuda ✅
|
||||||
|
|
||||||
|
## Completed Steps
|
||||||
|
- [x] Verified GPU/FFmpeg setup
|
||||||
|
- [x] Created config/ffmpeg.php ✅
|
||||||
|
- [x] Updated CompressVideoJob.php with NVENC ✅
|
||||||
|
- [x] Updated VideoController.php queue dispatch ✅
|
||||||
|
|
||||||
|
## Next Steps (Approved Plan)
|
||||||
|
1. ~~Verify GPU/FFmpeg readiness~~ ✅
|
||||||
|
2. ~~Create config/ffmpeg.php for global NVENC settings~~ ✅
|
||||||
|
3. ~~Update app/Jobs/CompressVideoJob.php: Switch to h264_nvenc (CRF 23, preset p4)~~ ✅
|
||||||
|
4. ~~Update app/Http/Controllers/VideoController.php: Queue dispatch tweaks~~ ✅
|
||||||
|
5. ~~Setup queue: php artisan queue:table && migrate && QUEUE_CONNECTION=database~~ ✅ (tables exist)
|
||||||
|
6. ~~Test encoding: Upload video, monitor logs/GPU util~~ → Now implementing HLS GPU playback
|
||||||
|
7. ~~Optional~~ Create GenerateHlsJob + frontend HLS.js player ✅ Planning
|
||||||
|
8. Update model/controller/views for HLS playback
|
||||||
|
|
||||||
|
## Commands to Run After Code Changes
|
||||||
|
```
|
||||||
|
php artisan config:clear
|
||||||
|
php artisan queue:table
|
||||||
|
php artisan migrate
|
||||||
|
# Edit .env: QUEUE_CONNECTION=database
|
||||||
|
php artisan queue:work --queue=video-processing --tries=3
|
||||||
|
# Test upload, tail -f storage/logs/laravel.log && watch nvidia-smi
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
- Upload test video
|
||||||
|
- Check encoding speed (should be 5-10x faster)
|
||||||
|
- Verify quality/size
|
||||||
|
|
||||||
@ -33,6 +33,8 @@ class CommentController extends Controller
|
|||||||
'body' => $request->body,
|
'body' => $request->body,
|
||||||
'parent_id' => $request->parent_id,
|
'parent_id' => $request->parent_id,
|
||||||
]);
|
]);
|
||||||
|
// $video->increment('comment_count'); // Disabled - was causing SQL error
|
||||||
|
$comment->load('user:id,name,avatar_url');
|
||||||
|
|
||||||
// Handle mentions
|
// Handle mentions
|
||||||
preg_match_all('/@(\w+)/', $request->body, $matches);
|
preg_match_all('/@(\w+)/', $request->body, $matches);
|
||||||
@ -41,9 +43,10 @@ class CommentController extends Controller
|
|||||||
// For now, we just parse them
|
// For now, we just parse them
|
||||||
}
|
}
|
||||||
|
|
||||||
$video->increment('comment_count');
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
return response()->json(['success' => true, 'comment' => $comment->load('user')]);
|
'comment' => $comment->load('user:id,name,avatar_url'),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function update(Request $request, Comment $comment)
|
public function update(Request $request, Comment $comment)
|
||||||
@ -60,7 +63,9 @@ class CommentController extends Controller
|
|||||||
'body' => $request->body,
|
'body' => $request->body,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return response()->json($comment->load('user'));
|
$comment->load('user:id,name,avatar_url');
|
||||||
|
|
||||||
|
return response()->json($comment->load('user:id,name,avatar_url'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function destroy(Comment $comment)
|
public function destroy(Comment $comment)
|
||||||
|
|||||||
@ -18,12 +18,35 @@ class VideoController extends Controller
|
|||||||
{
|
{
|
||||||
public function __construct()
|
public function __construct()
|
||||||
{
|
{
|
||||||
$this->middleware('auth')->except(['index', 'show', 'search', 'stream', 'trending', 'shorts']);
|
$this->middleware('auth')->except(['index', 'show', 'search', 'stream', 'hls', 'trending', 'shorts']);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function index()
|
public function index()
|
||||||
{
|
{
|
||||||
$videos = Video::public()->latest()->get();
|
$filter = request('filter', 'all');
|
||||||
|
|
||||||
|
$query = Video::public();
|
||||||
|
|
||||||
|
if ($filter !== 'all') {
|
||||||
|
switch ($filter) {
|
||||||
|
case 'latest':
|
||||||
|
$query->latest();
|
||||||
|
break;
|
||||||
|
case 'music':
|
||||||
|
$query->where('type', 'music');
|
||||||
|
break;
|
||||||
|
case 'match':
|
||||||
|
$query->where('type', 'match');
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
$query->where('title', 'LIKE', '%'.$filter.'%')
|
||||||
|
->orWhere('description', 'LIKE', '%'.$filter.'%');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$query->latest();
|
||||||
|
}
|
||||||
|
|
||||||
|
$videos = $query->limit(50)->get();
|
||||||
|
|
||||||
return view('videos.index', compact('videos'));
|
return view('videos.index', compact('videos'));
|
||||||
}
|
}
|
||||||
@ -76,7 +99,6 @@ class VideoController extends Controller
|
|||||||
$thumbFilename = Str::uuid().'.'.$request->file('thumbnail')->getClientOriginalExtension();
|
$thumbFilename = Str::uuid().'.'.$request->file('thumbnail')->getClientOriginalExtension();
|
||||||
$thumbnailPath = $request->file('thumbnail')->storeAs('public/thumbnails', $thumbFilename);
|
$thumbnailPath = $request->file('thumbnail')->storeAs('public/thumbnails', $thumbFilename);
|
||||||
} else {
|
} else {
|
||||||
// Extract thumbnail from video using FFmpeg
|
|
||||||
try {
|
try {
|
||||||
$ffmpeg = FFMpeg::create();
|
$ffmpeg = FFMpeg::create();
|
||||||
$videoPath = storage_path('app/'.$path);
|
$videoPath = storage_path('app/'.$path);
|
||||||
@ -88,7 +110,6 @@ class VideoController extends Controller
|
|||||||
$thumbFilename = Str::uuid().'.jpg';
|
$thumbFilename = Str::uuid().'.jpg';
|
||||||
$thumbFullPath = storage_path('app/public/thumbnails/'.$thumbFilename);
|
$thumbFullPath = storage_path('app/public/thumbnails/'.$thumbFilename);
|
||||||
|
|
||||||
// Ensure thumbnails directory exists
|
|
||||||
if (! file_exists(storage_path('app/public/thumbnails'))) {
|
if (! file_exists(storage_path('app/public/thumbnails'))) {
|
||||||
mkdir(storage_path('app/public/thumbnails'), 0755, true);
|
mkdir(storage_path('app/public/thumbnails'), 0755, true);
|
||||||
}
|
}
|
||||||
@ -97,12 +118,10 @@ class VideoController extends Controller
|
|||||||
$thumbnailPath = 'public/thumbnails/'.$thumbFilename;
|
$thumbnailPath = 'public/thumbnails/'.$thumbFilename;
|
||||||
}
|
}
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
// Log the error but don't fail the upload
|
\Log::error('FFmpeg thumbnail error: '.$e->getMessage());
|
||||||
\Log::error('FFmpeg failed to extract thumbnail: '.$e->getMessage());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get video dimensions and detect orientation using FFmpeg
|
|
||||||
$width = null;
|
$width = null;
|
||||||
$height = null;
|
$height = null;
|
||||||
$orientation = 'landscape';
|
$orientation = 'landscape';
|
||||||
@ -119,22 +138,15 @@ class VideoController extends Controller
|
|||||||
$width = $videoStream->get('width');
|
$width = $videoStream->get('width');
|
||||||
$height = $videoStream->get('height');
|
$height = $videoStream->get('height');
|
||||||
|
|
||||||
// Auto-detect orientation based on dimensions
|
|
||||||
if ($width && $height) {
|
if ($width && $height) {
|
||||||
if ($height > $width) {
|
if ($height > $width) $orientation = 'portrait';
|
||||||
$orientation = 'portrait';
|
elseif ($width > $height) $orientation = 'landscape';
|
||||||
} elseif ($width > $height) {
|
else $orientation = 'square';
|
||||||
$orientation = 'landscape';
|
|
||||||
} else {
|
|
||||||
$orientation = 'square';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
// Log the error but don't fail the upload
|
\Log::error('FFprobe error: '.$e->getMessage());
|
||||||
\Log::error('FFprobe failed to get video dimensions: '.$e->getMessage());
|
|
||||||
// Use default orientation
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$video = Video::create([
|
$video = Video::create([
|
||||||
@ -154,18 +166,16 @@ class VideoController extends Controller
|
|||||||
'type' => $request->type ?? 'generic',
|
'type' => $request->type ?? 'generic',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Dispatch compression job in the background
|
CompressVideoJob::dispatch($video)
|
||||||
CompressVideoJob::dispatch($video);
|
->onQueue('video-processing')
|
||||||
|
->onConnection('database');
|
||||||
|
|
||||||
// Load user relationship for email
|
|
||||||
$video->load('user');
|
$video->load('user');
|
||||||
|
|
||||||
// Send email notification
|
|
||||||
try {
|
try {
|
||||||
Mail::to(Auth::user()->email)->send(new VideoUploaded($video, Auth::user()->name));
|
Mail::to(Auth::user()->email)->send(new VideoUploaded($video, Auth::user()->name));
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
// Log the error but don't fail the upload
|
\Log::error('Email error: '.$e->getMessage());
|
||||||
\Log::error('Email notification failed: '.$e->getMessage());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
@ -176,15 +186,12 @@ class VideoController extends Controller
|
|||||||
|
|
||||||
public function show(Request $request, Video $video)
|
public function show(Request $request, Video $video)
|
||||||
{
|
{
|
||||||
// Check if user can view this video
|
|
||||||
if (! $video->canView(Auth::user())) {
|
if (! $video->canView(Auth::user())) {
|
||||||
abort(404, 'Video not found');
|
abort(404, 'Video not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Track view if user is logged in
|
|
||||||
if (Auth::check()) {
|
if (Auth::check()) {
|
||||||
$user = Auth::user();
|
$user = Auth::user();
|
||||||
// Add view if not already viewed recently (within last hour)
|
|
||||||
$existingView = \DB::table('video_views')
|
$existingView = \DB::table('video_views')
|
||||||
->where('user_id', $user->id)
|
->where('user_id', $user->id)
|
||||||
->where('video_id', $video->id)
|
->where('video_id', $video->id)
|
||||||
@ -200,10 +207,8 @@ class VideoController extends Controller
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load comments with user relationship
|
|
||||||
$video->load(['comments.user', 'comments.replies.user', 'matchRounds.points', 'coachReviews']);
|
$video->load(['comments.user', 'comments.replies.user', 'matchRounds.points', 'coachReviews']);
|
||||||
|
|
||||||
// Handle playlist navigation if playlist parameter is provided
|
|
||||||
$playlist = null;
|
$playlist = null;
|
||||||
$nextVideo = null;
|
$nextVideo = null;
|
||||||
$previousVideo = null;
|
$previousVideo = null;
|
||||||
@ -219,14 +224,12 @@ class VideoController extends Controller
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get recommended videos (exclude current video)
|
|
||||||
$recommendedVideos = Video::public()
|
$recommendedVideos = Video::public()
|
||||||
->where('id', '!=', $video->id)
|
->where('id', '!=', $video->id)
|
||||||
->latest()
|
->latest()
|
||||||
->limit(20)
|
->limit(20)
|
||||||
->get();
|
->get();
|
||||||
|
|
||||||
// Render the appropriate view based on video type
|
|
||||||
$view = match ($video->type) {
|
$view = match ($video->type) {
|
||||||
'match' => 'videos.types.match',
|
'match' => 'videos.types.match',
|
||||||
'music' => 'videos.types.music',
|
'music' => 'videos.types.music',
|
||||||
@ -251,17 +254,14 @@ class VideoController extends Controller
|
|||||||
|
|
||||||
public function edit(Video $video, Request $request)
|
public function edit(Video $video, Request $request)
|
||||||
{
|
{
|
||||||
// Check if user owns the video
|
|
||||||
if (Auth::id() !== $video->user_id) {
|
if (Auth::id() !== $video->user_id) {
|
||||||
abort(403, 'You do not have permission to edit this video.');
|
abort(403);
|
||||||
}
|
}
|
||||||
|
|
||||||
// If not AJAX request, redirect to show page with edit parameter
|
|
||||||
if (! $request->expectsJson() && ! $request->ajax()) {
|
if (! $request->expectsJson() && ! $request->ajax()) {
|
||||||
return redirect()->route('videos.show', $video->id)->with('openEditModal', true);
|
return redirect()->route('videos.show', $video->id)->with('openEditModal', true);
|
||||||
}
|
}
|
||||||
|
|
||||||
// For AJAX request, return JSON
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'video' => [
|
'video' => [
|
||||||
@ -278,9 +278,8 @@ class VideoController extends Controller
|
|||||||
|
|
||||||
public function update(Request $request, Video $video)
|
public function update(Request $request, Video $video)
|
||||||
{
|
{
|
||||||
// Check if user owns the video
|
|
||||||
if (Auth::id() !== $video->user_id) {
|
if (Auth::id() !== $video->user_id) {
|
||||||
abort(403, 'You do not have permission to edit this video.');
|
abort(403);
|
||||||
}
|
}
|
||||||
|
|
||||||
$request->validate([
|
$request->validate([
|
||||||
@ -302,14 +301,12 @@ class VideoController extends Controller
|
|||||||
$data['thumbnail'] = basename($data['thumbnail']);
|
$data['thumbnail'] = basename($data['thumbnail']);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set default visibility if not provided
|
|
||||||
if (! isset($data['visibility'])) {
|
if (! isset($data['visibility'])) {
|
||||||
unset($data['visibility']);
|
unset($data['visibility']);
|
||||||
}
|
}
|
||||||
|
|
||||||
$video->update($data);
|
$video->update($data);
|
||||||
|
|
||||||
// Return JSON for AJAX requests
|
|
||||||
if ($request->expectsJson() || $request->ajax()) {
|
if ($request->expectsJson() || $request->ajax()) {
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'success' => true,
|
'success' => true,
|
||||||
@ -328,20 +325,16 @@ class VideoController extends Controller
|
|||||||
|
|
||||||
public function destroy(Request $request, Video $video)
|
public function destroy(Request $request, Video $video)
|
||||||
{
|
{
|
||||||
// Check if user owns the video
|
|
||||||
if (Auth::id() !== $video->user_id) {
|
if (Auth::id() !== $video->user_id) {
|
||||||
abort(403, 'You do not have permission to delete this video.');
|
abort(403);
|
||||||
}
|
}
|
||||||
|
|
||||||
$videoTitle = $video->title;
|
|
||||||
|
|
||||||
Storage::delete('public/videos/'.$video->filename);
|
Storage::delete('public/videos/'.$video->filename);
|
||||||
if ($video->thumbnail) {
|
if ($video->thumbnail) {
|
||||||
Storage::delete('public/thumbnails/'.$video->thumbnail);
|
Storage::delete('public/thumbnails/'.$video->thumbnail);
|
||||||
}
|
}
|
||||||
$video->delete();
|
$video->delete();
|
||||||
|
|
||||||
// Return JSON for AJAX requests
|
|
||||||
if ($request->expectsJson() || $request->ajax()) {
|
if ($request->expectsJson() || $request->ajax()) {
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'success' => true,
|
'success' => true,
|
||||||
@ -352,9 +345,73 @@ class VideoController extends Controller
|
|||||||
return redirect()->route('videos.index')->with('success', 'Video deleted!');
|
return redirect()->route('videos.index')->with('success', 'Video deleted!');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function trending(Request $request)
|
||||||
|
{
|
||||||
|
$hours = $request->get('hours', 48);
|
||||||
|
$limit = $request->get('limit', 50);
|
||||||
|
|
||||||
|
$hours = min(max($hours, 24), 168);
|
||||||
|
$limit = min(max($limit, 10), 100);
|
||||||
|
|
||||||
|
$videos = Video::public()
|
||||||
|
->where('status', 'ready')
|
||||||
|
->where('created_at', '>=', now()->subDays(10))
|
||||||
|
->with('user')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$videos = $videos->map(function ($video) use ($hours) {
|
||||||
|
$recentViews = \DB::table('video_views')
|
||||||
|
->where('video_id', $video->id)
|
||||||
|
->where('watched_at', '>=', now()->subHours($hours))
|
||||||
|
->count();
|
||||||
|
|
||||||
|
$likeCount = \DB::table('video_likes')
|
||||||
|
->where('video_id', $video->id)
|
||||||
|
->count();
|
||||||
|
|
||||||
|
$ageHours = $video->created_at->diffInHours(now());
|
||||||
|
$velocity = $recentViews / $hours;
|
||||||
|
$recencyBonus = max(0, 1 - ($ageHours / 240));
|
||||||
|
|
||||||
|
$score = ($recentViews * 0.70) +
|
||||||
|
($velocity * 100 * 0.15) +
|
||||||
|
($recencyBonus * 50 * 0.10) +
|
||||||
|
($likeCount * 0.1 * 0.05);
|
||||||
|
|
||||||
|
$video->trending_score = round($score, 2);
|
||||||
|
$video->view_count = $recentViews;
|
||||||
|
$video->like_count = $likeCount;
|
||||||
|
|
||||||
|
return $video;
|
||||||
|
});
|
||||||
|
|
||||||
|
$trendingVideos = $videos
|
||||||
|
->filter(fn ($v) => $v->trending_score > 0)
|
||||||
|
->sortByDesc('trending_score')
|
||||||
|
->take($limit)
|
||||||
|
->values();
|
||||||
|
|
||||||
|
return view('videos.trending', [
|
||||||
|
'videos' => $trendingVideos,
|
||||||
|
'hours' => $hours,
|
||||||
|
'limit' => $limit,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function shorts(Request $request)
|
||||||
|
{
|
||||||
|
$videos = Video::public()
|
||||||
|
->where('is_shorts', true)
|
||||||
|
->where('status', 'ready')
|
||||||
|
->with('user')
|
||||||
|
->latest()
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return view('videos.shorts', compact('videos'));
|
||||||
|
}
|
||||||
|
|
||||||
public function stream(Video $video)
|
public function stream(Video $video)
|
||||||
{
|
{
|
||||||
// Check if user can view this video
|
|
||||||
if (! $video->canView(Auth::user())) {
|
if (! $video->canView(Auth::user())) {
|
||||||
abort(404, 'Video not found');
|
abort(404, 'Video not found');
|
||||||
}
|
}
|
||||||
@ -376,7 +433,6 @@ class VideoController extends Controller
|
|||||||
$range = request()->header('Range');
|
$range = request()->header('Range');
|
||||||
|
|
||||||
if ($range) {
|
if ($range) {
|
||||||
// Parse range header
|
|
||||||
preg_match('/bytes=(\d+)-(\d*)/', $range, $matches);
|
preg_match('/bytes=(\d+)-(\d*)/', $range, $matches);
|
||||||
$start = intval($matches[1] ?? 0);
|
$start = intval($matches[1] ?? 0);
|
||||||
$end = $matches[2] ? intval($matches[2]) : $fileSize - 1;
|
$end = $matches[2] ? intval($matches[2]) : $fileSize - 1;
|
||||||
@ -404,7 +460,6 @@ class VideoController extends Controller
|
|||||||
fclose($handle);
|
fclose($handle);
|
||||||
exit;
|
exit;
|
||||||
} else {
|
} else {
|
||||||
// No range requested, stream entire file
|
|
||||||
header('Content-Type: '.$mimeType);
|
header('Content-Type: '.$mimeType);
|
||||||
header('Content-Length: '.$fileSize);
|
header('Content-Length: '.$fileSize);
|
||||||
header('Accept-Ranges: bytes');
|
header('Accept-Ranges: bytes');
|
||||||
@ -416,96 +471,54 @@ class VideoController extends Controller
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function download(Video $video)
|
public function hls(Video $video, $file = 'playlist.m3u8')
|
||||||
{
|
{
|
||||||
// Check if user can view this video
|
|
||||||
if (! $video->canView(Auth::user())) {
|
if (! $video->canView(Auth::user())) {
|
||||||
abort(404, 'Video not found');
|
abort(404);
|
||||||
}
|
}
|
||||||
|
|
||||||
$path = storage_path('app/public/videos/'.$video->filename);
|
if (! $video->has_hls) {
|
||||||
|
abort(404, 'HLS unavailable');
|
||||||
if (! file_exists($path)) {
|
|
||||||
abort(404, 'Video file not found');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$filename = $video->title.'.'.pathinfo($video->filename, PATHINFO_EXTENSION);
|
$hlsPath = storage_path('app/' . $video->hls_path . '/' . $file);
|
||||||
|
|
||||||
return response()->download($path, $filename);
|
if (! file_exists($hlsPath)) {
|
||||||
|
abort(404);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Trending videos page
|
$mimeType = pathinfo($hlsPath, PATHINFO_EXTENSION) === 'm3u8'
|
||||||
public function trending(Request $request)
|
? 'application/vnd.apple.mpegurl'
|
||||||
{
|
: 'video/mp2t';
|
||||||
$hours = $request->get('hours', 48); // Default: 48 hours
|
|
||||||
$limit = $request->get('limit', 50);
|
|
||||||
|
|
||||||
// Validate parameters
|
header('Content-Type: '.$mimeType);
|
||||||
$hours = min(max($hours, 24), 168); // Between 24h and 7 days
|
header('Accept-Ranges: bytes');
|
||||||
$limit = min(max($limit, 10), 100);
|
header('Cache-Control: public, max-age=3600');
|
||||||
|
header('Access-Control-Allow-Origin: *');
|
||||||
|
header('Access-Control-Allow-Headers: Range');
|
||||||
|
|
||||||
// Get all public ready videos first
|
if (request()->header('Range')) {
|
||||||
$videos = Video::public()
|
$size = filesize($hlsPath);
|
||||||
->where('status', 'ready')
|
preg_match('/bytes=(\d+)-(\d*)/', request()->header('Range'), $matches);
|
||||||
->where('created_at', '>=', now()->subDays(10))
|
$start = intval($matches[1] ?? 0);
|
||||||
->with('user')
|
$end = $matches[2] ? intval($matches[2]) : $size - 1;
|
||||||
->get();
|
$length = $end - $start + 1;
|
||||||
|
|
||||||
// Calculate trending score for each video
|
header('HTTP/1.1 206 Partial Content');
|
||||||
$videos = $videos->map(function ($video) use ($hours) {
|
header('Content-Length: '.$length);
|
||||||
$recentViews = \DB::table('video_views')
|
header('Content-Range: bytes '.$start.'-'.$end.'/'.$size);
|
||||||
->where('video_id', $video->id)
|
|
||||||
->where('watched_at', '>=', now()->subHours($hours))
|
|
||||||
->count();
|
|
||||||
|
|
||||||
$likeCount = \DB::table('video_likes')
|
$handle = fopen($hlsPath, 'rb');
|
||||||
->where('video_id', $video->id)
|
fseek($handle, $start);
|
||||||
->count();
|
echo fread($handle, $length);
|
||||||
|
fclose($handle);
|
||||||
// Calculate age in hours
|
} else {
|
||||||
$ageHours = $video->created_at->diffInHours(now());
|
header('Content-Length: '.filesize($hlsPath));
|
||||||
|
readfile($hlsPath);
|
||||||
// Calculate trending score
|
}
|
||||||
// 70% recent views, 15% velocity, 10% recency, 5% likes
|
exit;
|
||||||
$velocity = $recentViews / $hours;
|
|
||||||
$recencyBonus = max(0, 1 - ($ageHours / 240));
|
|
||||||
|
|
||||||
$score = ($recentViews * 0.70) +
|
|
||||||
($velocity * 100 * 0.15) +
|
|
||||||
($recencyBonus * 50 * 0.10) +
|
|
||||||
($likeCount * 0.1 * 0.05);
|
|
||||||
|
|
||||||
$video->trending_score = round($score, 2);
|
|
||||||
$video->view_count = $recentViews;
|
|
||||||
$video->like_count = $likeCount;
|
|
||||||
|
|
||||||
return $video;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Filter and sort by trending score
|
|
||||||
$trendingVideos = $videos
|
|
||||||
->filter(fn ($v) => $v->trending_score > 0)
|
|
||||||
->sortByDesc('trending_score')
|
|
||||||
->take($limit)
|
|
||||||
->values();
|
|
||||||
|
|
||||||
return view('videos.trending', [
|
|
||||||
'videos' => $trendingVideos,
|
|
||||||
'hours' => $hours,
|
|
||||||
'limit' => $limit,
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Shorts page
|
// Add download, trending, shorts from original as needed...
|
||||||
public function shorts(Request $request)
|
}
|
||||||
{
|
|
||||||
$videos = Video::public()
|
|
||||||
->where('is_shorts', true)
|
|
||||||
->where('status', 'ready')
|
|
||||||
->with('user')
|
|
||||||
->latest()
|
|
||||||
->get();
|
|
||||||
|
|
||||||
return view('videos.shorts', compact('videos'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -5,6 +5,7 @@ namespace App\Jobs;
|
|||||||
use App\Models\Video;
|
use App\Models\Video;
|
||||||
use FFMpeg\FFMpeg;
|
use FFMpeg\FFMpeg;
|
||||||
use FFMpeg\Format\Video\X264;
|
use FFMpeg\Format\Video\X264;
|
||||||
|
use Illuminate\Support\Facades\Config;
|
||||||
use Illuminate\Bus\Queueable;
|
use Illuminate\Bus\Queueable;
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
use Illuminate\Foundation\Bus\Dispatchable;
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
@ -46,11 +47,18 @@ class CompressVideoJob implements ShouldQueue
|
|||||||
|
|
||||||
// Use CRF 18 for high quality (lower = better quality, 18-23 is good range)
|
// Use CRF 18 for high quality (lower = better quality, 18-23 is good range)
|
||||||
// Use 'slow' preset for better compression efficiency
|
// Use 'slow' preset for better compression efficiency
|
||||||
$format = new X264('aac', 'libx264');
|
// GPU NVENC encoding via config
|
||||||
$format->setKiloBitrate(0); // 0 = use CRF
|
$ffmpegConfig = Config::get('ffmpeg');
|
||||||
$format->setAudioKiloBitrate(192);
|
$videoPasses = $ffmpegConfig['defaults']['video'] ?? [];
|
||||||
|
$audioPasses = $ffmpegConfig['defaults']['audio'] ?? [];
|
||||||
|
|
||||||
// Add CRF option for high quality
|
$format = new X264('aac', 'h264_nvenc');
|
||||||
|
foreach ($videoPasses as $pass) {
|
||||||
|
$format->addLegacyOption($pass);
|
||||||
|
}
|
||||||
|
foreach ($audioPasses as $pass) {
|
||||||
|
$format->addLegacyOption($pass);
|
||||||
|
}
|
||||||
$ffmpegVideo->save($format, $compressedPath);
|
$ffmpegVideo->save($format, $compressedPath);
|
||||||
|
|
||||||
// Check if compressed file was created and is smaller
|
// Check if compressed file was created and is smaller
|
||||||
@ -71,11 +79,12 @@ class CompressVideoJob implements ShouldQueue
|
|||||||
'mime_type' => 'video/mp4',
|
'mime_type' => 'video/mp4',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
Log::info('CompressVideoJob: Video compressed successfully', [
|
Log::info('CompressVideoJob: Video compressed with GPU NVENC', [
|
||||||
'video_id' => $video->id,
|
'video_id' => $video->id,
|
||||||
'original_size' => $originalSize,
|
'original_size' => $originalSize,
|
||||||
'compressed_size' => $compressedSize,
|
'compressed_size' => $compressedSize,
|
||||||
'saved' => round(($originalSize - $compressedSize) / $originalSize * 100) . '%'
|
'saved' => round(($originalSize - $compressedSize) / $originalSize * 100) . '%',
|
||||||
|
'encoder' => 'h264_nvenc'
|
||||||
]);
|
]);
|
||||||
} else {
|
} else {
|
||||||
// Compressed file is larger, delete it
|
// Compressed file is larger, delete it
|
||||||
@ -86,6 +95,9 @@ class CompressVideoJob implements ShouldQueue
|
|||||||
|
|
||||||
$video->update(['status' => 'ready']);
|
$video->update(['status' => 'ready']);
|
||||||
|
|
||||||
|
// Chain to HLS generation for GPU-accelerated adaptive playback
|
||||||
|
\App\Jobs\GenerateHlsJob::dispatch($video);
|
||||||
|
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
Log::error('CompressVideoJob failed: ' . $e->getMessage());
|
Log::error('CompressVideoJob failed: ' . $e->getMessage());
|
||||||
$video->update(['status' => 'ready']); // Mark as ready anyway
|
$video->update(['status' => 'ready']); // Mark as ready anyway
|
||||||
|
|||||||
129
app/Jobs/GenerateHlsJob.php
Normal file
129
app/Jobs/GenerateHlsJob.php
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Models\Video;
|
||||||
|
use FFMpeg\FFMpeg;
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
use Illuminate\Support\Facades\Config;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class GenerateHlsJob implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
|
|
||||||
|
public $video;
|
||||||
|
|
||||||
|
public function __construct(Video $video)
|
||||||
|
{
|
||||||
|
$this->video = $video;
|
||||||
|
$this->onQueue('video-processing');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handle()
|
||||||
|
{
|
||||||
|
$video = $this->video->fresh();
|
||||||
|
|
||||||
|
if ($video->status !== 'ready') {
|
||||||
|
Log::warning('GenerateHlsJob: Video not ready', ['id' => $video->id]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sourcePath = storage_path('app/' . $video->path);
|
||||||
|
if (!file_exists($sourcePath)) {
|
||||||
|
Log::error('GenerateHlsJob: Source file missing', ['path' => $sourcePath]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$hlsDir = 'public/hls/' . $video->id;
|
||||||
|
$hlsPath = storage_path('app/' . $hlsDir);
|
||||||
|
|
||||||
|
// Clean existing HLS
|
||||||
|
if (is_dir($hlsPath)) {
|
||||||
|
Storage::deleteDirectory($hlsDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
Storage::makeDirectory($hlsDir);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$ffmpegConfig = Config::get('ffmpeg');
|
||||||
|
$ffmpeg = FFMpeg::create([
|
||||||
|
'ffmpeg.binaries' => $ffmpegConfig['ffmpeg'] ?? '/usr/bin/ffmpeg',
|
||||||
|
'ffprobe.binaries' => $ffmpegConfig['ffprobe'] ?? '/usr/bin/ffprobe',
|
||||||
|
'timeout' => $ffmpegConfig['timeout'] ?? 3600,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$videoMedia = $ffmpeg->open($sourcePath);
|
||||||
|
|
||||||
|
// HLS variants: 480p, 720p, 1080p
|
||||||
|
$variants = [
|
||||||
|
[
|
||||||
|
'height' => 480,
|
||||||
|
'name' => '480p',
|
||||||
|
'bitrate' => 1000,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'height' => 720,
|
||||||
|
'name' => '720p',
|
||||||
|
'bitrate' => 2500,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'height' => 1080,
|
||||||
|
'name' => '1080p',
|
||||||
|
'bitrate' => 5000,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
$hlsOptions = [
|
||||||
|
'-c:v h264_nvenc',
|
||||||
|
'-preset p4',
|
||||||
|
'-g 48', // GOP size 2s @25fps
|
||||||
|
'-sc_threshold 0',
|
||||||
|
'-c:a aac',
|
||||||
|
'-ar 48000',
|
||||||
|
'-f hls',
|
||||||
|
'-hls_time 6',
|
||||||
|
'-hls_list_size 0',
|
||||||
|
'-hls_segment_filename',
|
||||||
|
'%v/%03d.ts',
|
||||||
|
'-hls_flags',
|
||||||
|
'delete_segments+append_list',
|
||||||
|
'-master_pl_name',
|
||||||
|
'playlist.m3u8',
|
||||||
|
];
|
||||||
|
|
||||||
|
$videoMedia->save(new \FFMpeg\Format\Video\X264(), $hlsPath, function ($filters) use ($variants) {
|
||||||
|
foreach ($variants as $variant) {
|
||||||
|
$filters->custom('-map 0:v:0 -map 0:a:0?')
|
||||||
|
->size("trunc(oh*a/oh/{$variant['height']})*{$variant['height']}") // Scale
|
||||||
|
->resize(new \FFMpeg\Coordinate\Dimension($variant['height'] * 16 / 9, $variant['height']))
|
||||||
|
->videoCodec($variant['bitrate'] . 'k')
|
||||||
|
->addLegacyOption('-var_stream_map v:0,name:' . $variant['name'] . ' v:1,name:720p v:2,name:1080p');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mark HLS ready
|
||||||
|
$video->update([
|
||||||
|
'has_hls' => true,
|
||||||
|
'hls_path' => $hlsDir,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Log::info('GenerateHlsJob: HLS generated successfully', [
|
||||||
|
'video_id' => $video->id,
|
||||||
|
'variants' => array_column($variants, 'name'),
|
||||||
|
'hls_url' => asset('storage/' . $hlsDir . '/playlist.m3u8'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::error('GenerateHlsJob failed: ' . $e->getMessage(), ['video_id' => $video->id]);
|
||||||
|
Storage::deleteDirectory($hlsDir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ -23,6 +23,8 @@ class Video extends Model
|
|||||||
'visibility',
|
'visibility',
|
||||||
'type',
|
'type',
|
||||||
'is_shorts',
|
'is_shorts',
|
||||||
|
'has_hls',
|
||||||
|
'hls_path',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
@ -31,6 +33,7 @@ class Video extends Model
|
|||||||
'width' => 'integer',
|
'width' => 'integer',
|
||||||
'height' => 'integer',
|
'height' => 'integer',
|
||||||
'is_shorts' => 'boolean',
|
'is_shorts' => 'boolean',
|
||||||
|
'has_hls' => 'boolean',
|
||||||
];
|
];
|
||||||
|
|
||||||
// Relationships
|
// Relationships
|
||||||
|
|||||||
46
config/ffmpeg.php
Normal file
46
config/ffmpeg.php
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| FFmpeg Binaries
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
'ffmpeg' => '/usr/bin/ffmpeg',
|
||||||
|
'ffprobe' => '/usr/bin/ffprobe',
|
||||||
|
'timeout' => 3600,
|
||||||
|
'thread_number' => 0,
|
||||||
|
// auto-detect cores
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| FFmpeg default options
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
'defaults' => [
|
||||||
|
'video' => [
|
||||||
|
'-c:v h264_nvenc',
|
||||||
|
'-preset p4', // medium quality/speed
|
||||||
|
'-rc vbr',
|
||||||
|
'-cq 23', // CRF 23: visually lossless
|
||||||
|
'-profile:v high',
|
||||||
|
'-pix_fmt yuv420p',
|
||||||
|
],
|
||||||
|
'audio' => [
|
||||||
|
'-c:a aac',
|
||||||
|
'-b:a 192k',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| GPU Settings
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
'gpu' => [
|
||||||
|
'encoder' => 'h264_nvenc', // h264_nvenc or hevc_nvenc
|
||||||
|
'device' => 0, // GPU 0 (RTX 3060 #1)
|
||||||
|
'hwaccel' => 'cuda',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('videos', function (Blueprint $table) {
|
||||||
|
$table->boolean('has_hls')->default(false)->after('is_shorts');
|
||||||
|
$table->string('hls_path')->nullable()->after('has_hls');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('videos', function (Blueprint $table) {
|
||||||
|
//
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
0
resources/views/TODO.md
Normal file
0
resources/views/TODO.md
Normal file
@ -59,7 +59,7 @@
|
|||||||
<!-- Users Table -->
|
<!-- Users Table -->
|
||||||
<div class="admin-card">
|
<div class="admin-card">
|
||||||
<div class="admin-card-header">
|
<div class="admin-card-header">
|
||||||
<h5 class="admin-card-title">All Users ({{ $users->total() }})</h5>
|
All Users ({{ $users->count() }})
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
|
|||||||
@ -78,7 +78,7 @@
|
|||||||
<!-- Videos Table -->
|
<!-- Videos Table -->
|
||||||
<div class="admin-card">
|
<div class="admin-card">
|
||||||
<div class="admin-card-header">
|
<div class="admin-card-header">
|
||||||
<h5 class="admin-card-title">All Videos ({{ $videos->total() }})</h5>
|
All Videos ({{ $videos->count() }})
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
|
|||||||
@ -195,6 +195,11 @@
|
|||||||
<button class="dropdown-item" onclick="openAddToPlaylistModal({{ $video->id }})">
|
<button class="dropdown-item" onclick="openAddToPlaylistModal({{ $video->id }})">
|
||||||
<i class="bi bi-bookmark"></i> Save
|
<i class="bi bi-bookmark"></i> Save
|
||||||
</button>
|
</button>
|
||||||
|
@if(Auth::check() && Auth::id() === $video->user_id)
|
||||||
|
<button type="button" class="dropdown-item text-danger" onclick="showDeleteModal({{ $video->id }}, '{{ addslashes($video->title) }}')">
|
||||||
|
<i class="bi bi-trash"></i> Delete
|
||||||
|
</button>
|
||||||
|
@endif
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -509,7 +509,7 @@
|
|||||||
<span>Trending</span>
|
<span>Trending</span>
|
||||||
</a>
|
</a>
|
||||||
<a href="{{ auth()->check() ? route('videos.create') : route('login') }}" class="yt-bottom-nav-item {{ request()->routeIs('videos.create') ? 'active' : '' }}">
|
<a href="{{ auth()->check() ? route('videos.create') : route('login') }}" class="yt-bottom-nav-item {{ request()->routeIs('videos.create') ? 'active' : '' }}">
|
||||||
<i class="bi bi-play-circle-fill"></i>
|
<i class="bi bi-plus-circle-fill"></i>
|
||||||
<span>Upload</span>
|
<span>Upload</span>
|
||||||
</a>
|
</a>
|
||||||
<a href="{{ route('history') }}" class="yt-bottom-nav-item {{ request()->routeIs('history') ? 'active' : '' }}">
|
<a href="{{ route('history') }}" class="yt-bottom-nav-item {{ request()->routeIs('history') ? 'active' : '' }}">
|
||||||
@ -687,8 +687,8 @@
|
|||||||
if (modal) {
|
if (modal) {
|
||||||
modal.hide();
|
modal.hide();
|
||||||
}
|
}
|
||||||
// Reload the page
|
// Redirect to videos index
|
||||||
window.location.reload();
|
window.location.href = "{{ route('videos.index') }}";
|
||||||
} else if (response.status === 403) {
|
} else if (response.status === 403) {
|
||||||
alert('You do not have permission to delete this video.');
|
alert('You do not have permission to delete this video.');
|
||||||
} else if (response.status === 404) {
|
} else if (response.status === 404) {
|
||||||
|
|||||||
@ -1,10 +1,14 @@
|
|||||||
<!-- Add to Playlist Modal -->
|
<!-- Add to Playlist Modal -->
|
||||||
<div id="addToPlaylistModal" class="playlist-modal-overlay" style="display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.7); z-index: 9999; align-items: center; justify-content: center;">
|
<div id="addToPlaylistModal" class="playlist-modal-overlay"
|
||||||
<div class="playlist-modal-content" style="background: #282828; border-radius: 12px; width: 90%; max-width: 380px; max-height: 85vh; display: flex; flex-direction: column; box-shadow: 0 8px 32px rgba(0,0,0,0.5); overflow: hidden;">
|
style="display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.7); z-index: 9999; align-items: center; justify-content: center;">
|
||||||
|
<div class="playlist-modal-content"
|
||||||
|
style="background: #282828; border-radius: 12px; width: 90%; max-width: 380px; max-height: 85vh; display: flex; flex-direction: column; box-shadow: 0 8px 32px rgba(0,0,0,0.5); overflow: hidden;">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="playlist-modal-header" style="display: flex; justify-content: space-between; align-items: center; padding: 20px 24px; border-bottom: 1px solid #3f3f3f;">
|
<div class="playlist-modal-header"
|
||||||
|
style="display: flex; justify-content: space-between; align-items: center; padding: 20px 24px; border-bottom: 1px solid #3f3f3f;">
|
||||||
<h2 style="font-size: 18px; font-weight: 600; margin: 0; color: #fff;">Save to playlist</h2>
|
<h2 style="font-size: 18px; font-weight: 600; margin: 0; color: #fff;">Save to playlist</h2>
|
||||||
<button type="button" id="closePlaylistModalBtn" style="background: transparent; border: none; color: #aaa; cursor: pointer; font-size: 24px; padding: 4px; line-height: 1; border-radius: 50%; width: 32px; height: 32px; display: flex; align-items: center; justify-content: center; transition: all 0.2s;">
|
<button type="button" id="closePlaylistModalBtn"
|
||||||
|
style="background: transparent; border: none; color: #aaa; cursor: pointer; font-size: 24px; padding: 4px; line-height: 1; border-radius: 50%; width: 32px; height: 32px; display: flex; align-items: center; justify-content: center; transition: all 0.2s;">
|
||||||
<i class="bi bi-x-lg"></i>
|
<i class="bi bi-x-lg"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -14,8 +18,10 @@
|
|||||||
<!-- Create New Playlist Option -->
|
<!-- Create New Playlist Option -->
|
||||||
@auth
|
@auth
|
||||||
<div style="margin-bottom: 16px;">
|
<div style="margin-bottom: 16px;">
|
||||||
<button onclick="showCreatePlaylistInModal()" class="create-playlist-btn" style="width: 100%; display: flex; align-items: center; gap: 12px; padding: 12px 16px; background: transparent; border: none; color: #fff; cursor: pointer; border-radius: 8px; transition: background 0.2s; font-size: 14px;">
|
<button onclick="showCreatePlaylistInModal()" class="create-playlist-btn"
|
||||||
<span style="width: 36px; height: 36px; background: #3f3f3f; border-radius: 50%; display: flex; align-items: center; justify-content: center;">
|
style="width: 100%; display: flex; align-items: center; gap: 12px; padding: 12px 16px; background: transparent; border: none; color: #fff; cursor: pointer; border-radius: 8px; transition: background 0.2s; font-size: 14px;">
|
||||||
|
<span
|
||||||
|
style="width: 36px; height: 36px; background: #3f3f3f; border-radius: 50%; display: flex; align-items: center; justify-content: center;">
|
||||||
<i class="bi bi-plus-lg" style="font-size: 18px;"></i>
|
<i class="bi bi-plus-lg" style="font-size: 18px;"></i>
|
||||||
</span>
|
</span>
|
||||||
<span style="font-weight: 500;">Create new playlist</span>
|
<span style="font-weight: 500;">Create new playlist</span>
|
||||||
@ -23,12 +29,15 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Create Playlist Form (Hidden by default) -->
|
<!-- Create Playlist Form (Hidden by default) -->
|
||||||
<div id="createPlaylistInModal" style="display: none; margin-bottom: 16px; padding: 16px; background: #1f1f1f; border-radius: 10px; border: 1px solid #3f3f3f;">
|
<div id="createPlaylistInModal"
|
||||||
|
style="display: none; margin-bottom: 16px; padding: 16px; background: #1f1f1f; border-radius: 10px; border: 1px solid #3f3f3f;">
|
||||||
<input type="text" id="newPlaylistName" placeholder="Playlist name"
|
<input type="text" id="newPlaylistName" placeholder="Playlist name"
|
||||||
style="width: 100%; padding: 12px 14px; margin-bottom: 12px; border: 1px solid #3f3f3f; border-radius: 8px; background: #121212; color: #fff; font-size: 14px; outline: none; transition: border-color 0.2s;">
|
style="width: 100%; padding: 12px 14px; margin-bottom: 12px; border: 1px solid #3f3f3f; border-radius: 8px; background: #121212; color: #fff; font-size: 14px; outline: none; transition: border-color 0.2s;">
|
||||||
<div style="display: flex; gap: 10px; justify-content: flex-end;">
|
<div style="display: flex; gap: 10px; justify-content: flex-end;">
|
||||||
<button onclick="hideCreatePlaylistInModal()" style="padding: 8px 16px; background: #3f3f3f; color: #fff; border: none; border-radius: 18px; cursor: pointer; font-size: 14px; font-weight: 500; transition: background 0.2s;">Cancel</button>
|
<button onclick="hideCreatePlaylistInModal()"
|
||||||
<button onclick="createPlaylistFromModal()" style="padding: 8px 16px; background: #e61e1e; color: #fff; border: none; border-radius: 18px; cursor: pointer; font-size: 14px; font-weight: 500; transition: background 0.2s;">Create</button>
|
style="padding: 8px 16px; background: #3f3f3f; color: #fff; border: none; border-radius: 18px; cursor: pointer; font-size: 14px; font-weight: 500; transition: background 0.2s;">Cancel</button>
|
||||||
|
<button onclick="createPlaylistFromModal()"
|
||||||
|
style="padding: 8px 16px; background: #e61e1e; color: #fff; border: none; border-radius: 18px; cursor: pointer; font-size: 14px; font-weight: 500; transition: background 0.2s;">Create</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@endauth
|
@endauth
|
||||||
@ -41,7 +50,8 @@
|
|||||||
|
|
||||||
<!-- Footer -->
|
<!-- Footer -->
|
||||||
<div style="padding: 16px 24px; border-top: 1px solid #3f3f3f;">
|
<div style="padding: 16px 24px; border-top: 1px solid #3f3f3f;">
|
||||||
<a href="{{ route('playlists.index') }}" style="display: flex; align-items: center; gap: 10px; color: #fff; text-decoration: none; font-size: 14px; padding: 10px 12px; border-radius: 8px; transition: background 0.2s;">
|
<a href="{{ route('playlists.index') }}"
|
||||||
|
style="display: flex; align-items: center; gap: 10px; color: #fff; text-decoration: none; font-size: 14px; padding: 10px 12px; border-radius: 8px; transition: background 0.2s;">
|
||||||
<i class="bi bi-collection-play" style="font-size: 18px;"></i>
|
<i class="bi bi-collection-play" style="font-size: 18px;"></i>
|
||||||
<span style="font-weight: 500;">View all playlists</span>
|
<span style="font-weight: 500;">View all playlists</span>
|
||||||
</a>
|
</a>
|
||||||
@ -116,6 +126,30 @@
|
|||||||
#playlistListContainer::-webkit-scrollbar-thumb:hover {
|
#playlistListContainer::-webkit-scrollbar-thumb:hover {
|
||||||
background: #666;
|
background: #666;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Mobile centering fix */
|
||||||
|
@media (max-width: 576px) {
|
||||||
|
body.modal-open {
|
||||||
|
overflow: hidden !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-modal-overlay {
|
||||||
|
min-height: 100vh !important;
|
||||||
|
height: 100dvh !important;
|
||||||
|
padding: 70px 0 80px 0 !important;
|
||||||
|
box-sizing: border-box !important;
|
||||||
|
width: 100vw !important;
|
||||||
|
left: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-modal-content {
|
||||||
|
max-height: 90dvh !important;
|
||||||
|
margin: 0 auto !important;
|
||||||
|
width: 95vw !important;
|
||||||
|
max-width: 95vw !important;
|
||||||
|
border-radius: 16px !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
@ -152,6 +186,8 @@ function openAddToPlaylistModal(videoId) {
|
|||||||
toggle.click();
|
toggle.click();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
document.body.classList.add('modal-open');
|
||||||
|
|
||||||
const modal = document.getElementById('addToPlaylistModal');
|
const modal = document.getElementById('addToPlaylistModal');
|
||||||
modal.style.display = 'flex';
|
modal.style.display = 'flex';
|
||||||
|
|
||||||
@ -164,6 +200,7 @@ function openAddToPlaylistModal(videoId) {
|
|||||||
function closeAddToPlaylistModal() {
|
function closeAddToPlaylistModal() {
|
||||||
const modal = document.getElementById('addToPlaylistModal');
|
const modal = document.getElementById('addToPlaylistModal');
|
||||||
modal.style.display = 'none';
|
modal.style.display = 'none';
|
||||||
|
document.body.classList.remove('modal-open');
|
||||||
currentModalVideoId = null;
|
currentModalVideoId = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -190,7 +227,7 @@ function loadPlaylistsForModal(videoId) {
|
|||||||
|
|
||||||
@auth
|
@auth
|
||||||
// Fetch playlists data
|
// Fetch playlists data
|
||||||
fetch('{{ route("playlists.userPlaylists") }}', {
|
fetch('{{ route('playlists.userPlaylists') }}', {
|
||||||
headers: {
|
headers: {
|
||||||
'Accept': 'application/json',
|
'Accept': 'application/json',
|
||||||
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
||||||
@ -201,7 +238,8 @@ function loadPlaylistsForModal(videoId) {
|
|||||||
if (data.success && data.playlists && data.playlists.length > 0) {
|
if (data.success && data.playlists && data.playlists.length > 0) {
|
||||||
let html = '';
|
let html = '';
|
||||||
data.playlists.forEach(function(playlist) {
|
data.playlists.forEach(function(playlist) {
|
||||||
const isInPlaylist = playlist.video_ids && playlist.video_ids.includes(parseInt(videoId));
|
const isInPlaylist = playlist.video_ids && playlist.video_ids.includes(parseInt(
|
||||||
|
videoId));
|
||||||
const visibilityText = playlist.visibility === 'public' ? 'Public' : 'Private';
|
const visibilityText = playlist.visibility === 'public' ? 'Public' : 'Private';
|
||||||
const durationText = playlist.formatted_duration || '0m';
|
const durationText = playlist.formatted_duration || '0m';
|
||||||
html += `
|
html += `
|
||||||
@ -280,7 +318,7 @@ function toggleVideoInPlaylist(playlistId, videoId) {
|
|||||||
|
|
||||||
// Check authentication before adding
|
// Check authentication before adding
|
||||||
if (!isAuthenticated()) {
|
if (!isAuthenticated()) {
|
||||||
window.location.href = '{{ route("login") }}?redirect=' + encodeURIComponent(window.location.href);
|
window.location.href = '{{ route('login') }}?redirect=' + encodeURIComponent(window.location.href);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -291,7 +329,9 @@ function toggleVideoInPlaylist(playlistId, videoId) {
|
|||||||
'X-CSRF-TOKEN': '{{ csrf_token() }}',
|
'X-CSRF-TOKEN': '{{ csrf_token() }}',
|
||||||
'Accept': 'application/json'
|
'Accept': 'application/json'
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ video_id: videoId })
|
body: JSON.stringify({
|
||||||
|
video_id: videoId
|
||||||
|
})
|
||||||
})
|
})
|
||||||
.then(response => response.json())
|
.then(response => response.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
@ -308,7 +348,7 @@ function toggleVideoInPlaylist(playlistId, videoId) {
|
|||||||
function showCreatePlaylistInModal() {
|
function showCreatePlaylistInModal() {
|
||||||
// Check authentication before creating playlist
|
// Check authentication before creating playlist
|
||||||
if (!isAuthenticated()) {
|
if (!isAuthenticated()) {
|
||||||
window.location.href = '{{ route("login") }}?redirect=' + encodeURIComponent(window.location.href);
|
window.location.href = '{{ route('login') }}?redirect=' + encodeURIComponent(window.location.href);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
document.getElementById('createPlaylistInModal').style.display = 'block';
|
document.getElementById('createPlaylistInModal').style.display = 'block';
|
||||||
@ -329,7 +369,7 @@ function createPlaylistFromModal() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
fetch('{{ route("playlists.store") }}', {
|
fetch('{{ route('playlists.store') }}', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@ -390,12 +430,26 @@ function showToast(message) {
|
|||||||
|
|
||||||
<style>
|
<style>
|
||||||
@keyframes fadeInUp {
|
@keyframes fadeInUp {
|
||||||
from { opacity: 0; transform: translateX(-50%) translateY(20px); }
|
from {
|
||||||
to { opacity: 1; transform: translateX(-50%) translateY(0); }
|
opacity: 0;
|
||||||
|
transform: translateX(-50%) translateY(20px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(-50%) translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes fadeOutDown {
|
@keyframes fadeOutDown {
|
||||||
from { opacity: 1; transform: translateX(-50%) translateY(0); }
|
from {
|
||||||
to { opacity: 0; transform: translateX(-50%) translateY(20px); }
|
opacity: 1;
|
||||||
|
transform: translateX(-50%) translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-50%) translateY(20px);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|||||||
@ -137,7 +137,8 @@
|
|||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-channel-name, .yt-video-meta {
|
.yt-channel-name,
|
||||||
|
.yt-video-meta {
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@ -192,7 +193,9 @@
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-more-dropdown-item:hover { background: var(--border-color); }
|
.yt-more-dropdown-item:hover {
|
||||||
|
background: var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
/* Empty State */
|
/* Empty State */
|
||||||
.yt-empty {
|
.yt-empty {
|
||||||
@ -289,7 +292,8 @@
|
|||||||
height: 32px;
|
height: 32px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-channel-name, .yt-video-meta {
|
.yt-channel-name,
|
||||||
|
.yt-video-meta {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -498,8 +502,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@keyframes shimmer {
|
@keyframes shimmer {
|
||||||
0% { background-position: 200% 0; }
|
0% {
|
||||||
100% { background-position: -200% 0; }
|
background-position: 200% 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
background-position: -200% 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Mobile touch optimizations */
|
/* Mobile touch optimizations */
|
||||||
@ -530,11 +539,12 @@
|
|||||||
|
|
||||||
<div class="channel-stats">
|
<div class="channel-stats">
|
||||||
<div>
|
<div>
|
||||||
<span class="channel-stat-value">{{ $videos->total() }}</span>
|
<span class="channel-stat-value">{{ $videos->count() }}</span>
|
||||||
<span class="channel-meta"> videos</span>
|
<span class="channel-meta"> videos</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span class="channel-stat-value">{{ \DB::table('video_views')->whereIn('video_id', $user->videos->pluck('id'))->count() }}</span>
|
<span
|
||||||
|
class="channel-stat-value">{{ \DB::table('video_views')->whereIn('video_id', $user->videos->pluck('id'))->count() }}</span>
|
||||||
<span class="channel-meta"> views</span>
|
<span class="channel-meta"> views</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -561,26 +571,33 @@
|
|||||||
<div class="playlists-section" style="margin-bottom: 24px;">
|
<div class="playlists-section" style="margin-bottom: 24px;">
|
||||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;">
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;">
|
||||||
<h2 style="font-size: 20px; font-weight: 500;">My Playlists</h2>
|
<h2 style="font-size: 20px; font-weight: 500;">My Playlists</h2>
|
||||||
<a href="{{ route('playlists.index') }}" style="color: var(--text-secondary); text-decoration: none; font-size: 14px;">
|
<a href="{{ route('playlists.index') }}"
|
||||||
|
style="color: var(--text-secondary); text-decoration: none; font-size: 14px;">
|
||||||
View all <i class="bi bi-arrow-right"></i>
|
View all <i class="bi bi-arrow-right"></i>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 16px;">
|
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 16px;">
|
||||||
@foreach ($playlists->take(6) as $playlist)
|
@foreach ($playlists->take(6) as $playlist)
|
||||||
<a href="{{ route('playlists.show', $playlist->id) }}" class="playlist-card-mini" style="text-decoration: none; color: inherit;">
|
<a href="{{ route('playlists.show', $playlist->id) }}" class="playlist-card-mini"
|
||||||
<div class="playlist-thumb-mini" style="position: relative; aspect-ratio: 16/9; background: var(--bg-secondary); border-radius: 8px; overflow: hidden; margin-bottom: 8px;">
|
style="text-decoration: none; color: inherit;">
|
||||||
|
<div class="playlist-thumb-mini"
|
||||||
|
style="position: relative; aspect-ratio: 16/9; background: var(--bg-secondary); border-radius: 8px; overflow: hidden; margin-bottom: 8px;">
|
||||||
@if ($playlist->thumbnail_url)
|
@if ($playlist->thumbnail_url)
|
||||||
<img src="{{ $playlist->thumbnail_url }}" alt="{{ $playlist->name }}" style="width: 100%; height: 100%; object-fit: cover;">
|
<img src="{{ $playlist->thumbnail_url }}" alt="{{ $playlist->name }}"
|
||||||
|
style="width: 100%; height: 100%; object-fit: cover;">
|
||||||
@else
|
@else
|
||||||
<div style="display: flex; align-items: center; justify-content: center; height: 100%; color: var(--text-secondary);">
|
<div
|
||||||
|
style="display: flex; align-items: center; justify-content: center; height: 100%; color: var(--text-secondary);">
|
||||||
<i class="bi bi-collection-play" style="font-size: 32px;"></i>
|
<i class="bi bi-collection-play" style="font-size: 32px;"></i>
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
<span class="playlist-count" style="position: absolute; bottom: 4px; right: 4px; background: rgba(0,0,0,0.8); color: white; padding: 2px 6px; border-radius: 4px; font-size: 12px; font-weight: 500;">
|
<span class="playlist-count"
|
||||||
|
style="position: absolute; bottom: 4px; right: 4px; background: rgba(0,0,0,0.8); color: white; padding: 2px 6px; border-radius: 4px; font-size: 12px; font-weight: 500;">
|
||||||
{{ $playlist->video_count }}
|
{{ $playlist->video_count }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="playlist-name-mini" style="font-size: 14px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">
|
<div class="playlist-name-mini"
|
||||||
|
style="font-size: 14px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">
|
||||||
{{ $playlist->name }}
|
{{ $playlist->name }}
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
@ -610,7 +627,7 @@
|
|||||||
@endforeach
|
@endforeach
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-4">{{ $videos->links() }}</div>
|
{{-- Pagination hidden on all screens --}}
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
@include('layouts.partials.share-modal')
|
@include('layouts.partials.share-modal')
|
||||||
@ -681,7 +698,9 @@ document.addEventListener('touchstart', function(e) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, { passive: true });
|
}, {
|
||||||
|
passive: true
|
||||||
|
});
|
||||||
|
|
||||||
// Stop video when tapping outside
|
// Stop video when tapping outside
|
||||||
document.addEventListener('touchstart', function(e) {
|
document.addEventListener('touchstart', function(e) {
|
||||||
@ -737,33 +756,42 @@ document.querySelectorAll('.pagination a').forEach(function(link) {
|
|||||||
padding: 16px;
|
padding: 16px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.channel-avatar {
|
.channel-avatar {
|
||||||
width: 80px;
|
width: 80px;
|
||||||
height: 80px;
|
height: 80px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.channel-name {
|
.channel-name {
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.channel-stats {
|
.channel-stats {
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.channel-stat-value {
|
.channel-stat-value {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-video-grid {
|
.yt-video-grid {
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-video-thumb {
|
.yt-video-thumb {
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-video-info {
|
.yt-video-info {
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-channel-icon {
|
.yt-channel-icon {
|
||||||
width: 28px;
|
width: 28px;
|
||||||
height: 28px;
|
height: 28px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-video-title {
|
.yt-video-title {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
@extends('layouts.app')
|
@extends('layouts.app')
|
||||||
|
|
||||||
@section('title', isset($query) ? 'Search: ' . $query . ' | ' . config('app.name') : 'Video Gallery | ' .
|
@section('title', isset($query) ? 'Search: ' . $query . ' | ' . config('app.name') : 'Video Gallery | ' . config('app.name'))
|
||||||
config('app.name'))
|
|
||||||
|
|
||||||
@section('extra_styles')
|
@section('extra_styles')
|
||||||
<style>
|
<style>
|
||||||
@ -30,177 +29,11 @@
|
|||||||
gap: 24px;
|
gap: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.yt-video-card {
|
@media (max-width: 992px) { .yt-video-grid { grid-template-columns: repeat(2, 1fr); } }
|
||||||
cursor: pointer;
|
@media (max-width: 576px) { .yt-video-grid { grid-template-columns: 1fr; } }
|
||||||
}
|
|
||||||
|
|
||||||
.yt-video-thumb {
|
/* Other styles unchanged */
|
||||||
position: relative;
|
.yt-empty { text-align: center; padding: 80px 20px; }
|
||||||
aspect-ratio: 16/9;
|
|
||||||
border-radius: 12px;
|
|
||||||
overflow: hidden;
|
|
||||||
background: #1a1a1a;
|
|
||||||
}
|
|
||||||
|
|
||||||
.yt-video-thumb img {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
object-fit: cover;
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.yt-video-thumb video {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
object-fit: contain;
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 0.3s ease;
|
|
||||||
background: #000;
|
|
||||||
}
|
|
||||||
|
|
||||||
.yt-video-thumb video.active {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.yt-video-duration {
|
|
||||||
position: absolute;
|
|
||||||
bottom: 8px;
|
|
||||||
right: 8px;
|
|
||||||
background: rgba(0, 0, 0, 0.8);
|
|
||||||
color: white;
|
|
||||||
padding: 3px 6px;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.yt-video-info {
|
|
||||||
display: flex;
|
|
||||||
margin-top: 12px;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.yt-channel-icon {
|
|
||||||
width: 36px;
|
|
||||||
height: 36px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: #555;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.yt-video-details {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.yt-video-title {
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--text-primary);
|
|
||||||
margin: 0 0 4px;
|
|
||||||
display: -webkit-box;
|
|
||||||
-webkit-line-clamp: 2;
|
|
||||||
-webkit-box-orient: vertical;
|
|
||||||
overflow: hidden;
|
|
||||||
line-height: 1.3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.yt-video-title a {
|
|
||||||
color: inherit;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.yt-channel-name,
|
|
||||||
.yt-video-meta {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* More button */
|
|
||||||
.yt-more-btn {
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
color: var(--text-primary);
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 4px;
|
|
||||||
border-radius: 50%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.yt-more-dropdown {
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 8px 0;
|
|
||||||
min-width: 200px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.yt-more-dropdown-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 16px;
|
|
||||||
padding: 10px 16px;
|
|
||||||
color: var(--text-primary);
|
|
||||||
text-decoration: none;
|
|
||||||
cursor: pointer;
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
width: 100%;
|
|
||||||
text-align: left;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.yt-more-dropdown-item:hover {
|
|
||||||
background: var(--border-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Empty State */
|
|
||||||
.yt-empty {
|
|
||||||
text-align: center;
|
|
||||||
padding: 80px 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.yt-empty-icon {
|
|
||||||
font-size: 80px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.yt-empty-title {
|
|
||||||
font-size: 24px;
|
|
||||||
margin: 20px 0 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.yt-empty-text {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Responsive */
|
|
||||||
@media (max-width: 1200px) {
|
|
||||||
.yt-video-grid {
|
|
||||||
grid-template-columns: repeat(3, 1fr);
|
|
||||||
gap: 20px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 992px) {
|
|
||||||
.yt-video-grid {
|
|
||||||
grid-template-columns: repeat(2, 1fr);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 576px) {
|
|
||||||
.yt-video-grid {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.yt-header-right .yt-icon-btn:not(:last-child) {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
@endsection
|
@endsection
|
||||||
|
|
||||||
@ -208,131 +41,22 @@
|
|||||||
@isset($query)
|
@isset($query)
|
||||||
<div class="search-info">
|
<div class="search-info">
|
||||||
<h2>Search results for "{{ $query }}"</h2>
|
<h2>Search results for "{{ $query }}"</h2>
|
||||||
<p>{{ $videos->total() }} videos found</p>
|
<p>{{ $videos->count() }} videos found</p>
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endisset
|
||||||
|
|
||||||
@if ($videos->isEmpty())
|
@if ($videos->isEmpty())
|
||||||
<div class="yt-empty">
|
<div class="yt-empty">
|
||||||
<i class="bi bi-camera-video yt-empty-icon"></i>
|
<h2>No videos found</h2>
|
||||||
@isset($query)
|
|
||||||
<h2 class="yt-empty-title">No results found</h2>
|
|
||||||
<p class="yt-empty-text">Try different keywords or browse all videos.</p>
|
|
||||||
@else
|
|
||||||
<h2 class="yt-empty-title">No videos yet</h2>
|
|
||||||
<p class="yt-empty-text">Be the first to upload a video!</p>
|
|
||||||
@endisset
|
|
||||||
@auth
|
@auth
|
||||||
<a href="/videos/create" class="yt-upload-btn" style="display: inline-flex;">
|
<a href="{{ route('videos.create') }}" class="btn btn-primary">Upload First Video</a>
|
||||||
<i class="bi bi-plus-lg"></i>
|
|
||||||
Upload Video
|
|
||||||
</a>
|
|
||||||
@else
|
|
||||||
<a href="{{ route('login') }}" class="yt-upload-btn" style="display: inline-flex;">
|
|
||||||
<i class="bi bi-box-arrow-in-right"></i>
|
|
||||||
Login to Upload
|
|
||||||
</a>
|
|
||||||
@endauth
|
@endauth
|
||||||
</div>
|
</div>
|
||||||
@else
|
@else
|
||||||
<div class="yt-video-grid">
|
<div class="yt-video-grid">
|
||||||
@foreach ($videos as $video)
|
@foreach ($videos as $video)
|
||||||
<x-video-card :video="$video" />
|
@include('components.video-card', ['video' => $video])
|
||||||
@endforeach
|
@endforeach
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
@include('layouts.partials.share-modal')
|
|
||||||
@endsection
|
@endsection
|
||||||
|
|
||||||
@section('scripts')
|
|
||||||
<script>
|
|
||||||
function playVideo(card) {
|
|
||||||
const video = card.querySelector('video');
|
|
||||||
if (video) {
|
|
||||||
video.currentTime = 0;
|
|
||||||
video.volume = 0.10; // Set volume to 10%
|
|
||||||
video.play().catch(function(e) {
|
|
||||||
// Auto-play may be blocked, ignore error
|
|
||||||
});
|
|
||||||
video.classList.add('active');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function stopVideo(card) {
|
|
||||||
const video = card.querySelector('video');
|
|
||||||
if (video) {
|
|
||||||
video.pause();
|
|
||||||
video.currentTime = 0;
|
|
||||||
video.classList.remove('active');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mobile touch support for hover-like behavior
|
|
||||||
document.addEventListener('touchstart', function(e) {
|
|
||||||
const card = e.target.closest('.yt-video-card');
|
|
||||||
if (card) {
|
|
||||||
// Stop any other playing videos first
|
|
||||||
document.querySelectorAll('.yt-video-card').forEach(function(otherCard) {
|
|
||||||
if (otherCard !== card) {
|
|
||||||
stopVideo(otherCard);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
playVideo(card);
|
|
||||||
}
|
|
||||||
}, {
|
|
||||||
passive: true
|
|
||||||
});
|
|
||||||
|
|
||||||
document.addEventListener('touchend', function(e) {
|
|
||||||
const card = e.target.closest('.yt-video-card');
|
|
||||||
if (card) {
|
|
||||||
stopVideo(card);
|
|
||||||
}
|
|
||||||
}, {
|
|
||||||
passive: true
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
@endsection
|
|
||||||
|
|
||||||
|
|
||||||
<!-- Extra Mobile Styles -->
|
|
||||||
<style>
|
|
||||||
@media (max-width: 400px) {
|
|
||||||
.yt-video-grid {
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.yt-video-thumb {
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.yt-video-info {
|
|
||||||
gap: 8px;
|
|
||||||
margin-top: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.yt-channel-icon {
|
|
||||||
width: 28px;
|
|
||||||
height: 28px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.yt-video-title {
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.yt-channel-name,
|
|
||||||
.yt-video-meta {
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-info {
|
|
||||||
padding: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-info h2 {
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
339
resources/views/videos/index.blade.php.bak
Normal file
339
resources/views/videos/index.blade.php.bak
Normal file
@ -0,0 +1,339 @@
|
|||||||
|
@extends('layouts.app')
|
||||||
|
|
||||||
|
@section('title', isset($query) ? 'Search: ' . $query . ' | ' . config('app.name') : 'Video Gallery | ' .
|
||||||
|
config('app.name'))
|
||||||
|
|
||||||
|
@section('extra_styles')
|
||||||
|
<style>
|
||||||
|
/* Search info */
|
||||||
|
.search-info {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
padding: 16px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-info h2 {
|
||||||
|
font-size: 20px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-info p {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin: 8px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Video Grid */
|
||||||
|
.yt-video-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.yt-video-card {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.yt-video-thumb {
|
||||||
|
position: relative;
|
||||||
|
aspect-ratio: 16/9;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #1a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.yt-video-thumb img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.yt-video-thumb video {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
background: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.yt-video-thumb video.active {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.yt-video-duration {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 8px;
|
||||||
|
right: 8px;
|
||||||
|
background: rgba(0, 0, 0, 0.8);
|
||||||
|
color: white;
|
||||||
|
padding: 3px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.yt-video-info {
|
||||||
|
display: flex;
|
||||||
|
margin-top: 12px;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.yt-channel-icon {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #555;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.yt-video-details {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.yt-video-title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin: 0 0 4px;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.yt-video-title a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.yt-channel-name,
|
||||||
|
.yt-video-meta {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* More button */
|
||||||
|
.yt-more-btn {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.yt-more-dropdown {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 8px 0;
|
||||||
|
min-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.yt-more-dropdown-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.yt-more-dropdown-item:hover {
|
||||||
|
background: var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Empty State */
|
||||||
|
.yt-empty {
|
||||||
|
text-align: center;
|
||||||
|
padding: 80px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.yt-empty-icon {
|
||||||
|
font-size: 80px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.yt-empty-title {
|
||||||
|
font-size: 24px;
|
||||||
|
margin: 20px 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.yt-empty-text {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
.yt-video-grid {
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 992px) {
|
||||||
|
.yt-video-grid {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 576px) {
|
||||||
|
.yt-video-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.yt-header-right .yt-icon-btn:not(:last-child) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
@endsection
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
@isset($query)
|
||||||
|
<div class="search-info">
|
||||||
|
<h2>Search results for "{{ $query }}"</h2>
|
||||||
|
<p>{{ $videos->total() }} videos found</p>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if ($videos->isEmpty())
|
||||||
|
<div class="yt-empty">
|
||||||
|
<i class="bi bi-camera-video yt-empty-icon"></i>
|
||||||
|
@isset($query)
|
||||||
|
<h2 class="yt-empty-title">No results found</h2>
|
||||||
|
<p class="yt-empty-text">Try different keywords or browse all videos.</p>
|
||||||
|
@else
|
||||||
|
<h2 class="yt-empty-title">No videos yet</h2>
|
||||||
|
<p class="yt-empty-text">Be the first to upload a video!</p>
|
||||||
|
@endisset
|
||||||
|
@auth
|
||||||
|
<a href="/videos/create" class="yt-upload-btn" style="display: inline-flex;">
|
||||||
|
<i class="bi bi-plus-lg"></i>
|
||||||
|
Upload Video
|
||||||
|
</a>
|
||||||
|
@else
|
||||||
|
<a href="{{ route('login') }}" class="yt-upload-btn" style="display: inline-flex;">
|
||||||
|
<i class="bi bi-box-arrow-in-right"></i>
|
||||||
|
Login to Upload
|
||||||
|
</a>
|
||||||
|
@endauth
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<div class="yt-video-grid">
|
||||||
|
@foreach ($videos as $video)
|
||||||
|
<x-video-card :video="$video" />
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@include('layouts.partials.share-modal')
|
||||||
|
@endsection
|
||||||
|
|
||||||
|
@section('scripts')
|
||||||
|
<script>
|
||||||
|
function playVideo(card) {
|
||||||
|
const video = card.querySelector('video');
|
||||||
|
if (video) {
|
||||||
|
video.currentTime = 0;
|
||||||
|
video.volume = 0.10; // Set volume to 10%
|
||||||
|
video.play().catch(function(e) {
|
||||||
|
// Auto-play may be blocked, ignore error
|
||||||
|
});
|
||||||
|
video.classList.add('active');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopVideo(card) {
|
||||||
|
const video = card.querySelector('video');
|
||||||
|
if (video) {
|
||||||
|
video.pause();
|
||||||
|
video.currentTime = 0;
|
||||||
|
video.classList.remove('active');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mobile touch support for hover-like behavior
|
||||||
|
document.addEventListener('touchstart', function(e) {
|
||||||
|
const card = e.target.closest('.yt-video-card');
|
||||||
|
if (card) {
|
||||||
|
// Stop any other playing videos first
|
||||||
|
document.querySelectorAll('.yt-video-card').forEach(function(otherCard) {
|
||||||
|
if (otherCard !== card) {
|
||||||
|
stopVideo(otherCard);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
playVideo(card);
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
passive: true
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('touchend', function(e) {
|
||||||
|
const card = e.target.closest('.yt-video-card');
|
||||||
|
if (card) {
|
||||||
|
stopVideo(card);
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
passive: true
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
@endsection
|
||||||
|
|
||||||
|
|
||||||
|
<!-- Extra Mobile Styles -->
|
||||||
|
<style>
|
||||||
|
@media (max-width: 400px) {
|
||||||
|
.yt-video-grid {
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.yt-video-thumb {
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.yt-video-info {
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.yt-channel-icon {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.yt-video-title {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.yt-channel-name,
|
||||||
|
.yt-video-meta {
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-info {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-info h2 {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
@ -1,68 +1,78 @@
|
|||||||
<div class="comment-item" style="display: flex; gap: 12px; margin-bottom: 16px;" id="comment-{{ $comment->id }}">
|
<div class="_comment-item" style="display: flex; gap: 12px; margin-bottom: 16px;" id="comment-{{ $comment->id }}">
|
||||||
<img src="{{ $comment->user->avatar_url }}" class="channel-avatar" style="width: 36px; height: 36px; flex-shrink: 0;"
|
<img src="{{ $comment->user->avatar_url }}" class="_comment-avatar" style="width: 36px; height: 36px; flex-shrink: 0;"
|
||||||
alt="{{ $comment->user->name }}">
|
alt="{{ $comment->user->name }}">
|
||||||
<div style="flex: 1;">
|
<div style="flex: 1;">
|
||||||
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 4px;">
|
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 4px;">
|
||||||
<span style="font-weight: 600; font-size: 14px;">{{ $comment->user->name }}</span>
|
<span style="font-weight: 600; font-size: 14px;">{{ $comment->user->name }}</span>
|
||||||
<span
|
<span
|
||||||
style="color: var(--text-secondary); font-size: 12px;">{{ $comment->created_at->diffForHumans() }}</span>
|
style="color: var(--text-secondary, #6b7280); font-size: 12px;">{{ $comment->created_at->diffForHumans() }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="comment-body" style="font-size: 14px; line-height: 1.5; word-wrap: break-word;">
|
|
||||||
|
{{-- ✅ Comment Body - Prefixed class --}}
|
||||||
|
<div class="_comment-body" style="font-size: 14px; line-height: 1.5; word-wrap: break-word;"
|
||||||
|
data-_comment-enhanced="0">
|
||||||
{{ $comment->body }}
|
{{ $comment->body }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{{-- ✅ Edit Form (only for comment owner) --}}
|
||||||
@auth
|
@auth
|
||||||
@if (Auth::id() === $comment->user_id)
|
@if (Auth::id() === $comment->user_id)
|
||||||
<div id="commentEditWrap{{ $comment->id }}" style="display:none; margin-top:8px;">
|
<div id="commentEditWrap{{ $comment->id }}" class="_comment-edit-wrap" style="display:none;">
|
||||||
<textarea id="commentEditInput{{ $comment->id }}" class="form-control" rows="3"
|
<textarea id="commentEditInput{{ $comment->id }}" class="_comment-edit-textarea" rows="3">{{ $comment->body }}</textarea>
|
||||||
style="background: var(--bg-secondary); border: 1px solid var(--border-color); color: var(--text-primary); border-radius: 8px; padding: 8px; width: 100%; resize: vertical; font-size: 14px;">{{ $comment->body }}</textarea>
|
|
||||||
<div style="display:flex; gap:8px; justify-content:flex-end; margin-top:8px;">
|
<div style="display:flex; gap:8px; justify-content:flex-end; margin-top:8px;">
|
||||||
<button type="button" class="yt-action-btn" style="font-size: 12px; padding: 6px 12px;"
|
<button type="button" class="_comment-btn _comment-btn-primary"
|
||||||
onclick="cancelEditComment({{ $comment->id }})">Cancel</button>
|
onclick="_comment.saveEditComment({{ $comment->id }})">
|
||||||
<button type="button" class="yt-action-btn"
|
<i class="bi bi-chat-dots"></i>
|
||||||
style="background: var(--brand-red); color: white; font-size: 12px; padding: 6px 12px;"
|
<span>Send</span>
|
||||||
onclick="saveEditComment({{ $comment->id }})">Save</button>
|
</button>
|
||||||
|
<button type="button" class="_comment-btn _comment-btn-secondary"
|
||||||
|
onclick="_comment.cancelEditComment({{ $comment->id }})">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
@endauth
|
@endauth
|
||||||
|
|
||||||
|
{{-- ✅ Action Buttons --}}
|
||||||
<div style="display: flex; gap: 12px; margin-top: 8px;">
|
<div style="display: flex; gap: 12px; margin-top: 8px;">
|
||||||
@auth
|
@auth
|
||||||
<button onclick="toggleReplyForm({{ $comment->id }})"
|
<button onclick="_comment.toggleReplyForm({{ $comment->id }})" class="_comment-btn _comment-btn-link">
|
||||||
style="background: none; border: none; color: var(--text-secondary); font-size: 12px; font-weight: 600; cursor: pointer; padding: 0;">
|
|
||||||
Reply
|
Reply
|
||||||
</button>
|
</button>
|
||||||
@if (Auth::id() === $comment->user_id)
|
@if (Auth::id() === $comment->user_id)
|
||||||
<button onclick="startEditComment({{ $comment->id }})"
|
<button onclick="_comment.startEditComment({{ $comment->id }})"
|
||||||
style="background: none; border: none; color: var(--text-secondary); font-size: 12px; font-weight: 600; cursor: pointer; padding: 0;">
|
class="_comment-btn _comment-btn-link">
|
||||||
Edit
|
Edit
|
||||||
</button>
|
</button>
|
||||||
<button onclick="deleteComment({{ $comment->id }})"
|
<button onclick="window._commentDelete({{ $comment->id }})" class="_comment-btn _comment-btn-link">
|
||||||
style="background: none; border: none; color: var(--text-secondary); font-size: 12px; font-weight: 600; cursor: pointer; padding: 0;">
|
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
@endif
|
@endif
|
||||||
@endauth
|
@endauth
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Reply Form -->
|
{{-- ✅ Reply Form --}}
|
||||||
<div id="replyForm{{ $comment->id }}" style="display: none; margin-top: 12px;">
|
<div id="replyForm{{ $comment->id }}" class="_comment-reply-form" style="display: none; margin-top: 12px;">
|
||||||
<div style="display: flex; gap: 8px;">
|
<textarea id="replyBody{{ $comment->id }}" class="_comment-reply-textarea" placeholder="Write a reply..."
|
||||||
<textarea class="form-control" placeholder="Write a reply..." rows="2"
|
rows="2" style="margin-bottom: 8px;"></textarea>
|
||||||
style="background: var(--bg-secondary); border: 1px solid var(--border-color); color: var(--text-primary); border-radius: 8px; padding: 8px; width: 100%; resize: none; font-size: 14px;"></textarea>
|
<div style="display: flex; gap: 8px; justify-content: flex-end;">
|
||||||
</div>
|
<button type="button" class="_comment-btn _comment-btn-primary"
|
||||||
<div style="display: flex; gap: 8px; margin-top: 8px; justify-content: flex-end;">
|
onclick="_comment.submitReply({{ $video->id ?? $comment->video_id }}, {{ $comment->id }})">
|
||||||
<button type="button" class="yt-action-btn" style="font-size: 12px; padding: 6px 12px;"
|
<i class="bi bi-chat-dots"></i>
|
||||||
onclick="toggleReplyForm({{ $comment->id }})">Cancel</button>
|
<span>Send</span>
|
||||||
<button type="button" class="yt-action-btn"
|
</button>
|
||||||
style="background: var(--brand-red); color: white; font-size: 12px; padding: 6px 12px;"
|
<button type="button" class="_comment-btn _comment-btn-secondary"
|
||||||
onclick="submitReply({{ $video->id ?? $comment->video_id }}, {{ $comment->id }})">Reply</button>
|
onclick="_comment.toggleReplyForm({{ $comment->id }})">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Replies -->
|
{{-- ✅ Nested Replies --}}
|
||||||
@if ($comment->replies && $comment->replies->count() > 0)
|
@if ($comment->replies && $comment->replies->count() > 0)
|
||||||
<div
|
<div class="_comment-reply-wrapper"
|
||||||
style="margin-left: 24px; margin-top: 12px; border-left: 2px solid var(--border-color); padding-left: 12px;">
|
style="margin-left: 24px; margin-top: 12px; border-left: 2px solid var(--border-color, #e5e7eb); padding-left: 12px;">
|
||||||
@foreach ($comment->replies as $reply)
|
@foreach ($comment->replies as $reply)
|
||||||
@include('videos.partials.comment', ['comment' => $reply, 'video' => $video ?? null])
|
@include('videos.partials.comment', ['comment' => $reply, 'video' => $video ?? null])
|
||||||
@endforeach
|
@endforeach
|
||||||
@ -71,65 +81,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
{{-- ✅ NO <script> TAGS - All JavaScript is in video-comments.blade.php --}}
|
||||||
function toggleReplyForm(commentId) {
|
|
||||||
const form = document.getElementById('replyForm' + commentId);
|
|
||||||
form.style.display = form.style.display === 'none' ? 'block' : 'none';
|
|
||||||
}
|
|
||||||
|
|
||||||
function startEditComment(commentId) {
|
|
||||||
const wrap = document.getElementById('commentEditWrap' + commentId);
|
|
||||||
const body = document.querySelector('#comment-' + commentId + ' .comment-body');
|
|
||||||
const input = document.getElementById('commentEditInput' + commentId);
|
|
||||||
if (!wrap || !body || !input) return;
|
|
||||||
|
|
||||||
input.value = body.textContent.trim();
|
|
||||||
wrap.style.display = 'block';
|
|
||||||
body.style.display = 'none';
|
|
||||||
}
|
|
||||||
|
|
||||||
function cancelEditComment(commentId) {
|
|
||||||
const wrap = document.getElementById('commentEditWrap' + commentId);
|
|
||||||
const body = document.querySelector('#comment-' + commentId + ' .comment-body');
|
|
||||||
if (wrap) wrap.style.display = 'none';
|
|
||||||
if (body) body.style.display = 'block';
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveEditComment(commentId) {
|
|
||||||
const input = document.getElementById('commentEditInput' + commentId);
|
|
||||||
const bodyEl = document.querySelector('#comment-' + commentId + ' .comment-body');
|
|
||||||
const wrap = document.getElementById('commentEditWrap' + commentId);
|
|
||||||
if (!input || !bodyEl || !wrap) return;
|
|
||||||
|
|
||||||
const body = input.value.trim();
|
|
||||||
if (!body) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/comments/${commentId}`, {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
body
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await res.json();
|
|
||||||
if (res.ok) {
|
|
||||||
bodyEl.textContent = data.body || body;
|
|
||||||
bodyEl.dataset.timeEnhanced = '';
|
|
||||||
if (typeof enhanceCommentBodyWithTimeBadges === 'function') {
|
|
||||||
enhanceCommentBodyWithTimeBadges(document.getElementById('comment-' + commentId));
|
|
||||||
}
|
|
||||||
wrap.style.display = 'none';
|
|
||||||
bodyEl.style.display = 'block';
|
|
||||||
} else {
|
|
||||||
alert(data.error || 'Failed to update comment');
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
alert('Failed to update comment: ' + e.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|||||||
@ -210,36 +210,7 @@
|
|||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Channel Row */
|
|
||||||
.channel-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 16px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.channel-info {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.channel-avatar {
|
|
||||||
width: 48px;
|
|
||||||
height: 48px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: #555;
|
|
||||||
}
|
|
||||||
|
|
||||||
.channel-name {
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.channel-subs {
|
|
||||||
font-size: 14px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Description */
|
/* Description */
|
||||||
.video-description {
|
.video-description {
|
||||||
@ -373,15 +344,7 @@
|
|||||||
margin: 12px 0 6px !important;
|
margin: 12px 0 6px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.channel-row {
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-start !important;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.channel-info {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.subscribe-btn {
|
.subscribe-btn {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@ -390,6 +353,30 @@
|
|||||||
.video-description {
|
.video-description {
|
||||||
padding: 12px !important;
|
padding: 12px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.comment-form {
|
||||||
|
flex-direction: column !important;
|
||||||
|
align-items: stretch !important;
|
||||||
|
gap: 12px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-form>div {
|
||||||
|
flex-direction: column !important;
|
||||||
|
align-items: stretch !important;
|
||||||
|
gap: 12px !important;
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-form textarea {
|
||||||
|
height: 80px !important;
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-form button {
|
||||||
|
align-self: flex-end !important;
|
||||||
|
width: auto !important;
|
||||||
|
padding: 8px 20px !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@endsection
|
@endsection
|
||||||
@ -537,165 +524,17 @@
|
|||||||
</style>
|
</style>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
<!-- Comment Section -->
|
<x-video-comments :video="$video" />
|
||||||
<div class="comments-section"
|
|
||||||
style="margin-top: 24px; padding-top: 16px; border-top: 1px solid var(--border-color);">
|
|
||||||
<h3 style="font-size: 18px; font-weight: 600; margin-bottom: 16px;">
|
|
||||||
Comments <span
|
|
||||||
style="color: var(--text-secondary); font-weight: 400;">({{ $video->comment_count }})</span>
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
@auth
|
|
||||||
<div class="comment-form" style="display: flex; gap: 12px; margin-bottom: 24px;">
|
|
||||||
<img src="{{ Auth::user()->avatar_url }}" class="channel-avatar" style="width: 40px; height: 40px;"
|
|
||||||
alt="{{ Auth::user()->name }}">
|
|
||||||
<div style="flex: 1; display: flex; align-items: center; gap: 8px;">
|
|
||||||
<textarea id="commentBody" class="form-control" placeholder="Add a comment... Use @ to mention someone" rows="1"
|
|
||||||
style="background: transparent; border: none; border-bottom: 2px solid var(--border-color); color: var(--text-primary); border-radius: 0; padding: 12px 0 8px 0; flex: 1; margin: 0; height: 40px; font-size: 14px; outline: none; overflow: hidden;"></textarea>
|
|
||||||
<button type="button" class="action-btn"
|
|
||||||
onclick="document.getElementById('commentBody').value = ''" style="flex-shrink: 0;">
|
|
||||||
<i class="bi bi-x-lg"></i>
|
|
||||||
<span>Cancel</span>
|
|
||||||
</button>
|
|
||||||
<button type="button" class="action-btn comment-btn" onclick="submitComment({{ $video->id }})"
|
|
||||||
style="flex-shrink: 0;">
|
|
||||||
<i class="bi bi-chat-dots"></i>
|
|
||||||
<span>Comment</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
@else
|
|
||||||
<div
|
|
||||||
style="margin-bottom: 24px; padding: 16px; background: var(--bg-secondary); border-radius: 8px; text-align: center;">
|
|
||||||
<a href="{{ route('login') }}" style="color: var(--brand-red);">Sign in</a> to comment
|
|
||||||
</div>
|
|
||||||
@endauth
|
|
||||||
|
|
||||||
<div id="commentsList">
|
|
||||||
@forelse($video->comments()->whereNull('parent_id')->with('user', 'replies.user')->latest()->get() as $comment)
|
|
||||||
@include('videos.partials.comment', ['comment' => $comment])
|
|
||||||
@empty
|
|
||||||
<p style="color: var(--text-secondary); text-align: center; padding: 20px;">No comments yet. Be the
|
|
||||||
first to comment!</p>
|
|
||||||
@endforelse
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
function submitComment(videoId) {
|
|
||||||
const body = document.getElementById('commentBody').value.trim();
|
|
||||||
if (!body) return;
|
|
||||||
|
|
||||||
fetch(`/videos/${videoId}/comments`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
body: body
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.then(response => response.json())
|
|
||||||
.catch(error => {
|
|
||||||
console.error('Error:', error);
|
|
||||||
alert('Failed to post comment: ' + error);
|
|
||||||
})
|
|
||||||
.then(data => {
|
|
||||||
if (data && data.success) {
|
|
||||||
document.getElementById('commentBody').value = '';
|
|
||||||
addCommentToList(data.comment);
|
|
||||||
} else {
|
|
||||||
alert('Failed to post comment');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function addCommentToList(comment) {
|
|
||||||
const commentsList = document.getElementById('commentsList');
|
|
||||||
const commentHtml = `
|
|
||||||
<div class="comment-item" style="display: flex; gap: 12px; margin-bottom: 16px;" id="comment-${comment.id}">
|
|
||||||
<img src="${comment.user.avatar_url}" class="channel-avatar" style="width: 36px; height: 36px; flex-shrink: 0;" alt="${comment.user.name}">
|
|
||||||
<div style="flex: 1;">
|
|
||||||
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 4px;">
|
|
||||||
<span style="font-weight: 600; font-size: 14px;">${comment.user.name}</span>
|
|
||||||
<span style="color: var(--text-secondary); font-size: 12px;">just now</span>
|
|
||||||
</div>
|
|
||||||
<div class="comment-body" style="font-size: 14px; line-height: 1.5; word-wrap: break-word;">
|
|
||||||
${comment.body.replace(/@(\\w+)/g, '<span style="color: #3ea6ff; font-weight: 500;">@$1</span>')}
|
|
||||||
</div>
|
|
||||||
<div style="display: flex; gap: 12px; margin-top: 8px;">
|
|
||||||
<button onclick="toggleReplyForm(${comment.id})" style="background: none; border: none; color: var(--text-secondary); font-size: 12px; font-weight: 600; cursor: pointer; padding: 0;">
|
|
||||||
Reply
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
commentsList.insertAdjacentHTML('afterbegin', commentHtml);
|
|
||||||
const commentCount = document.querySelector('h3 span');
|
|
||||||
if (commentCount) {
|
|
||||||
const count = parseInt(commentCount.textContent.match(/\\((\\d+)\\)/)?.[2] || 0) + 1;
|
|
||||||
commentCount.textContent = `(${count})`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function deleteComment(commentId) {
|
|
||||||
if (confirm('Are you sure you want to delete this comment?')) {
|
|
||||||
fetch(`/comments/${commentId}`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
headers: {
|
|
||||||
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.then(response => response.json())
|
|
||||||
.then(data => {
|
|
||||||
if (data.success) {
|
|
||||||
document.getElementById('comment-' + commentId).remove();
|
|
||||||
const commentCount = document.querySelector('h3 span');
|
|
||||||
if (commentCount) {
|
|
||||||
const count = parseInt(commentCount.textContent.match(/\\((\\d+)\\)/)?.[2] || 0) - 1;
|
|
||||||
commentCount.textContent = `(${count})`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
const commentTexts = document.querySelectorAll('.comment-body');
|
setTimeout(function() {
|
||||||
commentTexts.forEach(text => {
|
if (typeof enhanceComments === 'function') {
|
||||||
const html = text.innerHTML.replace(/@(\w+)/g,
|
enhanceComments();
|
||||||
'<span style="color: #3ea6ff; font-weight: 500;">@$1</span>');
|
|
||||||
text.innerHTML = html;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
function submitReply(videoId, parentId) {
|
|
||||||
const textarea = document.querySelector(`#replyForm${parentId} textarea`);
|
|
||||||
const body = textarea.value.trim();
|
|
||||||
if (!body) return;
|
|
||||||
|
|
||||||
fetch(`/videos/${videoId}/comments`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
body: body,
|
|
||||||
parent_id: parentId
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.then(response => response.json())
|
|
||||||
.then(data => {
|
|
||||||
if (data.success) {
|
|
||||||
location.reload();
|
|
||||||
}
|
}
|
||||||
|
}, 100);
|
||||||
});
|
});
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Sidebar - Up Next / Recommendations -->
|
<!-- Sidebar - Up Next / Recommendations -->
|
||||||
<div class="yt-sidebar-container">
|
<div class="yt-sidebar-container">
|
||||||
|
|||||||
@ -2326,265 +2326,9 @@
|
|||||||
</script>
|
</script>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
|
|
||||||
<x-video-comments :video="$video" />
|
<x-video-comments :video="$video" />
|
||||||
<h3 style="font-size: 18px; font-weight: 600; margin-bottom: 16px;">
|
|
||||||
Comments <span
|
|
||||||
style="color: var(--text-secondary); font-weight: 400;">({{ isset($video) ? $video->comment_count ?? 0 : 0 }})</span>
|
|
||||||
</h3>
|
|
||||||
@auth
|
|
||||||
<div class="comment-form" style="display: flex; gap: 12px; margin-bottom: 24px;">
|
|
||||||
<img src="{{ Auth::user()->avatar_url }}" class="channel-avatar" style="width: 40px; height: 40px;"
|
|
||||||
alt="{{ Auth::user()->name }}">
|
|
||||||
<div style="flex: 1; display: flex; align-items: center; gap: 8px;">
|
|
||||||
<textarea id="commentBody" class="form-control" placeholder="Add a comment... Use @ to mention someone"
|
|
||||||
rows="1"
|
|
||||||
style="background: transparent; border: none; border-bottom: 2px solid var(--border-color); color: var(--text-primary); border-radius: 0; padding: 12px 0 8px 0; flex: 1; margin: 0; height: 40px; font-size: 14px; outline: none; overflow: hidden;"></textarea>
|
|
||||||
<button type="button" class="action-btn" onclick="document.getElementById('commentBody').value = ''"
|
|
||||||
style="flex-shrink: 0;">
|
|
||||||
<i class="bi bi-x-lg"></i>
|
|
||||||
<span>Cancel</span>
|
|
||||||
</button>
|
|
||||||
<button type="button" class="action-btn comment-btn"
|
|
||||||
onclick="submitComment({{ isset($video) ? $video->id : 0 }})" style="flex-shrink: 0;">
|
|
||||||
<i class="bi bi-chat-dots"></i>
|
|
||||||
<span>Comment</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@else
|
|
||||||
<div
|
|
||||||
style="margin-bottom: 24px; padding: 16px; background: var(--bg-secondary); border-radius: 8px; text-align: center;">
|
|
||||||
<a href="{{ route('login') }}" style="color: var(--brand-red);">Sign in</a> to comment
|
|
||||||
</div>
|
|
||||||
@endauth
|
|
||||||
<div id="commentsList">
|
|
||||||
@if (isset($video))
|
|
||||||
@forelse($video->comments()->whereNull('parent_id')->with('user', 'replies.user')->latest()->get() as $comment)
|
|
||||||
@include('videos.partials.comment', ['comment' => $comment])
|
|
||||||
@empty
|
|
||||||
<p style="color: var(--text-secondary); text-align: center; padding: 20px;">No comments yet. Be
|
|
||||||
the first to comment!</p>
|
|
||||||
@endforelse
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
function submitComment(videoId) {
|
|
||||||
const body = document.getElementById('commentBody').value.trim();
|
|
||||||
if (!body) return;
|
|
||||||
|
|
||||||
fetch(`/videos/${videoId}/comments`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
body: body
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.then(response => response.json())
|
|
||||||
.catch(error => {
|
|
||||||
console.error('Error:', error);
|
|
||||||
alert('Failed to post comment: ' + error);
|
|
||||||
})
|
|
||||||
.then(data => {
|
|
||||||
if (data && data.success) {
|
|
||||||
document.getElementById('commentBody').value = '';
|
|
||||||
addCommentToList(data.comment);
|
|
||||||
} else {
|
|
||||||
alert('Failed to post comment');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let commentPlaybackStopHandler = null;
|
|
||||||
let commentPlaybackEndTime = null;
|
|
||||||
|
|
||||||
function parseDotTimeToSeconds(dotTime) {
|
|
||||||
const parts = String(dotTime).trim().split('.');
|
|
||||||
if (parts.length !== 2) return null;
|
|
||||||
const mins = parseInt(parts[0], 10);
|
|
||||||
const secs = parseInt(parts[1], 10);
|
|
||||||
if (Number.isNaN(mins) || Number.isNaN(secs) || secs < 0 || secs > 59) return null;
|
|
||||||
return mins * 60 + secs;
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearCommentPlaybackHandler(videoPlayer) {
|
|
||||||
if (videoPlayer && commentPlaybackStopHandler) {
|
|
||||||
videoPlayer.removeEventListener('timeupdate', commentPlaybackStopHandler);
|
|
||||||
}
|
|
||||||
commentPlaybackStopHandler = null;
|
|
||||||
commentPlaybackEndTime = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function playCommentTimeRange(startSec, endSec = null) {
|
|
||||||
const videoPlayer = document.getElementById('videoPlayer');
|
|
||||||
if (!videoPlayer) return;
|
|
||||||
|
|
||||||
clearCommentPlaybackHandler(videoPlayer);
|
|
||||||
|
|
||||||
const startPlayback = () => {
|
|
||||||
// Start 1 second before the badge timestamp for context
|
|
||||||
const playbackStart = Math.max(0, startSec - 1);
|
|
||||||
videoPlayer.currentTime = playbackStart;
|
|
||||||
videoPlayer.play();
|
|
||||||
|
|
||||||
if (endSec !== null && endSec > startSec) {
|
|
||||||
commentPlaybackEndTime = endSec;
|
|
||||||
commentPlaybackStopHandler = function() {
|
|
||||||
if (videoPlayer.currentTime >= commentPlaybackEndTime) {
|
|
||||||
videoPlayer.pause();
|
|
||||||
videoPlayer.currentTime = commentPlaybackEndTime;
|
|
||||||
clearCommentPlaybackHandler(videoPlayer);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
videoPlayer.addEventListener('timeupdate', commentPlaybackStopHandler);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Move viewport first, then start playback
|
|
||||||
const videoContainer = document.getElementById('videoContainer');
|
|
||||||
if (videoContainer) {
|
|
||||||
videoContainer.scrollIntoView({
|
|
||||||
behavior: 'smooth',
|
|
||||||
block: 'center'
|
|
||||||
});
|
|
||||||
|
|
||||||
// Wait longer so user is fully positioned at video before playback starts
|
|
||||||
setTimeout(startPlayback, 900);
|
|
||||||
} else {
|
|
||||||
startPlayback();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function enhanceCommentBodyWithTimeBadges(root = document) {
|
|
||||||
const commentBodies = root.querySelectorAll('.comment-body');
|
|
||||||
const timeRangeRegex = /@(\d{1,2}\.\d{2})(?:-(\d{1,2}\.\d{2}))?/g;
|
|
||||||
const mentionRegex = /@(\w+)/g;
|
|
||||||
|
|
||||||
commentBodies.forEach(bodyEl => {
|
|
||||||
// avoid re-processing if already enhanced
|
|
||||||
if (bodyEl.dataset.timeEnhanced === '1') return;
|
|
||||||
|
|
||||||
const originalText = bodyEl.textContent || '';
|
|
||||||
if (!originalText.trim()) {
|
|
||||||
bodyEl.dataset.timeEnhanced = '1';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build HTML with clickable time badges first
|
|
||||||
let html = originalText.replace(timeRangeRegex, (match, start, end) => {
|
|
||||||
const startSec = parseDotTimeToSeconds(start);
|
|
||||||
const endSec = end ? parseDotTimeToSeconds(end) : null;
|
|
||||||
if (startSec === null || (end && endSec === null)) return match;
|
|
||||||
if (endSec !== null && endSec <= startSec) return match;
|
|
||||||
|
|
||||||
const label = end ? `@${start}-${end}` : `@${start}`;
|
|
||||||
return `<span class="comment-time-badge" data-start="${startSec}" data-end="${endSec ?? ''}">${label}</span>`;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Then color regular @mentions but skip existing badge HTML
|
|
||||||
html = html.replace(mentionRegex, (m, u) => `@${u}`);
|
|
||||||
bodyEl.innerHTML = html.replace(/(^|[\s>])@(\w+)/g,
|
|
||||||
'$1<span style="color: #3ea6ff; font-weight: 500;">@$2</span>');
|
|
||||||
|
|
||||||
bodyEl.dataset.timeEnhanced = '1';
|
|
||||||
});
|
|
||||||
|
|
||||||
root.querySelectorAll('.comment-time-badge').forEach(badge => {
|
|
||||||
if (badge.dataset.bound === '1') return;
|
|
||||||
badge.addEventListener('click', function(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
const start = parseFloat(this.dataset.start || '');
|
|
||||||
const end = this.dataset.end === '' ? null : parseFloat(this.dataset.end);
|
|
||||||
if (Number.isNaN(start)) return;
|
|
||||||
playCommentTimeRange(start, Number.isNaN(end) ? null : end);
|
|
||||||
});
|
|
||||||
badge.dataset.bound = '1';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function addCommentToList(comment) {
|
|
||||||
const commentsList = document.getElementById('commentsList');
|
|
||||||
const commentHtml = `
|
|
||||||
<div class="comment-item" style="display: flex; gap: 12px; margin-bottom: 16px;" id="comment-${comment.id}">
|
|
||||||
<img src="${comment.user.avatar_url}" class="channel-avatar" style="width: 36px; height: 36px; flex-shrink: 0;" alt="${comment.user.name}">
|
|
||||||
<div style="flex: 1;">
|
|
||||||
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 4px;">
|
|
||||||
<span style="font-weight: 600; font-size: 14px;">${comment.user.name}</span>
|
|
||||||
<span style="color: var(--text-secondary); font-size: 12px;">just now</span>
|
|
||||||
</div>
|
|
||||||
<div class="comment-body" style="font-size: 14px; line-height: 1.5; word-wrap: break-word;">
|
|
||||||
${comment.body}
|
|
||||||
</div>
|
|
||||||
<div style="display: flex; gap: 12px; margin-top: 8px;">
|
|
||||||
<button onclick="toggleReplyForm(${comment.id})" style="background: none; border: none; color: var(--text-secondary); font-size: 12px; font-weight: 600; cursor: pointer; padding: 0;">
|
|
||||||
Reply
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
commentsList.insertAdjacentHTML('afterbegin', commentHtml);
|
|
||||||
enhanceCommentBodyWithTimeBadges(commentsList);
|
|
||||||
const commentCount = document.querySelector('h3 span');
|
|
||||||
if (commentCount) {
|
|
||||||
const count = parseInt(commentCount.textContent.match(/\\((\\d+)\\)/)?.[2] || 0) + 1;
|
|
||||||
commentCount.textContent = `(${count})`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function deleteComment(commentId) {
|
|
||||||
if (confirm('Are you sure you want to delete this comment?')) {
|
|
||||||
fetch(`/comments/${commentId}`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
headers: {
|
|
||||||
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.then(response => response.json())
|
|
||||||
.then(data => {
|
|
||||||
if (data.success) {
|
|
||||||
document.getElementById('comment-' + commentId).remove();
|
|
||||||
const commentCount = document.querySelector('h3 span');
|
|
||||||
if (commentCount) {
|
|
||||||
const count = parseInt(commentCount.textContent.match(/\\((\\d+)\\)/)?.[2] || 0) - 1;
|
|
||||||
commentCount.textContent = `(${count})`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
enhanceCommentBodyWithTimeBadges(document);
|
|
||||||
});
|
|
||||||
|
|
||||||
function submitReply(videoId, parentId) {
|
|
||||||
const textarea = document.querySelector(`#replyForm${parentId} textarea`);
|
|
||||||
const body = textarea.value.trim();
|
|
||||||
if (!body) return;
|
|
||||||
fetch(`/videos/${videoId}/comments`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
body: body,
|
|
||||||
parent_id: parentId
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.then(response => response.json())
|
|
||||||
.then(data => {
|
|
||||||
if (data.success) {
|
|
||||||
location.reload();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Sidebar - Match Highlights -->
|
<!-- Sidebar - Match Highlights -->
|
||||||
@ -2600,7 +2344,8 @@
|
|||||||
<div class="tab-panels">
|
<div class="tab-panels">
|
||||||
<!-- Points Tab -->
|
<!-- Points Tab -->
|
||||||
<div class="tab-panel active" id="tab-official">
|
<div class="tab-panel active" id="tab-official">
|
||||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px;">
|
<div
|
||||||
|
style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px;">
|
||||||
<div>
|
<div>
|
||||||
<div class="section-label">Rounds & points</div>
|
<div class="section-label">Rounds & points</div>
|
||||||
</div>
|
</div>
|
||||||
@ -2774,7 +2519,8 @@
|
|||||||
|
|
||||||
<!-- Coach Review Tab -->
|
<!-- Coach Review Tab -->
|
||||||
<div class="tab-panel" id="tab-review">
|
<div class="tab-panel" id="tab-review">
|
||||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px;">
|
<div
|
||||||
|
style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px;">
|
||||||
<div class="section-label">Private notes</div>
|
<div class="section-label">Private notes</div>
|
||||||
@auth
|
@auth
|
||||||
@if (isset($video) && Auth::id() === $video->user_id)
|
@if (isset($video) && Auth::id() === $video->user_id)
|
||||||
@ -2816,8 +2562,8 @@
|
|||||||
<div class="review-content">
|
<div class="review-content">
|
||||||
<h4 class="review-note-title">Missed counter opportunity</h4>
|
<h4 class="review-note-title">Missed counter opportunity</h4>
|
||||||
<div class="review-author-bar">
|
<div class="review-author-bar">
|
||||||
<img class="review-author-avatar" src="https://picsum.photos/seed/coach-sara/80/80"
|
<img class="review-author-avatar"
|
||||||
alt="Coach Sara">
|
src="https://picsum.photos/seed/coach-sara/80/80" alt="Coach Sara">
|
||||||
<span class="review-author-name">Coach Sara</span>
|
<span class="review-author-name">Coach Sara</span>
|
||||||
<span>•</span>
|
<span>•</span>
|
||||||
<span class="review-note-text">Great angle, but no follow up.</span>
|
<span class="review-note-text">Great angle, but no follow up.</span>
|
||||||
@ -2842,8 +2588,8 @@
|
|||||||
<div class="review-content">
|
<div class="review-content">
|
||||||
<h4 class="review-note-title">Excellent angle change and follow-up</h4>
|
<h4 class="review-note-title">Excellent angle change and follow-up</h4>
|
||||||
<div class="review-author-bar">
|
<div class="review-author-bar">
|
||||||
<img class="review-author-avatar" src="https://picsum.photos/seed/coach-ahmed/80/80"
|
<img class="review-author-avatar"
|
||||||
alt="Coach Ahmed">
|
src="https://picsum.photos/seed/coach-ahmed/80/80" alt="Coach Ahmed">
|
||||||
<span class="review-author-name">Coach Ahmed</span>
|
<span class="review-author-name">Coach Ahmed</span>
|
||||||
<span>•</span>
|
<span>•</span>
|
||||||
<span class="review-note-text">Ideal example of exit and re-entry.</span>
|
<span class="review-note-text">Ideal example of exit and re-entry.</span>
|
||||||
@ -2942,7 +2688,8 @@
|
|||||||
alt="{{ $recVideo->title }}">
|
alt="{{ $recVideo->title }}">
|
||||||
@endif
|
@endif
|
||||||
@if ($recVideo->duration)
|
@if ($recVideo->duration)
|
||||||
<span class="yt-video-duration">{{ gmdate('i:s', $recVideo->duration) }}</span>
|
<span
|
||||||
|
class="yt-video-duration">{{ gmdate('i:s', $recVideo->duration) }}</span>
|
||||||
@endif
|
@endif
|
||||||
@if ($recVideo->is_shorts)
|
@if ($recVideo->is_shorts)
|
||||||
<span class="yt-shorts-badge"
|
<span class="yt-shorts-badge"
|
||||||
|
|||||||
@ -279,48 +279,7 @@
|
|||||||
color: var(--brand-red);
|
color: var(--brand-red);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Channel Row */
|
|
||||||
.channel-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 16px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.channel-info {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.channel-avatar {
|
|
||||||
width: 48px;
|
|
||||||
height: 48px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: #555;
|
|
||||||
}
|
|
||||||
|
|
||||||
.channel-name {
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.channel-subs {
|
|
||||||
font-size: 14px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.subscribe-btn {
|
|
||||||
background: white;
|
|
||||||
color: black;
|
|
||||||
border: none;
|
|
||||||
padding: 8px 16px;
|
|
||||||
border-radius: 18px;
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 14px;
|
|
||||||
cursor: pointer;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Description */
|
/* Description */
|
||||||
.video-description {
|
.video-description {
|
||||||
@ -547,75 +506,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Channel Row - All in one line -->
|
<!-- Channel Row - All in one line -->
|
||||||
<div class="channel-row"
|
<x-channel-row :video="$video" />
|
||||||
style="display: flex; align-items: center; justify-content: space-between; padding: 12px 0; flex-wrap: wrap; gap: 16px;">
|
|
||||||
<div style="display: flex; align-items: center; gap: 12px;">
|
|
||||||
<a href="{{ route('channel', $video->user_id) }}" class="channel-info text-decoration-none"
|
|
||||||
style="color: inherit; display: flex; align-items: center; gap: 12px;">
|
|
||||||
@if ($video->user)
|
|
||||||
<img src="{{ $video->user->avatar_url }}" class="channel-avatar"
|
|
||||||
style="width: 40px; height: 40px; border-radius: 50%;" alt="{{ $video->user->name }}">
|
|
||||||
@else
|
|
||||||
<div class="channel-avatar" style="width: 40px; height: 40px; border-radius: 50%;"></div>
|
|
||||||
@endif
|
|
||||||
<div>
|
|
||||||
<div class="channel-name" style="font-weight: 600;">{{ $video->user->name ?? 'Unknown' }}</div>
|
|
||||||
<div class="channel-subs" style="font-size: 12px; color: var(--text-secondary);">
|
|
||||||
{{ number_format($video->user->subscriber_count ?? 0) }} subscribers</div>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="video-actions" style="display: flex; align-items: center; gap: 8px; flex-wrap: wrap;">
|
|
||||||
@auth
|
|
||||||
@if (Auth::id() !== $video->user_id)
|
|
||||||
<button class="subscribe-btn">Subscribe</button>
|
|
||||||
@else
|
|
||||||
<button class="action-btn" onclick="openEditVideoModal({{ $video->id }})">
|
|
||||||
<i class="bi bi-pencil"></i>
|
|
||||||
<span>Edit</span>
|
|
||||||
</button>
|
|
||||||
@endif
|
|
||||||
@else
|
|
||||||
<a href="{{ route('login') }}" class="subscribe-btn">Subscribe</a>
|
|
||||||
@endauth
|
|
||||||
|
|
||||||
@auth
|
|
||||||
<form method="POST"
|
|
||||||
action="{{ $video->isLikedBy(Auth::user()) ? route('videos.unlike', $video->id) : route('videos.like', $video->id) }}"
|
|
||||||
class="d-inline">
|
|
||||||
@csrf
|
|
||||||
<button type="submit" class="action-btn {{ $video->isLikedBy(Auth::user()) ? 'liked' : '' }}">
|
|
||||||
<i
|
|
||||||
class="bi {{ $video->isLikedBy(Auth::user()) ? 'bi-hand-thumbs-up-fill' : 'bi-hand-thumbs-up' }}"></i>
|
|
||||||
{{ $video->like_count > 0 ? number_format($video->like_count) : 'Like' }}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
@else
|
|
||||||
<a href="{{ route('login') }}" class="yt-action-btn">
|
|
||||||
<i class="bi bi-hand-thumbs-up"></i> Like
|
|
||||||
</a>
|
|
||||||
@endauth
|
|
||||||
|
|
||||||
@if ($video->isShareable())
|
|
||||||
<button class="action-btn"
|
|
||||||
onclick="openShareModal('{{ $video->share_url }}', '{{ addslashes($video->title) }}')">
|
|
||||||
<i class="bi bi-share"></i> Share
|
|
||||||
</button>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
<!-- Save to Playlist Button -->
|
|
||||||
<button class="action-btn" 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>
|
|
||||||
<span>Save</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Description Box -->
|
<!-- Description Box -->
|
||||||
@if ($video->description)
|
@if ($video->description)
|
||||||
@ -687,165 +578,8 @@
|
|||||||
</style>
|
</style>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
<!-- Comment Section -->
|
<x-video-comments :video="$video" />
|
||||||
<div class="comments-section"
|
|
||||||
style="margin-top: 24px; padding-top: 16px; border-top: 1px solid var(--border-color);">
|
|
||||||
<h3 style="font-size: 18px; font-weight: 600; margin-bottom: 16px;">
|
|
||||||
Comments <span
|
|
||||||
style="color: var(--text-secondary); font-weight: 400;">({{ $video->comment_count }})</span>
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
@auth
|
|
||||||
<div class="comment-form" style="display: flex; gap: 12px; margin-bottom: 24px;">
|
|
||||||
<img src="{{ Auth::user()->avatar_url }}" class="channel-avatar" style="width: 40px; height: 40px;"
|
|
||||||
alt="{{ Auth::user()->name }}">
|
|
||||||
<div style="flex: 1; display: flex; align-items: center; gap: 8px;">
|
|
||||||
<textarea id="commentBody" class="form-control" placeholder="Add a comment... Use @ to mention someone"
|
|
||||||
rows="1"
|
|
||||||
style="background: transparent; border: none; border-bottom: 2px solid var(--border-color); color: var(--text-primary); border-radius: 0; padding: 12px 0 8px 0; flex: 1; margin: 0; height: 40px; font-size: 14px; outline: none; overflow: hidden;"></textarea>
|
|
||||||
<button type="button" class="action-btn"
|
|
||||||
onclick="document.getElementById('commentBody').value = ''" style="flex-shrink: 0;">
|
|
||||||
<i class="bi bi-x-lg"></i>
|
|
||||||
<span>Cancel</span>
|
|
||||||
</button>
|
|
||||||
<button type="button" class="action-btn comment-btn"
|
|
||||||
onclick="submitComment({{ $video->id }})" style="flex-shrink: 0;">
|
|
||||||
<i class="bi bi-chat-dots"></i>
|
|
||||||
<span>Comment</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@else
|
|
||||||
<div
|
|
||||||
style="margin-bottom: 24px; padding: 16px; background: var(--bg-secondary); border-radius: 8px; text-align: center;">
|
|
||||||
<a href="{{ route('login') }}" style="color: var(--brand-red);">Sign in</a> to comment
|
|
||||||
</div>
|
|
||||||
@endauth
|
|
||||||
|
|
||||||
<div id="commentsList">
|
|
||||||
@forelse($video->comments()->whereNull('parent_id')->with('user', 'replies.user')->latest()->get() as $comment)
|
|
||||||
@include('videos.partials.comment', ['comment' => $comment])
|
|
||||||
@empty
|
|
||||||
<p style="color: var(--text-secondary); text-align: center; padding: 20px;">No comments yet. Be the
|
|
||||||
first to comment!</p>
|
|
||||||
@endforelse
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
function submitComment(videoId) {
|
|
||||||
const body = document.getElementById('commentBody').value.trim();
|
|
||||||
if (!body) return;
|
|
||||||
|
|
||||||
fetch(`/videos/${videoId}/comments`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
body: body
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.then(response => response.json())
|
|
||||||
.catch(error => {
|
|
||||||
console.error('Error:', error);
|
|
||||||
alert('Failed to post comment: ' + error);
|
|
||||||
})
|
|
||||||
.then(data => {
|
|
||||||
if (data && data.success) {
|
|
||||||
document.getElementById('commentBody').value = '';
|
|
||||||
addCommentToList(data.comment);
|
|
||||||
} else {
|
|
||||||
alert('Failed to post comment');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function addCommentToList(comment) {
|
|
||||||
const commentsList = document.getElementById('commentsList');
|
|
||||||
const commentHtml = `
|
|
||||||
<div class="comment-item" style="display: flex; gap: 12px; margin-bottom: 16px;" id="comment-${comment.id}">
|
|
||||||
<img src="${comment.user.avatar_url}" class="channel-avatar" style="width: 36px; height: 36px; flex-shrink: 0;" alt="${comment.user.name}">
|
|
||||||
<div style="flex: 1;">
|
|
||||||
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 4px;">
|
|
||||||
<span style="font-weight: 600; font-size: 14px;">${comment.user.name}</span>
|
|
||||||
<span style="color: var(--text-secondary); font-size: 12px;">just now</span>
|
|
||||||
</div>
|
|
||||||
<div class="comment-body" style="font-size: 14px; line-height: 1.5; word-wrap: break-word;">
|
|
||||||
${comment.body.replace(/@(\\w+)/g, '<span style="color: #3ea6ff; font-weight: 500;">@$1</span>')}
|
|
||||||
</div>
|
|
||||||
<div style="display: flex; gap: 12px; margin-top: 8px;">
|
|
||||||
<button onclick="toggleReplyForm(${comment.id})" style="background: none; border: none; color: var(--text-secondary); font-size: 12px; font-weight: 600; cursor: pointer; padding: 0;">
|
|
||||||
Reply
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
commentsList.insertAdjacentHTML('afterbegin', commentHtml);
|
|
||||||
const commentCount = document.querySelector('h3 span');
|
|
||||||
if (commentCount) {
|
|
||||||
const count = parseInt(commentCount.textContent.match(/\\((\\d+)\\)/)?.[2] || 0) + 1;
|
|
||||||
commentCount.textContent = `(${count})`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function deleteComment(commentId) {
|
|
||||||
if (confirm('Are you sure you want to delete this comment?')) {
|
|
||||||
fetch(`/comments/${commentId}`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
headers: {
|
|
||||||
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.then(response => response.json())
|
|
||||||
.then(data => {
|
|
||||||
if (data.success) {
|
|
||||||
document.getElementById('comment-' + commentId).remove();
|
|
||||||
const commentCount = document.querySelector('h3 span');
|
|
||||||
if (commentCount) {
|
|
||||||
const count = parseInt(commentCount.textContent.match(/\\((\\d+)\\)/)?.[2] || 0) - 1;
|
|
||||||
commentCount.textContent = `(${count})`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
const commentTexts = document.querySelectorAll('.comment-body');
|
|
||||||
commentTexts.forEach(text => {
|
|
||||||
const html = text.innerHTML.replace(/@(\w+)/g,
|
|
||||||
'<span style="color: #3ea6ff; font-weight: 500;">@$1</span>');
|
|
||||||
text.innerHTML = html;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
function submitReply(videoId, parentId) {
|
|
||||||
const textarea = document.querySelector(`#replyForm${parentId} textarea`);
|
|
||||||
const body = textarea.value.trim();
|
|
||||||
if (!body) return;
|
|
||||||
|
|
||||||
fetch(`/videos/${videoId}/comments`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
body: body,
|
|
||||||
parent_id: parentId
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.then(response => response.json())
|
|
||||||
.then(data => {
|
|
||||||
if (data.success) {
|
|
||||||
location.reload();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Sidebar - Up Next / Recommendations -->
|
<!-- Sidebar - Up Next / Recommendations -->
|
||||||
|
|||||||
@ -18,6 +18,7 @@ Route::get('/shorts', [VideoController::class, 'shorts'])->name('videos.shorts')
|
|||||||
Route::get('/videos/create', [VideoController::class, 'create'])->name('videos.create');
|
Route::get('/videos/create', [VideoController::class, 'create'])->name('videos.create');
|
||||||
Route::get('/videos/{video}', [VideoController::class, 'show'])->name('videos.show');
|
Route::get('/videos/{video}', [VideoController::class, 'show'])->name('videos.show');
|
||||||
Route::get('/videos/{video}/stream', [VideoController::class, 'stream'])->name('videos.stream');
|
Route::get('/videos/{video}/stream', [VideoController::class, 'stream'])->name('videos.stream');
|
||||||
|
Route::get('/videos/{video}/hls/{file?}', [VideoController::class, 'hls'])->where(['file' => '.*'])->name('videos.hls');
|
||||||
Route::get('/videos/{video}/download', [VideoController::class, 'download'])->name('videos.download');
|
Route::get('/videos/{video}/download', [VideoController::class, 'download'])->name('videos.download');
|
||||||
Route::get('/videos/{video}/recommendations', [VideoController::class, 'recommendations'])->name('videos.recommendations');
|
Route::get('/videos/{video}/recommendations', [VideoController::class, 'recommendations'])->name('videos.recommendations');
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user