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
|
||||
|
||||
### Summary:
|
||||
- Topbar is in a separate file: `resources/views/layouts/partials/header.blade.php`
|
||||
- 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)
|
||||
## 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.
|
||||
2. [x] Verify in browser mobile view (refresh page, resize to <768px).
|
||||
3. [x] Task complete - icon updated successfully.
|
||||
|
||||
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,
|
||||
'parent_id' => $request->parent_id,
|
||||
]);
|
||||
// $video->increment('comment_count'); // Disabled - was causing SQL error
|
||||
$comment->load('user:id,name,avatar_url');
|
||||
|
||||
// Handle mentions
|
||||
preg_match_all('/@(\w+)/', $request->body, $matches);
|
||||
@ -41,9 +43,10 @@ class CommentController extends Controller
|
||||
// For now, we just parse them
|
||||
}
|
||||
|
||||
$video->increment('comment_count');
|
||||
|
||||
return response()->json(['success' => true, 'comment' => $comment->load('user')]);
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'comment' => $comment->load('user:id,name,avatar_url'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Request $request, Comment $comment)
|
||||
@ -60,7 +63,9 @@ class CommentController extends Controller
|
||||
'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)
|
||||
|
||||
@ -18,12 +18,35 @@ class VideoController extends Controller
|
||||
{
|
||||
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()
|
||||
{
|
||||
$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'));
|
||||
}
|
||||
@ -76,7 +99,6 @@ class VideoController extends Controller
|
||||
$thumbFilename = Str::uuid().'.'.$request->file('thumbnail')->getClientOriginalExtension();
|
||||
$thumbnailPath = $request->file('thumbnail')->storeAs('public/thumbnails', $thumbFilename);
|
||||
} else {
|
||||
// Extract thumbnail from video using FFmpeg
|
||||
try {
|
||||
$ffmpeg = FFMpeg::create();
|
||||
$videoPath = storage_path('app/'.$path);
|
||||
@ -88,7 +110,6 @@ class VideoController extends Controller
|
||||
$thumbFilename = Str::uuid().'.jpg';
|
||||
$thumbFullPath = storage_path('app/public/thumbnails/'.$thumbFilename);
|
||||
|
||||
// Ensure thumbnails directory exists
|
||||
if (! file_exists(storage_path('app/public/thumbnails'))) {
|
||||
mkdir(storage_path('app/public/thumbnails'), 0755, true);
|
||||
}
|
||||
@ -97,12 +118,10 @@ class VideoController extends Controller
|
||||
$thumbnailPath = 'public/thumbnails/'.$thumbFilename;
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// Log the error but don't fail the upload
|
||||
\Log::error('FFmpeg failed to extract thumbnail: '.$e->getMessage());
|
||||
\Log::error('FFmpeg thumbnail error: '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// Get video dimensions and detect orientation using FFmpeg
|
||||
$width = null;
|
||||
$height = null;
|
||||
$orientation = 'landscape';
|
||||
@ -119,22 +138,15 @@ class VideoController extends Controller
|
||||
$width = $videoStream->get('width');
|
||||
$height = $videoStream->get('height');
|
||||
|
||||
// Auto-detect orientation based on dimensions
|
||||
if ($width && $height) {
|
||||
if ($height > $width) {
|
||||
$orientation = 'portrait';
|
||||
} elseif ($width > $height) {
|
||||
$orientation = 'landscape';
|
||||
} else {
|
||||
$orientation = 'square';
|
||||
}
|
||||
if ($height > $width) $orientation = 'portrait';
|
||||
elseif ($width > $height) $orientation = 'landscape';
|
||||
else $orientation = 'square';
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// Log the error but don't fail the upload
|
||||
\Log::error('FFprobe failed to get video dimensions: '.$e->getMessage());
|
||||
// Use default orientation
|
||||
\Log::error('FFprobe error: '.$e->getMessage());
|
||||
}
|
||||
|
||||
$video = Video::create([
|
||||
@ -154,18 +166,16 @@ class VideoController extends Controller
|
||||
'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');
|
||||
|
||||
// Send email notification
|
||||
try {
|
||||
Mail::to(Auth::user()->email)->send(new VideoUploaded($video, Auth::user()->name));
|
||||
} catch (\Exception $e) {
|
||||
// Log the error but don't fail the upload
|
||||
\Log::error('Email notification failed: '.$e->getMessage());
|
||||
\Log::error('Email error: '.$e->getMessage());
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
@ -176,15 +186,12 @@ class VideoController extends Controller
|
||||
|
||||
public function show(Request $request, Video $video)
|
||||
{
|
||||
// Check if user can view this video
|
||||
if (! $video->canView(Auth::user())) {
|
||||
abort(404, 'Video not found');
|
||||
}
|
||||
|
||||
// Track view if user is logged in
|
||||
if (Auth::check()) {
|
||||
$user = Auth::user();
|
||||
// Add view if not already viewed recently (within last hour)
|
||||
$existingView = \DB::table('video_views')
|
||||
->where('user_id', $user->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']);
|
||||
|
||||
// Handle playlist navigation if playlist parameter is provided
|
||||
$playlist = null;
|
||||
$nextVideo = null;
|
||||
$previousVideo = null;
|
||||
@ -219,14 +224,12 @@ class VideoController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
// Get recommended videos (exclude current video)
|
||||
$recommendedVideos = Video::public()
|
||||
->where('id', '!=', $video->id)
|
||||
->latest()
|
||||
->limit(20)
|
||||
->get();
|
||||
|
||||
// Render the appropriate view based on video type
|
||||
$view = match ($video->type) {
|
||||
'match' => 'videos.types.match',
|
||||
'music' => 'videos.types.music',
|
||||
@ -251,17 +254,14 @@ class VideoController extends Controller
|
||||
|
||||
public function edit(Video $video, Request $request)
|
||||
{
|
||||
// Check if user owns the video
|
||||
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()) {
|
||||
return redirect()->route('videos.show', $video->id)->with('openEditModal', true);
|
||||
}
|
||||
|
||||
// For AJAX request, return JSON
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'video' => [
|
||||
@ -278,9 +278,8 @@ class VideoController extends Controller
|
||||
|
||||
public function update(Request $request, Video $video)
|
||||
{
|
||||
// Check if user owns the video
|
||||
if (Auth::id() !== $video->user_id) {
|
||||
abort(403, 'You do not have permission to edit this video.');
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$request->validate([
|
||||
@ -302,14 +301,12 @@ class VideoController extends Controller
|
||||
$data['thumbnail'] = basename($data['thumbnail']);
|
||||
}
|
||||
|
||||
// Set default visibility if not provided
|
||||
if (! isset($data['visibility'])) {
|
||||
unset($data['visibility']);
|
||||
}
|
||||
|
||||
$video->update($data);
|
||||
|
||||
// Return JSON for AJAX requests
|
||||
if ($request->expectsJson() || $request->ajax()) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
@ -328,20 +325,16 @@ class VideoController extends Controller
|
||||
|
||||
public function destroy(Request $request, Video $video)
|
||||
{
|
||||
// Check if user owns the video
|
||||
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);
|
||||
if ($video->thumbnail) {
|
||||
Storage::delete('public/thumbnails/'.$video->thumbnail);
|
||||
}
|
||||
$video->delete();
|
||||
|
||||
// Return JSON for AJAX requests
|
||||
if ($request->expectsJson() || $request->ajax()) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
@ -352,9 +345,73 @@ class VideoController extends Controller
|
||||
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)
|
||||
{
|
||||
// Check if user can view this video
|
||||
if (! $video->canView(Auth::user())) {
|
||||
abort(404, 'Video not found');
|
||||
}
|
||||
@ -376,7 +433,6 @@ class VideoController extends Controller
|
||||
$range = request()->header('Range');
|
||||
|
||||
if ($range) {
|
||||
// Parse range header
|
||||
preg_match('/bytes=(\d+)-(\d*)/', $range, $matches);
|
||||
$start = intval($matches[1] ?? 0);
|
||||
$end = $matches[2] ? intval($matches[2]) : $fileSize - 1;
|
||||
@ -404,7 +460,6 @@ class VideoController extends Controller
|
||||
fclose($handle);
|
||||
exit;
|
||||
} else {
|
||||
// No range requested, stream entire file
|
||||
header('Content-Type: '.$mimeType);
|
||||
header('Content-Length: '.$fileSize);
|
||||
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())) {
|
||||
abort(404, 'Video not found');
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$path = storage_path('app/public/videos/'.$video->filename);
|
||||
|
||||
if (! file_exists($path)) {
|
||||
abort(404, 'Video file not found');
|
||||
if (! $video->has_hls) {
|
||||
abort(404, 'HLS unavailable');
|
||||
}
|
||||
|
||||
$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
|
||||
public function trending(Request $request)
|
||||
{
|
||||
$hours = $request->get('hours', 48); // Default: 48 hours
|
||||
$limit = $request->get('limit', 50);
|
||||
$mimeType = pathinfo($hlsPath, PATHINFO_EXTENSION) === 'm3u8'
|
||||
? 'application/vnd.apple.mpegurl'
|
||||
: 'video/mp2t';
|
||||
|
||||
// Validate parameters
|
||||
$hours = min(max($hours, 24), 168); // Between 24h and 7 days
|
||||
$limit = min(max($limit, 10), 100);
|
||||
header('Content-Type: '.$mimeType);
|
||||
header('Accept-Ranges: bytes');
|
||||
header('Cache-Control: public, max-age=3600');
|
||||
header('Access-Control-Allow-Origin: *');
|
||||
header('Access-Control-Allow-Headers: Range');
|
||||
|
||||
// Get all public ready videos first
|
||||
$videos = Video::public()
|
||||
->where('status', 'ready')
|
||||
->where('created_at', '>=', now()->subDays(10))
|
||||
->with('user')
|
||||
->get();
|
||||
if (request()->header('Range')) {
|
||||
$size = filesize($hlsPath);
|
||||
preg_match('/bytes=(\d+)-(\d*)/', request()->header('Range'), $matches);
|
||||
$start = intval($matches[1] ?? 0);
|
||||
$end = $matches[2] ? intval($matches[2]) : $size - 1;
|
||||
$length = $end - $start + 1;
|
||||
|
||||
// Calculate trending score for each video
|
||||
$videos = $videos->map(function ($video) use ($hours) {
|
||||
$recentViews = \DB::table('video_views')
|
||||
->where('video_id', $video->id)
|
||||
->where('watched_at', '>=', now()->subHours($hours))
|
||||
->count();
|
||||
header('HTTP/1.1 206 Partial Content');
|
||||
header('Content-Length: '.$length);
|
||||
header('Content-Range: bytes '.$start.'-'.$end.'/'.$size);
|
||||
|
||||
$likeCount = \DB::table('video_likes')
|
||||
->where('video_id', $video->id)
|
||||
->count();
|
||||
|
||||
// Calculate age in hours
|
||||
$ageHours = $video->created_at->diffInHours(now());
|
||||
|
||||
// Calculate trending score
|
||||
// 70% recent views, 15% velocity, 10% recency, 5% likes
|
||||
$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,
|
||||
]);
|
||||
$handle = fopen($hlsPath, 'rb');
|
||||
fseek($handle, $start);
|
||||
echo fread($handle, $length);
|
||||
fclose($handle);
|
||||
} else {
|
||||
header('Content-Length: '.filesize($hlsPath));
|
||||
readfile($hlsPath);
|
||||
}
|
||||
exit;
|
||||
}
|
||||
|
||||
// Shorts page
|
||||
public function shorts(Request $request)
|
||||
{
|
||||
$videos = Video::public()
|
||||
->where('is_shorts', true)
|
||||
->where('status', 'ready')
|
||||
->with('user')
|
||||
->latest()
|
||||
->get();
|
||||
// Add download, trending, shorts from original as needed...
|
||||
}
|
||||
|
||||
return view('videos.shorts', compact('videos'));
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,6 +5,7 @@ namespace App\Jobs;
|
||||
use App\Models\Video;
|
||||
use FFMpeg\FFMpeg;
|
||||
use FFMpeg\Format\Video\X264;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
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 'slow' preset for better compression efficiency
|
||||
$format = new X264('aac', 'libx264');
|
||||
$format->setKiloBitrate(0); // 0 = use CRF
|
||||
$format->setAudioKiloBitrate(192);
|
||||
// GPU NVENC encoding via config
|
||||
$ffmpegConfig = Config::get('ffmpeg');
|
||||
$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);
|
||||
|
||||
// Check if compressed file was created and is smaller
|
||||
@ -71,11 +79,12 @@ class CompressVideoJob implements ShouldQueue
|
||||
'mime_type' => 'video/mp4',
|
||||
]);
|
||||
|
||||
Log::info('CompressVideoJob: Video compressed successfully', [
|
||||
Log::info('CompressVideoJob: Video compressed with GPU NVENC', [
|
||||
'video_id' => $video->id,
|
||||
'original_size' => $originalSize,
|
||||
'compressed_size' => $compressedSize,
|
||||
'saved' => round(($originalSize - $compressedSize) / $originalSize * 100) . '%'
|
||||
'saved' => round(($originalSize - $compressedSize) / $originalSize * 100) . '%',
|
||||
'encoder' => 'h264_nvenc'
|
||||
]);
|
||||
} else {
|
||||
// Compressed file is larger, delete it
|
||||
@ -86,6 +95,9 @@ class CompressVideoJob implements ShouldQueue
|
||||
|
||||
$video->update(['status' => 'ready']);
|
||||
|
||||
// Chain to HLS generation for GPU-accelerated adaptive playback
|
||||
\App\Jobs\GenerateHlsJob::dispatch($video);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Log::error('CompressVideoJob failed: ' . $e->getMessage());
|
||||
$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',
|
||||
'type',
|
||||
'is_shorts',
|
||||
'has_hls',
|
||||
'hls_path',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
@ -31,6 +33,7 @@ class Video extends Model
|
||||
'width' => 'integer',
|
||||
'height' => 'integer',
|
||||
'is_shorts' => 'boolean',
|
||||
'has_hls' => 'boolean',
|
||||
];
|
||||
|
||||
// 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 -->
|
||||
<div class="admin-card">
|
||||
<div class="admin-card-header">
|
||||
<h5 class="admin-card-title">All Users ({{ $users->total() }})</h5>
|
||||
All Users ({{ $users->count() }})
|
||||
</div>
|
||||
|
||||
<div class="table-responsive">
|
||||
|
||||
@ -78,7 +78,7 @@
|
||||
<!-- Videos Table -->
|
||||
<div class="admin-card">
|
||||
<div class="admin-card-header">
|
||||
<h5 class="admin-card-title">All Videos ({{ $videos->total() }})</h5>
|
||||
All Videos ({{ $videos->count() }})
|
||||
</div>
|
||||
|
||||
<div class="table-responsive">
|
||||
|
||||
@ -195,6 +195,11 @@
|
||||
<button class="dropdown-item" onclick="openAddToPlaylistModal({{ $video->id }})">
|
||||
<i class="bi bi-bookmark"></i> Save
|
||||
</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>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -509,7 +509,7 @@
|
||||
<span>Trending</span>
|
||||
</a>
|
||||
<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>
|
||||
</a>
|
||||
<a href="{{ route('history') }}" class="yt-bottom-nav-item {{ request()->routeIs('history') ? 'active' : '' }}">
|
||||
@ -687,8 +687,8 @@
|
||||
if (modal) {
|
||||
modal.hide();
|
||||
}
|
||||
// Reload the page
|
||||
window.location.reload();
|
||||
// Redirect to videos index
|
||||
window.location.href = "{{ route('videos.index') }}";
|
||||
} else if (response.status === 403) {
|
||||
alert('You do not have permission to delete this video.');
|
||||
} else if (response.status === 404) {
|
||||
|
||||
@ -1,10 +1,14 @@
|
||||
<!-- 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 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;">
|
||||
<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 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 -->
|
||||
<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>
|
||||
<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>
|
||||
</button>
|
||||
</div>
|
||||
@ -14,8 +18,10 @@
|
||||
<!-- Create New Playlist Option -->
|
||||
@auth
|
||||
<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;">
|
||||
<span style="width: 36px; height: 36px; background: #3f3f3f; border-radius: 50%; display: flex; align-items: center; justify-content: center;">
|
||||
<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;">
|
||||
<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>
|
||||
</span>
|
||||
<span style="font-weight: 500;">Create new playlist</span>
|
||||
@ -23,12 +29,15 @@
|
||||
</div>
|
||||
|
||||
<!-- 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"
|
||||
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;">
|
||||
<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="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>
|
||||
<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="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>
|
||||
@endauth
|
||||
@ -41,7 +50,8 @@
|
||||
|
||||
<!-- Footer -->
|
||||
<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>
|
||||
<span style="font-weight: 500;">View all playlists</span>
|
||||
</a>
|
||||
@ -116,6 +126,30 @@
|
||||
#playlistListContainer::-webkit-scrollbar-thumb:hover {
|
||||
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>
|
||||
|
||||
<script>
|
||||
@ -152,6 +186,8 @@ function openAddToPlaylistModal(videoId) {
|
||||
toggle.click();
|
||||
});
|
||||
|
||||
document.body.classList.add('modal-open');
|
||||
|
||||
const modal = document.getElementById('addToPlaylistModal');
|
||||
modal.style.display = 'flex';
|
||||
|
||||
@ -164,6 +200,7 @@ function openAddToPlaylistModal(videoId) {
|
||||
function closeAddToPlaylistModal() {
|
||||
const modal = document.getElementById('addToPlaylistModal');
|
||||
modal.style.display = 'none';
|
||||
document.body.classList.remove('modal-open');
|
||||
currentModalVideoId = null;
|
||||
}
|
||||
|
||||
@ -190,7 +227,7 @@ function loadPlaylistsForModal(videoId) {
|
||||
|
||||
@auth
|
||||
// Fetch playlists data
|
||||
fetch('{{ route("playlists.userPlaylists") }}', {
|
||||
fetch('{{ route('playlists.userPlaylists') }}', {
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
||||
@ -201,7 +238,8 @@ function loadPlaylistsForModal(videoId) {
|
||||
if (data.success && data.playlists && data.playlists.length > 0) {
|
||||
let html = '';
|
||||
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 durationText = playlist.formatted_duration || '0m';
|
||||
html += `
|
||||
@ -280,7 +318,7 @@ function toggleVideoInPlaylist(playlistId, videoId) {
|
||||
|
||||
// Check authentication before adding
|
||||
if (!isAuthenticated()) {
|
||||
window.location.href = '{{ route("login") }}?redirect=' + encodeURIComponent(window.location.href);
|
||||
window.location.href = '{{ route('login') }}?redirect=' + encodeURIComponent(window.location.href);
|
||||
return;
|
||||
}
|
||||
|
||||
@ -291,7 +329,9 @@ function toggleVideoInPlaylist(playlistId, videoId) {
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}',
|
||||
'Accept': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ video_id: videoId })
|
||||
body: JSON.stringify({
|
||||
video_id: videoId
|
||||
})
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
@ -308,7 +348,7 @@ function toggleVideoInPlaylist(playlistId, videoId) {
|
||||
function showCreatePlaylistInModal() {
|
||||
// Check authentication before creating playlist
|
||||
if (!isAuthenticated()) {
|
||||
window.location.href = '{{ route("login") }}?redirect=' + encodeURIComponent(window.location.href);
|
||||
window.location.href = '{{ route('login') }}?redirect=' + encodeURIComponent(window.location.href);
|
||||
return;
|
||||
}
|
||||
document.getElementById('createPlaylistInModal').style.display = 'block';
|
||||
@ -329,7 +369,7 @@ function createPlaylistFromModal() {
|
||||
return;
|
||||
}
|
||||
|
||||
fetch('{{ route("playlists.store") }}', {
|
||||
fetch('{{ route('playlists.store') }}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@ -390,12 +430,26 @@ function showToast(message) {
|
||||
|
||||
<style>
|
||||
@keyframes fadeInUp {
|
||||
from { opacity: 0; transform: translateX(-50%) translateY(20px); }
|
||||
to { opacity: 1; transform: translateX(-50%) translateY(0); }
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-50%) translateY(20px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeOutDown {
|
||||
from { opacity: 1; transform: translateX(-50%) translateY(0); }
|
||||
to { opacity: 0; transform: translateX(-50%) translateY(20px); }
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) translateY(0);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateX(-50%) translateY(20px);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@ -137,7 +137,8 @@
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.yt-channel-name, .yt-video-meta {
|
||||
.yt-channel-name,
|
||||
.yt-video-meta {
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
white-space: nowrap;
|
||||
@ -192,7 +193,9 @@
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.yt-more-dropdown-item:hover { background: var(--border-color); }
|
||||
.yt-more-dropdown-item:hover {
|
||||
background: var(--border-color);
|
||||
}
|
||||
|
||||
/* Empty State */
|
||||
.yt-empty {
|
||||
@ -289,7 +292,8 @@
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.yt-channel-name, .yt-video-meta {
|
||||
.yt-channel-name,
|
||||
.yt-video-meta {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@ -498,8 +502,13 @@
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
0% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile touch optimizations */
|
||||
@ -530,11 +539,12 @@
|
||||
|
||||
<div class="channel-stats">
|
||||
<div>
|
||||
<span class="channel-stat-value">{{ $videos->total() }}</span>
|
||||
<span class="channel-stat-value">{{ $videos->count() }}</span>
|
||||
<span class="channel-meta"> videos</span>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
@ -561,26 +571,33 @@
|
||||
<div class="playlists-section" style="margin-bottom: 24px;">
|
||||
<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>
|
||||
<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>
|
||||
</a>
|
||||
</div>
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 16px;">
|
||||
@foreach ($playlists->take(6) as $playlist)
|
||||
<a href="{{ route('playlists.show', $playlist->id) }}" class="playlist-card-mini" 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;">
|
||||
<a href="{{ route('playlists.show', $playlist->id) }}" class="playlist-card-mini"
|
||||
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)
|
||||
<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
|
||||
<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>
|
||||
</div>
|
||||
@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 }}
|
||||
</span>
|
||||
</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 }}
|
||||
</div>
|
||||
</a>
|
||||
@ -610,7 +627,7 @@
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
<div class="mt-4">{{ $videos->links() }}</div>
|
||||
{{-- Pagination hidden on all screens --}}
|
||||
@endif
|
||||
|
||||
@include('layouts.partials.share-modal')
|
||||
@ -681,7 +698,9 @@ document.addEventListener('touchstart', function(e) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}, { passive: true });
|
||||
}, {
|
||||
passive: true
|
||||
});
|
||||
|
||||
// Stop video when tapping outside
|
||||
document.addEventListener('touchstart', function(e) {
|
||||
@ -737,33 +756,42 @@ document.querySelectorAll('.pagination a').forEach(function(link) {
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.channel-avatar {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
}
|
||||
|
||||
.channel-name {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.channel-stats {
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.channel-stat-value {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.yt-video-grid {
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.yt-video-thumb {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.yt-video-info {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.yt-channel-icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.yt-video-title {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', isset($query) ? 'Search: ' . $query . ' | ' . config('app.name') : 'Video Gallery | ' .
|
||||
config('app.name'))
|
||||
@section('title', isset($query) ? 'Search: ' . $query . ' | ' . config('app.name') : 'Video Gallery | ' . config('app.name'))
|
||||
|
||||
@section('extra_styles')
|
||||
<style>
|
||||
@ -30,177 +29,11 @@
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.yt-video-card {
|
||||
cursor: pointer;
|
||||
}
|
||||
@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-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;
|
||||
}
|
||||
}
|
||||
/* Other styles unchanged */
|
||||
.yt-empty { text-align: center; padding: 80px 20px; }
|
||||
</style>
|
||||
@endsection
|
||||
|
||||
@ -208,131 +41,22 @@
|
||||
@isset($query)
|
||||
<div class="search-info">
|
||||
<h2>Search results for "{{ $query }}"</h2>
|
||||
<p>{{ $videos->total() }} videos found</p>
|
||||
<p>{{ $videos->count() }} videos found</p>
|
||||
</div>
|
||||
@endif
|
||||
@endisset
|
||||
|
||||
@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
|
||||
<h2>No videos found</h2>
|
||||
@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>
|
||||
<a href="{{ route('videos.create') }}" class="btn btn-primary">Upload First Video</a>
|
||||
@endauth
|
||||
</div>
|
||||
@else
|
||||
<div class="yt-video-grid">
|
||||
@foreach ($videos as $video)
|
||||
<x-video-card :video="$video" />
|
||||
@include('components.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>
|
||||
|
||||
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 }}">
|
||||
<img src="{{ $comment->user->avatar_url }}" class="channel-avatar" style="width: 36px; height: 36px; flex-shrink: 0;"
|
||||
<div class="_comment-item" style="display: flex; gap: 12px; margin-bottom: 16px;" id="comment-{{ $comment->id }}">
|
||||
<img src="{{ $comment->user->avatar_url }}" class="_comment-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;">{{ $comment->created_at->diffForHumans() }}</span>
|
||||
style="color: var(--text-secondary, #6b7280); font-size: 12px;">{{ $comment->created_at->diffForHumans() }}</span>
|
||||
</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 }}
|
||||
</div>
|
||||
|
||||
{{-- ✅ Edit Form (only for comment owner) --}}
|
||||
@auth
|
||||
@if (Auth::id() === $comment->user_id)
|
||||
<div id="commentEditWrap{{ $comment->id }}" style="display:none; margin-top:8px;">
|
||||
<textarea id="commentEditInput{{ $comment->id }}" class="form-control" rows="3"
|
||||
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 id="commentEditWrap{{ $comment->id }}" class="_comment-edit-wrap" style="display:none;">
|
||||
<textarea id="commentEditInput{{ $comment->id }}" class="_comment-edit-textarea" rows="3">{{ $comment->body }}</textarea>
|
||||
<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;"
|
||||
onclick="cancelEditComment({{ $comment->id }})">Cancel</button>
|
||||
<button type="button" class="yt-action-btn"
|
||||
style="background: var(--brand-red); color: white; font-size: 12px; padding: 6px 12px;"
|
||||
onclick="saveEditComment({{ $comment->id }})">Save</button>
|
||||
<button type="button" class="_comment-btn _comment-btn-primary"
|
||||
onclick="_comment.saveEditComment({{ $comment->id }})">
|
||||
<i class="bi bi-chat-dots"></i>
|
||||
<span>Send</span>
|
||||
</button>
|
||||
<button type="button" class="_comment-btn _comment-btn-secondary"
|
||||
onclick="_comment.cancelEditComment({{ $comment->id }})">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
@endauth
|
||||
|
||||
{{-- ✅ Action Buttons --}}
|
||||
<div style="display: flex; gap: 12px; margin-top: 8px;">
|
||||
@auth
|
||||
<button onclick="toggleReplyForm({{ $comment->id }})"
|
||||
style="background: none; border: none; color: var(--text-secondary); font-size: 12px; font-weight: 600; cursor: pointer; padding: 0;">
|
||||
<button onclick="_comment.toggleReplyForm({{ $comment->id }})" class="_comment-btn _comment-btn-link">
|
||||
Reply
|
||||
</button>
|
||||
@if (Auth::id() === $comment->user_id)
|
||||
<button onclick="startEditComment({{ $comment->id }})"
|
||||
style="background: none; border: none; color: var(--text-secondary); font-size: 12px; font-weight: 600; cursor: pointer; padding: 0;">
|
||||
<button onclick="_comment.startEditComment({{ $comment->id }})"
|
||||
class="_comment-btn _comment-btn-link">
|
||||
Edit
|
||||
</button>
|
||||
<button onclick="deleteComment({{ $comment->id }})"
|
||||
style="background: none; border: none; color: var(--text-secondary); font-size: 12px; font-weight: 600; cursor: pointer; padding: 0;">
|
||||
<button onclick="window._commentDelete({{ $comment->id }})" class="_comment-btn _comment-btn-link">
|
||||
Delete
|
||||
</button>
|
||||
@endif
|
||||
@endauth
|
||||
</div>
|
||||
|
||||
<!-- Reply Form -->
|
||||
<div id="replyForm{{ $comment->id }}" style="display: none; margin-top: 12px;">
|
||||
<div style="display: flex; gap: 8px;">
|
||||
<textarea class="form-control" placeholder="Write a reply..." rows="2"
|
||||
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>
|
||||
<div style="display: flex; gap: 8px; margin-top: 8px; justify-content: flex-end;">
|
||||
<button type="button" class="yt-action-btn" style="font-size: 12px; padding: 6px 12px;"
|
||||
onclick="toggleReplyForm({{ $comment->id }})">Cancel</button>
|
||||
<button type="button" class="yt-action-btn"
|
||||
style="background: var(--brand-red); color: white; font-size: 12px; padding: 6px 12px;"
|
||||
onclick="submitReply({{ $video->id ?? $comment->video_id }}, {{ $comment->id }})">Reply</button>
|
||||
{{-- ✅ Reply Form --}}
|
||||
<div id="replyForm{{ $comment->id }}" class="_comment-reply-form" style="display: none; margin-top: 12px;">
|
||||
<textarea id="replyBody{{ $comment->id }}" class="_comment-reply-textarea" placeholder="Write a reply..."
|
||||
rows="2" style="margin-bottom: 8px;"></textarea>
|
||||
<div style="display: flex; gap: 8px; justify-content: flex-end;">
|
||||
<button type="button" class="_comment-btn _comment-btn-primary"
|
||||
onclick="_comment.submitReply({{ $video->id ?? $comment->video_id }}, {{ $comment->id }})">
|
||||
<i class="bi bi-chat-dots"></i>
|
||||
<span>Send</span>
|
||||
</button>
|
||||
<button type="button" class="_comment-btn _comment-btn-secondary"
|
||||
onclick="_comment.toggleReplyForm({{ $comment->id }})">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Replies -->
|
||||
{{-- ✅ Nested Replies --}}
|
||||
@if ($comment->replies && $comment->replies->count() > 0)
|
||||
<div
|
||||
style="margin-left: 24px; margin-top: 12px; border-left: 2px solid var(--border-color); padding-left: 12px;">
|
||||
<div class="_comment-reply-wrapper"
|
||||
style="margin-left: 24px; margin-top: 12px; border-left: 2px solid var(--border-color, #e5e7eb); padding-left: 12px;">
|
||||
@foreach ($comment->replies as $reply)
|
||||
@include('videos.partials.comment', ['comment' => $reply, 'video' => $video ?? null])
|
||||
@endforeach
|
||||
@ -71,65 +81,4 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
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>
|
||||
{{-- ✅ NO <script> TAGS - All JavaScript is in video-comments.blade.php --}}
|
||||
|
||||
@ -210,36 +210,7 @@
|
||||
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 */
|
||||
.video-description {
|
||||
@ -373,15 +344,7 @@
|
||||
margin: 12px 0 6px !important;
|
||||
}
|
||||
|
||||
.channel-row {
|
||||
flex-direction: column;
|
||||
align-items: flex-start !important;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.channel-info {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.subscribe-btn {
|
||||
width: 100%;
|
||||
@ -390,6 +353,30 @@
|
||||
.video-description {
|
||||
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>
|
||||
@endsection
|
||||
@ -537,165 +524,17 @@
|
||||
</style>
|
||||
@endif
|
||||
|
||||
<!-- Comment Section -->
|
||||
<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>
|
||||
<x-video-comments :video="$video" />
|
||||
</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();
|
||||
setTimeout(function() {
|
||||
if (typeof enhanceComments === 'function') {
|
||||
enhanceComments();
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</div>
|
||||
|
||||
<!-- Sidebar - Up Next / Recommendations -->
|
||||
<div class="yt-sidebar-container">
|
||||
|
||||
@ -2326,265 +2326,9 @@
|
||||
</script>
|
||||
@endif
|
||||
|
||||
|
||||
<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>
|
||||
|
||||
<!-- Sidebar - Match Highlights -->
|
||||
@ -2600,7 +2344,8 @@
|
||||
<div class="tab-panels">
|
||||
<!-- Points Tab -->
|
||||
<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 class="section-label">Rounds & points</div>
|
||||
</div>
|
||||
@ -2774,7 +2519,8 @@
|
||||
|
||||
<!-- Coach Review Tab -->
|
||||
<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>
|
||||
@auth
|
||||
@if (isset($video) && Auth::id() === $video->user_id)
|
||||
@ -2816,8 +2562,8 @@
|
||||
<div class="review-content">
|
||||
<h4 class="review-note-title">Missed counter opportunity</h4>
|
||||
<div class="review-author-bar">
|
||||
<img class="review-author-avatar" src="https://picsum.photos/seed/coach-sara/80/80"
|
||||
alt="Coach Sara">
|
||||
<img class="review-author-avatar"
|
||||
src="https://picsum.photos/seed/coach-sara/80/80" alt="Coach Sara">
|
||||
<span class="review-author-name">Coach Sara</span>
|
||||
<span>•</span>
|
||||
<span class="review-note-text">Great angle, but no follow up.</span>
|
||||
@ -2842,8 +2588,8 @@
|
||||
<div class="review-content">
|
||||
<h4 class="review-note-title">Excellent angle change and follow-up</h4>
|
||||
<div class="review-author-bar">
|
||||
<img class="review-author-avatar" src="https://picsum.photos/seed/coach-ahmed/80/80"
|
||||
alt="Coach Ahmed">
|
||||
<img class="review-author-avatar"
|
||||
src="https://picsum.photos/seed/coach-ahmed/80/80" alt="Coach Ahmed">
|
||||
<span class="review-author-name">Coach Ahmed</span>
|
||||
<span>•</span>
|
||||
<span class="review-note-text">Ideal example of exit and re-entry.</span>
|
||||
@ -2942,7 +2688,8 @@
|
||||
alt="{{ $recVideo->title }}">
|
||||
@endif
|
||||
@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
|
||||
@if ($recVideo->is_shorts)
|
||||
<span class="yt-shorts-badge"
|
||||
|
||||
@ -279,48 +279,7 @@
|
||||
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 */
|
||||
.video-description {
|
||||
@ -547,75 +506,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Channel Row - All in one line -->
|
||||
<div class="channel-row"
|
||||
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>
|
||||
<x-channel-row :video="$video" />
|
||||
|
||||
<!-- Description Box -->
|
||||
@if ($video->description)
|
||||
@ -687,165 +578,8 @@
|
||||
</style>
|
||||
@endif
|
||||
|
||||
<!-- Comment Section -->
|
||||
<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>
|
||||
<x-video-comments :video="$video" />
|
||||
|
||||
@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>
|
||||
|
||||
<!-- 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/{video}', [VideoController::class, 'show'])->name('videos.show');
|
||||
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}/recommendations', [VideoController::class, 'recommendations'])->name('videos.recommendations');
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user