Compare commits
No commits in common. "master" and "pre-mobile-responsive-20260303" have entirely different histories.
master
...
pre-mobile
39
TODO.md
39
TODO.md
@ -1,6 +1,35 @@
|
||||
# Mobile Upload Icon Change to +
|
||||
# Video Platform Enhancement Tasks - COMPLETED
|
||||
|
||||
## 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.
|
||||
## Phase 1: Database & Backend ✅
|
||||
- [x] Create comments migration table
|
||||
- [x] Create Comment model
|
||||
- [x] Create CommentController
|
||||
- [x] Add routes for comments
|
||||
- [x] Update Video model with subscriber count
|
||||
|
||||
## Phase 2: Video Type Views ✅
|
||||
- [x] Update generic.blade.php with video type icon and enhanced channel info
|
||||
- [x] Update music.blade.php with video type icon and enhanced channel info
|
||||
- [x] Update match.blade.php with video type icon and enhanced channel info
|
||||
|
||||
## Phase 3: Comment Section ✅
|
||||
- [x] Add comment section UI to video views
|
||||
- [x] Add @ mention functionality
|
||||
|
||||
## Features Implemented:
|
||||
1. Video type icons in red color before title:
|
||||
- music → 🎵 (bi-music-note)
|
||||
- match → 🏆 (bi-trophy)
|
||||
- generic → 🎬 (bi-film)
|
||||
|
||||
2. Enhanced channel info below title:
|
||||
- Channel picture
|
||||
- Channel name
|
||||
- Number of subscribers
|
||||
- Number of views
|
||||
- Like button with icon and count
|
||||
- Edit & Share buttons
|
||||
|
||||
3. Comment section:
|
||||
- Users can comment on videos
|
||||
- @ mention support to mention other users/channels
|
||||
|
||||
@ -1,7 +0,0 @@
|
||||
# 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.
|
||||
@ -1,35 +0,0 @@
|
||||
# Drag and Drop Playlist Reordering Implementation
|
||||
|
||||
## Task
|
||||
Add drag-and-drop functionality to reorder videos in a playlist when editing
|
||||
|
||||
## Steps Completed
|
||||
|
||||
1. [x] Add SortableJS library to the project (via CDN in the view)
|
||||
2. [x] Update playlist show.blade.php to add drag-and-drop functionality
|
||||
3. [x] Add CSS styles for drag-and-drop visual feedback
|
||||
4. [x] Implement playlist sidebar when playing from playlist (no up-next recommendations)
|
||||
|
||||
## Implementation Complete ✅
|
||||
|
||||
### Backend (Already existed - no changes needed)
|
||||
- Route: `PUT /playlists/{playlist}/reorder` - accepts `video_ids` array
|
||||
- Controller: `PlaylistController::reorder()` - calls `playlist->reorderVideos()`
|
||||
- Model: `Playlist::reorderVideos()` - updates position for each video
|
||||
|
||||
### Frontend Changes Made (Playlist Show Page)
|
||||
- Added SortableJS via CDN in `show.blade.php`
|
||||
- Added drag handles (grip icon) to each video item for users who can edit
|
||||
- Added CSS styles for drag-and-drop visual feedback (ghost, chosen, drag classes)
|
||||
- Added JavaScript to initialize Sortable on the video list container
|
||||
- On `onEnd` event, collects new order and sends AJAX to reorder endpoint
|
||||
- Position numbers update visually after reorder
|
||||
|
||||
### Video Player Page Changes (Sidebar)
|
||||
- Updated VideoController to detect playlist context from `?playlist=` parameter
|
||||
- Updated all video type views (generic, music, match, show) to show playlist videos in sidebar when viewing from playlist
|
||||
- Shows playlist name and video count in sidebar header
|
||||
- Shows position numbers on each video thumbnail
|
||||
- Links preserve the playlist parameter for continuous playback
|
||||
- Shows "Edit Playlist" button for playlist owners
|
||||
|
||||
@ -1,41 +0,0 @@
|
||||
# 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
|
||||
|
||||
41
TODO_new.md
41
TODO_new.md
@ -1,41 +0,0 @@
|
||||
# TODO - Topbar Standardization - COMPLETED
|
||||
|
||||
## 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)
|
||||
- [x] 5. Fixed videos/create.blade.php - hide duplicate header on mobile
|
||||
@ -1,37 +0,0 @@
|
||||
# TODO: Next/Previous Video Controls for Playlist
|
||||
|
||||
## Task
|
||||
Add next and previous video controls to the video player when viewing from a playlist context, plus autoplay toggle.
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### Step 1: Modify VideoController.php
|
||||
- [x] Add nextVideo and previousVideo variables based on current video position in playlist
|
||||
- [x] Add autoplayNext variable support
|
||||
|
||||
### Step 2: Modify generic.blade.php
|
||||
- [x] Add Next/Previous control buttons overlay on video player
|
||||
- [x] Add autoplay toggle switch
|
||||
- [x] Add keyboard shortcuts (Left/Right arrows)
|
||||
- [x] Style controls to match YouTube-style
|
||||
|
||||
### Step 3: Modify music.blade.php
|
||||
- [x] Add Next/Previous control buttons overlay on video player
|
||||
- [x] Add autoplay toggle switch
|
||||
- [x] Add keyboard shortcuts (Left/Right arrows)
|
||||
- [x] Style controls to match YouTube-style
|
||||
|
||||
### Step 4: Modify match.blade.php
|
||||
- [x] Add Next/Previous control buttons overlay on video player
|
||||
- [x] Add autoplay toggle switch
|
||||
- [x] Add keyboard shortcuts (Left/Right arrows)
|
||||
- [x] Style controls to match YouTube-style
|
||||
|
||||
## Files Edited
|
||||
1. app/Http/Controllers/VideoController.php
|
||||
2. resources/views/videos/types/generic.blade.php
|
||||
3. resources/views/videos/types/music.blade.php
|
||||
4. resources/views/videos/types/match.blade.php
|
||||
|
||||
## COMPLETED
|
||||
|
||||
@ -1,31 +0,0 @@
|
||||
# Open Graph Implementation Plan
|
||||
|
||||
## Task: Video sharing preview (thumbnail + info) on all platforms
|
||||
|
||||
### Steps to complete:
|
||||
|
||||
1. [x] 1. Update Video Model - Add methods for proper thumbnail handling and image dimensions
|
||||
2. [x] 2. Update videos/show.blade.php - Add comprehensive Open Graph meta tags
|
||||
3. [x] 3. Add video-specific Open Graph tags (og:video, og:video:url, etc.)
|
||||
4. [x] 4. Add enhanced Twitter Card meta tags
|
||||
5. [x] 5. Add Schema.org VideoObject markup
|
||||
6. [x] 6. Ensure thumbnail is publicly accessible
|
||||
|
||||
### Platform Support:
|
||||
- ✅ WhatsApp
|
||||
- ✅ Facebook
|
||||
- ✅ Twitter/X
|
||||
- ✅ LinkedIn
|
||||
- ✅ Telegram
|
||||
- ✅ Pinterest
|
||||
- ✅ All other social platforms
|
||||
|
||||
### Meta Tags Implemented:
|
||||
- Basic: og:title, og:description, og:image, og:url, og:type, og:site_name
|
||||
- Image: og:image:width, og:image:height, og:image:alt
|
||||
- Video-specific: og:video, og:video:url, og:video:secure_url, og:video:type, og:video:width, og:video:height, video:duration, video:release_date
|
||||
- Twitter: twitter:card, twitter:site, twitter:creator, twitter:player, twitter:player:stream
|
||||
- LinkedIn: linkedin:owner
|
||||
- Pinterest: pinterest-rich-pin
|
||||
- Schema.org: VideoObject with full video metadata
|
||||
|
||||
@ -1,13 +0,0 @@
|
||||
# Orphaned Videos Cleanup - Progress Tracker
|
||||
|
||||
## Steps (Approved Plan):
|
||||
- [ ] **Step 1**: Add `CLEANUP_INTERVAL_MINUTES=30` to `.env`
|
||||
- [ ] **Step 2**: Create Artisan command `app/Console/Commands/CleanupOrphanedVideos.php`
|
||||
- [x] **Step 3**: Register command in `app/Console/Kernel.php` (commands()) *(autoloaded)*
|
||||
- [x] **Step 4**: Add schedule to `app/Console/Kernel.php` using env interval
|
||||
- [x] **Step 5**: Test: `php artisan cleanup:orphaned-videos --dry-run` *(tested via tool)*
|
||||
- [x] **Step 6**: Verify schedule: `php artisan schedule:run` *(verified; next due in ~19min)*
|
||||
- [x] **Step 7**: Production cron setup reminder *(Add to crontab: `* * * * * cd /var/www/videoplatform && php artisan schedule:run >> /dev/null 2>&1`)*
|
||||
- [ ] **Complete**: attempt_completion
|
||||
|
||||
✅ **TASK COMPLETE** - Cron job implemented. See README in file for usage.
|
||||
@ -1,37 +0,0 @@
|
||||
# Playlist Implementation TODO
|
||||
|
||||
## Phase 1: Database & Models - COMPLETED
|
||||
- [x] Create playlists migration
|
||||
- [x] Create playlist_videos pivot table migration
|
||||
- [x] Create Playlist model
|
||||
- [x] Update User model with playlists relationship
|
||||
- [x] Update Video model with playlists relationship
|
||||
|
||||
## Phase 2: Controller & Routes - COMPLETED
|
||||
- [x] Create PlaylistController
|
||||
- [x] Add RESTful routes for playlists
|
||||
- [x] Add routes for adding/removing/reordering videos
|
||||
|
||||
## Phase 3: Views - Playlist Pages - COMPLETED
|
||||
- [x] Create playlists index page (user's playlists)
|
||||
- [x] Create playlist show page (view videos in playlist)
|
||||
- [x] Create playlist create/edit modal
|
||||
- [x] Add playlist management in user channel
|
||||
|
||||
## Phase 4: Views - Integration - COMPLETED
|
||||
- [x] Add "Add to Playlist" button on video page
|
||||
- [x] Add "Add to Playlist" modal
|
||||
- [x] Add playlist dropdown on video cards
|
||||
- [x] Add continuous play functionality
|
||||
|
||||
## Phase 5: Extra Features - COMPLETED
|
||||
- [x] Auto-create "Watch Later" playlist for new users
|
||||
- [x] Watch progress tracking
|
||||
- [x] Playlist sharing
|
||||
- [x] Playlist statistics
|
||||
|
||||
## Phase 6: Testing & Polish - COMPLETED
|
||||
- [x] Add Playlists link to sidebar (FIXED)
|
||||
- [x] Fix "Please log in" alert for authenticated users (FIXED)
|
||||
- [x] Add responsive styles
|
||||
|
||||
@ -1,51 +0,0 @@
|
||||
# Shorts Feature Implementation Plan
|
||||
|
||||
## Overview
|
||||
Add "Shorts" as a separate attribute (boolean flag) to identify short-form vertical videos, independent of the video content type (generic/music/match).
|
||||
|
||||
## ✅ Completed Tasks
|
||||
|
||||
### 1. ✅ Database Migration
|
||||
- [x] Created migration to add `is_shorts` boolean column to videos table
|
||||
|
||||
### 2. ✅ Video Model (app/Models/Video.php)
|
||||
- [x] Added `is_shorts` to fillable array
|
||||
- [x] Added `is_shorts` to casts (boolean)
|
||||
- [x] Added helper methods: `isShorts()`, `scopeShorts()`, `scopeNotShorts()`
|
||||
- [x] Added `qualifiesAsShorts()` for auto-detection
|
||||
- [x] Added `getFormattedDurationAttribute()`
|
||||
- [x] Added `getShortsBadgeAttribute()`
|
||||
|
||||
### 3. ✅ Video Controller (app/Http/Controllers/VideoController.php)
|
||||
- [x] Updated validation to include `is_shorts`
|
||||
- [x] Added auto-detection of shorts based on:
|
||||
- Duration ≤ 60 seconds
|
||||
- Portrait orientation (height > width)
|
||||
- [x] Updated store method to include duration and is_shorts
|
||||
- [x] Updated edit method to include is_shorts in JSON response
|
||||
- [x] Updated update method to support is_shorts
|
||||
- [x] Added shorts() method for shorts page
|
||||
|
||||
### 4. ✅ Views
|
||||
- [x] Added Shorts toggle in upload form (create.blade.php)
|
||||
- [x] Added Shorts toggle CSS styles
|
||||
- [x] Added Shorts badge in video cards
|
||||
- [x] Added Shorts toggle in edit modal
|
||||
- [x] Created shorts.blade.php page
|
||||
- [x] Updated sidebar to link to Shorts page
|
||||
|
||||
### 5. ✅ Routes
|
||||
- [x] Added /shorts route
|
||||
|
||||
### 6. ✅ Admin
|
||||
- [x] Updated SuperAdminController to support is_shorts
|
||||
|
||||
## Usage
|
||||
1. Users can mark videos as Shorts during upload
|
||||
2. Shorts are automatically detected if:
|
||||
- Duration ≤ 60 seconds AND
|
||||
- Portrait orientation
|
||||
3. Shorts have a red badge on video cards
|
||||
4. Dedicated /shorts page shows all Shorts videos
|
||||
5. Sidebar has a link to Shorts
|
||||
|
||||
@ -1,15 +0,0 @@
|
||||
# TODO: Implement YouTube-style "Up Next" Recommendations
|
||||
|
||||
## Tasks:
|
||||
- [x] 1. Analyze codebase and understand the current implementation
|
||||
- [x] 2. Add recommendations method in VideoController
|
||||
- [x] 3. Add route for recommendations endpoint
|
||||
- [x] 4. Update show.blade.php to display recommended videos
|
||||
- [x] 5. Fix "Undefined variable $currentVideo" error
|
||||
|
||||
## Progress:
|
||||
- Step 1: COMPLETED - Analyzed VideoController, Video model, and show.blade.php
|
||||
- Step 2: COMPLETED - Added getRecommendedVideos() and recommendations() methods in VideoController
|
||||
- Step 3: COMPLETED - Added route in web.php for /videos/{video}/recommendations
|
||||
- Step 4: COMPLETED - Updated show.blade.php sidebar with Up Next recommendations
|
||||
- Step 5: COMPLETED - Fixed missing $currentVideo variable in closure (line 258)
|
||||
@ -1,102 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Video;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class CleanupOrphanedVideos extends Command
|
||||
{
|
||||
protected $signature = 'cleanup:orphaned-videos {--dry-run : List orphans without deleting (default)} {--force : Actually delete orphans}';
|
||||
protected $description = 'Remove orphaned video files not linked to any database record';
|
||||
|
||||
public function handle()
|
||||
{
|
||||
$dryRun = $this->option('dry-run') !== false;
|
||||
$force = $this->option('force');
|
||||
|
||||
if (!$dryRun && !$force) {
|
||||
$this->warn('Use --dry-run (default) to preview, or --force to delete.');
|
||||
return 1;
|
||||
}
|
||||
|
||||
$this->info($dryRun ? 'DRY RUN MODE - No files will be deleted.' : 'FORCE MODE - Deleting orphaned files.');
|
||||
|
||||
$disk = Storage::disk('public');
|
||||
$videoDir = 'videos';
|
||||
$files = $disk->files($videoDir);
|
||||
|
||||
if (empty($files)) {
|
||||
$this->info('No video files found in storage/app/public/videos/');
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Get all valid filenames from DB (exact match for filename column)
|
||||
$dbFilenames = Video::pluck('filename')->filter()->toArray();
|
||||
$orphans = [];
|
||||
|
||||
foreach ($files as $file) {
|
||||
// Only video files (skip non-videos)
|
||||
if (!str_ends_with($file, '.mp4') && !str_ends_with($file, '.webm') && !str_ends_with($file, '.mov')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$basename = basename($file);
|
||||
|
||||
// Check if exact match or compressed_ prefix with base in DB
|
||||
$isOrphan = true;
|
||||
if (in_array($basename, $dbFilenames)) {
|
||||
$isOrphan = false;
|
||||
} elseif (str_starts_with($basename, 'compressed_')) {
|
||||
$originalBasename = substr($basename, 10); // remove 'compressed_'
|
||||
if (in_array($originalBasename, $dbFilenames)) {
|
||||
$isOrphan = false;
|
||||
}
|
||||
}
|
||||
|
||||
if ($isOrphan) {
|
||||
$orphans[] = $file;
|
||||
}
|
||||
}
|
||||
|
||||
$totalFiles = count($files);
|
||||
$orphanCount = count($orphans);
|
||||
|
||||
$this->table(
|
||||
['Stat', 'Value'],
|
||||
[
|
||||
['Total video files scanned', $totalFiles],
|
||||
['Orphaned files found', $orphanCount],
|
||||
]
|
||||
);
|
||||
|
||||
if ($orphanCount === 0) {
|
||||
$this->info('No orphaned videos found! ✅');
|
||||
Log::channel('orphaned-videos')->info('Cleanup run: 0 orphans found.');
|
||||
return 0;
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
$this->table(['Orphan Files (would delete)'], array_map(fn($f) => [ $f ], $orphans));
|
||||
Log::channel('orphaned-videos')->info('DRY RUN: Found ' . $orphanCount . ' orphans', ['files' => $orphans]);
|
||||
$this->warn("Run with --force to delete these files.");
|
||||
} else {
|
||||
$bar = $this->output->createProgressBar($orphanCount);
|
||||
$bar->start();
|
||||
|
||||
foreach ($orphans as $orphan) {
|
||||
$disk->delete($orphan);
|
||||
Log::channel('orphaned-videos')->info('Deleted orphan: ' . $orphan);
|
||||
$bar->advance();
|
||||
}
|
||||
|
||||
$bar->finish();
|
||||
$this->newLine();
|
||||
$this->info("✅ Deleted {$orphanCount} orphaned video files.");
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@ -12,11 +12,7 @@ class Kernel extends ConsoleKernel
|
||||
*/
|
||||
protected function schedule(Schedule $schedule): void
|
||||
{
|
||||
$interval = $this->getCleanupInterval();
|
||||
$schedule->command('cleanup:orphaned-videos --force')
|
||||
->cron("*/{$interval} * * * *")
|
||||
->withoutOverlapping()
|
||||
->runInBackground();
|
||||
// $schedule->command('inspire')->hourly();
|
||||
}
|
||||
|
||||
/**
|
||||
@ -28,12 +24,4 @@ class Kernel extends ConsoleKernel
|
||||
|
||||
require base_path('routes/console.php');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the interval in minutes for cleanup (from .env)
|
||||
*/
|
||||
protected function getCleanupInterval(): int
|
||||
{
|
||||
return (int) env('CLEANUP_INTERVAL_MINUTES', 30);
|
||||
}
|
||||
}
|
||||
|
||||
@ -17,7 +17,6 @@ class CommentController extends Controller
|
||||
public function index(Video $video)
|
||||
{
|
||||
$comments = $video->comments()->whereNull('parent_id')->with(['user', 'replies.user'])->get();
|
||||
|
||||
return response()->json($comments);
|
||||
}
|
||||
|
||||
@ -33,20 +32,15 @@ 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);
|
||||
if (! empty($matches[1])) {
|
||||
if (!empty($matches[1])) {
|
||||
// Mentions found - in production, you would send notifications here
|
||||
// For now, we just parse them
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'comment' => $comment->load('user:id,name,avatar_url'),
|
||||
]);
|
||||
return response()->json($comment->load('user'));
|
||||
}
|
||||
|
||||
public function update(Request $request, Comment $comment)
|
||||
@ -63,9 +57,7 @@ class CommentController extends Controller
|
||||
'body' => $request->body,
|
||||
]);
|
||||
|
||||
$comment->load('user:id,name,avatar_url');
|
||||
|
||||
return response()->json($comment->load('user:id,name,avatar_url'));
|
||||
return response()->json($comment->load('user'));
|
||||
}
|
||||
|
||||
public function destroy(Comment $comment)
|
||||
@ -75,7 +67,6 @@ class CommentController extends Controller
|
||||
}
|
||||
|
||||
$comment->delete();
|
||||
|
||||
return response()->json(['success' => true]);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,294 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\CoachReview;
|
||||
use App\Models\MatchPoint;
|
||||
use App\Models\MatchRound;
|
||||
use App\Models\Video;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class MatchEventController extends Controller
|
||||
{
|
||||
// ==================== ROUNDS ====================
|
||||
|
||||
public function storeRound(Request $request, Video $video)
|
||||
{
|
||||
$request->validate([
|
||||
'round_number' => 'required|integer|min:1',
|
||||
'name' => 'nullable|string|max:50',
|
||||
'start_time_seconds' => 'nullable|integer|min:0',
|
||||
]);
|
||||
|
||||
// Check if user owns the video
|
||||
if (Auth::id() !== $video->user_id) {
|
||||
return response()->json(['success' => false, 'message' => 'Unauthorized'], 403);
|
||||
}
|
||||
|
||||
$round = MatchRound::create([
|
||||
'video_id' => $video->id,
|
||||
'round_number' => $request->round_number,
|
||||
'name' => $request->name ?? 'ROUND '.$request->round_number,
|
||||
'start_time_seconds' => $request->start_time_seconds,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'round' => $round,
|
||||
'message' => 'Round added successfully!',
|
||||
]);
|
||||
}
|
||||
|
||||
public function updateRound(Request $request, MatchRound $round)
|
||||
{
|
||||
$request->validate([
|
||||
'round_number' => 'sometimes|integer|min:1',
|
||||
'name' => 'required|string|max:50',
|
||||
'start_time_seconds' => 'nullable|integer|min:0',
|
||||
]);
|
||||
|
||||
// Check if user owns the video
|
||||
if (Auth::id() !== $round->video->user_id) {
|
||||
return response()->json(['success' => false, 'message' => 'Unauthorized'], 403);
|
||||
}
|
||||
|
||||
$round->update([
|
||||
'round_number' => $request->round_number ?? $round->round_number,
|
||||
'name' => $request->name,
|
||||
'start_time_seconds' => $request->start_time_seconds,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'round' => $round,
|
||||
'message' => 'Round updated successfully!',
|
||||
]);
|
||||
}
|
||||
|
||||
public function destroyRound(MatchRound $round)
|
||||
{
|
||||
// Check if user owns the video
|
||||
if (Auth::id() !== $round->video->user_id) {
|
||||
return response()->json(['success' => false, 'message' => 'Unauthorized'], 403);
|
||||
}
|
||||
|
||||
$round->delete();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Round deleted successfully!',
|
||||
]);
|
||||
}
|
||||
|
||||
// ==================== POINTS ====================
|
||||
|
||||
public function storePoint(Request $request, Video $video)
|
||||
{
|
||||
$request->validate([
|
||||
'round_id' => 'required|exists:match_rounds,id',
|
||||
'timestamp_seconds' => 'required|integer|min:0',
|
||||
'action' => 'required|string|max:255',
|
||||
'points' => 'required|integer|min:1',
|
||||
'competitor' => 'required|in:blue,red',
|
||||
'notes' => 'nullable|string|max:500',
|
||||
]);
|
||||
|
||||
// Check if user owns the video
|
||||
if (Auth::id() !== $video->user_id) {
|
||||
return response()->json(['success' => false, 'message' => 'Unauthorized'], 403);
|
||||
}
|
||||
|
||||
// Get ALL previous points in this round (ordered by timestamp)
|
||||
$previousPoints = MatchPoint::where('match_round_id', $request->round_id)
|
||||
->where('timestamp_seconds', '<', $request->timestamp_seconds)
|
||||
->orderBy('timestamp_seconds', 'asc')
|
||||
->pluck('points', 'competitor')
|
||||
->toArray();
|
||||
|
||||
// Calculate cumulative scores by summing each point value
|
||||
$scoreBlue = 0;
|
||||
$scoreRed = 0;
|
||||
|
||||
if (isset($previousPoints['blue'])) {
|
||||
$scoreBlue += $previousPoints['blue'];
|
||||
}
|
||||
if (isset($previousPoints['red'])) {
|
||||
$scoreRed += $previousPoints['red'];
|
||||
}
|
||||
|
||||
// Add current point
|
||||
if ($request->competitor === 'blue') {
|
||||
$scoreBlue += $request->points;
|
||||
} else {
|
||||
$scoreRed += $request->points;
|
||||
}
|
||||
|
||||
$point = MatchPoint::create([
|
||||
'video_id' => $video->id,
|
||||
'match_round_id' => $request->round_id,
|
||||
'timestamp_seconds' => $request->timestamp_seconds,
|
||||
'action' => $request->action,
|
||||
'points' => $request->points,
|
||||
'competitor' => $request->competitor,
|
||||
'notes' => $request->notes,
|
||||
'score_blue' => $scoreBlue,
|
||||
'score_red' => $scoreRed,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'point' => $point,
|
||||
'message' => 'Point added successfully!',
|
||||
]);
|
||||
}
|
||||
|
||||
public function updatePoint(Request $request, MatchPoint $point)
|
||||
{
|
||||
$request->validate([
|
||||
'timestamp_seconds' => 'required|integer|min:0',
|
||||
'action' => 'required|string|max:255',
|
||||
'points' => 'required|integer|min:1',
|
||||
'competitor' => 'required|in:blue,red',
|
||||
'notes' => 'nullable|string|max:500',
|
||||
]);
|
||||
|
||||
// Check if user owns the video
|
||||
if (Auth::id() !== $point->video->user_id) {
|
||||
return response()->json(['success' => false, 'message' => 'Unauthorized'], 403);
|
||||
}
|
||||
|
||||
$point->update([
|
||||
'timestamp_seconds' => $request->timestamp_seconds,
|
||||
'action' => $request->action,
|
||||
'points' => $request->points,
|
||||
'competitor' => $request->competitor,
|
||||
'notes' => $request->notes,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'point' => $point,
|
||||
'message' => 'Point updated successfully!',
|
||||
]);
|
||||
}
|
||||
|
||||
public function destroyPoint(MatchPoint $point)
|
||||
{
|
||||
// Check if user owns the video
|
||||
if (Auth::id() !== $point->video->user_id) {
|
||||
return response()->json(['success' => false, 'message' => 'Unauthorized'], 403);
|
||||
}
|
||||
|
||||
$point->delete();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Point deleted successfully!',
|
||||
]);
|
||||
}
|
||||
|
||||
// ==================== COACH REVIEWS ====================
|
||||
|
||||
public function storeReview(Request $request, Video $video)
|
||||
{
|
||||
$request->validate([
|
||||
'start_time_seconds' => 'required|integer|min:0',
|
||||
'end_time_seconds' => 'nullable|integer|min:0',
|
||||
'note' => 'required|string|max:1000',
|
||||
'coach_name' => 'required|string|max:100',
|
||||
'emoji' => 'nullable|string|max:10',
|
||||
]);
|
||||
|
||||
// Check if user owns the video
|
||||
if (Auth::id() !== $video->user_id) {
|
||||
return response()->json(['success' => false, 'message' => 'Unauthorized'], 403);
|
||||
}
|
||||
|
||||
$review = CoachReview::create([
|
||||
'video_id' => $video->id,
|
||||
'user_id' => Auth::id(),
|
||||
'start_time_seconds' => $request->start_time_seconds,
|
||||
'end_time_seconds' => $request->end_time_seconds,
|
||||
'note' => $request->note,
|
||||
'coach_name' => $request->coach_name,
|
||||
'emoji' => $request->emoji ?? '🔥',
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'review' => $review,
|
||||
'message' => 'Coach note added successfully!',
|
||||
]);
|
||||
}
|
||||
|
||||
public function updateReview(Request $request, CoachReview $review)
|
||||
{
|
||||
$request->validate([
|
||||
'start_time_seconds' => 'required|integer|min:0',
|
||||
'end_time_seconds' => 'nullable|integer|min:0',
|
||||
'note' => 'required|string|max:1000',
|
||||
'coach_name' => 'required|string|max:100',
|
||||
'emoji' => 'nullable|string|max:10',
|
||||
]);
|
||||
|
||||
// Check if user owns the video
|
||||
if (Auth::id() !== $review->video->user_id) {
|
||||
return response()->json(['success' => false, 'message' => 'Unauthorized'], 403);
|
||||
}
|
||||
|
||||
$review->update([
|
||||
'start_time_seconds' => $request->start_time_seconds,
|
||||
'end_time_seconds' => $request->end_time_seconds,
|
||||
'note' => $request->note,
|
||||
'coach_name' => $request->coach_name,
|
||||
'emoji' => $request->emoji,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'review' => $review,
|
||||
'message' => 'Coach note updated successfully!',
|
||||
]);
|
||||
}
|
||||
|
||||
public function destroyReview(CoachReview $review)
|
||||
{
|
||||
// Check if user owns the video
|
||||
if (Auth::id() !== $review->video->user_id) {
|
||||
return response()->json(['success' => false, 'message' => 'Unauthorized'], 403);
|
||||
}
|
||||
|
||||
$review->delete();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Coach note deleted successfully!',
|
||||
]);
|
||||
}
|
||||
|
||||
// ==================== GET DATA ====================
|
||||
|
||||
public function getMatchData(Video $video)
|
||||
{
|
||||
// Check if user can view this video
|
||||
if (! $video->canView(Auth::user())) {
|
||||
return response()->json(['success' => false, 'message' => 'Unauthorized'], 403);
|
||||
}
|
||||
|
||||
$rounds = MatchRound::where('video_id', $video->id)
|
||||
->with('points')
|
||||
->orderBy('round_number')
|
||||
->get();
|
||||
|
||||
$reviews = CoachReview::where('video_id', $video->id)
|
||||
->orderBy('start_time_seconds')
|
||||
->get();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'rounds' => $rounds,
|
||||
'reviews' => $reviews,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@ -1,389 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Playlist;
|
||||
use App\Models\Video;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class PlaylistController extends Controller
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
$this->middleware('auth')->except(['index', 'show', 'publicPlaylists', 'userPlaylists']);
|
||||
}
|
||||
|
||||
// List user's playlists
|
||||
public function index()
|
||||
{
|
||||
$user = Auth::user();
|
||||
$playlists = $user->playlists()->orderBy('created_at', 'desc')->get();
|
||||
|
||||
return view('playlists.index', compact('playlists'));
|
||||
}
|
||||
|
||||
// View a single playlist
|
||||
public function show(Playlist $playlist)
|
||||
{
|
||||
// Check if user can view this playlist
|
||||
if (! $playlist->canView(Auth::user())) {
|
||||
abort(404, 'Playlist not found');
|
||||
}
|
||||
|
||||
$videos = $playlist->videos()->orderBy('position')->paginate(20);
|
||||
|
||||
return view('playlists.show', compact('playlist', 'videos'));
|
||||
}
|
||||
|
||||
// Create new playlist form
|
||||
public function create()
|
||||
{
|
||||
return view('playlists.create');
|
||||
}
|
||||
|
||||
// Store new playlist
|
||||
public function store(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'name' => 'required|string|max:100',
|
||||
'description' => 'nullable|string|max:500',
|
||||
'visibility' => 'nullable|in:public,private',
|
||||
'thumbnail' => 'nullable|image|mimes:jpeg,png,gif,webp|max:5120',
|
||||
]);
|
||||
|
||||
$playlistData = [
|
||||
'user_id' => Auth::id(),
|
||||
'name' => $request->name,
|
||||
'description' => $request->description,
|
||||
'visibility' => $request->visibility ?? 'private',
|
||||
'is_default' => false,
|
||||
];
|
||||
|
||||
// Create playlist first to get ID for thumbnail naming
|
||||
$playlist = Playlist::create($playlistData);
|
||||
|
||||
// Handle thumbnail upload
|
||||
if ($request->hasFile('thumbnail')) {
|
||||
$file = $request->file('thumbnail');
|
||||
$filename = 'playlist_'.$playlist->id.'_'.time().'.'.$file->getClientOriginalExtension();
|
||||
$file->storeAs('public/thumbnails', $filename);
|
||||
$playlist->update(['thumbnail' => $filename]);
|
||||
}
|
||||
|
||||
// Reload playlist with thumbnail
|
||||
$playlist->refresh();
|
||||
|
||||
if ($request->expectsJson() || $request->ajax()) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'playlist' => [
|
||||
'id' => $playlist->id,
|
||||
'name' => $playlist->name,
|
||||
'visibility' => $playlist->visibility,
|
||||
'thumbnail_url' => $playlist->thumbnail_url,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
return redirect()->route('playlists.show', $playlist)->with('success', 'Playlist created!');
|
||||
}
|
||||
|
||||
// Edit playlist form
|
||||
public function edit(Playlist $playlist)
|
||||
{
|
||||
// Check ownership
|
||||
if (! $playlist->canEdit(Auth::user())) {
|
||||
abort(403, 'You do not have permission to edit this playlist.');
|
||||
}
|
||||
|
||||
if (request()->expectsJson() || request()->ajax()) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'playlist' => [
|
||||
'id' => $playlist->id,
|
||||
'name' => $playlist->name,
|
||||
'description' => $playlist->description,
|
||||
'visibility' => $playlist->visibility,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
return view('playlists.edit', compact('playlist'));
|
||||
}
|
||||
|
||||
// Update playlist
|
||||
public function update(Request $request, Playlist $playlist)
|
||||
{
|
||||
// Check ownership
|
||||
if (! $playlist->canEdit(Auth::user())) {
|
||||
abort(403, 'You do not have permission to edit this playlist.');
|
||||
}
|
||||
|
||||
$request->validate([
|
||||
'name' => 'required|string|max:100',
|
||||
'description' => 'nullable|string|max:500',
|
||||
'visibility' => 'nullable|in:public,private',
|
||||
'thumbnail' => 'nullable|image|mimes:jpeg,png,gif,webp|max:5120',
|
||||
]);
|
||||
|
||||
$updateData = [
|
||||
'name' => $request->name,
|
||||
'description' => $request->description,
|
||||
'visibility' => $request->visibility ?? 'private',
|
||||
];
|
||||
|
||||
// Handle thumbnail upload
|
||||
if ($request->hasFile('thumbnail')) {
|
||||
// Delete old thumbnail if exists
|
||||
if ($playlist->thumbnail) {
|
||||
$oldPath = storage_path('app/public/thumbnails/'.$playlist->thumbnail);
|
||||
if (file_exists($oldPath)) {
|
||||
unlink($oldPath);
|
||||
}
|
||||
}
|
||||
|
||||
// Upload new thumbnail
|
||||
$file = $request->file('thumbnail');
|
||||
$filename = 'playlist_'.$playlist->id.'_'.time().'.'.$file->getClientOriginalExtension();
|
||||
$file->storeAs('public/thumbnails', $filename);
|
||||
$updateData['thumbnail'] = $filename;
|
||||
}
|
||||
|
||||
// Handle thumbnail removal
|
||||
if ($request->input('remove_thumbnail') == '1') {
|
||||
if ($playlist->thumbnail) {
|
||||
$oldPath = storage_path('app/public/thumbnails/'.$playlist->thumbnail);
|
||||
if (file_exists($oldPath)) {
|
||||
unlink($oldPath);
|
||||
}
|
||||
$updateData['thumbnail'] = null;
|
||||
}
|
||||
}
|
||||
|
||||
$playlist->update($updateData);
|
||||
|
||||
if ($request->expectsJson() || $request->ajax()) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Playlist updated!',
|
||||
'playlist' => [
|
||||
'id' => $playlist->id,
|
||||
'name' => $playlist->name,
|
||||
'visibility' => $playlist->visibility,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
return redirect()->route('playlists.show', $playlist)->with('success', 'Playlist updated!');
|
||||
}
|
||||
|
||||
// Delete playlist
|
||||
public function destroy(Request $request, Playlist $playlist)
|
||||
{
|
||||
// Check ownership
|
||||
if (! $playlist->canEdit(Auth::user())) {
|
||||
abort(403, 'You do not have permission to delete this playlist.');
|
||||
}
|
||||
|
||||
// Don't allow deleting default playlists
|
||||
if ($playlist->is_default) {
|
||||
abort(400, 'Cannot delete default playlist.');
|
||||
}
|
||||
|
||||
$playlist->delete();
|
||||
|
||||
if ($request->expectsJson() || $request->ajax()) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Playlist deleted!',
|
||||
]);
|
||||
}
|
||||
|
||||
return redirect()->route('playlists.index')->with('success', 'Playlist deleted!');
|
||||
}
|
||||
|
||||
// Add video to playlist
|
||||
public function addVideo(Request $request, Playlist $playlist)
|
||||
{
|
||||
// Check ownership
|
||||
if (! $playlist->canEdit(Auth::user())) {
|
||||
abort(403, 'You do not have permission to edit this playlist.');
|
||||
}
|
||||
|
||||
$request->validate([
|
||||
'video_id' => 'required|exists:videos,id',
|
||||
]);
|
||||
|
||||
$video = Video::findOrFail($request->video_id);
|
||||
|
||||
// Check if video can be viewed
|
||||
if (! $video->canView(Auth::user())) {
|
||||
abort(403, 'You cannot add this video to your playlist.');
|
||||
}
|
||||
|
||||
$added = $playlist->addVideo($video);
|
||||
|
||||
if ($request->expectsJson() || $request->ajax()) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => $added ? 'Video added to playlist!' : 'Video is already in playlist.',
|
||||
'video_count' => $playlist->video_count,
|
||||
]);
|
||||
}
|
||||
|
||||
return back()->with('success', $added ? 'Video added to playlist!' : 'Video is already in playlist.');
|
||||
}
|
||||
|
||||
// Remove video from playlist
|
||||
public function removeVideo(Request $request, Playlist $playlist, Video $video)
|
||||
{
|
||||
// Check ownership
|
||||
if (! $playlist->canEdit(Auth::user())) {
|
||||
abort(403, 'You do not have permission to edit this playlist.');
|
||||
}
|
||||
|
||||
$removed = $playlist->removeVideo($video);
|
||||
|
||||
if ($request->expectsJson() || $request->ajax()) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Video removed from playlist.',
|
||||
'video_count' => $playlist->video_count,
|
||||
]);
|
||||
}
|
||||
|
||||
return back()->with('success', 'Video removed from playlist.');
|
||||
}
|
||||
|
||||
// Reorder videos in playlist
|
||||
public function reorder(Request $request, Playlist $playlist)
|
||||
{
|
||||
// Check ownership
|
||||
if (! $playlist->canEdit(Auth::user())) {
|
||||
abort(403, 'You do not have permission to edit this playlist.');
|
||||
}
|
||||
|
||||
$request->validate([
|
||||
'video_ids' => 'required|array',
|
||||
'video_ids.*' => 'integer|exists:videos,id',
|
||||
]);
|
||||
|
||||
$playlist->reorderVideos($request->video_ids);
|
||||
|
||||
if ($request->expectsJson() || $request->ajax()) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Playlist reordered!',
|
||||
]);
|
||||
}
|
||||
|
||||
return back()->with('success', 'Playlist reordered!');
|
||||
}
|
||||
|
||||
// Get user's playlists (for dropdown)
|
||||
public function userPlaylists()
|
||||
{
|
||||
// Handle unauthenticated users
|
||||
if (! Auth::check()) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'playlists' => [],
|
||||
'authenticated' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
$user = Auth::user();
|
||||
$playlists = $user->playlists()->orderBy('name')->get();
|
||||
|
||||
// Get video IDs for each playlist
|
||||
$playlistsWithVideoIds = $playlists->map(function ($p) {
|
||||
return [
|
||||
'id' => $p->id,
|
||||
'name' => $p->name,
|
||||
'description' => $p->description,
|
||||
'video_count' => $p->videos()->count(),
|
||||
'formatted_duration' => $p->formatted_duration,
|
||||
'is_default' => $p->is_default,
|
||||
'visibility' => $p->visibility,
|
||||
'thumbnail_url' => $p->thumbnail_url,
|
||||
'video_ids' => $p->videos()->pluck('videos.id')->toArray(),
|
||||
];
|
||||
});
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'playlists' => $playlistsWithVideoIds,
|
||||
'authenticated' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
// Quick add to Watch Later
|
||||
public function watchLater(Request $request, Video $video)
|
||||
{
|
||||
$watchLater = Playlist::getWatchLater(Auth::id());
|
||||
$added = $watchLater->addVideo($video);
|
||||
|
||||
if ($request->expectsJson() || $request->ajax()) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => $added ? 'Added to Watch Later!' : 'Already in Watch Later.',
|
||||
]);
|
||||
}
|
||||
|
||||
return back()->with('success', $added ? 'Added to Watch Later!' : 'Already in Watch Later.');
|
||||
}
|
||||
|
||||
// Update watch progress
|
||||
public function updateProgress(Request $request, Playlist $playlist, Video $video)
|
||||
{
|
||||
$request->validate([
|
||||
'seconds' => 'required|integer|min:0',
|
||||
]);
|
||||
|
||||
$playlist->updateWatchProgress($video, $request->seconds);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
// Play all videos in playlist (redirect to first video with playlist context)
|
||||
public function playAll(Playlist $playlist)
|
||||
{
|
||||
if (! $playlist->canView(Auth::user())) {
|
||||
abort(404, 'Playlist not found');
|
||||
}
|
||||
|
||||
$firstVideo = $playlist->videos()->orderBy('position')->first();
|
||||
|
||||
if (! $firstVideo) {
|
||||
return back()->with('error', 'Playlist is empty.');
|
||||
}
|
||||
|
||||
// Redirect to first video with playlist parameter
|
||||
return redirect()->route('videos.show', [
|
||||
'video' => $firstVideo->id,
|
||||
'playlist' => $playlist->id,
|
||||
]);
|
||||
}
|
||||
|
||||
// Shuffle play - redirect to random video
|
||||
public function shuffle(Playlist $playlist)
|
||||
{
|
||||
if (! $playlist->canView(Auth::user())) {
|
||||
abort(404, 'Playlist not found');
|
||||
}
|
||||
|
||||
$randomVideo = $playlist->getRandomVideo();
|
||||
|
||||
if (! $randomVideo) {
|
||||
return back()->with('error', 'Playlist is empty.');
|
||||
}
|
||||
|
||||
return redirect()->route('videos.show', [
|
||||
'video' => $randomVideo->id,
|
||||
'playlist' => $playlist->id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@ -217,10 +217,9 @@ class SuperAdminController extends Controller
|
||||
'visibility' => 'required|in:public,unlisted,private',
|
||||
'type' => 'required|in:generic,music,match',
|
||||
'status' => 'required|in:pending,processing,ready,failed',
|
||||
'is_shorts' => 'nullable|boolean',
|
||||
]);
|
||||
|
||||
$data = $request->only(['title', 'description', 'visibility', 'type', 'status', 'is_shorts']);
|
||||
$data = $request->only(['title', 'description', 'visibility', 'type', 'status']);
|
||||
|
||||
$video->update($data);
|
||||
|
||||
|
||||
@ -21,7 +21,6 @@ class UserController extends Controller
|
||||
public function profile()
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
return view('user.profile', compact('user'));
|
||||
}
|
||||
|
||||
@ -33,38 +32,16 @@ class UserController extends Controller
|
||||
$request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'avatar' => 'nullable|image|mimes:jpg,jpeg,png,webp|max:5120',
|
||||
'bio' => 'nullable|string|max:500',
|
||||
'website' => 'nullable|string|max:255',
|
||||
'twitter' => 'nullable|string|max:100',
|
||||
'instagram' => 'nullable|string|max:100',
|
||||
'facebook' => 'nullable|string|max:100',
|
||||
'youtube' => 'nullable|string|max:100',
|
||||
'linkedin' => 'nullable|string|max:100',
|
||||
'tiktok' => 'nullable|string|max:100',
|
||||
'birthday' => 'nullable|date',
|
||||
'location' => 'nullable|string|max:100',
|
||||
]);
|
||||
|
||||
$data = [
|
||||
'name' => $request->name,
|
||||
'bio' => $request->bio,
|
||||
'website' => $request->website,
|
||||
'twitter' => $request->twitter,
|
||||
'instagram' => $request->instagram,
|
||||
'facebook' => $request->facebook,
|
||||
'youtube' => $request->youtube,
|
||||
'linkedin' => $request->linkedin,
|
||||
'tiktok' => $request->tiktok,
|
||||
'birthday' => $request->birthday,
|
||||
'location' => $request->location,
|
||||
];
|
||||
$data = ['name' => $request->name];
|
||||
|
||||
if ($request->hasFile('avatar')) {
|
||||
// Delete old avatar
|
||||
if ($user->avatar) {
|
||||
Storage::delete('public/avatars/'.$user->avatar);
|
||||
Storage::delete('public/avatars/' . $user->avatar);
|
||||
}
|
||||
$filename = Str::uuid().'.'.$request->file('avatar')->getClientOriginalExtension();
|
||||
$filename = Str::uuid() . '.' . $request->file('avatar')->getClientOriginalExtension();
|
||||
$request->file('avatar')->storeAs('public/avatars', $filename);
|
||||
$data['avatar'] = $filename;
|
||||
}
|
||||
@ -78,7 +55,6 @@ class UserController extends Controller
|
||||
public function settings()
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
return view('user.settings', compact('user'));
|
||||
}
|
||||
|
||||
@ -92,12 +68,12 @@ class UserController extends Controller
|
||||
'new_password' => 'required|min:8|confirmed',
|
||||
]);
|
||||
|
||||
if (! Hash::check($request->current_password, $user->password)) {
|
||||
if (!Hash::check($request->current_password, $user->password)) {
|
||||
return back()->withErrors(['current_password' => 'Current password is incorrect']);
|
||||
}
|
||||
|
||||
$user->update([
|
||||
'password' => Hash::make($request->new_password),
|
||||
'password' => Hash::make($request->new_password)
|
||||
]);
|
||||
|
||||
return redirect()->route('settings')->with('success', 'Password updated successfully!');
|
||||
@ -118,18 +94,14 @@ class UserController extends Controller
|
||||
$videos = Video::where('user_id', $user->id)
|
||||
->latest()
|
||||
->paginate(12);
|
||||
|
||||
// Also get user's playlists for their own channel
|
||||
$playlists = $user->playlists()->orderBy('created_at', 'desc')->get();
|
||||
} else {
|
||||
$videos = Video::public()
|
||||
->where('user_id', $user->id)
|
||||
->latest()
|
||||
->paginate(12);
|
||||
$playlists = null;
|
||||
}
|
||||
|
||||
return view('user.channel', compact('user', 'videos', 'playlists'));
|
||||
return view('user.channel', compact('user', 'videos'));
|
||||
}
|
||||
|
||||
// Watch history
|
||||
@ -146,7 +118,7 @@ class UserController extends Controller
|
||||
->unique();
|
||||
|
||||
$videos = Video::whereIn('id', $videoIds)
|
||||
->where(function ($q) use ($user) {
|
||||
->where(function($q) use ($user) {
|
||||
$q->where('visibility', '!=', 'private')
|
||||
->orWhere('user_id', $user->id);
|
||||
})
|
||||
@ -164,7 +136,7 @@ class UserController extends Controller
|
||||
$user = Auth::user();
|
||||
// Include private videos in liked (user's own private videos)
|
||||
$videos = $user->likes()
|
||||
->where(function ($q) use ($user) {
|
||||
->where(function($q) use ($user) {
|
||||
$q->where('visibility', '!=', 'private')
|
||||
->orWhere('videos.user_id', $user->id);
|
||||
})
|
||||
@ -179,7 +151,7 @@ class UserController extends Controller
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
if (! $video->isLikedBy($user)) {
|
||||
if (!$video->isLikedBy($user)) {
|
||||
$video->likes()->attach($user->id);
|
||||
}
|
||||
|
||||
@ -211,7 +183,8 @@ class UserController extends Controller
|
||||
|
||||
return response()->json([
|
||||
'liked' => $liked,
|
||||
'like_count' => $video->like_count,
|
||||
'like_count' => $video->like_count
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -4,7 +4,6 @@ namespace App\Http\Controllers;
|
||||
|
||||
use App\Jobs\CompressVideoJob;
|
||||
use App\Mail\VideoUploaded;
|
||||
use App\Models\Playlist;
|
||||
use App\Models\Video;
|
||||
use FFMpeg\FFMpeg;
|
||||
use FFMpeg\FFProbe;
|
||||
@ -18,36 +17,12 @@ class VideoController extends Controller
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
$this->middleware('auth')->except(['index', 'show', 'search', 'stream', 'hls', 'trending', 'shorts']);
|
||||
$this->middleware('auth')->except(['index', 'show', 'search', 'stream']);
|
||||
}
|
||||
|
||||
public function index()
|
||||
{
|
||||
$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();
|
||||
|
||||
$videos = Video::public()->latest()->paginate(12);
|
||||
return view('videos.index', compact('videos'));
|
||||
}
|
||||
|
||||
@ -60,12 +35,12 @@ class VideoController extends Controller
|
||||
}
|
||||
|
||||
$videos = Video::public()
|
||||
->where(function ($q) use ($query) {
|
||||
->where(function($q) use ($query) {
|
||||
$q->where('title', 'like', "%{$query}%")
|
||||
->orWhere('description', 'like', "%{$query}%");
|
||||
})
|
||||
->latest()
|
||||
->get();
|
||||
->paginate(12);
|
||||
|
||||
return view('videos.index', compact('videos', 'query'));
|
||||
}
|
||||
@ -87,7 +62,7 @@ class VideoController extends Controller
|
||||
]);
|
||||
|
||||
$videoFile = $request->file('video');
|
||||
$filename = Str::slug($request->title).'-'.time().'.'.$videoFile->getClientOriginalExtension();
|
||||
$filename = Str::slug($request->title) . '-' . time() . '.' . $videoFile->getClientOriginalExtension();
|
||||
$path = $videoFile->storeAs('public/videos', $filename);
|
||||
|
||||
// Get file info
|
||||
@ -96,39 +71,43 @@ class VideoController extends Controller
|
||||
|
||||
$thumbnailPath = null;
|
||||
if ($request->hasFile('thumbnail')) {
|
||||
$thumbFilename = Str::uuid().'.'.$request->file('thumbnail')->getClientOriginalExtension();
|
||||
$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);
|
||||
$videoPath = storage_path('app/' . $path);
|
||||
|
||||
if (file_exists($videoPath)) {
|
||||
$video = $ffmpeg->open($videoPath);
|
||||
$frame = $video->frame(\FFMpeg\Coordinate\TimeCode::fromSeconds(1));
|
||||
|
||||
$thumbFilename = Str::uuid().'.jpg';
|
||||
$thumbFullPath = storage_path('app/public/thumbnails/'.$thumbFilename);
|
||||
$thumbFilename = Str::uuid() . '.jpg';
|
||||
$thumbFullPath = storage_path('app/public/thumbnails/' . $thumbFilename);
|
||||
|
||||
if (! file_exists(storage_path('app/public/thumbnails'))) {
|
||||
// Ensure thumbnails directory exists
|
||||
if (!file_exists(storage_path('app/public/thumbnails'))) {
|
||||
mkdir(storage_path('app/public/thumbnails'), 0755, true);
|
||||
}
|
||||
|
||||
$frame->save($thumbFullPath);
|
||||
$thumbnailPath = 'public/thumbnails/'.$thumbFilename;
|
||||
$thumbnailPath = 'public/thumbnails/' . $thumbFilename;
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
\Log::error('FFmpeg thumbnail error: '.$e->getMessage());
|
||||
// Log the error but don't fail the upload
|
||||
\Log::error('FFmpeg failed to extract thumbnail: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// Get video dimensions and detect orientation using FFmpeg
|
||||
$width = null;
|
||||
$height = null;
|
||||
$orientation = 'landscape';
|
||||
|
||||
try {
|
||||
$ffprobe = FFProbe::create();
|
||||
$videoPath = storage_path('app/'.$path);
|
||||
$videoPath = storage_path('app/' . $path);
|
||||
|
||||
if (file_exists($videoPath)) {
|
||||
$streams = $ffprobe->streams($videoPath);
|
||||
@ -138,15 +117,22 @@ 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::error('FFprobe error: '.$e->getMessage());
|
||||
// Log the error but don't fail the upload
|
||||
\Log::error('FFprobe failed to get video dimensions: ' . $e->getMessage());
|
||||
// Use default orientation
|
||||
}
|
||||
|
||||
$video = Video::create([
|
||||
@ -166,102 +152,78 @@ class VideoController extends Controller
|
||||
'type' => $request->type ?? 'generic',
|
||||
]);
|
||||
|
||||
CompressVideoJob::dispatch($video)
|
||||
->onQueue('video-processing')
|
||||
->onConnection('database');
|
||||
// Dispatch compression job in the background
|
||||
CompressVideoJob::dispatch($video);
|
||||
|
||||
// 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::error('Email error: '.$e->getMessage());
|
||||
// Log the error but don't fail the upload
|
||||
\Log::error('Email notification failed: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'redirect' => route('videos.show', $video->id),
|
||||
'redirect' => route('videos.show', $video->id)
|
||||
]);
|
||||
}
|
||||
|
||||
public function show(Request $request, Video $video)
|
||||
public function show(Video $video)
|
||||
{
|
||||
if (! $video->canView(Auth::user())) {
|
||||
// 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)
|
||||
->where('watched_at', '>', now()->subHour())
|
||||
->first();
|
||||
|
||||
if (! $existingView) {
|
||||
if (!$existingView) {
|
||||
\DB::table('video_views')->insert([
|
||||
'user_id' => $user->id,
|
||||
'video_id' => $video->id,
|
||||
'watched_at' => now(),
|
||||
'watched_at' => now()
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$video->load(['comments.user', 'comments.replies.user', 'matchRounds.points', 'coachReviews']);
|
||||
// Load comments with user relationship
|
||||
$video->load(['comments.user', 'comments.replies.user']);
|
||||
|
||||
$playlist = null;
|
||||
$nextVideo = null;
|
||||
$previousVideo = null;
|
||||
$playlistVideos = null;
|
||||
|
||||
$playlistId = $request->query('playlist');
|
||||
if ($playlistId) {
|
||||
$playlist = Playlist::find($playlistId);
|
||||
if ($playlist && $playlist->canView(Auth::user())) {
|
||||
$nextVideo = $playlist->getNextVideo($video);
|
||||
$previousVideo = $playlist->getPreviousVideo($video);
|
||||
$playlistVideos = $playlist->videos;
|
||||
}
|
||||
}
|
||||
|
||||
$recommendedVideos = Video::public()
|
||||
->where('id', '!=', $video->id)
|
||||
->latest()
|
||||
->limit(20)
|
||||
->get();
|
||||
|
||||
$view = match ($video->type) {
|
||||
// Render the appropriate view based on video type
|
||||
$view = match($video->type) {
|
||||
'match' => 'videos.types.match',
|
||||
'music' => 'videos.types.music',
|
||||
default => 'videos.types.generic',
|
||||
};
|
||||
|
||||
return view($view, compact('video', 'playlist', 'nextVideo', 'previousVideo', 'recommendedVideos', 'playlistVideos'));
|
||||
}
|
||||
|
||||
public function matchData(Video $video)
|
||||
{
|
||||
if (! $video->canView(Auth::user())) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'rounds' => $video->matchRounds()->with('points')->orderBy('round_number')->get(),
|
||||
'reviews' => $video->coachReviews()->orderBy('start_time_seconds')->get(),
|
||||
]);
|
||||
return view($view, compact('video'));
|
||||
}
|
||||
|
||||
public function edit(Video $video, Request $request)
|
||||
{
|
||||
// Check if user owns the video
|
||||
if (Auth::id() !== $video->user_id) {
|
||||
abort(403);
|
||||
abort(403, 'You do not have permission to edit this video.');
|
||||
}
|
||||
|
||||
if (! $request->expectsJson() && ! $request->ajax()) {
|
||||
// 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' => [
|
||||
@ -269,17 +231,18 @@ class VideoController extends Controller
|
||||
'title' => $video->title,
|
||||
'description' => $video->description,
|
||||
'thumbnail' => $video->thumbnail,
|
||||
'thumbnail_url' => $video->thumbnail ? asset('storage/thumbnails/'.$video->thumbnail) : null,
|
||||
'thumbnail_url' => $video->thumbnail ? asset('storage/thumbnails/' . $video->thumbnail) : null,
|
||||
'visibility' => $video->visibility ?? 'public',
|
||||
'type' => $video->type ?? 'generic',
|
||||
],
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Request $request, Video $video)
|
||||
{
|
||||
// Check if user owns the video
|
||||
if (Auth::id() !== $video->user_id) {
|
||||
abort(403);
|
||||
abort(403, 'You do not have permission to edit this video.');
|
||||
}
|
||||
|
||||
$request->validate([
|
||||
@ -294,19 +257,21 @@ class VideoController extends Controller
|
||||
|
||||
if ($request->hasFile('thumbnail')) {
|
||||
if ($video->thumbnail) {
|
||||
Storage::delete('public/thumbnails/'.$video->thumbnail);
|
||||
Storage::delete('public/thumbnails/' . $video->thumbnail);
|
||||
}
|
||||
$thumbFilename = Str::uuid().'.'.$request->file('thumbnail')->getClientOriginalExtension();
|
||||
$thumbFilename = Str::uuid() . '.' . $request->file('thumbnail')->getClientOriginalExtension();
|
||||
$data['thumbnail'] = $request->file('thumbnail')->storeAs('public/thumbnails', $thumbFilename);
|
||||
$data['thumbnail'] = basename($data['thumbnail']);
|
||||
}
|
||||
|
||||
if (! isset($data['visibility'])) {
|
||||
// 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,
|
||||
@ -316,7 +281,7 @@ class VideoController extends Controller
|
||||
'title' => $video->title,
|
||||
'description' => $video->description,
|
||||
'visibility' => $video->visibility,
|
||||
],
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
@ -325,16 +290,20 @@ 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);
|
||||
abort(403, 'You do not have permission to delete this video.');
|
||||
}
|
||||
|
||||
Storage::delete('public/videos/'.$video->filename);
|
||||
$videoTitle = $video->title;
|
||||
|
||||
Storage::delete('public/videos/' . $video->filename);
|
||||
if ($video->thumbnail) {
|
||||
Storage::delete('public/thumbnails/'.$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,
|
||||
@ -345,80 +314,16 @@ 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)
|
||||
{
|
||||
if (! $video->canView(Auth::user())) {
|
||||
// Check if user can view this video
|
||||
if (!$video->canView(Auth::user())) {
|
||||
abort(404, 'Video not found');
|
||||
}
|
||||
|
||||
$path = storage_path('app/public/videos/'.$video->filename);
|
||||
$path = storage_path('app/public/videos/' . $video->filename);
|
||||
|
||||
if (! file_exists($path)) {
|
||||
if (!file_exists($path)) {
|
||||
abort(404, 'Video file not found');
|
||||
}
|
||||
|
||||
@ -426,13 +331,14 @@ class VideoController extends Controller
|
||||
$mimeType = $video->mime_type ?: 'video/mp4';
|
||||
|
||||
$handle = fopen($path, 'rb');
|
||||
if (! $handle) {
|
||||
if (!$handle) {
|
||||
abort(500, 'Cannot open video file');
|
||||
}
|
||||
|
||||
$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;
|
||||
@ -440,9 +346,9 @@ class VideoController extends Controller
|
||||
$length = $end - $start + 1;
|
||||
|
||||
header('HTTP/1.1 206 Partial Content');
|
||||
header('Content-Type: '.$mimeType);
|
||||
header('Content-Length: '.$length);
|
||||
header('Content-Range: bytes '.$start.'-'.$end.'/'.$fileSize);
|
||||
header('Content-Type: ' . $mimeType);
|
||||
header('Content-Length: ' . $length);
|
||||
header('Content-Range: bytes ' . $start . '-' . $end . '/' . $fileSize);
|
||||
header('Accept-Ranges: bytes');
|
||||
header('Cache-Control: public, max-age=3600');
|
||||
|
||||
@ -450,7 +356,7 @@ class VideoController extends Controller
|
||||
$chunkSize = 8192;
|
||||
$bytesToRead = $length;
|
||||
|
||||
while (! feof($handle) && $bytesToRead > 0) {
|
||||
while (!feof($handle) && $bytesToRead > 0) {
|
||||
$buffer = fread($handle, min($chunkSize, $bytesToRead));
|
||||
echo $buffer;
|
||||
flush();
|
||||
@ -460,8 +366,9 @@ class VideoController extends Controller
|
||||
fclose($handle);
|
||||
exit;
|
||||
} else {
|
||||
header('Content-Type: '.$mimeType);
|
||||
header('Content-Length: '.$fileSize);
|
||||
// No range requested, stream entire file
|
||||
header('Content-Type: ' . $mimeType);
|
||||
header('Content-Length: ' . $fileSize);
|
||||
header('Accept-Ranges: bytes');
|
||||
header('Cache-Control: public, max-age=3600');
|
||||
|
||||
@ -471,54 +378,21 @@ class VideoController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
public function hls(Video $video, $file = 'playlist.m3u8')
|
||||
public function download(Video $video)
|
||||
{
|
||||
if (! $video->canView(Auth::user())) {
|
||||
abort(404);
|
||||
// Check if user can view this video
|
||||
if (!$video->canView(Auth::user())) {
|
||||
abort(404, 'Video not found');
|
||||
}
|
||||
|
||||
if (! $video->has_hls) {
|
||||
abort(404, 'HLS unavailable');
|
||||
$path = storage_path('app/public/videos/' . $video->filename);
|
||||
|
||||
if (!file_exists($path)) {
|
||||
abort(404, 'Video file not found');
|
||||
}
|
||||
|
||||
$hlsPath = storage_path('app/' . $video->hls_path . '/' . $file);
|
||||
$filename = $video->title . '.' . pathinfo($video->filename, PATHINFO_EXTENSION);
|
||||
|
||||
if (! file_exists($hlsPath)) {
|
||||
abort(404);
|
||||
return response()->download($path, $filename);
|
||||
}
|
||||
|
||||
$mimeType = pathinfo($hlsPath, PATHINFO_EXTENSION) === 'm3u8'
|
||||
? 'application/vnd.apple.mpegurl'
|
||||
: 'video/mp2t';
|
||||
|
||||
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');
|
||||
|
||||
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;
|
||||
|
||||
header('HTTP/1.1 206 Partial Content');
|
||||
header('Content-Length: '.$length);
|
||||
header('Content-Range: bytes '.$start.'-'.$end.'/'.$size);
|
||||
|
||||
$handle = fopen($hlsPath, 'rb');
|
||||
fseek($handle, $start);
|
||||
echo fread($handle, $length);
|
||||
fclose($handle);
|
||||
} else {
|
||||
header('Content-Length: '.filesize($hlsPath));
|
||||
readfile($hlsPath);
|
||||
}
|
||||
exit;
|
||||
}
|
||||
|
||||
// Add download, trending, shorts from original as needed...
|
||||
}
|
||||
|
||||
|
||||
@ -5,7 +5,6 @@ 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;
|
||||
@ -47,18 +46,11 @@ 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
|
||||
// GPU NVENC encoding via config
|
||||
$ffmpegConfig = Config::get('ffmpeg');
|
||||
$videoPasses = $ffmpegConfig['defaults']['video'] ?? [];
|
||||
$audioPasses = $ffmpegConfig['defaults']['audio'] ?? [];
|
||||
$format = new X264('aac', 'libx264');
|
||||
$format->setKiloBitrate(0); // 0 = use CRF
|
||||
$format->setAudioKiloBitrate(192);
|
||||
|
||||
$format = new X264('aac', 'h264_nvenc');
|
||||
foreach ($videoPasses as $pass) {
|
||||
$format->addLegacyOption($pass);
|
||||
}
|
||||
foreach ($audioPasses as $pass) {
|
||||
$format->addLegacyOption($pass);
|
||||
}
|
||||
// Add CRF option for high quality
|
||||
$ffmpegVideo->save($format, $compressedPath);
|
||||
|
||||
// Check if compressed file was created and is smaller
|
||||
@ -79,12 +71,11 @@ class CompressVideoJob implements ShouldQueue
|
||||
'mime_type' => 'video/mp4',
|
||||
]);
|
||||
|
||||
Log::info('CompressVideoJob: Video compressed with GPU NVENC', [
|
||||
Log::info('CompressVideoJob: Video compressed successfully', [
|
||||
'video_id' => $video->id,
|
||||
'original_size' => $originalSize,
|
||||
'compressed_size' => $compressedSize,
|
||||
'saved' => round(($originalSize - $compressedSize) / $originalSize * 100) . '%',
|
||||
'encoder' => 'h264_nvenc'
|
||||
'saved' => round(($originalSize - $compressedSize) / $originalSize * 100) . '%'
|
||||
]);
|
||||
} else {
|
||||
// Compressed file is larger, delete it
|
||||
@ -95,9 +86,6 @@ 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
|
||||
|
||||
@ -1,129 +0,0 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,32 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class CoachReview extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'video_id',
|
||||
'user_id',
|
||||
'start_time_seconds',
|
||||
'end_time_seconds',
|
||||
'note',
|
||||
'coach_name',
|
||||
'emoji',
|
||||
];
|
||||
|
||||
public function video(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Video::class);
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
@ -1,34 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class MatchPoint extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'video_id',
|
||||
'match_round_id',
|
||||
'timestamp_seconds',
|
||||
'action',
|
||||
'points',
|
||||
'competitor',
|
||||
'notes',
|
||||
'score_blue',
|
||||
'score_red',
|
||||
];
|
||||
|
||||
public function video(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Video::class);
|
||||
}
|
||||
|
||||
public function round(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(MatchRound::class, 'match_round_id');
|
||||
}
|
||||
}
|
||||
@ -1,34 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class MatchRound extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'video_id',
|
||||
'round_number',
|
||||
'name',
|
||||
'start_time_seconds',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'start_time_seconds' => 'integer',
|
||||
];
|
||||
|
||||
public function video(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Video::class);
|
||||
}
|
||||
|
||||
public function points(): HasMany
|
||||
{
|
||||
return $this->hasMany(MatchPoint::class, 'match_round_id')->orderBy('timestamp_seconds');
|
||||
}
|
||||
}
|
||||
@ -1,321 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Playlist extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'name',
|
||||
'description',
|
||||
'thumbnail',
|
||||
'visibility',
|
||||
'is_default',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_default' => 'boolean',
|
||||
];
|
||||
|
||||
// Relationships
|
||||
public function user()
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function videos()
|
||||
{
|
||||
return $this->belongsToMany(Video::class, 'playlist_videos')
|
||||
->withPivot('position', 'watched_seconds', 'watched', 'added_at', 'last_watched_at')
|
||||
->orderBy('position');
|
||||
}
|
||||
|
||||
// Get videos with their pivot data
|
||||
public function getVideosWithPivot()
|
||||
{
|
||||
return $this->videos()->orderBy('position')->get();
|
||||
}
|
||||
|
||||
// Accessors
|
||||
public function getThumbnailUrlAttribute()
|
||||
{
|
||||
if ($this->thumbnail) {
|
||||
return asset('storage/thumbnails/'.$this->thumbnail);
|
||||
}
|
||||
|
||||
// Generate a placeholder based on playlist name
|
||||
return 'https://ui-avatars.com/api/?name='.urlencode($this->name).'&background=random&size=200';
|
||||
}
|
||||
|
||||
// Get total video count
|
||||
public function getVideoCountAttribute()
|
||||
{
|
||||
return $this->videos()->count();
|
||||
}
|
||||
|
||||
// Get total duration of all videos in playlist
|
||||
public function getTotalDurationAttribute()
|
||||
{
|
||||
return $this->videos()->sum('duration');
|
||||
}
|
||||
|
||||
// Get formatted total duration (e.g., "2h 30m")
|
||||
public function getFormattedDurationAttribute()
|
||||
{
|
||||
$totalSeconds = $this->total_duration;
|
||||
|
||||
if (! $totalSeconds) {
|
||||
return '0m';
|
||||
}
|
||||
|
||||
$hours = floor($totalSeconds / 3600);
|
||||
$minutes = floor(($totalSeconds % 3600) / 60);
|
||||
|
||||
if ($hours > 0) {
|
||||
return "{$hours}h {$minutes}m";
|
||||
}
|
||||
|
||||
return "{$minutes}m";
|
||||
}
|
||||
|
||||
// Get total views of all videos in playlist
|
||||
public function getTotalViewsAttribute()
|
||||
{
|
||||
return $this->videos()->get()->sum('view_count');
|
||||
}
|
||||
|
||||
// Check if user owns this playlist
|
||||
public function isOwnedBy($user)
|
||||
{
|
||||
if (! $user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->user_id === $user->id;
|
||||
}
|
||||
|
||||
// Check if video is in this playlist
|
||||
public function hasVideo(Video $video)
|
||||
{
|
||||
return $this->videos()->where('video_id', $video->id)->exists();
|
||||
}
|
||||
|
||||
// Get video position in playlist
|
||||
public function getVideoPosition(Video $video)
|
||||
{
|
||||
$pivot = $this->videos()->where('video_id', $video->id)->first();
|
||||
|
||||
return $pivot ? $pivot->pivot->position : null;
|
||||
}
|
||||
|
||||
// Get next video in playlist
|
||||
public function getNextVideo(Video $currentVideo)
|
||||
{
|
||||
$currentPosition = $this->getVideoPosition($currentVideo);
|
||||
|
||||
if ($currentPosition === null) {
|
||||
return $this->videos()->first();
|
||||
}
|
||||
|
||||
return $this->videos()
|
||||
->wherePivot('position', '>', $currentPosition)
|
||||
->orderBy('position')
|
||||
->first();
|
||||
}
|
||||
|
||||
// Get previous video in playlist
|
||||
public function getPreviousVideo(Video $currentVideo)
|
||||
{
|
||||
$currentPosition = $this->getVideoPosition($currentVideo);
|
||||
|
||||
if ($currentPosition === null) {
|
||||
return $this->videos()->last();
|
||||
}
|
||||
|
||||
return $this->videos()
|
||||
->wherePivot('position', '<', $currentPosition)
|
||||
->orderByDesc('position')
|
||||
->first();
|
||||
}
|
||||
|
||||
// Get shareable URL
|
||||
public function getShareUrlAttribute()
|
||||
{
|
||||
return route('playlists.show', $this->id);
|
||||
}
|
||||
|
||||
// Scope for public playlists
|
||||
public function scopePublic($query)
|
||||
{
|
||||
return $query->where('visibility', 'public');
|
||||
}
|
||||
|
||||
// Scope for user's playlists
|
||||
public function scopeForUser($query, $userId)
|
||||
{
|
||||
return $query->where('user_id', $userId);
|
||||
}
|
||||
|
||||
// Scope for default playlists (Watch Later)
|
||||
public function scopeDefault($query)
|
||||
{
|
||||
return $query->where('is_default', true);
|
||||
}
|
||||
|
||||
// Visibility helpers
|
||||
public function isPublic()
|
||||
{
|
||||
return $this->visibility === 'public';
|
||||
}
|
||||
|
||||
public function isPrivate()
|
||||
{
|
||||
return $this->visibility === 'private';
|
||||
}
|
||||
|
||||
// Check if user can view this playlist
|
||||
public function canView($user = null)
|
||||
{
|
||||
// Owner can always view
|
||||
if ($user && $this->user_id === $user->id) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Public playlists can be viewed by anyone
|
||||
return $this->visibility === 'public';
|
||||
}
|
||||
|
||||
// Check if user can edit this playlist
|
||||
public function canEdit($user = null)
|
||||
{
|
||||
return $user && $this->user_id === $user->id;
|
||||
}
|
||||
|
||||
// Update video positions after reordering
|
||||
public function reorderVideos($videoIds)
|
||||
{
|
||||
foreach ($videoIds as $index => $videoId) {
|
||||
$this->videos()->updateExistingPivot($videoId, ['position' => $index]);
|
||||
}
|
||||
}
|
||||
|
||||
// Add video to playlist
|
||||
public function addVideo(Video $video)
|
||||
{
|
||||
if ($this->hasVideo($video)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$maxPosition = $this->videos()->max('position') ?? -1;
|
||||
|
||||
$this->videos()->attach($video->id, [
|
||||
'position' => $maxPosition + 1,
|
||||
'added_at' => now(),
|
||||
]);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Remove video from playlist
|
||||
public function removeVideo(Video $video)
|
||||
{
|
||||
if (! $this->hasVideo($video)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->videos()->detach($video->id);
|
||||
|
||||
// Reorder remaining videos
|
||||
$this->reorderPositions();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Reorder positions after removal
|
||||
protected function reorderPositions()
|
||||
{
|
||||
$videos = $this->videos()->orderBy('position')->get();
|
||||
$position = 0;
|
||||
|
||||
foreach ($videos as $video) {
|
||||
$this->videos()->updateExistingPivot($video->id, ['position' => $position]);
|
||||
$position++;
|
||||
}
|
||||
}
|
||||
|
||||
// Update watch progress for a video in playlist
|
||||
public function updateWatchProgress(Video $video, $seconds)
|
||||
{
|
||||
$pivot = $this->videos()->where('video_id', $video->id)->first()?->pivot;
|
||||
|
||||
if ($pivot) {
|
||||
$pivot->watched_seconds = $seconds;
|
||||
$pivot->last_watched_at = now();
|
||||
|
||||
// Mark as watched if 90% complete
|
||||
if ($video->duration && $seconds >= ($video->duration * 0.9)) {
|
||||
$pivot->watched = true;
|
||||
}
|
||||
|
||||
$pivot->save();
|
||||
}
|
||||
}
|
||||
|
||||
// Get watch progress for a video in playlist
|
||||
public function getWatchProgress(Video $video)
|
||||
{
|
||||
$pivot = $this->videos()->where('video_id', $video->id)->first()?->pivot;
|
||||
|
||||
return $pivot ? [
|
||||
'watched_seconds' => $pivot->watched_seconds,
|
||||
'watched' => $pivot->watched,
|
||||
'last_watched_at' => $pivot->last_watched_at,
|
||||
] : null;
|
||||
}
|
||||
|
||||
// Get first unwatched video
|
||||
public function getFirstUnwatchedVideo()
|
||||
{
|
||||
return $this->videos()
|
||||
->wherePivot('watched', false)
|
||||
->orderBy('position')
|
||||
->first();
|
||||
}
|
||||
|
||||
// Get random video (for shuffle)
|
||||
public function getRandomVideo()
|
||||
{
|
||||
return $this->videos()->inRandomOrder()->first();
|
||||
}
|
||||
|
||||
// Static method to create default "Watch Later" playlist
|
||||
public static function createWatchLater($userId)
|
||||
{
|
||||
return self::create([
|
||||
'user_id' => $userId,
|
||||
'name' => 'Watch Later',
|
||||
'description' => 'Save videos to watch later',
|
||||
'visibility' => 'private',
|
||||
'is_default' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
// Get or create watch later playlist for user
|
||||
public static function getWatchLater($userId)
|
||||
{
|
||||
$playlist = self::where('user_id', $userId)
|
||||
->where('is_default', true)
|
||||
->first();
|
||||
|
||||
if (! $playlist) {
|
||||
$playlist = self::createWatchLater($userId);
|
||||
}
|
||||
|
||||
return $playlist;
|
||||
}
|
||||
}
|
||||
@ -19,16 +19,6 @@ class User extends Authenticatable
|
||||
'password',
|
||||
'avatar',
|
||||
'role',
|
||||
'bio',
|
||||
'website',
|
||||
'twitter',
|
||||
'instagram',
|
||||
'facebook',
|
||||
'youtube',
|
||||
'linkedin',
|
||||
'tiktok',
|
||||
'birthday',
|
||||
'location',
|
||||
];
|
||||
|
||||
protected $hidden = [
|
||||
@ -62,18 +52,12 @@ class User extends Authenticatable
|
||||
return $this->hasMany(Comment::class);
|
||||
}
|
||||
|
||||
public function playlists()
|
||||
{
|
||||
return $this->hasMany(Playlist::class);
|
||||
}
|
||||
|
||||
public function getAvatarUrlAttribute()
|
||||
{
|
||||
if ($this->avatar) {
|
||||
return asset('storage/avatars/'.$this->avatar);
|
||||
return asset('storage/avatars/' . $this->avatar);
|
||||
}
|
||||
|
||||
return 'https://i.pravatar.cc/150?u='.$this->id;
|
||||
return 'https://i.pravatar.cc/150?u=' . $this->id;
|
||||
}
|
||||
|
||||
// Role helper methods
|
||||
@ -98,39 +82,5 @@ class User extends Authenticatable
|
||||
// For now, return a placeholder - in production this would come from a subscriptions table
|
||||
return rand(100, 10000);
|
||||
}
|
||||
|
||||
// Get social links as an array
|
||||
public function getSocialLinksAttribute()
|
||||
{
|
||||
return [
|
||||
'twitter' => $this->twitter,
|
||||
'instagram' => $this->instagram,
|
||||
'facebook' => $this->facebook,
|
||||
'youtube' => $this->youtube,
|
||||
'linkedin' => $this->linkedin,
|
||||
'tiktok' => $this->tiktok,
|
||||
];
|
||||
}
|
||||
|
||||
// Check if user has any social links
|
||||
public function hasSocialLinks()
|
||||
{
|
||||
return $this->twitter || $this->instagram || $this->facebook ||
|
||||
$this->youtube || $this->linkedin || $this->tiktok;
|
||||
}
|
||||
|
||||
// Get formatted website URL
|
||||
public function getWebsiteUrlAttribute()
|
||||
{
|
||||
if (! $this->website) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Add https:// if no protocol is specified
|
||||
if (! preg_match('/^https?:\/\//', $this->website)) {
|
||||
return 'https://'.$this->website;
|
||||
}
|
||||
|
||||
return $this->website;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -22,9 +22,6 @@ class Video extends Model
|
||||
'status',
|
||||
'visibility',
|
||||
'type',
|
||||
'is_shorts',
|
||||
'has_hls',
|
||||
'hls_path',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
@ -32,8 +29,6 @@ class Video extends Model
|
||||
'size' => 'integer',
|
||||
'width' => 'integer',
|
||||
'height' => 'integer',
|
||||
'is_shorts' => 'boolean',
|
||||
'has_hls' => 'boolean',
|
||||
];
|
||||
|
||||
// Relationships
|
||||
@ -57,26 +52,21 @@ class Video extends Model
|
||||
// Accessors
|
||||
public function getUrlAttribute()
|
||||
{
|
||||
return asset('storage/videos/'.$this->filename);
|
||||
return asset('storage/videos/' . $this->filename);
|
||||
}
|
||||
|
||||
public function getThumbnailUrlAttribute()
|
||||
{
|
||||
if ($this->thumbnail) {
|
||||
return asset('storage/thumbnails/'.$this->thumbnail);
|
||||
return asset('storage/thumbnails/' . $this->thumbnail);
|
||||
}
|
||||
|
||||
// Return null when no thumbnail - social platforms will use their own preview
|
||||
return null;
|
||||
return asset('images/video-placeholder.jpg');
|
||||
}
|
||||
|
||||
// Check if video is liked by user
|
||||
public function isLikedBy($user)
|
||||
{
|
||||
if (! $user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!$user) return false;
|
||||
return $this->likes()->where('user_id', $user->id)->exists();
|
||||
}
|
||||
|
||||
@ -98,33 +88,6 @@ class Video extends Model
|
||||
return route('videos.show', $this->id);
|
||||
}
|
||||
|
||||
// Get formatted duration (e.g., "1:30" or "0:45" for shorts)
|
||||
public function getFormattedDurationAttribute()
|
||||
{
|
||||
if (! $this->duration) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$minutes = floor($this->duration / 60);
|
||||
$seconds = $this->duration % 60;
|
||||
|
||||
return sprintf('%d:%02d', $minutes, $seconds);
|
||||
}
|
||||
|
||||
// Get Shorts badge
|
||||
public function getShortsBadgeAttribute()
|
||||
{
|
||||
if ($this->isShorts()) {
|
||||
return [
|
||||
'icon' => 'bi-collection-play-fill',
|
||||
'label' => 'SHORTS',
|
||||
'color' => '#ff0000',
|
||||
];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Visibility helpers
|
||||
public function isPublic()
|
||||
{
|
||||
@ -175,14 +138,13 @@ class Video extends Model
|
||||
->orWhere('user_id', $user->id);
|
||||
});
|
||||
}
|
||||
|
||||
return $query->where('visibility', '!=', 'private');
|
||||
}
|
||||
|
||||
// Video type helpers
|
||||
public function getTypeIconAttribute()
|
||||
{
|
||||
return match ($this->type) {
|
||||
return match($this->type) {
|
||||
'music' => 'bi-music-note',
|
||||
'match' => 'bi-trophy',
|
||||
default => 'bi-film',
|
||||
@ -191,7 +153,7 @@ class Video extends Model
|
||||
|
||||
public function getTypeSymbolAttribute()
|
||||
{
|
||||
return match ($this->type) {
|
||||
return match($this->type) {
|
||||
'music' => '🎵',
|
||||
'match' => '🏆',
|
||||
default => '🎬',
|
||||
@ -213,31 +175,6 @@ class Video extends Model
|
||||
return $this->type === 'match';
|
||||
}
|
||||
|
||||
// Shorts helpers
|
||||
public function isShorts()
|
||||
{
|
||||
return $this->is_shorts === true || $this->is_shorts === 1 || $this->is_shorts === '1';
|
||||
}
|
||||
|
||||
// Scope for shorts videos
|
||||
public function scopeShorts($query)
|
||||
{
|
||||
return $query->where('is_shorts', true);
|
||||
}
|
||||
|
||||
// Scope for non-shorts videos
|
||||
public function scopeNotShorts($query)
|
||||
{
|
||||
return $query->where('is_shorts', false);
|
||||
}
|
||||
|
||||
// Check if video qualifies as shorts (auto-detection)
|
||||
public function qualifiesAsShorts()
|
||||
{
|
||||
// Shorts: duration <= 60 seconds AND portrait orientation
|
||||
return $this->duration <= 60 && $this->orientation === 'portrait';
|
||||
}
|
||||
|
||||
// Comments relationship
|
||||
public function comments()
|
||||
{
|
||||
@ -248,159 +185,4 @@ class Video extends Model
|
||||
{
|
||||
return $this->comments()->count();
|
||||
}
|
||||
|
||||
// Match events relationships
|
||||
public function matchRounds()
|
||||
{
|
||||
return $this->hasMany(MatchRound::class)->orderBy('round_number');
|
||||
}
|
||||
|
||||
public function matchPoints()
|
||||
{
|
||||
return $this->hasMany(MatchPoint::class);
|
||||
}
|
||||
|
||||
public function coachReviews()
|
||||
{
|
||||
return $this->hasMany(CoachReview::class)->orderBy('start_time_seconds');
|
||||
}
|
||||
|
||||
// Get recent views count (within hours)
|
||||
public function getRecentViews($hours = 48)
|
||||
{
|
||||
return \DB::table('video_views')
|
||||
->where('video_id', $this->id)
|
||||
->where('watched_at', '>=', now()->subHours($hours))
|
||||
->count();
|
||||
}
|
||||
|
||||
// Get views in last 24 hours (for velocity calculation)
|
||||
public function getViewsLast24Hours()
|
||||
{
|
||||
return $this->getRecentViews(24);
|
||||
}
|
||||
|
||||
// Calculate trending score (YouTube-style algorithm)
|
||||
public function getTrendingScore($hours = 48)
|
||||
{
|
||||
$recentViews = $this->getRecentViews($hours);
|
||||
|
||||
// Don't include videos older than 10 days
|
||||
if ($this->created_at->diffInDays(now()) > 10) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Don't include videos with no recent views
|
||||
if ($recentViews < 5) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Calculate view velocity (views per hour in last 48 hours)
|
||||
$velocity = $recentViews / $hours;
|
||||
|
||||
// Recency bonus: newer videos get a boost
|
||||
$ageHours = $this->created_at->diffInHours(now());
|
||||
$recencyBonus = max(0, 1 - ($ageHours / 240)); // Decreases over 10 days
|
||||
|
||||
// Like count bonus
|
||||
$likeBonus = $this->like_count * 0.1;
|
||||
|
||||
// Calculate final score
|
||||
// Weight: 70% recent views, 15% velocity, 10% recency, 5% likes
|
||||
$score = ($recentViews * 0.70) +
|
||||
($velocity * 100 * 0.15) +
|
||||
($recencyBonus * 50 * 0.10) +
|
||||
($likeBonus * 0.05);
|
||||
|
||||
return round($score, 2);
|
||||
}
|
||||
|
||||
// Get thumbnail dimensions for Open Graph
|
||||
public function getThumbnailWidthAttribute()
|
||||
{
|
||||
// Default OG recommended size is 1200x630
|
||||
return $this->width ?? 1280;
|
||||
}
|
||||
|
||||
public function getThumbnailHeightAttribute()
|
||||
{
|
||||
// Default OG recommended size is 1200x630
|
||||
return $this->height ?? 720;
|
||||
}
|
||||
|
||||
// Get video stream URL for Open Graph
|
||||
public function getStreamUrlAttribute()
|
||||
{
|
||||
return route('videos.stream', $this->id);
|
||||
}
|
||||
|
||||
// Get secure share URL
|
||||
public function getSecureShareUrlAttribute()
|
||||
{
|
||||
return secure_url(route('videos.show', $this->id));
|
||||
}
|
||||
|
||||
// Get secure thumbnail URL
|
||||
public function getSecureThumbnailUrlAttribute()
|
||||
{
|
||||
if ($this->thumbnail) {
|
||||
return secure_asset('storage/thumbnails/'.$this->thumbnail);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get full thumbnail URL with dimensions for Open Graph
|
||||
public function getOpenGraphImageAttribute()
|
||||
{
|
||||
$thumbnail = $this->thumbnail_url;
|
||||
|
||||
// Add cache busting for dynamic thumbnails
|
||||
if ($this->thumbnail) {
|
||||
$thumbnail .= '?v='.$this->updated_at->timestamp;
|
||||
}
|
||||
|
||||
return $thumbnail;
|
||||
}
|
||||
|
||||
// Get author/uploader name
|
||||
public function getAuthorNameAttribute()
|
||||
{
|
||||
return $this->user ? $this->user->name : config('app.name');
|
||||
}
|
||||
|
||||
// Get video duration in ISO 8601 format for Open Graph
|
||||
public function getIsoDurationAttribute()
|
||||
{
|
||||
if (! $this->duration) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$hours = floor($this->duration / 3600);
|
||||
$minutes = floor(($this->duration % 3600) / 60);
|
||||
$seconds = $this->duration % 60;
|
||||
|
||||
if ($hours > 0) {
|
||||
return sprintf('PT%dH%dM%dS', $hours, $minutes, $seconds);
|
||||
} elseif ($minutes > 0) {
|
||||
return sprintf('PT%dM%dS', $minutes, $seconds);
|
||||
}
|
||||
|
||||
return sprintf('PT%dS', $seconds);
|
||||
}
|
||||
|
||||
// Scope for trending videos
|
||||
public function scopeTrending($query, $hours = 48, $limit = 50)
|
||||
{
|
||||
return $query->public()
|
||||
->where('status', 'ready')
|
||||
->where('created_at', '>=', now()->subDays(10))
|
||||
->orderByDesc(\DB::raw('(
|
||||
(SELECT COUNT(*) FROM video_views vv WHERE vv.video_id = videos.id AND vv.watched_at >= DATE_SUB(NOW(), INTERVAL '.$hours.' HOUR)) * 0.70 +
|
||||
(SELECT COUNT(*) FROM video_views vv WHERE vv.video_id = videos.id AND vv.watched_at >= DATE_SUB(NOW(), INTERVAL '.$hours.' HOUR)) / '.$hours.' * 100 * 0.15 +
|
||||
GREATEST(0, TIMESTAMPDIFF(HOUR, videos.created_at, NOW()) / 240) * 50 * 0.10 +
|
||||
(SELECT COUNT(*) FROM video_likes vl WHERE vl.video_id = videos.id) * 0.1 * 0.05
|
||||
)'))
|
||||
->limit($limit);
|
||||
}
|
||||
}
|
||||
|
||||
@ -16,7 +16,7 @@ return [
|
||||
|
|
||||
*/
|
||||
|
||||
'name' => env('APP_NAME', 'TAKEONE'),
|
||||
'name' => env('APP_NAME', 'Laravel'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
@ -57,7 +57,7 @@ return [
|
||||
|
||||
'url' => env('APP_URL', 'http://localhost'),
|
||||
|
||||
'asset_url' => env('APP_URL'),
|
||||
'asset_url' => env('ASSET_URL'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
@ -70,7 +70,7 @@ return [
|
||||
|
|
||||
*/
|
||||
|
||||
'timezone' => 'Asia/Bahrain',
|
||||
'timezone' => 'UTC',
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
|
||||
@ -1,46 +0,0 @@
|
||||
<?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',
|
||||
],
|
||||
];
|
||||
|
||||
@ -118,12 +118,6 @@ return [
|
||||
'replace_placeholders' => true,
|
||||
],
|
||||
|
||||
'orphaned-videos' => [
|
||||
'driver' => 'single',
|
||||
'path' => storage_path('logs/orphaned-videos.log'),
|
||||
'level' => 'info',
|
||||
],
|
||||
|
||||
'null' => [
|
||||
'driver' => 'monolog',
|
||||
'handler' => NullHandler::class,
|
||||
|
||||
@ -1,48 +0,0 @@
|
||||
<?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('users', function (Blueprint $table) {
|
||||
$table->string('bio')->nullable()->after('avatar');
|
||||
$table->string('website')->nullable()->after('bio');
|
||||
$table->string('twitter')->nullable()->after('website');
|
||||
$table->string('instagram')->nullable()->after('twitter');
|
||||
$table->string('facebook')->nullable()->after('instagram');
|
||||
$table->string('youtube')->nullable()->after('facebook');
|
||||
$table->string('linkedin')->nullable()->after('youtube');
|
||||
$table->string('tiktok')->nullable()->after('linkedin');
|
||||
$table->date('birthday')->nullable()->after('tiktok');
|
||||
$table->string('location')->nullable()->after('birthday');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->dropColumn([
|
||||
'bio',
|
||||
'website',
|
||||
'twitter',
|
||||
'instagram',
|
||||
'facebook',
|
||||
'youtube',
|
||||
'linkedin',
|
||||
'tiktok',
|
||||
'birthday',
|
||||
'location'
|
||||
]);
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -1,23 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up()
|
||||
{
|
||||
Schema::table('videos', function (Blueprint $table) {
|
||||
$table->boolean('is_shorts')->default(false)->after('type');
|
||||
});
|
||||
}
|
||||
|
||||
public function down()
|
||||
{
|
||||
Schema::table('videos', function (Blueprint $table) {
|
||||
$table->dropColumn('is_shorts');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@ -1,29 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('playlists', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('user_id')->constrained()->onDelete('cascade');
|
||||
$table->string('name');
|
||||
$table->text('description')->nullable();
|
||||
$table->string('thumbnail')->nullable();
|
||||
$table->enum('visibility', ['public', 'private'])->default('private');
|
||||
$table->boolean('is_default')->default(false); // For "Watch Later" default playlist
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['user_id', 'visibility']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('playlists');
|
||||
}
|
||||
};
|
||||
@ -1,33 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('playlist_videos', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('playlist_id')->constrained()->onDelete('cascade');
|
||||
$table->foreignId('video_id')->constrained()->onDelete('cascade');
|
||||
$table->integer('position')->default(0); // For ordering videos in playlist
|
||||
$table->timestamp('added_at')->useCurrent();
|
||||
// Watch progress - remember where user left off
|
||||
$table->integer('watched_seconds')->default(0);
|
||||
$table->boolean('watched')->default(false);
|
||||
$table->timestamp('last_watched_at')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['playlist_id', 'video_id']);
|
||||
$table->index(['playlist_id', 'position']);
|
||||
$table->index(['video_id', 'last_watched_at']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('playlist_videos');
|
||||
}
|
||||
};
|
||||
@ -1,26 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('match_rounds', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('video_id')->constrained()->onDelete('cascade');
|
||||
$table->integer('round_number');
|
||||
$table->string('name')->default('ROUND');
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['video_id', 'round_number']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('match_rounds');
|
||||
}
|
||||
};
|
||||
@ -1,30 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('match_points', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('video_id')->constrained()->onDelete('cascade');
|
||||
$table->foreignId('match_round_id')->constrained('match_rounds')->onDelete('cascade');
|
||||
$table->integer('timestamp_seconds');
|
||||
$table->string('action');
|
||||
$table->integer('points');
|
||||
$table->string('competitor'); // 'blue' or 'red'
|
||||
$table->string('notes')->nullable();
|
||||
$table->integer('score_blue')->default(0);
|
||||
$table->integer('score_red')->default(0);
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('match_points');
|
||||
}
|
||||
};
|
||||
@ -1,28 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('coach_reviews', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('video_id')->constrained()->onDelete('cascade');
|
||||
$table->foreignId('user_id')->constrained()->onDelete('cascade');
|
||||
$table->integer('start_time_seconds');
|
||||
$table->integer('end_time_seconds')->nullable();
|
||||
$table->text('note');
|
||||
$table->string('coach_name');
|
||||
$table->string('emoji')->default('🔥');
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('coach_reviews');
|
||||
}
|
||||
};
|
||||
@ -1,32 +0,0 @@
|
||||
<?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('match_rounds', function (Blueprint $table) {
|
||||
if (! Schema::hasColumn('match_rounds', 'start_time_seconds')) {
|
||||
$table->integer('start_time_seconds')->nullable()->after('name');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('match_rounds', function (Blueprint $table) {
|
||||
if (Schema::hasColumn('match_rounds', 'start_time_seconds')) {
|
||||
$table->dropColumn('start_time_seconds');
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -1,29 +0,0 @@
|
||||
<?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) {
|
||||
//
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -5,8 +5,8 @@
|
||||
|
||||
@section('content')
|
||||
<!-- Stats Cards -->
|
||||
<div class="row mb-4 stats-grid">
|
||||
<div class="col-6 col-lg-3">
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-3">
|
||||
<div class="stats-card">
|
||||
<div class="stats-card-icon">
|
||||
<i class="bi bi-people"></i>
|
||||
@ -15,7 +15,7 @@
|
||||
<div class="stats-card-label">Total Users</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-lg-3">
|
||||
<div class="col-md-3">
|
||||
<div class="stats-card">
|
||||
<div class="stats-card-icon">
|
||||
<i class="bi bi-play-circle"></i>
|
||||
@ -24,7 +24,7 @@
|
||||
<div class="stats-card-label">Total Videos</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-lg-3">
|
||||
<div class="col-md-3">
|
||||
<div class="stats-card">
|
||||
<div class="stats-card-icon">
|
||||
<i class="bi bi-eye"></i>
|
||||
@ -33,7 +33,7 @@
|
||||
<div class="stats-card-label">Total Views</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-lg-3">
|
||||
<div class="col-md-3">
|
||||
<div class="stats-card">
|
||||
<div class="stats-card-icon">
|
||||
<i class="bi bi-hand-thumbs-up"></i>
|
||||
@ -47,13 +47,12 @@
|
||||
<!-- Recent Users & Videos -->
|
||||
<div class="row">
|
||||
<!-- Recent Users -->
|
||||
<div class="col-lg-6 mb-4">
|
||||
<div class="col-lg-6">
|
||||
<div class="admin-card">
|
||||
<div class="admin-card-header">
|
||||
<h5 class="admin-card-title">Recent Users</h5>
|
||||
<a href="{{ route('admin.users') }}" class="btn btn-outline-light btn-sm">View All</a>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
@ -70,7 +69,7 @@
|
||||
<img src="{{ $user->avatar_url }}" alt="{{ $user->name }}" class="user-avatar">
|
||||
<div>
|
||||
<div>{{ $user->name }}</div>
|
||||
<small class="text-secondary">{{ Str::limit($user->email, 20) }}</small>
|
||||
<small class="text-secondary">{{ $user->email }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
@ -94,16 +93,14 @@
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Videos -->
|
||||
<div class="col-lg-6 mb-4">
|
||||
<div class="col-lg-6">
|
||||
<div class="admin-card">
|
||||
<div class="admin-card-header">
|
||||
<h5 class="admin-card-title">Recent Videos</h5>
|
||||
<a href="{{ route('admin.videos') }}" class="btn btn-outline-light btn-sm">View All</a>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
@ -118,9 +115,9 @@
|
||||
<td>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
@if($video->thumbnail)
|
||||
<img src="{{ asset('storage/thumbnails/' . $video->thumbnail) }}" alt="{{ $video->title }}" style="width: 60px; height: 40px; object-fit: cover; border-radius: 4px; flex-shrink: 0;">
|
||||
<img src="{{ asset('storage/thumbnails/' . $video->thumbnail) }}" alt="{{ $video->title }}" style="width: 60px; height: 40px; object-fit: cover; border-radius: 4px;">
|
||||
@else
|
||||
<div style="width: 60px; height: 40px; background: #333; border-radius: 4px; display: flex; align-items: center; justify-content: center; flex-shrink: 0;">
|
||||
<div style="width: 60px; height: 40px; background: #333; border-radius: 4px; display: flex; align-items: center; justify-content: center;">
|
||||
<i class="bi bi-play-circle text-secondary"></i>
|
||||
</div>
|
||||
@endif
|
||||
@ -157,13 +154,12 @@
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Videos by Status & Visibility -->
|
||||
<div class="row">
|
||||
<!-- Videos by Status -->
|
||||
<div class="col-lg-6 mb-4">
|
||||
<div class="col-lg-6">
|
||||
<div class="admin-card">
|
||||
<div class="admin-card-header">
|
||||
<h5 class="admin-card-title">Videos by Status</h5>
|
||||
@ -190,7 +186,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Videos by Visibility -->
|
||||
<div class="col-lg-6 mb-4">
|
||||
<div class="col-lg-6">
|
||||
<div class="admin-card">
|
||||
<div class="admin-card-header">
|
||||
<h5 class="admin-card-title">Videos by Visibility</h5>
|
||||
|
||||
@ -18,8 +18,6 @@
|
||||
--border-color: #303030;
|
||||
--text-primary: #f1f1f1;
|
||||
--text-secondary: #aaaaaa;
|
||||
--sidebar-width: 240px;
|
||||
--header-height: 56px;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
@ -33,12 +31,13 @@
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.yt-header {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: var(--header-height);
|
||||
height: 56px;
|
||||
background: var(--bg-dark);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@ -84,15 +83,12 @@
|
||||
letter-spacing: -1px;
|
||||
}
|
||||
|
||||
/* Search */
|
||||
.yt-header-center {
|
||||
flex: 1;
|
||||
max-width: 640px;
|
||||
margin: 0 40px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (min-width: 992px) {
|
||||
.yt-header-center { display: flex; }
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.yt-search {
|
||||
@ -128,6 +124,7 @@
|
||||
|
||||
.yt-search-btn:hover { background: #303030; }
|
||||
|
||||
/* Header Right */
|
||||
.yt-header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@ -158,17 +155,59 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.admin-sidebar {
|
||||
/* Sidebar */
|
||||
.yt-sidebar {
|
||||
position: fixed;
|
||||
top: var(--header-height);
|
||||
top: 56px;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: var(--sidebar-width);
|
||||
width: 240px;
|
||||
background: var(--bg-dark);
|
||||
overflow-y: auto;
|
||||
padding: 12px;
|
||||
transition: transform 0.3s;
|
||||
z-index: 999;
|
||||
}
|
||||
|
||||
.yt-sidebar-section {
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.yt-sidebar-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
padding: 0 12px;
|
||||
height: 40px;
|
||||
border-radius: 10px;
|
||||
color: var(--text-primary);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.yt-sidebar-link:hover { background: var(--border-color); }
|
||||
|
||||
.yt-sidebar-link.active {
|
||||
background: var(--border-color);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.yt-sidebar-link i { font-size: 1.2rem; }
|
||||
|
||||
/* Admin Sidebar */
|
||||
.admin-sidebar {
|
||||
position: fixed;
|
||||
top: 56px;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: 240px;
|
||||
background: var(--bg-secondary);
|
||||
padding: 20px 0;
|
||||
z-index: 999;
|
||||
z-index: 1000;
|
||||
border-right: 1px solid var(--border-color);
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.admin-sidebar-brand {
|
||||
@ -206,35 +245,17 @@
|
||||
|
||||
.admin-sidebar-link i {
|
||||
font-size: 1.2rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: var(--header-height);
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 998;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
|
||||
.sidebar-overlay.active {
|
||||
display: block;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Main Content */
|
||||
.admin-main {
|
||||
margin-top: var(--header-height);
|
||||
margin-left: var(--sidebar-width);
|
||||
margin-top: 56px;
|
||||
margin-left: 240px;
|
||||
padding: 24px;
|
||||
min-height: calc(100vh - var(--header-height));
|
||||
transition: margin-left 0.3s ease;
|
||||
min-height: calc(100vh - 56px);
|
||||
}
|
||||
|
||||
/* Upload Button */
|
||||
.yt-upload-btn {
|
||||
background: var(--brand-red);
|
||||
color: white;
|
||||
@ -251,6 +272,7 @@
|
||||
|
||||
.yt-upload-btn:hover { background: #cc1a1a; }
|
||||
|
||||
/* Dropdown */
|
||||
.dropdown-menu-dark {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
@ -265,6 +287,7 @@
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Cards */
|
||||
.admin-card {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
@ -278,8 +301,6 @@
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 15px;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.admin-card-title {
|
||||
@ -288,13 +309,13 @@
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Stats Cards */
|
||||
.stats-card {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.stats-card-icon {
|
||||
@ -314,15 +335,10 @@
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.table-responsive {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
/* Tables */
|
||||
.admin-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
min-width: 500px;
|
||||
}
|
||||
|
||||
.admin-table th,
|
||||
@ -344,6 +360,7 @@
|
||||
background: rgba(255,255,255,0.02);
|
||||
}
|
||||
|
||||
/* Forms */
|
||||
.form-control, .form-select {
|
||||
background: #282828;
|
||||
border: 1px solid var(--border-color);
|
||||
@ -365,6 +382,7 @@
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn-primary {
|
||||
background: var(--brand-red);
|
||||
border-color: var(--brand-red);
|
||||
@ -386,6 +404,7 @@
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Badges */
|
||||
.badge-role {
|
||||
padding: 5px 10px;
|
||||
border-radius: 20px;
|
||||
@ -409,12 +428,12 @@
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Status badges */
|
||||
.badge-status {
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.badge-ready { background: #198754; color: white; }
|
||||
@ -422,31 +441,31 @@
|
||||
.badge-pending { background: #ffc107; color: black; }
|
||||
.badge-failed { background: #dc3545; color: white; }
|
||||
|
||||
/* Visibility badges */
|
||||
.badge-public { background: #198754; color: white; }
|
||||
.badge-unlisted { background: #fd7e14; color: white; }
|
||||
.badge-private { background: #6c757d; color: white; }
|
||||
|
||||
/* User avatar */
|
||||
.user-avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Search */
|
||||
.search-form {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.search-form .form-control {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
/* Filters */
|
||||
.filter-form {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
@ -470,16 +489,15 @@
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
/* Pagination */
|
||||
.pagination {
|
||||
margin-top: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.page-link {
|
||||
background: var(--bg-secondary);
|
||||
border-color: var(--border-color);
|
||||
color: var(--text-primary);
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.page-link:hover {
|
||||
@ -493,6 +511,7 @@
|
||||
border-color: var(--brand-red);
|
||||
}
|
||||
|
||||
/* Modal */
|
||||
.modal-content {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
@ -506,6 +525,7 @@
|
||||
border-top-color: var(--border-color);
|
||||
}
|
||||
|
||||
/* Alerts */
|
||||
.alert-success {
|
||||
background: #198754;
|
||||
border: none;
|
||||
@ -518,102 +538,24 @@
|
||||
color: white;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
/* MOBILE STYLES */
|
||||
@media (max-width: 991px) {
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.admin-sidebar {
|
||||
transform: translateX(-100%);
|
||||
width: 280px;
|
||||
width: 60px;
|
||||
}
|
||||
|
||||
.admin-sidebar.open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.admin-main {
|
||||
margin-left: 0;
|
||||
padding: 16px;
|
||||
.admin-sidebar-brand h4,
|
||||
.admin-sidebar-link span {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.admin-sidebar-link {
|
||||
padding: 14px 20px;
|
||||
justify-content: center;
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.stats-card {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
|
||||
.admin-card {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.admin-card-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 575px) {
|
||||
.stats-grid > div {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.stats-card-value {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.stats-card-icon {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.admin-table td .d-flex {
|
||||
flex-direction: column;
|
||||
align-items: flex-start !important;
|
||||
}
|
||||
|
||||
.admin-table td .text-secondary {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.admin-table td div[style*="max-width"] {
|
||||
max-width: 150px !important;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.search-form .form-control,
|
||||
.filter-form .form-select {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.badge-status {
|
||||
font-size: 0.65rem;
|
||||
padding: 3px 6px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (pointer: coarse) {
|
||||
.admin-sidebar-link,
|
||||
.btn,
|
||||
.page-link,
|
||||
.yt-menu-btn,
|
||||
.yt-icon-btn {
|
||||
min-height: 44px;
|
||||
min-width: 44px;
|
||||
}
|
||||
|
||||
input, select, textarea {
|
||||
font-size: 16px;
|
||||
.admin-main {
|
||||
margin-left: 60px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -621,13 +563,13 @@
|
||||
@yield('extra_styles')
|
||||
</head>
|
||||
<body>
|
||||
<!-- Header -->
|
||||
@include('layouts.partials.header')
|
||||
|
||||
<div class="sidebar-overlay" id="sidebarOverlay"></div>
|
||||
|
||||
<aside class="admin-sidebar" id="adminSidebar">
|
||||
<!-- Sidebar -->
|
||||
<aside class="admin-sidebar">
|
||||
<div class="admin-sidebar-brand">
|
||||
<h4><i class="bi bi-speedometer2"></i> <span class="d-none d-md-inline">Admin</span></h4>
|
||||
<h4><i class="bi bi-speedometer2"></i> Admin</h4>
|
||||
</div>
|
||||
|
||||
<nav>
|
||||
@ -651,60 +593,19 @@
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="admin-main">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4 flex-wrap gap-2">
|
||||
<h1 class="page-title" style="margin: 0; font-size: 1.8rem; font-weight: 600;">@yield('page_title', 'Dashboard')</h1>
|
||||
<!-- Page Title -->
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1 style="margin: 0; font-size: 1.8rem; font-weight: 600;">@yield('page_title', 'Dashboard')</h1>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
@yield('content')
|
||||
</main>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var menuBtn = document.querySelector('.yt-menu-btn');
|
||||
var sidebar = document.getElementById('adminSidebar');
|
||||
var overlay = document.getElementById('sidebarOverlay');
|
||||
|
||||
function checkMobile() {
|
||||
return window.innerWidth <= 991;
|
||||
}
|
||||
|
||||
function toggleSidebar() {
|
||||
if (checkMobile()) {
|
||||
sidebar.classList.toggle('open');
|
||||
overlay.classList.toggle('active');
|
||||
}
|
||||
}
|
||||
|
||||
if (menuBtn) {
|
||||
menuBtn.addEventListener('click', toggleSidebar);
|
||||
}
|
||||
|
||||
if (overlay) {
|
||||
overlay.addEventListener('click', toggleSidebar);
|
||||
}
|
||||
|
||||
window.addEventListener('resize', function() {
|
||||
if (!checkMobile()) {
|
||||
sidebar.classList.remove('open');
|
||||
overlay.classList.remove('active');
|
||||
}
|
||||
});
|
||||
|
||||
var navLinks = document.querySelectorAll('.admin-sidebar-link');
|
||||
navLinks.forEach(function(link) {
|
||||
link.addEventListener('click', function() {
|
||||
if (checkMobile()) {
|
||||
sidebar.classList.remove('open');
|
||||
overlay.classList.remove('active');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
@yield('scripts')
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -59,7 +59,7 @@
|
||||
<!-- Users Table -->
|
||||
<div class="admin-card">
|
||||
<div class="admin-card-header">
|
||||
All Users ({{ $users->count() }})
|
||||
<h5 class="admin-card-title">All Users ({{ $users->total() }})</h5>
|
||||
</div>
|
||||
|
||||
<div class="table-responsive">
|
||||
|
||||
@ -78,7 +78,7 @@
|
||||
<!-- Videos Table -->
|
||||
<div class="admin-card">
|
||||
<div class="admin-card-header">
|
||||
All Videos ({{ $videos->count() }})
|
||||
<h5 class="admin-card-title">All Videos ({{ $videos->total() }})</h5>
|
||||
</div>
|
||||
|
||||
<div class="table-responsive">
|
||||
|
||||
@ -1,66 +0,0 @@
|
||||
<div class="channel-row">
|
||||
<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" alt="{{ $video->user->name }}">
|
||||
@else
|
||||
<div class="channel-avatar"></div>
|
||||
@endif
|
||||
<div>
|
||||
<div class="channel-name">{{ $video->user->name ?? 'Unknown' }}</div>
|
||||
<div class="channel-subs">{{ number_format($video->user->subscriber_count ?? 0) }} subscribers</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<x-video-actions :video="$video" />
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Channel Row */
|
||||
.channel-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 0;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.channel-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.channel-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: #555;
|
||||
}
|
||||
|
||||
.channel-name {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.channel-subs {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 576px) {
|
||||
.channel-row {
|
||||
flex-direction: column;
|
||||
align-items: flex-start !important;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.channel-info {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@props(['video'])
|
||||
@ -1,205 +0,0 @@
|
||||
@props(['video'])
|
||||
|
||||
<style>
|
||||
.action-btn {
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 8px 14px;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
transition: all 0.2s ease;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
background: var(--border-color);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.action-btn:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.action-btn svg,
|
||||
.action-btn i {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.action-btn.comment-btn {
|
||||
background: var(--brand-red);
|
||||
color: white;
|
||||
border-color: var(--brand-red);
|
||||
}
|
||||
|
||||
.action-btn.liked {
|
||||
color: var(--brand-red) !important;
|
||||
}
|
||||
|
||||
.mobile-action-dropdown .dropdown-item.liked {
|
||||
color: var(--brand-red) !important;
|
||||
}
|
||||
|
||||
.mobile-action-dropdown {
|
||||
display: none;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.mobile-action-dropdown .dropdown-menu {
|
||||
right: 0;
|
||||
left: auto;
|
||||
min-width: 200px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 10px;
|
||||
padding: 6px 0;
|
||||
z-index: 1200;
|
||||
}
|
||||
|
||||
.mobile-action-dropdown .dropdown-item {
|
||||
color: var(--text-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
padding: 8px 12px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.mobile-action-dropdown .dropdown-item:hover {
|
||||
background: var(--border-color);
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
.video-actions>.desktop-action {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.mobile-action-dropdown {
|
||||
display: block;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="video-actions" style="display: flex; align-items: center; gap: 8px; flex-wrap: wrap; overflow: visible;">
|
||||
@auth
|
||||
@if (Auth::id() === $video->user_id)
|
||||
<button class="action-btn desktop-action" onclick="openEditVideoModal({{ $video->id }})">
|
||||
<i class="bi bi-pencil"></i>
|
||||
<span>Edit</span>
|
||||
</button>
|
||||
@elseif (Auth::id() !== $video->user_id)
|
||||
<button class="action-btn desktop-action"><i class="bi bi-bell"></i><span>Subscribe</span></button>
|
||||
@endif
|
||||
@else
|
||||
<button onclick="window.location.href='{{ route('login') }}'" class="action-btn desktop-action"><i
|
||||
class="bi bi-bell"></i><span>Subscribe</span></button>
|
||||
@endauth
|
||||
|
||||
@auth
|
||||
<form method="POST"
|
||||
action="{{ $video->isLikedBy(Auth::user()) ? route('videos.unlike', $video->id) : route('videos.like', $video->id) }}"
|
||||
class="d-inline desktop-action">
|
||||
@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
|
||||
<button onclick="window.location.href='{{ route('login') }}'" class="action-btn desktop-action">
|
||||
<i class="bi bi-hand-thumbs-up"></i>
|
||||
{{ $video->like_count > 0 ? number_format($video->like_count) : 'Like' }}
|
||||
</button>
|
||||
@endauth
|
||||
|
||||
@if ($video->isShareable())
|
||||
<button class="action-btn desktop-action"
|
||||
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 desktop-action" 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 class="dropdown mobile-action-dropdown">
|
||||
<button class="action-btn dropdown-toggle" type="button" id="dropdownMenuLinkMusic{{ $video->id }}"
|
||||
data-bs-toggle="dropdown" aria-expanded="false">
|
||||
<i class="bi bi-lightning-charge-fill"></i>
|
||||
<span>Action</span>
|
||||
</button>
|
||||
<div class="dropdown-menu" aria-labelledby="dropdownMenuLinkMusic{{ $video->id }}">
|
||||
@auth
|
||||
@if (Auth::id() !== $video->user_id)
|
||||
<button type="button" class="dropdown-item">
|
||||
<i class="bi bi-bell"></i> Subscribe
|
||||
</button>
|
||||
@else
|
||||
<button type="button" class="dropdown-item" onclick="openEditVideoModal({{ $video->id }})">
|
||||
<i class="bi bi-pencil"></i> Edit
|
||||
</button>
|
||||
@endif
|
||||
@else
|
||||
<button class="dropdown-item" onclick="window.location.href='{{ route('login') }}'">
|
||||
<i class="bi bi-bell"></i> Subscribe
|
||||
</button>
|
||||
@endauth
|
||||
|
||||
@auth
|
||||
<form method="POST"
|
||||
action="{{ $video->isLikedBy(Auth::user()) ? route('videos.unlike', $video->id) : route('videos.like', $video->id) }}"
|
||||
class="d-inline w-100">
|
||||
@csrf
|
||||
<button type="submit" class="dropdown-item {{ $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
|
||||
<button class="dropdown-item" onclick="window.location.href='{{ route('login') }}'">
|
||||
<i class="bi bi-hand-thumbs-up"></i>
|
||||
{{ $video->like_count > 0 ? number_format($video->like_count) : 'Like' }}
|
||||
</button>
|
||||
@endauth
|
||||
|
||||
@if ($video->isShareable())
|
||||
<button class="dropdown-item"
|
||||
onclick="openShareModal('{{ $video->share_url }}', '{{ addslashes($video->title) }}')">
|
||||
<i class="bi bi-share"></i> Share
|
||||
</button>
|
||||
@endif
|
||||
|
||||
<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>
|
||||
@ -12,9 +12,6 @@ $typeIcon = $video ? match($video->type) {
|
||||
default => 'bi-film',
|
||||
} : 'bi-film';
|
||||
|
||||
// Check if video is shorts
|
||||
$isShorts = $video && $video->isShorts();
|
||||
|
||||
// Check if current user is the owner of the video
|
||||
$isOwner = $video && auth()->check() && auth()->id() == $video->user_id;
|
||||
|
||||
@ -37,11 +34,6 @@ $sizeClasses = match($size) {
|
||||
@if($video && $video->duration)
|
||||
<span class="yt-video-duration">{{ gmdate('i:s', $video->duration) }}</span>
|
||||
@endif
|
||||
@if($isShorts)
|
||||
<span class="yt-shorts-badge">
|
||||
<i class="bi bi-collection-play-fill"></i> SHORTS
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
</a>
|
||||
<div class="yt-video-info">
|
||||
@ -179,16 +171,6 @@ $sizeClasses = match($size) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Shorts Toggle -->
|
||||
<div class="cute-form-group">
|
||||
<label><i class="bi bi-lightning-charge-fill"></i> Shorts</label>
|
||||
<label class="cute-shorts-toggle">
|
||||
<input type="checkbox" name="is_shorts" id="edit-is-shorts-{{ $video->id ?? '' }}" value="1">
|
||||
<span class="cute-shorts-slider"></span>
|
||||
<span class="cute-shorts-label">Mark as Short</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Thumbnail -->
|
||||
<div class="cute-form-group">
|
||||
<label><i class="bi bi-image"></i> Thumbnail</label>
|
||||
@ -285,27 +267,6 @@ $sizeClasses = match($size) {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.yt-video-card .yt-shorts-badge {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
background: rgba(230, 30, 30, 0.9);
|
||||
color: white;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.yt-video-card .yt-shorts-badge i {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.yt-video-card .yt-video-info {
|
||||
display: flex;
|
||||
margin-top: 12px;
|
||||
@ -583,56 +544,6 @@ $sizeClasses = match($size) {
|
||||
color: #ff6b8a;
|
||||
}
|
||||
|
||||
/* Shorts Toggle in Edit Modal */
|
||||
.cute-shorts-toggle {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.cute-shorts-toggle input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.cute-shorts-slider {
|
||||
position: relative;
|
||||
width: 44px;
|
||||
height: 24px;
|
||||
background-color: #333;
|
||||
border-radius: 12px;
|
||||
transition: 0.3s;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.cute-shorts-slider:before {
|
||||
position: absolute;
|
||||
content: "";
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
left: 3px;
|
||||
bottom: 3px;
|
||||
background-color: white;
|
||||
border-radius: 50%;
|
||||
transition: 0.3s;
|
||||
}
|
||||
|
||||
.cute-shorts-toggle input:checked + .cute-shorts-slider {
|
||||
background-color: #e63030;
|
||||
}
|
||||
|
||||
.cute-shorts-toggle input:checked + .cute-shorts-slider:before {
|
||||
transform: translateX(20px);
|
||||
}
|
||||
|
||||
.cute-shorts-label {
|
||||
color: #aaa;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Thumbnail Upload */
|
||||
.cute-thumbnail-upload {
|
||||
border: 2px dashed #444;
|
||||
@ -747,63 +658,6 @@ $sizeClasses = match($size) {
|
||||
</style>
|
||||
|
||||
<script>
|
||||
// Global function to save to Watch Later
|
||||
function saveToWatchLater(videoId) {
|
||||
fetch(`/videos/${videoId}/watch-later`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}',
|
||||
'Accept': 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
}
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
alert(data.message || 'Added to Watch Later');
|
||||
}
|
||||
})
|
||||
.catch(error => console.error('Error:', error));
|
||||
}
|
||||
|
||||
// Global function to open playlist modal
|
||||
function openPlaylistModal(videoId) {
|
||||
// Set the current video ID for the modal as global variable
|
||||
window.currentVideoIdForModal = videoId;
|
||||
|
||||
// Close any open dropdown menus first
|
||||
const activeDropdowns = document.querySelectorAll('.dropdown-menu.show');
|
||||
activeDropdowns.forEach(function(dropdown) {
|
||||
dropdown.classList.remove('show');
|
||||
});
|
||||
|
||||
// Also close Bootstrap dropdowns by clicking the toggle
|
||||
const dropdownToggles = document.querySelectorAll('.dropdown-toggle[aria-expanded="true"]');
|
||||
dropdownToggles.forEach(function(toggle) {
|
||||
toggle.click();
|
||||
});
|
||||
|
||||
// Try to open the add to playlist modal
|
||||
if (typeof openAddToPlaylistModal === 'function') {
|
||||
openAddToPlaylistModal(videoId);
|
||||
} else {
|
||||
// Modal might not be loaded, try to find and show it directly
|
||||
const modal = document.getElementById('addToPlaylistModal');
|
||||
if (modal) {
|
||||
modal.style.display = 'flex';
|
||||
modal.style.opacity = '1';
|
||||
} else {
|
||||
// Fallback - redirect to login
|
||||
window.location.href = '{{ route("login") }}?redirect=' + encodeURIComponent(window.location.href);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Global function to add to queue
|
||||
function addToQueue(videoId) {
|
||||
alert('Queue feature coming soon!');
|
||||
}
|
||||
|
||||
function playVideo(element) {
|
||||
const video = element.querySelector('video');
|
||||
if (video) {
|
||||
@ -867,12 +721,6 @@ function openEditVideoModal(videoId) {
|
||||
}
|
||||
});
|
||||
|
||||
// Set shorts toggle
|
||||
const shortsCheckbox = document.getElementById('edit-is-shorts-' + videoId);
|
||||
if (shortsCheckbox) {
|
||||
shortsCheckbox.checked = video.is_shorts === true || video.is_shorts === 1 || video.is_shorts === '1';
|
||||
}
|
||||
|
||||
// Clear status
|
||||
const statusEl = document.getElementById('edit-status-' + videoId);
|
||||
statusEl.className = 'cute-status';
|
||||
|
||||
@ -1,884 +0,0 @@
|
||||
{{-- ✅ DEFINE HELPER FUNCTION FIRST (before any HTML) --}}
|
||||
@php
|
||||
if (!function_exists('_renderComment')) {
|
||||
function _renderComment($comment, $video, $depth = 0)
|
||||
{
|
||||
$avatar =
|
||||
$comment->user->avatar_url ??
|
||||
'https://ui-avatars.com/api/?name=' .
|
||||
urlencode($comment->user->name ?? 'User') .
|
||||
'&background=ef4444&color=fff';
|
||||
$isOwn = $comment->user_id === (auth()->id() ?? 0);
|
||||
$videoId = $video->id ?? 0;
|
||||
|
||||
$html = '<div class="_comment-item" id="comment-' . $comment->id . '">';
|
||||
$html .=
|
||||
'<img src="' .
|
||||
e($avatar) .
|
||||
'" class="_comment-avatar" style="width:36px;height:36px;" alt="' .
|
||||
e($comment->user->name ?? 'User') .
|
||||
'">';
|
||||
$html .= '<div style="flex:1">';
|
||||
$html .= '<div style="display:flex;align-items:center;gap:8px;margin-bottom:4px">';
|
||||
$html .= '<span style="font-weight:600;font-size:14px">' . e($comment->user->name ?? 'User') . '</span>';
|
||||
$html .=
|
||||
'<span style="color:var(--text-secondary);font-size:12px">' .
|
||||
($comment->created_at ? $comment->created_at->diffForHumans() : '') .
|
||||
'</span>';
|
||||
$html .= '</div>';
|
||||
$html .=
|
||||
'<div class="_comment-body" data-_comment-enhanced="0" style="font-size:14px;line-height:1.5">' .
|
||||
e($comment->body) .
|
||||
'</div>';
|
||||
|
||||
// Edit form (only for owner)
|
||||
if ($isOwn) {
|
||||
$html .=
|
||||
'<div id="commentEditWrap' . $comment->id . '" class="_comment-edit-wrap" style="display:none;">';
|
||||
$html .=
|
||||
'<textarea id="commentEditInput' .
|
||||
$comment->id .
|
||||
'" class="_comment-edit-textarea" rows="3">' .
|
||||
e($comment->body) .
|
||||
'</textarea>';
|
||||
$html .= '<div style="display:flex;gap:8px;justify-content:flex-end;margin-top:8px;">';
|
||||
$html .=
|
||||
'<button type="button" class="_comment-btn _comment-btn-primary _comment-save-edit-trigger" data-comment-id="' .
|
||||
$comment->id .
|
||||
'"><i class="bi bi-chat-dots"></i><span>Send</span></button>';
|
||||
$html .=
|
||||
'<button type="button" class="_comment-btn _comment-btn-secondary _comment-cancel-edit-trigger" data-comment-id="' .
|
||||
$comment->id .
|
||||
'">Cancel</button>';
|
||||
$html .= '</div></div>';
|
||||
}
|
||||
|
||||
// Action buttons
|
||||
$html .= '<div style="display:flex;gap:12px;margin-top:8px;">';
|
||||
$html .=
|
||||
'<button type="button" class="_comment-btn _comment-btn-link _comment-reply-trigger" data-comment-id="' .
|
||||
$comment->id .
|
||||
'">Reply</button>';
|
||||
if ($isOwn) {
|
||||
$html .=
|
||||
'<button type="button" class="_comment-btn _comment-btn-link _comment-edit-trigger" data-comment-id="' .
|
||||
$comment->id .
|
||||
'">Edit</button>';
|
||||
$html .=
|
||||
'<button type="button" class="_comment-btn _comment-btn-link _comment-delete-trigger" data-comment-id="' .
|
||||
$comment->id .
|
||||
'">Delete</button>';
|
||||
}
|
||||
$html .= '</div>';
|
||||
|
||||
// Reply form
|
||||
$html .=
|
||||
'<div id="replyForm' .
|
||||
$comment->id .
|
||||
'" class="_comment-reply-form" style="display:none;margin-top:12px;">';
|
||||
$html .=
|
||||
'<textarea id="replyBody' .
|
||||
$comment->id .
|
||||
'" class="_comment-reply-textarea" placeholder="Write a reply..." rows="2" style="margin-bottom:8px;"></textarea>';
|
||||
$html .= '<div style="display:flex;gap:8px;justify-content:flex-end;">';
|
||||
$html .=
|
||||
'<button type="button" class="_comment-btn _comment-btn-primary _comment-submit-reply-trigger" data-video-id="' .
|
||||
$videoId .
|
||||
'" data-parent-id="' .
|
||||
$comment->id .
|
||||
'"><i class="bi bi-chat-dots"></i><span>Send</span></button>';
|
||||
$html .=
|
||||
'<button type="button" class="_comment-btn _comment-btn-secondary _comment-cancel-reply-trigger" data-comment-id="' .
|
||||
$comment->id .
|
||||
'">Cancel</button>';
|
||||
$html .= '</div></div>';
|
||||
|
||||
// Nested replies (max depth 3)
|
||||
if ($comment->replies && $comment->replies->count() > 0 && $depth < 3) {
|
||||
$html .= '<div class="_comment-reply-wrapper" style="margin-left:24px;margin-top:12px;">';
|
||||
foreach ($comment->replies as $reply) {
|
||||
$html .= _renderComment($reply, $video, $depth + 1);
|
||||
}
|
||||
$html .= '</div>';
|
||||
}
|
||||
|
||||
$html .= '</div></div>';
|
||||
return $html;
|
||||
}
|
||||
}
|
||||
@endphp
|
||||
|
||||
{{-- ✅ MAIN COMMENTS SECTION --}}
|
||||
<div class="_comment-section"
|
||||
style="margin-top: 24px; padding-top: 16px; border-top: 1px solid var(--border-color, #e5e7eb);">
|
||||
<h3 style="font-size: 18px; font-weight: 600; margin-bottom: 16px; color: var(--text-primary, #1f2937);">
|
||||
Comments <span style="color: var(--text-secondary, #6b7280); font-weight: 400;"
|
||||
id="_commentCount">({{ $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="_comment-avatar"
|
||||
style="width: 40px; height: 40px; border-radius: 50%; object-fit: cover;" alt="{{ Auth::user()->name }}">
|
||||
<div style="flex: 1; display: flex; align-items: center; gap: 8px;">
|
||||
<textarea id="_commentBody" class="_comment-textarea"
|
||||
placeholder="Add a comment... Use @ to mention someone or @mm.ss for timestamps" rows="1"
|
||||
style="background: transparent; border: none; border-bottom: 2px solid var(--border-color, #e5e7eb); color: var(--text-primary, #1f2937); padding: 12px 0 8px 0; flex: 1; margin: 0; height: 40px; font-size: 14px; outline: none; overflow: hidden; resize: none; font-family: inherit;"></textarea>
|
||||
<button type="button" id="_commentSubmitBtn" class="_comment-btn _comment-btn-primary"
|
||||
style="flex-shrink: 0;">
|
||||
<i class="bi bi-chat-dots"></i>
|
||||
<span>Send</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<div
|
||||
style="margin-bottom: 24px; padding: 16px; background: var(--bg-secondary, #f9fafb); border-radius: 8px; text-align: center;">
|
||||
<a href="{{ route('login') }}" style="color: var(--brand-red, #ef4444);">Sign in</a> to comment
|
||||
</div>
|
||||
@endauth
|
||||
|
||||
<div id="_commentsList">
|
||||
@forelse($video->comments()->whereNull('parent_id')->with('user', 'replies.user')->latest()->get() as $comment)
|
||||
{!! _renderComment($comment, $video, 0) !!}
|
||||
@empty
|
||||
<p style="color: var(--text-secondary, #6b7280); text-align: center; padding: 20px;">No comments yet. Be the
|
||||
first to comment!</p>
|
||||
@endforelse
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- ✅ DELETE MODAL --}}
|
||||
<div id="_commentDeleteModal" class="_comment-modal"
|
||||
style="display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); z-index: 9999; align-items: center; justify-content: center; opacity: 0; transition: opacity 0.2s ease;">
|
||||
<div class="_comment-modal-content"
|
||||
style="background: var(--bg-secondary, #f9fafb); border-radius: 12px; padding: 24px; width: 90%; max-width: 340px; box-shadow: 0 10px 40px rgba(0,0,0,0.2); transform: translateY(-10px); transition: transform 0.2s ease;">
|
||||
<div style="display: flex; align-items: flex-start; gap: 12px; margin-bottom: 16px;">
|
||||
<div class="_comment-modal-icon"
|
||||
style="width: 40px; height: 40px; border-radius: 50%; background: #fef2f2; display: flex; align-items: center; justify-content: center; flex-shrink: 0;">
|
||||
<i class="bi bi-exclamation-triangle" style="color: #dc2626; font-size: 20px;"></i>
|
||||
</div>
|
||||
<div style="flex: 1;">
|
||||
<h4 class="_comment-modal-title"
|
||||
style="font-weight: 600; font-size: 16px; margin: 0 0 6px 0; color: var(--text-primary, #1f2937);">
|
||||
Delete Comment?</h4>
|
||||
<p class="_comment-modal-message"
|
||||
style="font-size: 14px; color: var(--text-secondary, #6b7280); margin: 0; line-height: 1.5;">This
|
||||
action cannot be undone. The comment and its replies will be permanently removed.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="_comment-modal-actions" style="display: flex; gap: 10px; justify-content: flex-end;">
|
||||
<button id="_commentModalCancel" class="_comment-btn _comment-btn-secondary"
|
||||
style="background: transparent; color: var(--text-secondary, #6b7280); border: 1px solid var(--border-color, #e5e7eb); padding: 8px 16px; border-radius: 8px; font-size: 13px; font-weight: 500; cursor: pointer;">Cancel</button>
|
||||
<button id="_commentModalConfirm" class="_comment-btn _comment-btn-danger"
|
||||
style="background: #dc2626; color: white; border: none; padding: 8px 16px; border-radius: 8px; font-size: 13px; font-weight: 500; cursor: pointer; display: flex; align-items: center; gap: 6px;">
|
||||
<i class="bi bi-trash" style="font-size: 12px;"></i> Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- ✅ TOAST NOTIFICATION --}}
|
||||
<div id="_commentToast" class="_comment-toast"
|
||||
style="display: none; position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%) translateY(20px); background: #1f2937; color: white; padding: 12px 20px; border-radius: 8px; font-size: 14px; font-weight: 500; z-index: 10000; box-shadow: 0 4px 12px rgba(0,0,0,0.3); opacity: 0; transition: all 0.3s ease; max-width: 90%; text-align: center; pointer-events: none;">
|
||||
<span id="_commentToastMessage"></span>
|
||||
</div>
|
||||
|
||||
{{-- ✅ PREFIXED CSS --}}
|
||||
<style>
|
||||
._comment-section {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||
}
|
||||
|
||||
._comment-textarea,
|
||||
._comment-reply-textarea,
|
||||
._comment-edit-textarea {
|
||||
background: var(--bg-secondary, #f9fafb);
|
||||
border: 1px solid var(--border-color, #e5e7eb);
|
||||
color: var(--text-primary, #1f2937);
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
resize: vertical;
|
||||
outline: none;
|
||||
transition: border-color 0.15s ease;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
._comment-textarea:focus,
|
||||
._comment-reply-textarea:focus,
|
||||
._comment-edit-textarea:focus {
|
||||
border-color: var(--brand-red, #ef4444);
|
||||
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.1);
|
||||
}
|
||||
|
||||
._comment-form ._comment-textarea {
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 2px solid var(--border-color, #e5e7eb);
|
||||
border-radius: 0;
|
||||
padding: 12px 0 8px 0;
|
||||
}
|
||||
|
||||
._comment-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
border: none;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
._comment-btn-primary {
|
||||
background: var(--brand-red, #ef4444);
|
||||
color: white;
|
||||
}
|
||||
|
||||
._comment-btn-primary:hover {
|
||||
background: #dc2626;
|
||||
}
|
||||
|
||||
._comment-btn-primary:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
._comment-btn-secondary {
|
||||
background: transparent;
|
||||
color: var(--text-secondary, #6b7280);
|
||||
border: 1px solid var(--border-color, #e5e7eb);
|
||||
}
|
||||
|
||||
._comment-btn-secondary:hover {
|
||||
background: var(--bg-secondary, #f9fafb);
|
||||
color: var(--text-primary, #1f2937);
|
||||
}
|
||||
|
||||
._comment-btn-danger {
|
||||
background: #dc2626;
|
||||
color: white;
|
||||
}
|
||||
|
||||
._comment-btn-danger:hover {
|
||||
background: #b91c1c;
|
||||
}
|
||||
|
||||
._comment-btn-link {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-secondary, #6b7280);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
._comment-btn-link:hover {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
._comment-item {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
._comment-avatar {
|
||||
flex-shrink: 0;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
._comment-body {
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
word-wrap: break-word;
|
||||
color: var(--text-primary, #1f2937);
|
||||
}
|
||||
|
||||
._comment-reply-wrapper {
|
||||
padding-left: 48px;
|
||||
margin-bottom: 12px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
._comment-reply-wrapper::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 2px;
|
||||
background: var(--border-color, #e5e7eb);
|
||||
}
|
||||
|
||||
._comment-time-badge {
|
||||
display: inline-flex !important;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
background: var(--brand-red, #ef4444);
|
||||
border: 1px solid #dc2626;
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
user-select: none;
|
||||
margin: 0 2px;
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: 0 2px 4px rgba(220, 38, 38, 0.3);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
._comment-time-badge:hover {
|
||||
background: #dc2626 !important;
|
||||
border-color: #b91c1c !important;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 8px rgba(220, 38, 38, 0.4) !important;
|
||||
}
|
||||
|
||||
._comment-time-badge:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
._comment-time-badge i {
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
._comment-modal._comment-modal-show {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
._comment-modal._comment-modal-show ._comment-modal-content {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
._comment-toast._comment-toast-show {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) translateY(0);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
._comment-toast._comment-toast-success {
|
||||
background: #059669;
|
||||
}
|
||||
|
||||
._comment-toast._comment-toast-error {
|
||||
background: #dc2626;
|
||||
}
|
||||
|
||||
._comment-edit-wrap,
|
||||
._comment-reply-form {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
._comment-form {
|
||||
gap: 8px;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
._comment-form ._comment-avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
._comment-form ._comment-textarea {
|
||||
font-size: 16px;
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
._comment-btn-primary {
|
||||
min-width: 52px;
|
||||
padding: 8px 12px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
._comment-section {
|
||||
padding-left: 8px;
|
||||
padding-right: 8px;
|
||||
}
|
||||
|
||||
._comment-modal-content {
|
||||
max-width: 280px;
|
||||
padding: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
._comment-form ._comment-avatar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
._comment-form>div {
|
||||
gap: 4px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css">
|
||||
|
||||
{{-- ✅ JAVASCRIPT --}}
|
||||
<script>
|
||||
(function() {
|
||||
'use strict';
|
||||
const _comment = {
|
||||
videoId: {{ $video->id }},
|
||||
csrfToken: '{{ csrf_token() }}',
|
||||
userId: {{ Auth::id() ?? 0 }},
|
||||
elements: {},
|
||||
state: {
|
||||
deleteCommentId: null,
|
||||
toastTimeout: null,
|
||||
playbackHandler: null
|
||||
}
|
||||
};
|
||||
|
||||
function initElements() {
|
||||
_comment.elements.list = document.getElementById('_commentsList');
|
||||
_comment.elements.count = document.getElementById('_commentCount');
|
||||
_comment.elements.modal = document.getElementById('_commentDeleteModal');
|
||||
_comment.elements.modalConfirm = document.getElementById('_commentModalConfirm');
|
||||
_comment.elements.modalCancel = document.getElementById('_commentModalCancel');
|
||||
_comment.elements.toast = document.getElementById('_commentToast');
|
||||
_comment.elements.toastMessage = document.getElementById('_commentToastMessage');
|
||||
_comment.elements.submitBtn = document.getElementById('_commentSubmitBtn');
|
||||
}
|
||||
|
||||
function showToast(message, type = 'info') {
|
||||
if (!_comment.elements.toast || !_comment.elements.toastMessage) return;
|
||||
if (_comment.state.toastTimeout) clearTimeout(_comment.state.toastTimeout);
|
||||
_comment.elements.toastMessage.textContent = message;
|
||||
_comment.elements.toast.className = '_comment-toast _comment-toast-' + type;
|
||||
_comment.elements.toast.style.display = 'block';
|
||||
requestAnimationFrame(() => _comment.elements.toast.classList.add('_comment-toast-show'));
|
||||
_comment.state.toastTimeout = setTimeout(() => {
|
||||
_comment.elements.toast.classList.remove('_comment-toast-show');
|
||||
setTimeout(() => _comment.elements.toast.style.display = 'none', 300);
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
function openDeleteModal(commentId) {
|
||||
if (!_comment.elements.modal) return;
|
||||
_comment.state.deleteCommentId = commentId;
|
||||
_comment.elements.modal.style.display = 'flex';
|
||||
requestAnimationFrame(() => _comment.elements.modal.classList.add('_comment-modal-show'));
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
|
||||
function closeDeleteModal() {
|
||||
if (!_comment.elements.modal) return;
|
||||
_comment.elements.modal.classList.remove('_comment-modal-show');
|
||||
setTimeout(() => {
|
||||
_comment.elements.modal.style.display = 'none';
|
||||
_comment.state.deleteCommentId = null;
|
||||
document.body.style.overflow = '';
|
||||
}, 200);
|
||||
}
|
||||
|
||||
function initDeleteModal() {
|
||||
if (!_comment.elements.modal) return;
|
||||
_comment.elements.modalCancel?.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
closeDeleteModal();
|
||||
});
|
||||
_comment.elements.modal.addEventListener('click', (e) => {
|
||||
if (e.target === _comment.elements.modal) closeDeleteModal();
|
||||
});
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && _comment.elements.modal.style.display === 'flex')
|
||||
closeDeleteModal();
|
||||
});
|
||||
_comment.elements.modalConfirm?.addEventListener('click', async () => {
|
||||
if (!_comment.state.deleteCommentId) return;
|
||||
const btn = _comment.elements.modalConfirm;
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<i class="bi bi-hourglass-split"></i> Deleting...';
|
||||
try {
|
||||
const res = await fetch(`/comments/${_comment.state.deleteCommentId}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': _comment.csrfToken
|
||||
}
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok || !(data.success || data.deleted)) throw new Error(data.error ||
|
||||
'Failed to delete');
|
||||
const commentEl = document.getElementById('comment-' + _comment.state
|
||||
.deleteCommentId);
|
||||
if (commentEl) {
|
||||
commentEl.style.transition = 'opacity 0.2s, transform 0.2s';
|
||||
commentEl.style.opacity = '0';
|
||||
commentEl.style.transform = 'translateX(-20px)';
|
||||
setTimeout(() => commentEl.remove(), 200);
|
||||
}
|
||||
if (_comment.elements.count) {
|
||||
const current = parseInt(_comment.elements.count.textContent.match(/\d+/)?.[
|
||||
0] || 0);
|
||||
_comment.elements.count.textContent = `(${Math.max(0, current - 1)})`;
|
||||
}
|
||||
showToast('Comment deleted', 'success');
|
||||
closeDeleteModal();
|
||||
} catch (e) {
|
||||
showToast(e.message || 'Failed to delete', 'error');
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="bi bi-trash"></i> Delete';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function initEventDelegation() {
|
||||
if (!_comment.elements.list) return;
|
||||
_comment.elements.list.addEventListener('click', (e) => {
|
||||
const db = e.target.closest('._comment-delete-trigger');
|
||||
if (db) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const id = db.dataset.commentId;
|
||||
if (id) openDeleteModal(parseInt(id));
|
||||
return;
|
||||
}
|
||||
const rb = e.target.closest('._comment-reply-trigger');
|
||||
if (rb) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const id = rb.dataset.commentId;
|
||||
if (id) toggleReplyForm(id);
|
||||
return;
|
||||
}
|
||||
const eb = e.target.closest('._comment-edit-trigger');
|
||||
if (eb) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const id = eb.dataset.commentId;
|
||||
if (id) startEditComment(id);
|
||||
return;
|
||||
}
|
||||
const ceb = e.target.closest('._comment-cancel-edit-trigger');
|
||||
if (ceb) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const id = ceb.dataset.commentId;
|
||||
if (id) cancelEditComment(id);
|
||||
return;
|
||||
}
|
||||
const seb = e.target.closest('._comment-save-edit-trigger');
|
||||
if (seb) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const id = seb.dataset.commentId;
|
||||
if (id) saveEditComment(id);
|
||||
return;
|
||||
}
|
||||
const crb = e.target.closest('._comment-cancel-reply-trigger');
|
||||
if (crb) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const id = crb.dataset.commentId;
|
||||
if (id) toggleReplyForm(id);
|
||||
return;
|
||||
}
|
||||
const srb = e.target.closest('._comment-submit-reply-trigger');
|
||||
if (srb) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const vid = srb.dataset.videoId,
|
||||
pid = srb.dataset.parentId;
|
||||
if (vid && pid) submitReply(vid, pid);
|
||||
return;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function enhanceCommentBody(bodyEl) {
|
||||
if (!bodyEl || bodyEl.dataset._commentEnhanced === '1') return;
|
||||
let text = bodyEl.textContent || '';
|
||||
const timeRegex = /@(\d{1,2})\.(\d{2})(?:-(\d{1,2})\.(\d{2}))?/g;
|
||||
text = text.replace(timeRegex, (match, sM, sS, eM, eS) => {
|
||||
const startMin = parseInt(sM, 10),
|
||||
startSec = parseInt(sS, 10);
|
||||
if (isNaN(startMin) || isNaN(startSec) || startSec > 59) return match;
|
||||
const startDisplay =
|
||||
`${String(startMin).padStart(2,'0')}:${String(startSec).padStart(2,'0')}`,
|
||||
startSeconds = startMin * 60 + startSec;
|
||||
if (eM && eS) {
|
||||
const endMin = parseInt(eM, 10),
|
||||
endSec = parseInt(eS, 10);
|
||||
if (isNaN(endMin) || isNaN(endSec) || endSec > 59) return match;
|
||||
const endDisplay =
|
||||
`${String(endMin).padStart(2,'0')}:${String(endSec).padStart(2,'0')}`,
|
||||
endSeconds = endMin * 60 + endSec;
|
||||
return `<span class="_comment-time-badge" data-start="${startSeconds}" data-end="${endSeconds}" title="Play ${startDisplay} to ${endDisplay}"><i class="bi bi-clock"></i>${startDisplay}-${endDisplay}</span>`;
|
||||
}
|
||||
return `<span class="_comment-time-badge" data-start="${startSeconds}" title="Jump to ${startDisplay}"><i class="bi bi-play-fill"></i>${startDisplay}</span>`;
|
||||
});
|
||||
text = text.replace(/@([a-zA-Z0-9_]+)/g, '<span style="color:#3ea6ff;font-weight:500;">@$1</span>');
|
||||
bodyEl.innerHTML = text;
|
||||
bodyEl.dataset._commentEnhanced = '1';
|
||||
bodyEl.querySelectorAll('._comment-time-badge').forEach(badge => {
|
||||
badge.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
playTimeRange(parseFloat(badge.dataset.start), badge.dataset.end ? parseFloat(badge
|
||||
.dataset.end) : null);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function enhanceAllComments() {
|
||||
document.querySelectorAll(
|
||||
'._comment-body[data-_comment-enhanced="0"], ._comment-body:not([data-_comment-enhanced])')
|
||||
.forEach(enhanceCommentBody);
|
||||
}
|
||||
|
||||
function clearPlaybackHandler(video) {
|
||||
if (video && _comment.state.playbackHandler) video.removeEventListener('timeupdate', _comment.state
|
||||
.playbackHandler);
|
||||
_comment.state.playbackHandler = null;
|
||||
}
|
||||
|
||||
function playTimeRange(startSec, endSec = null) {
|
||||
const video = document.getElementById('videoPlayer');
|
||||
if (!video) {
|
||||
showToast('Video player not found', 'error');
|
||||
return;
|
||||
}
|
||||
clearPlaybackHandler(video);
|
||||
video.currentTime = Math.max(0, startSec - 1);
|
||||
video.play().catch(() => {});
|
||||
if (endSec) {
|
||||
_comment.state.playbackHandler = () => {
|
||||
if (video.currentTime >= endSec) {
|
||||
video.pause();
|
||||
clearPlaybackHandler(video);
|
||||
}
|
||||
};
|
||||
video.addEventListener('timeupdate', _comment.state.playbackHandler);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleReplyForm(commentId) {
|
||||
const form = document.getElementById('replyForm' + commentId);
|
||||
if (!form) return;
|
||||
form.style.display = form.style.display === 'none' ? 'block' : 'none';
|
||||
if (form.style.display === 'block') {
|
||||
const input = document.getElementById('replyBody' + commentId);
|
||||
if (input) input.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function startEditComment(commentId) {
|
||||
const body = document.querySelector(`#comment-${commentId} ._comment-body`),
|
||||
wrap = document.getElementById('commentEditWrap' + commentId),
|
||||
input = document.getElementById('commentEditInput' + commentId);
|
||||
if (!body || !wrap || !input) return;
|
||||
input.value = body.textContent.trim();
|
||||
body.style.display = 'none';
|
||||
wrap.style.display = 'block';
|
||||
input.focus();
|
||||
}
|
||||
|
||||
function cancelEditComment(commentId) {
|
||||
const body = document.querySelector(`#comment-${commentId} ._comment-body`),
|
||||
wrap = document.getElementById('commentEditWrap' + commentId);
|
||||
if (body) body.style.display = 'block';
|
||||
if (wrap) wrap.style.display = 'none';
|
||||
}
|
||||
|
||||
async function saveEditComment(commentId) {
|
||||
const input = document.getElementById('commentEditInput' + commentId),
|
||||
bodyEl = document.querySelector(`#comment-${commentId} ._comment-body`),
|
||||
wrap = document.getElementById('commentEditWrap' + commentId);
|
||||
if (!input || !bodyEl || !wrap) return;
|
||||
const body = input.value.trim();
|
||||
if (!body) {
|
||||
showToast('Comment cannot be empty', 'error');
|
||||
return;
|
||||
}
|
||||
const btn = wrap?.querySelector('._comment-btn-primary');
|
||||
if (btn) {
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<i class="bi bi-hourglass-split"></i> Sending...';
|
||||
}
|
||||
try {
|
||||
const res = await fetch(`/comments/${commentId}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': _comment.csrfToken
|
||||
},
|
||||
body: JSON.stringify({
|
||||
body
|
||||
})
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok && (data.success || data.body)) {
|
||||
bodyEl.textContent = data.body || body;
|
||||
bodyEl.dataset._commentEnhanced = '0';
|
||||
enhanceCommentBody(bodyEl);
|
||||
wrap.style.display = 'none';
|
||||
bodyEl.style.display = 'block';
|
||||
showToast('Comment updated', 'success');
|
||||
} else {
|
||||
throw new Error(data.error || 'Failed to update');
|
||||
}
|
||||
} catch (e) {
|
||||
showToast(e.message, 'error');
|
||||
} finally {
|
||||
if (btn) {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="bi bi-chat-dots"></i> <span>Send</span>';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function submitReply(vid, parentId) {
|
||||
const input = document.getElementById('replyBody' + parentId);
|
||||
if (!input) return;
|
||||
const body = input.value.trim();
|
||||
if (!body) {
|
||||
showToast('Reply cannot be empty', 'error');
|
||||
return;
|
||||
}
|
||||
const btn = input.parentElement?.querySelector('._comment-btn-primary');
|
||||
if (btn) {
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<i class="bi bi-hourglass-split"></i> Sending...';
|
||||
}
|
||||
try {
|
||||
const res = await fetch(`/videos/${vid}/comments`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': _comment.csrfToken
|
||||
},
|
||||
body: JSON.stringify({
|
||||
body,
|
||||
parent_id: parentId
|
||||
})
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok && data.success) {
|
||||
input.value = '';
|
||||
toggleReplyForm(parentId);
|
||||
refreshComments();
|
||||
showToast('Reply posted', 'success');
|
||||
} else {
|
||||
throw new Error(data.error || 'Failed to post reply');
|
||||
}
|
||||
} catch (e) {
|
||||
showToast(e.message, 'error');
|
||||
} finally {
|
||||
if (btn) {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="bi bi-chat-dots"></i> <span>Send</span>';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function submitComment(vid) {
|
||||
const input = document.getElementById('_commentBody');
|
||||
if (!input) return;
|
||||
const body = input.value.trim();
|
||||
if (!body) {
|
||||
showToast('Comment cannot be empty', 'error');
|
||||
return;
|
||||
}
|
||||
if (_comment.elements.submitBtn) {
|
||||
_comment.elements.submitBtn.disabled = true;
|
||||
_comment.elements.submitBtn.innerHTML = '<i class="bi bi-hourglass-split"></i> Sending...';
|
||||
}
|
||||
try {
|
||||
const res = await fetch(`/videos/${vid}/comments`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': _comment.csrfToken
|
||||
},
|
||||
body: JSON.stringify({
|
||||
body
|
||||
})
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok && data.success) {
|
||||
input.value = '';
|
||||
refreshComments();
|
||||
showToast('Comment posted', 'success');
|
||||
} else {
|
||||
throw new Error(data.error || 'Failed to post comment');
|
||||
}
|
||||
} catch (e) {
|
||||
showToast(e.message, 'error');
|
||||
} finally {
|
||||
if (_comment.elements.submitBtn) {
|
||||
_comment.elements.submitBtn.disabled = false;
|
||||
_comment.elements.submitBtn.innerHTML = '<i class="bi bi-chat-dots"></i> <span>Send</span>';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshComments() {
|
||||
try {
|
||||
const res = await fetch(`/videos/${_comment.videoId}/comments`),
|
||||
comments = await res.json(),
|
||||
list = _comment.elements.list;
|
||||
if (!list) return;
|
||||
if (Array.isArray(comments) && comments.length) {
|
||||
list.innerHTML = comments.map(c => {
|
||||
const avatar = c.user?.avatar_url ||
|
||||
`https://ui-avatars.com/api/?name=${encodeURIComponent(c.user?.name||'User')}&background=ef4444&color=fff`;
|
||||
return `<div class="_comment-item" id="comment-${c.id}"><img src="${avatar}" class="_comment-avatar" style="width:36px;height:36px;"><div style="flex:1"><div style="display:flex;align-items:center;gap:8px;margin-bottom:4px"><span style="font-weight:600;font-size:14px">${c.user?.name||'User'}</span><span style="color:var(--text-secondary);font-size:12px">${c.created_at||''}</span></div><div class="_comment-body" data-_comment-enhanced="0" style="font-size:14px;line-height:1.5">${c.body||''}</div></div></div>`;
|
||||
}).join('');
|
||||
} else {
|
||||
list.innerHTML =
|
||||
'<p style="color:var(--text-secondary);text-align:center;padding:20px">No comments yet. Be the first to comment!</p>';
|
||||
}
|
||||
if (_comment.elements.count) _comment.elements.count.textContent =
|
||||
`(${Array.isArray(comments) ? comments.length : 0})`;
|
||||
setTimeout(enhanceAllComments, 50);
|
||||
} catch (e) {
|
||||
showToast('Failed to load comments', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function init() {
|
||||
initElements();
|
||||
initDeleteModal();
|
||||
initEventDelegation();
|
||||
enhanceAllComments();
|
||||
_comment.elements.submitBtn?.addEventListener('click', () => submitComment(_comment.videoId));
|
||||
if (_comment.elements.list && !_comment.elements.list._observerAttached) {
|
||||
const observer = new MutationObserver(() => enhanceAllComments());
|
||||
observer.observe(_comment.elements.list, {
|
||||
childList: true,
|
||||
subtree: true
|
||||
});
|
||||
_comment.elements.list._observerAttached = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
window._comment = {
|
||||
deleteComment: openDeleteModal,
|
||||
playTimeRange: playTimeRange
|
||||
};
|
||||
})();
|
||||
</script>
|
||||
@ -1,41 +1,14 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.header {
|
||||
background: #e61e1e;
|
||||
color: white;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 20px;
|
||||
background: #f9f9f9;
|
||||
}
|
||||
|
||||
.footer {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
color: #666;
|
||||
font-size: 12px;
|
||||
}
|
||||
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }
|
||||
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
|
||||
.header { background: #e61e1e; color: white; padding: 20px; text-align: center; }
|
||||
.content { padding: 20px; background: #f9f9f9; }
|
||||
.footer { text-align: center; padding: 20px; color: #666; font-size: 12px; }
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
@ -46,44 +19,16 @@
|
||||
<p>Hi {{ $userName }},</p>
|
||||
<p>Your video <strong>"{{ $video->title }}"</strong> has been uploaded successfully!</p>
|
||||
<p>Your video is now being processed and will be available shortly.</p>
|
||||
|
||||
<p>Check out your uploaded video:</p>
|
||||
<div style="margin: 20px 0; text-align: center;">
|
||||
@if ($video->thumbnail)
|
||||
<a href="{{ url('/videos/' . $video->id) }}">
|
||||
<img src="{{ asset('storage/thumbnails/' . $video->thumbnail) }}" alt="{{ $video->title }}"
|
||||
style="max-width: 100%; height: auto; border-radius: 12px; box-shadow: 0 8px 24px rgba(0,0,0,0.3); display: block; margin: 0 auto;">
|
||||
</a>
|
||||
@else
|
||||
<div
|
||||
style="width: 100%; height: 200px; background: linear-gradient(45deg, #e61e1e, #ff4757); border-radius: 12px; display: flex; align-items: center; justify-content: center; margin: 0 auto; color: white; font-size: 18px; font-weight: bold; box-shadow: 0 8px 24px rgba(230,30,30,0.4);">
|
||||
<i class="bi bi-play-circle-fill" style="font-size: 48px; margin-right: 16px;"></i>
|
||||
{{ Str::limit($video->title, 30) }}
|
||||
</div>
|
||||
<p style="margin-top: 12px; color: #666; font-size: 14px;">Thumbnail generating...</p>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div style="background: #f0f0f0; padding: 16px; border-radius: 8px; margin: 20px 0;">
|
||||
<h4 style="margin-top: 0; color: #333;">Video Details:</h4>
|
||||
<ul style="margin: 0; padding-left: 20px;">
|
||||
<li><strong>Title:</strong> {{ $video->title }}</li>
|
||||
<li><strong>Size:</strong> {{ round($video->size / 1024 / 1024, 2) }} MB</li>
|
||||
<li><strong>Orientation:</strong> {{ $video->orientation ?? 'Horizontal' }}</li>
|
||||
<p>Video Details:</p>
|
||||
<ul>
|
||||
<li>Size: {{ round($video->size / 1024 / 1024, 2) }} MB</li>
|
||||
<li>Orientation: {{ $video->orientation }}</li>
|
||||
</ul>
|
||||
<p><a href="{{ url('/videos/' . $video->id) }}" style="background: #e61e1e; color: white; padding: 10px 20px; text-decoration: none; border-radius: 5px;">View Video</a></p>
|
||||
</div>
|
||||
|
||||
<p style="text-align: center;">
|
||||
<a href="{{ url('/videos/' . $video->id) }}"
|
||||
style="background: #e61e1e; color: white; padding: 12px 24px; text-decoration: none; border-radius: 25px; font-weight: 600; font-size: 16px; display: inline-block; box-shadow: 0 4px 12px rgba(230,30,30,0.4);">▶️
|
||||
Watch Video</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<p>TAKEONE Video Platform</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||
@ -5,9 +5,6 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>@yield('title', config('app.name'))</title>
|
||||
|
||||
<!-- Open Graph meta tags default - will be overridden by page-specific tags -->
|
||||
@stack('head')
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" type="image/png" href="{{ asset('storage/images/logo.png') }}">
|
||||
<link rel="apple-touch-icon" href="{{ asset('storage/images/logo.png') }}">
|
||||
@ -87,14 +84,12 @@
|
||||
letter-spacing: -1px;
|
||||
}
|
||||
|
||||
/* Search */
|
||||
/* Search */
|
||||
.yt-header-center {
|
||||
flex: 1;
|
||||
max-width: 640px;
|
||||
margin: 0 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.yt-search {
|
||||
@ -420,7 +415,7 @@
|
||||
|
||||
<!-- Mobile Search Overlay -->
|
||||
<div class="mobile-search-overlay" id="mobileSearchOverlay">
|
||||
<form action="{{ route('videos.trending') }}" method="GET" class="mobile-search-form">
|
||||
<form action="{{ route('videos.search') }}" method="GET" class="mobile-search-form">
|
||||
<input type="text" name="q" class="mobile-search-input" placeholder="Search" value="{{ request('q') }}">
|
||||
<button type="submit" class="mobile-search-submit">
|
||||
<i class="bi bi-search"></i>
|
||||
@ -435,7 +430,7 @@
|
||||
@include('layouts.partials.sidebar')
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="yt-main @yield('main_class')" id="main">
|
||||
<main class="yt-main" id="main">
|
||||
@yield('content')
|
||||
</main>
|
||||
|
||||
@ -443,13 +438,8 @@
|
||||
@auth
|
||||
@include('layouts.partials.upload-modal')
|
||||
@include('layouts.partials.edit-video-modal')
|
||||
@endauth
|
||||
|
||||
<!-- Add to Playlist Modal - Available for all users (shows login prompt if not authenticated) -->
|
||||
@include('layouts.partials.add-to-playlist-modal')
|
||||
|
||||
<!-- Delete Video Modal -->
|
||||
@auth
|
||||
<div class="modal fade" id="deleteVideoModal" tabindex="-1" aria-labelledby="deleteVideoModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content" style="background: #1a1a1a; border: 1px solid #3f3f3f; border-radius: 12px;">
|
||||
@ -498,30 +488,6 @@
|
||||
</div>
|
||||
@endauth
|
||||
|
||||
<!-- YouTube-style Bottom Navigation Bar (Mobile) -->
|
||||
<nav class="yt-bottom-nav">
|
||||
<a href="{{ route('videos.index') }}" class="yt-bottom-nav-item {{ request()->routeIs('videos.index') ? 'active' : '' }}">
|
||||
<i class="bi bi-house-door-fill"></i>
|
||||
<span>Home</span>
|
||||
</a>
|
||||
<a href="{{ route('videos.trending') }}" class="yt-bottom-nav-item {{ request()->routeIs('videos.trending') ? 'active' : '' }}">
|
||||
<i class="bi bi-fire"></i>
|
||||
<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-plus-circle-fill"></i>
|
||||
<span>Upload</span>
|
||||
</a>
|
||||
<a href="{{ route('history') }}" class="yt-bottom-nav-item {{ request()->routeIs('history') ? 'active' : '' }}">
|
||||
<i class="bi bi-collection-play-fill"></i>
|
||||
<span>History</span>
|
||||
</a>
|
||||
<a href="{{ auth()->check() ? route('channel', auth()->user()->channel) : route('login') }}" class="yt-bottom-nav-item">
|
||||
<i class="bi bi-person-fill"></i>
|
||||
<span>Profile</span>
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script>
|
||||
// Mobile search toggle function
|
||||
@ -687,8 +653,8 @@
|
||||
if (modal) {
|
||||
modal.hide();
|
||||
}
|
||||
// Redirect to videos index
|
||||
window.location.href = "{{ route('videos.index') }}";
|
||||
// Reload the page
|
||||
window.location.reload();
|
||||
} else if (response.status === 403) {
|
||||
alert('You do not have permission to delete this video.');
|
||||
} else if (response.status === 404) {
|
||||
@ -824,9 +790,8 @@
|
||||
@media (max-width: 768px) {
|
||||
.video-container {
|
||||
border-radius: 0 !important;
|
||||
margin: 0 !important;
|
||||
max-width: 100% !important;
|
||||
width: 100% !important;
|
||||
margin: 0 -16px !important;
|
||||
max-width: calc(100% + 32px) !important;
|
||||
}
|
||||
}
|
||||
|
||||
@ -842,66 +807,4 @@
|
||||
background: var(--border-color);
|
||||
}
|
||||
}
|
||||
|
||||
/* YouTube-style Bottom Navigation Bar */
|
||||
.yt-bottom-nav {
|
||||
display: none;
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 56px;
|
||||
background: var(--bg-dark);
|
||||
border-top: 1px solid var(--border-color);
|
||||
z-index: 999;
|
||||
justify-content: space-around;
|
||||
align-items: center;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.yt-bottom-nav-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
font-size: 12px;
|
||||
gap: 4px;
|
||||
transition: color 0.2s;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
border: none;
|
||||
min-width: 56px;
|
||||
}
|
||||
|
||||
.yt-bottom-nav-item:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.yt-bottom-nav-item.active {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.yt-bottom-nav-item i {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.yt-bottom-nav-item span {
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Show bottom nav on mobile only */
|
||||
@media (max-width: 768px) {
|
||||
.yt-bottom-nav {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.yt-main {
|
||||
padding-bottom: 72px !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -1,455 +0,0 @@
|
||||
<!-- 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;">
|
||||
<!-- Header -->
|
||||
<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;">
|
||||
<i class="bi bi-x-lg"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div style="padding: 16px 24px; flex: 1; overflow-y: auto;">
|
||||
<!-- 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;">
|
||||
<i class="bi bi-plus-lg" style="font-size: 18px;"></i>
|
||||
</span>
|
||||
<span style="font-weight: 500;">Create new playlist</span>
|
||||
</button>
|
||||
</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;">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@endauth
|
||||
|
||||
<!-- Playlist List Container -->
|
||||
<div id="playlistListContainer" style="flex: 1; overflow-y: auto; min-height: 80px;">
|
||||
<!-- Playlist items will be loaded here via JavaScript -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 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;">
|
||||
<i class="bi bi-collection-play" style="font-size: 18px;"></i>
|
||||
<span style="font-weight: 500;">View all playlists</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Modal Overlay with proper centering */
|
||||
.playlist-modal-overlay {
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
/* Modal Content */
|
||||
.playlist-modal-content {
|
||||
font-family: "Roboto", "Arial", sans-serif;
|
||||
}
|
||||
|
||||
/* Header button hover */
|
||||
.playlist-modal-header button:hover {
|
||||
background: #3f3f3f !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
/* Create playlist button hover */
|
||||
.create-playlist-btn:hover {
|
||||
background: #3f3f3f !important;
|
||||
}
|
||||
|
||||
/* Input focus */
|
||||
#newPlaylistName:focus {
|
||||
border-color: #e61e1e !important;
|
||||
}
|
||||
|
||||
/* Button hover effects */
|
||||
#createPlaylistInModal button:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
#createPlaylistInModal button:last-child:hover {
|
||||
background: #cc1a1a !important;
|
||||
}
|
||||
|
||||
/* Footer link hover */
|
||||
.playlist-modal-footer a:hover {
|
||||
background: #3f3f3f;
|
||||
}
|
||||
|
||||
/* Playlist items */
|
||||
.playlist-item {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.playlist-item:hover {
|
||||
background: #3f3f3f !important;
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
#playlistListContainer::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
#playlistListContainer::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
#playlistListContainer::-webkit-scrollbar-thumb {
|
||||
background: #555;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
#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>
|
||||
let currentModalVideoId = null;
|
||||
|
||||
// Check if user is authenticated
|
||||
function isAuthenticated() {
|
||||
return {{ auth()->check() ? 'true' : 'false' }};
|
||||
}
|
||||
|
||||
// Close button event listener
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const closeBtn = document.getElementById('closePlaylistModalBtn');
|
||||
if (closeBtn) {
|
||||
closeBtn.addEventListener('click', function(e) {
|
||||
e.stopPropagation();
|
||||
closeAddToPlaylistModal();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
function openAddToPlaylistModal(videoId) {
|
||||
currentModalVideoId = videoId;
|
||||
|
||||
// Close any open dropdown menus first
|
||||
const activeDropdowns = document.querySelectorAll('.dropdown-menu.show');
|
||||
activeDropdowns.forEach(function(dropdown) {
|
||||
dropdown.classList.remove('show');
|
||||
});
|
||||
|
||||
// Also close Bootstrap dropdowns
|
||||
const dropdownToggles = document.querySelectorAll('.dropdown-toggle[aria-expanded="true"]');
|
||||
dropdownToggles.forEach(function(toggle) {
|
||||
toggle.click();
|
||||
});
|
||||
|
||||
document.body.classList.add('modal-open');
|
||||
|
||||
const modal = document.getElementById('addToPlaylistModal');
|
||||
modal.style.display = 'flex';
|
||||
|
||||
document.getElementById('createPlaylistInModal').style.display = 'none';
|
||||
|
||||
// Load playlists when modal opens
|
||||
loadPlaylistsForModal(videoId);
|
||||
}
|
||||
|
||||
function closeAddToPlaylistModal() {
|
||||
const modal = document.getElementById('addToPlaylistModal');
|
||||
modal.style.display = 'none';
|
||||
document.body.classList.remove('modal-open');
|
||||
currentModalVideoId = null;
|
||||
}
|
||||
|
||||
// Close modal when clicking outside
|
||||
document.getElementById('addToPlaylistModal').addEventListener('click', function(e) {
|
||||
if (e.target === this) {
|
||||
closeAddToPlaylistModal();
|
||||
}
|
||||
});
|
||||
|
||||
// Close on escape key
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape') {
|
||||
const modal = document.getElementById('addToPlaylistModal');
|
||||
if (modal && modal.style.display === 'flex') {
|
||||
closeAddToPlaylistModal();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Load playlists for modal
|
||||
function loadPlaylistsForModal(videoId) {
|
||||
const container = document.getElementById('playlistListContainer');
|
||||
|
||||
@auth
|
||||
// Fetch playlists data
|
||||
fetch('{{ route('playlists.userPlaylists') }}', {
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
||||
}
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
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 visibilityText = playlist.visibility === 'public' ? 'Public' : 'Private';
|
||||
const durationText = playlist.formatted_duration || '0m';
|
||||
html += `
|
||||
<div class="playlist-item" style="display: flex; align-items: flex-start; justify-content: space-between; padding: 12px; border-radius: 8px; cursor: pointer; transition: background 0.2s; margin-bottom: 8px;"
|
||||
onmouseover="this.style.background='#3f3f3f'"
|
||||
onmouseout="this.style.background='transparent'"
|
||||
onclick="toggleVideoInPlaylist(${playlist.id}, ${videoId})">
|
||||
<div style="display: flex; align-items: flex-start; gap: 12px; flex: 1; min-width: 0;">
|
||||
<div style="width: 100px; height: 56px; background: #1a1a1a; border-radius: 8px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; overflow: hidden; position: relative;">
|
||||
${playlist.thumbnail_url
|
||||
? `<img src="${playlist.thumbnail_url}" style="width: 100%; height: 100%; object-fit: cover;">`
|
||||
: `<i class="bi bi-collection-play" style="font-size: 24px; color: #666;"></i>`
|
||||
}
|
||||
${playlist.is_default
|
||||
? `<span style="position: absolute; top: 2px; left: 2px; background: rgba(230,30,30,0.9); color: white; padding: 1px 4px; border-radius: 2px; font-size: 9px; font-weight: 600;">WL</span>`
|
||||
: ''
|
||||
}
|
||||
</div>
|
||||
<div style="min-width: 0; flex: 1;">
|
||||
<div style="font-weight: 500; font-size: 14px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: #fff;">${playlist.name}</div>
|
||||
<div style="font-size: 12px; color: #aaa; margin-top: 2px;">
|
||||
${playlist.video_count || 0} videos • ${durationText}
|
||||
</div>
|
||||
<div style="font-size: 11px; color: #777; margin-top: 2px;">
|
||||
${visibilityText}
|
||||
</div>
|
||||
${playlist.description ? `<div style="font-size: 11px; color: #777; margin-top: 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${playlist.description}</div>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div style="flex-shrink: 0; margin-left: 8px;">
|
||||
${isInPlaylist
|
||||
? '<i class="bi bi-check-circle-fill" style="color: #e61e1e; font-size: 20px;"></i>'
|
||||
: '<i class="bi bi-plus-circle" style="color: #aaa; font-size: 20px;"></i>'}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
container.innerHTML = html;
|
||||
} else {
|
||||
container.innerHTML = `
|
||||
<div style="text-align: center; color: #aaa; padding: 30px 20px;">
|
||||
<i class="bi bi-collection-play" style="font-size: 36px; margin-bottom: 12px; display: block; color: #555;"></i>
|
||||
<p style="margin: 0 0 8px; font-size: 14px; color: #fff;">No playlists yet</p>
|
||||
<p style="margin: 0; font-size: 12px;">Create a playlist to save videos</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error loading playlists:', error);
|
||||
container.innerHTML = `
|
||||
<div style="text-align: center; color: #aaa; padding: 30px 20px;">
|
||||
<i class="bi bi-exclamation-circle" style="font-size: 28px; margin-bottom: 12px; display: block; color: #ef4444;"></i>
|
||||
<p style="margin: 0; font-size: 14px; color: #fff;">Failed to load playlists</p>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
@else
|
||||
container.innerHTML = `
|
||||
<div style="text-align: center; color: #aaa; padding: 30px 20px;">
|
||||
<i class="bi bi-person-circle" style="font-size: 40px; margin-bottom: 12px; display: block; color: #555;"></i>
|
||||
<p style="margin: 0 0 8px; font-size: 14px; color: #fff;">Sign in to save videos to playlists</p>
|
||||
<p style="margin: 0 0 16px; font-size: 12px;">Create a playlist to organize your videos</p>
|
||||
<a href="{{ route('login') }}?redirect={{ urlencode(request()->fullUrl()) }}"
|
||||
style="display: inline-block; padding: 10px 24px; background: #e61e1e; color: white; border-radius: 20px; text-decoration: none; font-weight: 500; font-size: 14px; transition: background 0.2s;">
|
||||
Sign In
|
||||
</a>
|
||||
</div>
|
||||
`;
|
||||
@endauth
|
||||
}
|
||||
|
||||
// Toggle video in playlist
|
||||
function toggleVideoInPlaylist(playlistId, videoId) {
|
||||
if (!videoId) return;
|
||||
|
||||
// Check authentication before adding
|
||||
if (!isAuthenticated()) {
|
||||
window.location.href = '{{ route('login') }}?redirect=' + encodeURIComponent(window.location.href);
|
||||
return;
|
||||
}
|
||||
|
||||
fetch(`/playlists/${playlistId}/videos`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}',
|
||||
'Accept': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
video_id: videoId
|
||||
})
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
showToast(data.message || 'Video added to playlist');
|
||||
// Reload playlists to update checkmarks
|
||||
loadPlaylistsForModal(videoId);
|
||||
}
|
||||
})
|
||||
.catch(error => console.error('Error:', error));
|
||||
}
|
||||
|
||||
// Show create playlist form in modal
|
||||
function showCreatePlaylistInModal() {
|
||||
// Check authentication before creating playlist
|
||||
if (!isAuthenticated()) {
|
||||
window.location.href = '{{ route('login') }}?redirect=' + encodeURIComponent(window.location.href);
|
||||
return;
|
||||
}
|
||||
document.getElementById('createPlaylistInModal').style.display = 'block';
|
||||
document.getElementById('newPlaylistName').focus();
|
||||
}
|
||||
|
||||
// Hide create playlist form
|
||||
function hideCreatePlaylistInModal() {
|
||||
document.getElementById('createPlaylistInModal').style.display = 'none';
|
||||
document.getElementById('newPlaylistName').value = '';
|
||||
}
|
||||
|
||||
// Create playlist from modal
|
||||
function createPlaylistFromModal() {
|
||||
const name = document.getElementById('newPlaylistName').value.trim();
|
||||
if (!name) {
|
||||
alert('Please enter a playlist name');
|
||||
return;
|
||||
}
|
||||
|
||||
fetch('{{ route('playlists.store') }}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}',
|
||||
'Accept': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: name,
|
||||
visibility: 'private'
|
||||
})
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
hideCreatePlaylistInModal();
|
||||
showToast('Playlist created!');
|
||||
// Reload playlists to show new playlist
|
||||
loadPlaylistsForModal(currentModalVideoId);
|
||||
}
|
||||
})
|
||||
.catch(error => console.error('Error:', error));
|
||||
}
|
||||
|
||||
// Simple toast notification
|
||||
function showToast(message) {
|
||||
// Remove existing toast if any
|
||||
const existing = document.querySelector('.playlist-toast');
|
||||
if (existing) existing.remove();
|
||||
|
||||
const toast = document.createElement('div');
|
||||
toast.className = 'playlist-toast';
|
||||
toast.style.cssText = `
|
||||
position: fixed;
|
||||
bottom: 80px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: #fff;
|
||||
color: #0f0f0f;
|
||||
padding: 14px 28px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
z-index: 10000;
|
||||
animation: fadeInUp 0.3s ease;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.4);
|
||||
max-width: 90%;
|
||||
text-align: center;
|
||||
`;
|
||||
toast.textContent = message;
|
||||
document.body.appendChild(toast);
|
||||
|
||||
setTimeout(() => {
|
||||
toast.style.animation = 'fadeOutDown 0.3s ease';
|
||||
setTimeout(() => toast.remove(), 300);
|
||||
}, 2500);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
@keyframes fadeInUp {
|
||||
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);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -1,29 +0,0 @@
|
||||
<div class="modal fade" id="confirmDeleteModal{{ $commentId }}" tabindex="-1" aria-labelledby="confirmDeleteModalLabel{{ $commentId }}" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered modal-sm">
|
||||
<div class="modal-content" style="background: var(--bg-secondary); border: 1px solid var(--border-color); border-radius: 12px;">
|
||||
<div class="modal-header" style="border-bottom: 1px solid var(--border-color); padding: 20px;">
|
||||
<h5 class="modal-title" id="confirmDeleteModalLabel{{ $commentId }}" style="color: var(--text-primary); font-weight: 600;">
|
||||
<i class="bi bi-exclamation-triangle-fill" style="color: var(--brand-red); margin-right: 8px;"></i>
|
||||
Delete Comment
|
||||
</h5>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body" style="padding: 20px;">
|
||||
<p style="color: var(--text-primary); margin-bottom: 16px; line-height: 1.5;">
|
||||
Are you sure you want to delete this comment? This action cannot be undone.
|
||||
</p>
|
||||
<div class="comment-preview" style="background: var(--bg-primary); border-radius: 8px; padding: 12px; font-size: 14px; line-height: 1.4;">
|
||||
{{ Str::limit($body, 100) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer" style="border-top: 1px solid var(--border-color); padding: 16px 20px; gap: 8px;">
|
||||
<button type="button" class="btn" style="background: var(--bg-primary); color: var(--text-primary); border: 1px solid var(--border-color); padding: 8px 16px; border-radius: 6px; font-weight: 500;" data-bs-dismiss="modal">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="button" class="btn" style="background: var(--brand-red); color: white; padding: 8px 16px; border-radius: 6px; font-weight: 500; border: none;" onclick="deleteCommentWithModal({{ $commentId }})">
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,196 +1,10 @@
|
||||
<!-- Header -->
|
||||
<header class="yt-header">
|
||||
<style>
|
||||
.yt-header {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 56px;
|
||||
background: #0f0f0f;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 16px;
|
||||
z-index: 1000;
|
||||
border-bottom: 1px solid #303030;
|
||||
}
|
||||
|
||||
.yt-header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.yt-menu-btn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #f1f1f1;
|
||||
}
|
||||
|
||||
.yt-menu-btn:hover { background: #303030; }
|
||||
|
||||
.yt-logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
text-decoration: none;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.yt-logo-text {
|
||||
font-size: 1.4rem;
|
||||
font-weight: 700;
|
||||
color: #f1f1f1;
|
||||
letter-spacing: -1px;
|
||||
}
|
||||
|
||||
.yt-header-center {
|
||||
flex: 1;
|
||||
max-width: 640px;
|
||||
margin: 0 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.yt-search {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.yt-search-input {
|
||||
flex: 1;
|
||||
background: #121212;
|
||||
border: 1px solid #303030;
|
||||
border-right: none;
|
||||
border-radius: 20px 0 0 20px;
|
||||
padding: 0 16px;
|
||||
color: #f1f1f1;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.yt-search-input:focus {
|
||||
outline: none;
|
||||
border-color: #1c62b9;
|
||||
}
|
||||
|
||||
.yt-search-btn {
|
||||
width: 64px;
|
||||
background: #222;
|
||||
border: 1px solid #303030;
|
||||
border-radius: 0 20px 20px 0;
|
||||
color: #f1f1f1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.yt-search-btn:hover { background: #303030; }
|
||||
|
||||
.yt-header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.yt-mobile-search-toggle {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #f1f1f1;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.yt-mobile-search-toggle:hover {
|
||||
background: #303030;
|
||||
}
|
||||
|
||||
.yt-upload-btn {
|
||||
background: #e61e1e;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 8px 16px;
|
||||
border-radius: 20px;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.yt-upload-btn:hover { background: #cc1a1a; }
|
||||
|
||||
.yt-icon-btn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #f1f1f1;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.yt-icon-btn:hover { background: #303030; }
|
||||
|
||||
.yt-user-avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: #555;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
.yt-upload-btn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
padding: 0;
|
||||
border-radius: 50%;
|
||||
justify-content: center;
|
||||
}
|
||||
.yt-upload-btn span {
|
||||
display: none;
|
||||
}
|
||||
@media (min-width: 400px) {
|
||||
.yt-upload-btn {
|
||||
width: auto;
|
||||
border-radius: 20px;
|
||||
padding: 8px 16px;
|
||||
}
|
||||
.yt-upload-btn span {
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.yt-header-center { display: none; }
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="yt-header-left">
|
||||
<button class="yt-menu-btn" onclick="toggleSidebar()">
|
||||
<i class="bi bi-list fs-5"></i>
|
||||
</button>
|
||||
<a href="{{ route('home') }}" class="yt-logo">
|
||||
<a href="/videos" class="yt-logo">
|
||||
<!-- Mobile logo (visible only on mobile) -->
|
||||
<img src="{{ asset('storage/images/logo.png') }}" alt="{{ config('app.name') }}" class="d-md-none" style="height: 30px;">
|
||||
<!-- Desktop logo (visible only on desktop) -->
|
||||
@ -215,7 +29,7 @@
|
||||
|
||||
@auth
|
||||
<!-- Upload Button - Opens Modal -->
|
||||
<button type="button" class="yt-upload-btn d-none d-md-flex" data-bs-toggle="modal" data-bs-target="#uploadModal">
|
||||
<button type="button" class="yt-upload-btn" data-bs-toggle="modal" data-bs-target="#uploadModal">
|
||||
<i class="bi bi-plus-lg"></i>
|
||||
<span>Upload</span>
|
||||
</button>
|
||||
|
||||
@ -1,18 +1,14 @@
|
||||
<!-- Sidebar -->
|
||||
<nav class="yt-sidebar" id="sidebar">
|
||||
<div class="yt-sidebar-section">
|
||||
<a href="{{ route('home') }}" class="yt-sidebar-link {{ request()->is('/') || request()->is('videos') ? 'active' : '' }}">
|
||||
<a href="/videos" class="yt-sidebar-link {{ request()->is('/') || request()->is('videos') ? 'active' : '' }}">
|
||||
<i class="bi bi-house-door-fill"></i>
|
||||
<span>Home</span>
|
||||
</a>
|
||||
<a href="{{ route('videos.shorts') }}" class="yt-sidebar-link {{ request()->is('shorts') ? 'active' : '' }}">
|
||||
<a href="#" class="yt-sidebar-link">
|
||||
<i class="bi bi-play-btn"></i>
|
||||
<span>Shorts</span>
|
||||
</a>
|
||||
<a href="{{ route('videos.trending') }}" class="yt-sidebar-link {{ request()->is('trending') ? 'active' : '' }}">
|
||||
<i class="bi bi-fire"></i>
|
||||
<span>Trending</span>
|
||||
</a>
|
||||
@auth
|
||||
<a href="#" class="yt-sidebar-link">
|
||||
<i class="bi bi-collection-play"></i>
|
||||
@ -27,10 +23,6 @@
|
||||
<i class="bi bi-person-video"></i>
|
||||
<span>Your Channel</span>
|
||||
</a>
|
||||
<a href="{{ route('playlists.index') }}" class="yt-sidebar-link {{ request()->is('playlists*') ? 'active' : '' }}">
|
||||
<i class="bi bi-collection-play"></i>
|
||||
<span>Playlists</span>
|
||||
</a>
|
||||
<a href="{{ route('history') }}" class="yt-sidebar-link">
|
||||
<i class="bi bi-clock-history"></i>
|
||||
<span>History</span>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -4,10 +4,6 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>@yield('title', config('app.name'))</title>
|
||||
|
||||
<!-- Open Graph meta tags default - will be overridden by page-specific tags -->
|
||||
@stack('head')
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" type="image/png" href="{{ asset('storage/images/logo.png') }}">
|
||||
<link rel="apple-touch-icon" href="{{ asset('storage/images/logo.png') }}">
|
||||
@ -32,122 +28,16 @@
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.yt-header {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 56px;
|
||||
background: var(--bg-dark);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 16px;
|
||||
z-index: 1000;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.yt-header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.yt-menu-btn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.yt-menu-btn:hover { background: var(--border-color); }
|
||||
|
||||
.yt-logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
text-decoration: none;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.yt-logo-text {
|
||||
font-size: 1.4rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: -1px;
|
||||
}
|
||||
|
||||
/* Search - Hidden on plain layout */
|
||||
.yt-header-center { display: none; }
|
||||
|
||||
/* Header Right */
|
||||
.yt-header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.yt-icon-btn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-primary);
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.yt-icon-btn:hover { background: var(--border-color); }
|
||||
|
||||
.yt-user-avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: #555;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Main Content */
|
||||
.plain-main {
|
||||
width: 100%;
|
||||
padding: 20px;
|
||||
padding-top: 76px; /* 56px header + 20px */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: calc(100vh);
|
||||
}
|
||||
|
||||
/* Upload Button */
|
||||
.yt-upload-btn {
|
||||
background: var(--brand-red);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 8px 16px;
|
||||
border-radius: 20px;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.yt-upload-btn:hover { background: #cc1a1a; }
|
||||
|
||||
/* Dropdown */
|
||||
.dropdown-menu-dark {
|
||||
background: var(--bg-secondary);
|
||||
@ -167,10 +57,7 @@
|
||||
@yield('extra_styles')
|
||||
</head>
|
||||
<body>
|
||||
<!-- Header -->
|
||||
@include('layouts.partials.header')
|
||||
|
||||
<!-- Main Content -->
|
||||
<!-- Main Content - No header or sidebar -->
|
||||
<main class="plain-main">
|
||||
@yield('content')
|
||||
</main>
|
||||
@ -179,4 +66,5 @@
|
||||
|
||||
@yield('scripts')
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
@ -1,522 +0,0 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', 'My Playlists | ' . config('app.name'))
|
||||
|
||||
@section('extra_styles')
|
||||
<style>
|
||||
.playlist-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.playlist-card {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
padding: 12px;
|
||||
border-radius: 12px;
|
||||
background: var(--bg-secondary);
|
||||
transition: background 0.2s;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.playlist-card:hover {
|
||||
background: var(--border-color);
|
||||
}
|
||||
|
||||
.playlist-thumbnail {
|
||||
width: 160px;
|
||||
height: 90px;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: #333;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.playlist-thumbnail img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.playlist-thumbnail .placeholder {
|
||||
font-size: 32px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.playlist-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.playlist-name {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 4px;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 1;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.playlist-meta {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.playlist-description {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 4px;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.playlist-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.create-playlist-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 12px 24px;
|
||||
background: var(--brand-red);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 20px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.create-playlist-btn:hover {
|
||||
background: #cc1a1a;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 64px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.empty-title {
|
||||
font-size: 20px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
.playlist-header {
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.playlist-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.playlist-card {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.playlist-thumbnail {
|
||||
width: 100%;
|
||||
height: 120px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@endsection
|
||||
|
||||
@section('content')
|
||||
<div class="playlist-header">
|
||||
<h1 style="font-size: 24px; font-weight: 500;">My Playlists</h1>
|
||||
<button class="create-playlist-btn" onclick="openCreatePlaylistModal()">
|
||||
<i class="bi bi-plus-lg"></i> New Playlist
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if($playlists->isEmpty())
|
||||
<div class="empty-state">
|
||||
<i class="bi bi-collection-play empty-icon"></i>
|
||||
<h2 class="empty-title">No playlists yet</h2>
|
||||
<p class="empty-text">Create your first playlist to organize your favorite videos.</p>
|
||||
<button class="create-playlist-btn" onclick="openCreatePlaylistModal()">
|
||||
<i class="bi bi-plus-lg"></i> Create Playlist
|
||||
</button>
|
||||
</div>
|
||||
@else
|
||||
<div class="playlist-grid">
|
||||
@foreach($playlists as $playlist)
|
||||
<a href="{{ route('playlists.show', $playlist->id) }}" class="playlist-card">
|
||||
<div class="playlist-thumbnail">
|
||||
@if($playlist->thumbnail_url)
|
||||
<img src="{{ $playlist->thumbnail_url }}" alt="{{ $playlist->name }}">
|
||||
@else
|
||||
<span class="placeholder">
|
||||
<i class="bi bi-collection-play"></i>
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
<div class="playlist-info">
|
||||
<div class="playlist-name">{{ $playlist->name }}</div>
|
||||
<div class="playlist-meta">
|
||||
{{ $playlist->video_count }} videos • {{ $playlist->formatted_duration }}
|
||||
</div>
|
||||
@if($playlist->description)
|
||||
<div class="playlist-description">{{ $playlist->description }}</div>
|
||||
@endif
|
||||
</div>
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<!-- Create Playlist Modal - Modern YouTube Style -->
|
||||
<div id="createPlaylistModal" class="playlist-create-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; backdrop-filter: blur(2px);">
|
||||
<div class="playlist-create-modal-content" style="background: #282828; border-radius: 12px; width: 90%; max-width: 480px; box-shadow: 0 8px 32px rgba(0,0,0,0.5); overflow: hidden; animation: modalSlideIn 0.2s ease;">
|
||||
<!-- Header -->
|
||||
<div class="playlist-create-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;">Create new playlist</h2>
|
||||
<button type="button" id="closeCreatePlaylistModalBtn" 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>
|
||||
|
||||
<!-- Body -->
|
||||
<div style="padding: 24px;">
|
||||
<form id="createPlaylistForm" enctype="multipart/form-data">
|
||||
@csrf
|
||||
<!-- Thumbnail Upload -->
|
||||
<div class="form-group" style="margin-bottom: 20px;">
|
||||
<label style="display: block; margin-bottom: 8px; font-weight: 500; font-size: 14px; color: #fff;">Thumbnail</label>
|
||||
<div id="playlist-thumbnail-dropzone" class="playlist-thumbnail-dropzone" style="border: 2px dashed #3f3f3f; border-radius: 12px; padding: 20px; text-align: center; cursor: pointer; transition: all 0.2s; background: #1a1a1a;"
|
||||
onmouseover="this.style.borderColor='#e61e1e'; this.style.background='rgba(230,30,30,0.05)'"
|
||||
onmouseout="this.style.borderColor='#3f3f3f'; this.style.background='#1a1a1a'">
|
||||
<input type="file" name="thumbnail" id="playlist-thumbnail-input" accept="image/*" style="display: none;">
|
||||
<div id="playlist-thumbnail-default">
|
||||
<div style="font-size: 36px; color: #666; margin-bottom: 8px;">
|
||||
<i class="bi bi-card-image"></i>
|
||||
</div>
|
||||
<p style="color: #aaa; font-size: 13px; margin: 0 0 4px;">Click to upload thumbnail</p>
|
||||
<p style="color: #555; font-size: 11px; margin: 0;">JPG, PNG, GIF, WebP (max 5MB)</p>
|
||||
</div>
|
||||
<div id="playlist-thumbnail-preview" class="playlist-thumbnail-preview" style="display: none;">
|
||||
<img id="playlist-thumbnail-img" src="" alt="Thumbnail preview" style="max-width: 100%; max-height: 160px; border-radius: 8px; object-fit: cover;">
|
||||
<button type="button" onclick="removePlaylistThumbnail(event)" style="position: absolute; top: -8px; right: -8px; width: 24px; height: 24px; background: #e61e1e; color: white; border: none; border-radius: 50%; cursor: pointer; display: flex; align-items: center; justify-content: center; font-size: 14px;">
|
||||
<i class="bi bi-x"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Playlist Name -->
|
||||
<div class="form-group" style="margin-bottom: 20px;">
|
||||
<label style="display: block; margin-bottom: 8px; font-weight: 500; font-size: 14px; color: #fff;">Name *</label>
|
||||
<input type="text" name="name" required
|
||||
placeholder="Enter playlist name"
|
||||
style="width: 100%; padding: 12px 14px; border: 1px solid #3f3f3f; border-radius: 8px; background: #121212; color: #fff; font-size: 14px; outline: none; transition: border-color 0.2s; box-sizing: border-box;"
|
||||
onfocus="this.style.borderColor = '#e61e1e';"
|
||||
onblur="this.style.borderColor = '#3f3f3f';">
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div class="form-group" style="margin-bottom: 20px;">
|
||||
<label style="display: block; margin-bottom: 8px; font-weight: 500; font-size: 14px; color: #fff;">Description</label>
|
||||
<textarea name="description" rows="3"
|
||||
placeholder="Add a description (optional)"
|
||||
style="width: 100%; padding: 12px 14px; border: 1px solid #3f3f3f; border-radius: 8px; background: #121212; color: #fff; font-size: 14px; outline: none; transition: border-color 0.2s; resize: none; box-sizing: border-box;"
|
||||
onfocus="this.style.borderColor = '#e61e1e';"
|
||||
onblur="this.style.borderColor = '#3f3f3f';"></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Privacy -->
|
||||
<div class="form-group" style="margin-bottom: 24px;">
|
||||
<label style="display: flex; align-items: center; gap: 12px; cursor: pointer; padding: 10px 12px; border-radius: 8px; transition: background 0.2s;"
|
||||
onmouseover="this.style.background='#3f3f3f'"
|
||||
onmouseout="this.style.background='transparent'">
|
||||
<input type="checkbox" name="visibility" value="public"
|
||||
style="width: 20px; height: 20px; accent-color: #e61e1e; cursor: pointer;">
|
||||
<div style="flex: 1;">
|
||||
<span style="color: #fff; font-size: 14px; font-weight: 500;">Make playlist public</span>
|
||||
<div style="color: #aaa; font-size: 12px; margin-top: 2px;">Anyone can search for and view this playlist</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div style="display: flex; gap: 12px; justify-content: flex-end;">
|
||||
<button type="button" onclick="closeCreatePlaylistModal()"
|
||||
style="padding: 10px 20px; background: #3f3f3f; color: #fff; border: none; border-radius: 20px; cursor: pointer; font-size: 14px; font-weight: 500; transition: background 0.2s;"
|
||||
onmouseover="this.style.background='#555'"
|
||||
onmouseout="this.style.background='#3f3f3f'">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit"
|
||||
style="padding: 10px 24px; background: #e61e1e; color: #fff; border: none; border-radius: 20px; cursor: pointer; font-size: 14px; font-weight: 500; transition: background 0.2s;"
|
||||
onmouseover="this.style.background='#cc1a1a'"
|
||||
onmouseout="this.style.background='#e61e1e'">
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
|
||||
<style>
|
||||
@keyframes modalSlideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-20px) scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* Header button hover */
|
||||
#closeCreatePlaylistModalBtn:hover {
|
||||
background: #3f3f3f !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@section('scripts')
|
||||
<script>
|
||||
// Thumbnail upload handling
|
||||
const thumbnailDropzone = document.getElementById('playlist-thumbnail-dropzone');
|
||||
const thumbnailInput = document.getElementById('playlist-thumbnail-input');
|
||||
const thumbnailDefault = document.getElementById('playlist-thumbnail-default');
|
||||
const thumbnailPreview = document.getElementById('playlist-thumbnail-preview');
|
||||
const thumbnailImg = document.getElementById('playlist-thumbnail-img');
|
||||
|
||||
// Click to upload
|
||||
thumbnailDropzone.addEventListener('click', function(e) {
|
||||
if (e.target.closest('button')) return;
|
||||
thumbnailInput.click();
|
||||
});
|
||||
|
||||
// File input change
|
||||
thumbnailInput.addEventListener('change', function() {
|
||||
handleThumbnailSelect(this);
|
||||
});
|
||||
|
||||
// Drag and drop
|
||||
thumbnailDropzone.addEventListener('dragover', function(e) {
|
||||
e.preventDefault();
|
||||
this.style.borderColor = '#e61e1e';
|
||||
this.style.background = 'rgba(230,30,30,0.1)';
|
||||
});
|
||||
|
||||
thumbnailDropzone.addEventListener('dragleave', function(e) {
|
||||
e.preventDefault();
|
||||
this.style.borderColor = '#3f3f3f';
|
||||
this.style.background = '#1a1a1a';
|
||||
});
|
||||
|
||||
thumbnailDropzone.addEventListener('drop', function(e) {
|
||||
e.preventDefault();
|
||||
this.style.borderColor = '#3f3f3f';
|
||||
this.style.background = '#1a1a1a';
|
||||
|
||||
if (e.dataTransfer.files.length) {
|
||||
thumbnailInput.files = e.dataTransfer.files;
|
||||
handleThumbnailSelect(thumbnailInput);
|
||||
}
|
||||
});
|
||||
|
||||
function handleThumbnailSelect(input) {
|
||||
if (input.files && input.files[0]) {
|
||||
const file = input.files[0];
|
||||
const maxSize = 5 * 1024 * 1024; // 5MB
|
||||
|
||||
// Validate file type
|
||||
const validTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
|
||||
const validExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp'];
|
||||
|
||||
let isValidType = validTypes.includes(file.type);
|
||||
let fileExtension = '.' + file.name.split('.').pop().toLowerCase();
|
||||
if (!isValidType) {
|
||||
isValidType = validExtensions.includes(fileExtension);
|
||||
}
|
||||
|
||||
if (!isValidType) {
|
||||
showPlaylistToast('Invalid image format. Please select JPG, PNG, GIF, or WebP.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.size > maxSize) {
|
||||
showPlaylistToast('File size exceeds 5MB limit. Please select a smaller image.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Show preview
|
||||
const reader = new FileReader();
|
||||
reader.onload = function(e) {
|
||||
thumbnailImg.src = e.target.result;
|
||||
thumbnailDefault.style.display = 'none';
|
||||
thumbnailPreview.style.display = 'block';
|
||||
thumbnailPreview.style.position = 'relative';
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
}
|
||||
|
||||
function removePlaylistThumbnail(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
thumbnailInput.value = '';
|
||||
thumbnailDefault.style.display = 'block';
|
||||
thumbnailPreview.style.display = 'none';
|
||||
thumbnailImg.src = '';
|
||||
}
|
||||
|
||||
function openCreatePlaylistModal() {
|
||||
document.getElementById('createPlaylistModal').style.display = 'flex';
|
||||
}
|
||||
|
||||
function closeCreatePlaylistModal() {
|
||||
document.getElementById('createPlaylistModal').style.display = 'none';
|
||||
document.getElementById('createPlaylistForm').reset();
|
||||
// Reset thumbnail
|
||||
removePlaylistThumbnail({ preventDefault: function(){}, stopPropagation: function(){} });
|
||||
}
|
||||
|
||||
// Close button event listener
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const closeBtn = document.getElementById('closeCreatePlaylistModalBtn');
|
||||
if (closeBtn) {
|
||||
closeBtn.addEventListener('click', function(e) {
|
||||
e.stopPropagation();
|
||||
closeCreatePlaylistModal();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Close modal when clicking outside
|
||||
document.getElementById('createPlaylistModal').addEventListener('click', function(e) {
|
||||
if (e.target === this) {
|
||||
closeCreatePlaylistModal();
|
||||
}
|
||||
});
|
||||
|
||||
// Close on escape key
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape') {
|
||||
const modal = document.getElementById('createPlaylistModal');
|
||||
if (modal && modal.style.display === 'flex') {
|
||||
closeCreatePlaylistModal();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Create playlist form submission
|
||||
document.getElementById('createPlaylistForm').addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const formData = new FormData(this);
|
||||
|
||||
fetch('{{ route("playlists.store") }}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}',
|
||||
'Accept': 'application/json'
|
||||
},
|
||||
body: formData
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
closeCreatePlaylistModal();
|
||||
window.location.href = '{{ route("playlists.show", "") }}/' + data.playlist.id;
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
showPlaylistToast('Failed to create playlist. Please try again.', 'error');
|
||||
});
|
||||
});
|
||||
|
||||
// Toast notification function
|
||||
function showPlaylistToast(message, type = 'success') {
|
||||
// Remove existing toast if any
|
||||
const existing = document.querySelector('.playlist-create-toast');
|
||||
if (existing) existing.remove();
|
||||
|
||||
const toast = document.createElement('div');
|
||||
toast.className = 'playlist-create-toast';
|
||||
|
||||
const bgColor = type === 'error' ? 'rgba(239, 68, 68, 0.9)' : '#282828';
|
||||
const borderColor = type === 'error' ? '#ef4444' : '#4ade80';
|
||||
const icon = type === 'error' ? 'bi-exclamation-circle-fill' : 'bi-check-circle-fill';
|
||||
const iconColor = type === 'error' ? '#ef4444' : '#4ade80';
|
||||
|
||||
toast.style.cssText = `
|
||||
position: fixed;
|
||||
bottom: 80px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: ${bgColor};
|
||||
color: #fff;
|
||||
padding: 14px 24px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
z-index: 10000;
|
||||
animation: toastSlideUp 0.3s ease;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.4);
|
||||
max-width: 90%;
|
||||
text-align: center;
|
||||
border-left: 4px solid ${borderColor};
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
`;
|
||||
|
||||
toast.innerHTML = `
|
||||
<i class="bi ${icon}" style="color: ${iconColor}; font-size: 18px;"></i>
|
||||
<span>${message}</span>
|
||||
`;
|
||||
document.body.appendChild(toast);
|
||||
|
||||
setTimeout(() => {
|
||||
toast.style.animation = 'toastSlideDown 0.3s ease';
|
||||
setTimeout(() => toast.remove(), 300);
|
||||
}, 3000);
|
||||
}
|
||||
</script>
|
||||
@endsection
|
||||
|
||||
<style>
|
||||
@keyframes toastSlideUp {
|
||||
from { opacity: 0; transform: translateX(-50%) translateY(20px); }
|
||||
to { opacity: 1; transform: translateX(-50%) translateY(0); }
|
||||
}
|
||||
@keyframes toastSlideDown {
|
||||
from { opacity: 1; transform: translateX(-50%) translateY(0); }
|
||||
to { opacity: 0; transform: translateX(-50%) translateY(20px); }
|
||||
}
|
||||
</style>
|
||||
@ -1,838 +0,0 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', $playlist->name . ' | ' . config('app.name'))
|
||||
|
||||
@section('extra_styles')
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/sortablejs@1.15.0/Sortable.min.css">
|
||||
<style>
|
||||
.playlist-header {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
margin-bottom: 24px;
|
||||
padding: 24px;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.playlist-thumbnail {
|
||||
width: 240px;
|
||||
height: 135px;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
background: #333;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.playlist-thumbnail img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.playlist-thumbnail .placeholder {
|
||||
font-size: 48px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.playlist-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.playlist-title {
|
||||
font-size: 24px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.playlist-meta {
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.playlist-description {
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
margin-bottom: 16px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.playlist-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.yt-play-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 24px;
|
||||
background: var(--brand-red);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 20px;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.yt-play-btn:hover {
|
||||
background: #cc1a1a;
|
||||
}
|
||||
|
||||
.yt-shuffle-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 24px;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 20px;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.yt-shuffle-btn:hover {
|
||||
background: var(--border-color);
|
||||
}
|
||||
|
||||
.playlist-video-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.playlist-video-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
background: var(--bg-secondary);
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.playlist-video-item:hover {
|
||||
background: var(--border-color);
|
||||
}
|
||||
|
||||
.video-position {
|
||||
width: 32px;
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.video-position.with-drag-handle {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.video-thumb {
|
||||
width: 160px;
|
||||
height: 90px;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: #333;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.video-thumb img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.video-duration {
|
||||
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;
|
||||
}
|
||||
|
||||
.video-details {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.video-title {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 4px;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.video-title a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.video-meta {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.video-channel {
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.video-channel:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.video-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.remove-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
padding: 8px;
|
||||
border-radius: 50%;
|
||||
transition: background 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
.remove-btn:hover {
|
||||
background: var(--brand-red);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.drag-handle {
|
||||
cursor: grab;
|
||||
color: var(--text-secondary);
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.drag-handle:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.drag-handle i {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* Drag and drop styles */
|
||||
.playlist-video-list.sortable-ghost {
|
||||
opacity: 0.4;
|
||||
background: var(--border-color);
|
||||
}
|
||||
|
||||
.playlist-video-list.sortable-drag {
|
||||
background: var(--bg-secondary);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.playlist-video-item.sortable-chosen {
|
||||
background: var(--border-color);
|
||||
}
|
||||
|
||||
.playlist-video-item.dragging {
|
||||
cursor: grabbing !important;
|
||||
}
|
||||
|
||||
.drag-overlay {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 64px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.empty-title {
|
||||
font-size: 20px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.playlist-header {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.playlist-thumbnail {
|
||||
width: 100%;
|
||||
height: 180px;
|
||||
}
|
||||
|
||||
.video-thumb {
|
||||
width: 120px;
|
||||
height: 68px;
|
||||
}
|
||||
|
||||
.playlist-video-item {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.video-position {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@endsection
|
||||
|
||||
@section('content')
|
||||
<div class="playlist-header">
|
||||
<div class="playlist-thumbnail">
|
||||
@if($playlist->thumbnail_url)
|
||||
<img src="{{ $playlist->thumbnail_url }}" alt="{{ $playlist->name }}">
|
||||
@else
|
||||
<span class="placeholder">
|
||||
<i class="bi bi-collection-play"></i>
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
<div class="playlist-info">
|
||||
<h1 class="playlist-title">{{ $playlist->name }}</h1>
|
||||
<div class="playlist-meta">
|
||||
{{ $playlist->video_count }} videos • {{ $playlist->formatted_duration }} •
|
||||
{{ $playlist->visibility === 'public' ? 'Public' : 'Private' }}
|
||||
@if($playlist->is_default)
|
||||
<span style="background: var(--brand-red); color: white; padding: 2px 8px; border-radius: 4px; font-size: 12px; margin-left: 8px;">Watch Later</span>
|
||||
@endif
|
||||
</div>
|
||||
@if($playlist->description)
|
||||
<div class="playlist-description">{{ $playlist->description }}</div>
|
||||
@endif
|
||||
<div class="playlist-actions">
|
||||
@if($playlist->video_count > 0)
|
||||
<a href="{{ route('playlists.playAll', $playlist->id) }}" class="yt-play-btn">
|
||||
<i class="bi bi-play-fill"></i> Play All
|
||||
</a>
|
||||
<a href="{{ route('playlists.shuffle', $playlist->id) }}" class="yt-shuffle-btn">
|
||||
<i class="bi bi-shuffle"></i> Shuffle
|
||||
</a>
|
||||
@endif
|
||||
@if($playlist->canEdit(Auth::user()))
|
||||
<button class="yt-shuffle-btn" onclick="openEditPlaylistModal()">
|
||||
<i class="bi bi-pencil"></i> Edit
|
||||
</button>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if($videos->isEmpty())
|
||||
<div class="empty-state">
|
||||
<i class="bi bi-collection-play empty-icon"></i>
|
||||
<h2 class="empty-title">No videos in this playlist</h2>
|
||||
<p class="empty-text">Add videos to this playlist to watch them here.</p>
|
||||
</div>
|
||||
@else
|
||||
<div class="playlist-video-list" id="playlistVideoList" data-current-page="{{ $videos->currentPage() }}" data-per-page="{{ $videos->perPage() }}">
|
||||
@foreach($videos as $index => $video)
|
||||
<div class="playlist-video-item{{ $playlist->canEdit(Auth::user()) ? ' has-drag-handle' : '' }}" data-video-id="{{ $video->id }}" data-global-position="{{ $index + ($videos->currentPage() - 1) * $videos->perPage() }}">
|
||||
@if($playlist->canEdit(Auth::user()))
|
||||
<div class="drag-handle" title="Drag to reorder">
|
||||
<i class="bi bi-grip-vertical"></i>
|
||||
</div>
|
||||
@endif
|
||||
<div class="video-position{{ !$playlist->canEdit(Auth::user()) ? '' : ' with-drag-handle' }}">{{ $index + 1 + ($videos->currentPage() - 1) * $videos->perPage() }}</div>
|
||||
<a href="{{ route('videos.show', $video->id) }}?playlist={{ $playlist->id }}" class="video-thumb">
|
||||
<img src="{{ $video->thumbnail_url }}" alt="{{ $video->title }}">
|
||||
<span class="video-duration">{{ $video->formatted_duration }}</span>
|
||||
</a>
|
||||
<div class="video-details">
|
||||
<div class="video-title">
|
||||
<a href="{{ route('videos.show', $video->id) }}?playlist={{ $playlist->id }}">{{ $video->title }}</a>
|
||||
</div>
|
||||
<div class="video-meta">
|
||||
<a href="{{ route('channel', $video->user_id) }}" class="video-channel">{{ $video->user->name }}</a>
|
||||
• {{ $video->view_count }} views
|
||||
• {{ $video->created_at->diffForHumans() }}
|
||||
</div>
|
||||
</div>
|
||||
@if($playlist->canEdit(Auth::user()))
|
||||
<div class="video-actions">
|
||||
<form action="{{ route('playlists.removeVideo', [$playlist->id, $video->id]) }}" method="POST">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<button type="submit" class="remove-btn" title="Remove from playlist">
|
||||
<i class="bi bi-x-lg"></i>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 24px;">
|
||||
{{ $videos->links() }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<!-- SortableJS Library -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.0/Sortable.min.js"></script>
|
||||
|
||||
<!-- Edit Playlist Modal -->
|
||||
@if($playlist->canEdit(Auth::user()))
|
||||
<div id="editPlaylistModal" class="edit-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: 2000; align-items: center; justify-content: center;">
|
||||
<div class="edit-playlist-modal-content" style="background: #282828; border-radius: 16px; width: 90%; max-width: 480px; display: flex; flex-direction: column; box-shadow: 0 12px 48px rgba(0,0,0,0.6); overflow: hidden; animation: modalSlideIn 0.3s ease;">
|
||||
<!-- Header -->
|
||||
<div class="edit-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: 20px; font-weight: 600; margin: 0; color: #fff;">Edit Details</h2>
|
||||
<button type="button" onclick="closeEditPlaylistModal()" style="background: transparent; border: none; color: #aaa; cursor: pointer; font-size: 24px; padding: 4px; line-height: 1; border-radius: 50%; width: 36px; height: 36px; display: flex; align-items: center; justify-content: center; transition: all 0.2s;">
|
||||
<i class="bi bi-x-lg"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div style="padding: 24px; flex: 1; overflow-y: auto;">
|
||||
<!-- Thumbnail Upload -->
|
||||
<div style="margin-bottom: 24px;">
|
||||
<label style="display: block; margin-bottom: 12px; font-weight: 500; color: #fff; font-size: 14px;">Thumbnail</label>
|
||||
<div id="playlistThumbnailContainer" style="position: relative; width: 100%; aspect-ratio: 16/9; max-width: 240px; border-radius: 12px; overflow: hidden; background: #1a1a1a; cursor: pointer; transition: all 0.2s; margin: 0 auto;" onclick="document.getElementById('playlistThumbnailInput').click()">
|
||||
@if($playlist->thumbnail_url && strpos($playlist->thumbnail_url, 'ui-avatars.com') === false)
|
||||
<img id="playlistThumbnailPreview" src="{{ $playlist->thumbnail_url }}" style="width: 100%; height: 100%; object-fit: cover;">
|
||||
@else
|
||||
<img id="playlistThumbnailPreview" src="" style="width: 100%; height: 100%; object-fit: cover; display: none;">
|
||||
@endif
|
||||
<div id="playlistThumbnailPlaceholder" style="position: absolute; top: 0; left: 0; right: 0; bottom: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; background: #1a1a1a; @if($playlist->thumbnail_url && strpos($playlist->thumbnail_url, 'ui-avatars.com') === false)display: none;@endif">
|
||||
<i class="bi bi-image" style="font-size: 32px; color: #555; margin-bottom: 8px;"></i>
|
||||
<span style="font-size: 12px; color: #777;">Upload thumbnail</span>
|
||||
</div>
|
||||
<div style="position: absolute; bottom: 8px; right: 8px; background: rgba(0,0,0,0.7); padding: 6px 10px; border-radius: 6px; display: flex; align-items: center; gap: 4px;">
|
||||
<i class="bi bi-camera" style="color: white; font-size: 14px;"></i>
|
||||
<span style="color: white; font-size: 12px;">Change</span>
|
||||
</div>
|
||||
</div>
|
||||
<input type="file" id="playlistThumbnailInput" name="thumbnail" accept="image/*" style="display: none;" onchange="handlePlaylistThumbnailUpload(this)">
|
||||
@if($playlist->thumbnail_url && strpos($playlist->thumbnail_url, 'ui-avatars.com') === false)
|
||||
<button type="button" onclick="removePlaylistThumbnail(event)" style="display: block; margin: 12px auto 0; background: transparent; border: 1px solid #555; color: #aaa; padding: 6px 16px; border-radius: 18px; cursor: pointer; font-size: 12px; transition: all 0.2s;">Remove thumbnail</button>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<!-- Name Input -->
|
||||
<div class="form-group" style="margin-bottom: 20px;">
|
||||
<label style="display: block; margin-bottom: 8px; font-weight: 500; color: #fff; font-size: 14px;">Name</label>
|
||||
<input type="text" name="name" value="{{ $playlist->name }}" required
|
||||
style="width: 100%; padding: 14px 16px; border: 1px solid #3f3f3f; border-radius: 10px; background: #1a1a1a; color: #fff; font-size: 14px; outline: none; transition: all 0.2s;">
|
||||
</div>
|
||||
|
||||
<!-- Description Input -->
|
||||
<div class="form-group" style="margin-bottom: 20px;">
|
||||
<label style="display: block; margin-bottom: 8px; font-weight: 500; color: #fff; font-size: 14px;">Description</label>
|
||||
<textarea name="description" class="form-control" rows="3" placeholder="Tell viewers about your playlist"
|
||||
style="width: 100%; padding: 14px 16px; border: 1px solid #3f3f3f; border-radius: 10px; background: #1a1a1a; color: #fff; font-size: 14px; outline: none; transition: all 0.2s; resize: none;">{{ $playlist->description }}</textarea>
|
||||
</div>
|
||||
|
||||
<!-- Visibility Toggle -->
|
||||
<div class="form-group" style="margin-bottom: 20px;">
|
||||
<label style="display: flex; align-items: center; gap: 12px; cursor: pointer; padding: 12px 16px; background: #1a1a1a; border-radius: 10px; border: 1px solid #3f3f3f; transition: all 0.2s;">
|
||||
<input type="checkbox" name="visibility" value="public" {{ $playlist->visibility === 'public' ? 'checked' : '' }}
|
||||
style="width: 20px; height: 20px; accent-color: #e61e1e; cursor: pointer;">
|
||||
<div style="flex: 1;">
|
||||
<span style="color: #fff; font-size: 14px; font-weight: 500;">Make playlist public</span>
|
||||
<p style="margin: 2px 0 0; font-size: 12px; color: #777;">Anyone can search for and view this playlist</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div style="padding: 16px 24px; border-top: 1px solid #3f3f3f; display: flex; gap: 12px; justify-content: space-between;">
|
||||
@if(!$playlist->is_default)
|
||||
<button type="button" onclick="deletePlaylist()" style="background: transparent; border: 1px solid #e61e1e; color: #e61e1e; padding: 12px 20px; border-radius: 20px; cursor: pointer; font-size: 14px; font-weight: 500; transition: all 0.2s;">
|
||||
<i class="bi bi-trash" style="margin-right: 6px;"></i>Delete
|
||||
</button>
|
||||
@else
|
||||
<div></div>
|
||||
@endif
|
||||
<div style="display: flex; gap: 12px;">
|
||||
<button type="button" onclick="closeEditPlaylistModal()" style="background: #3f3f3f; border: none; color: #fff; padding: 12px 20px; border-radius: 20px; cursor: pointer; font-size: 14px; font-weight: 500; transition: all 0.2s;">Cancel</button>
|
||||
<button type="submit" form="editPlaylistForm" style="background: #e61e1e; border: none; color: #fff; padding: 12px 24px; border-radius: 20px; cursor: pointer; font-size: 14px; font-weight: 600; transition: all 0.2s;">
|
||||
<i class="bi bi-check-lg" style="margin-right: 4px;"></i>Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form id="editPlaylistForm" method="POST" action="{{ route('playlists.update', $playlist->id) }}" enctype="multipart/form-data" style="display: none;">
|
||||
@csrf
|
||||
@method('PUT')
|
||||
<input type="hidden" name="name" value="{{ $playlist->name }}">
|
||||
<input type="hidden" name="description" value="{{ $playlist->description }}">
|
||||
<input type="hidden" name="visibility" value="{{ $playlist->visibility }}">
|
||||
<input type="file" name="thumbnail" id="editPlaylistThumbnailFile" accept="image/*">
|
||||
</form>
|
||||
|
||||
<script>
|
||||
// Open Edit Playlist Modal
|
||||
function openEditPlaylistModal() {
|
||||
const modal = document.getElementById('editPlaylistModal');
|
||||
modal.style.display = 'flex';
|
||||
|
||||
// Sync form values
|
||||
const nameInput = modal.querySelector('input[name="name"]');
|
||||
const descInput = modal.querySelector('textarea[name="description"]');
|
||||
const visInput = modal.querySelector('input[name="visibility"]');
|
||||
|
||||
if (nameInput) nameInput.value = '{{ $playlist->name }}';
|
||||
if (descInput) descInput.value = '{{ $playlist->description }}';
|
||||
}
|
||||
|
||||
// Close Edit Playlist Modal
|
||||
function closeEditPlaylistModal() {
|
||||
const modal = document.getElementById('editPlaylistModal');
|
||||
modal.style.display = 'none';
|
||||
}
|
||||
|
||||
// Handle Thumbnail Upload
|
||||
function handlePlaylistThumbnailUpload(input) {
|
||||
if (input.files && input.files[0]) {
|
||||
const file = input.files[0];
|
||||
|
||||
// Validate file type
|
||||
if (!file.type.startsWith('image/')) {
|
||||
alert('Please select an image file');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate file size (max 5MB)
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
alert('Image size must be less than 5MB');
|
||||
return;
|
||||
}
|
||||
|
||||
// Preview image
|
||||
const reader = new FileReader();
|
||||
reader.onload = function(e) {
|
||||
const preview = document.getElementById('playlistThumbnailPreview');
|
||||
const placeholder = document.getElementById('playlistThumbnailPlaceholder');
|
||||
|
||||
preview.src = e.target.result;
|
||||
preview.style.display = 'block';
|
||||
if (placeholder) placeholder.style.display = 'none';
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
|
||||
// Copy file to hidden form input
|
||||
const formFileInput = document.getElementById('editPlaylistThumbnailFile');
|
||||
const dataTransfer = new DataTransfer();
|
||||
dataTransfer.items.add(file);
|
||||
formFileInput.files = dataTransfer.files;
|
||||
}
|
||||
}
|
||||
|
||||
// Remove Thumbnail
|
||||
function removePlaylistThumbnail(event) {
|
||||
event.stopPropagation();
|
||||
|
||||
if (confirm('Are you sure you want to remove the thumbnail?')) {
|
||||
// Show placeholder
|
||||
const preview = document.getElementById('playlistThumbnailPreview');
|
||||
const placeholder = document.getElementById('playlistThumbnailPlaceholder');
|
||||
|
||||
preview.src = '';
|
||||
preview.style.display = 'none';
|
||||
if (placeholder) placeholder.style.display = 'flex';
|
||||
|
||||
// Clear file inputs
|
||||
document.getElementById('playlistThumbnailInput').value = '';
|
||||
document.getElementById('editPlaylistThumbnailFile').value = '';
|
||||
|
||||
// Add hidden input to indicate thumbnail removal
|
||||
const form = document.getElementById('editPlaylistForm');
|
||||
let removeInput = form.querySelector('input[name="remove_thumbnail"]');
|
||||
if (!removeInput) {
|
||||
removeInput = document.createElement('input');
|
||||
removeInput.type = 'hidden';
|
||||
removeInput.name = 'remove_thumbnail';
|
||||
removeInput.value = '1';
|
||||
form.appendChild(removeInput);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Submit Edit Playlist Form
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const editForm = document.getElementById('editPlaylistForm');
|
||||
if (editForm) {
|
||||
editForm.addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
// Sync visible form values to hidden form
|
||||
const modal = document.getElementById('editPlaylistModal');
|
||||
const visibleName = modal.querySelector('input[name="name"]');
|
||||
const visibleDesc = modal.querySelector('textarea[name="description"]');
|
||||
const visibleVis = modal.querySelector('input[name="visibility"]');
|
||||
|
||||
const hiddenName = editForm.querySelector('input[name="name"]');
|
||||
const hiddenDesc = editForm.querySelector('input[name="description"]');
|
||||
const hiddenVis = editForm.querySelector('input[name="visibility"]');
|
||||
|
||||
if (hiddenName) hiddenName.value = visibleName ? visibleName.value : '';
|
||||
if (hiddenDesc) hiddenDesc.value = visibleDesc ? visibleDesc.value : '';
|
||||
if (hiddenVis) hiddenVis.value = visibleVis.checked ? 'public' : 'private';
|
||||
|
||||
// Submit the form via AJAX
|
||||
const formData = new FormData(editForm);
|
||||
|
||||
fetch(editForm.action, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}',
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
showToast('Playlist updated!');
|
||||
closeEditPlaylistModal();
|
||||
// Reload page to show updated info
|
||||
window.location.reload();
|
||||
} else {
|
||||
alert(data.message || 'Failed to update playlist');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
alert('Failed to update playlist');
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Simple toast notification
|
||||
function showToast(message) {
|
||||
const existing = document.querySelector('.edit-playlist-toast');
|
||||
if (existing) existing.remove();
|
||||
|
||||
const toast = document.createElement('div');
|
||||
toast.className = 'edit-playlist-toast';
|
||||
toast.style.cssText = `
|
||||
position: fixed;
|
||||
bottom: 80px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: #fff;
|
||||
color: #0f0f0f;
|
||||
padding: 14px 28px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
z-index: 10001;
|
||||
animation: fadeInUp 0.3s ease;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.4);
|
||||
max-width: 90%;
|
||||
text-align: center;
|
||||
`;
|
||||
toast.textContent = message;
|
||||
document.body.appendChild(toast);
|
||||
|
||||
setTimeout(() => {
|
||||
toast.style.animation = 'fadeOutDown 0.3s ease';
|
||||
setTimeout(() => toast.remove(), 300);
|
||||
}, 2500);
|
||||
}
|
||||
|
||||
// Initialize Sortable for drag and drop reordering
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const videoList = document.getElementById('playlistVideoList');
|
||||
|
||||
if (videoList && typeof Sortable !== 'undefined') {
|
||||
new Sortable(videoList, {
|
||||
handle: '.drag-handle',
|
||||
animation: 150,
|
||||
ghostClass: 'sortable-ghost',
|
||||
chosenClass: 'sortable-chosen',
|
||||
dragClass: 'sortable-drag',
|
||||
easing: 'cubic-bezier(1, 0, 0, 1)',
|
||||
delay: 0,
|
||||
forceFallback: false,
|
||||
|
||||
onStart: function(evt) {
|
||||
evt.item.classList.add('dragging');
|
||||
},
|
||||
|
||||
onEnd: function(evt) {
|
||||
evt.item.classList.remove('dragging');
|
||||
|
||||
// Get the new order of video IDs with their global positions
|
||||
const items = videoList.querySelectorAll('.playlist-video-item');
|
||||
|
||||
// Collect video IDs in new order
|
||||
const videoIds = Array.from(items).map(item => parseInt(item.dataset.videoId));
|
||||
|
||||
// Get global starting position from the first item
|
||||
const firstItem = items[0];
|
||||
const startPosition = firstItem ? parseInt(firstItem.dataset.globalPosition) : 0;
|
||||
|
||||
// Update position numbers visually (relative to current page)
|
||||
items.forEach((item, index) => {
|
||||
const positionEl = item.querySelector('.video-position');
|
||||
if (positionEl) {
|
||||
positionEl.textContent = index + 1;
|
||||
}
|
||||
// Update global position data attribute for future reorders
|
||||
item.dataset.globalPosition = startPosition + index;
|
||||
});
|
||||
|
||||
// Send AJAX request to save the new order
|
||||
fetch('{{ route("playlists.reorder", $playlist->id) }}', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}',
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ video_ids: videoIds })
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
console.log('Playlist reordered successfully');
|
||||
} else {
|
||||
console.error('Failed to reorder playlist:', data.message);
|
||||
// Optionally reload to restore original order on error
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error reordering playlist:', error);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
function deletePlaylist() {
|
||||
if (confirm('Are you sure you want to delete this playlist?')) {
|
||||
fetch('{{ route("playlists.destroy", $playlist->id) }}', {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}',
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
window.location.href = '{{ route("playlists.index") }}';
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Close modal when clicking outside
|
||||
document.getElementById('editPlaylistModal').addEventListener('click', function(e) {
|
||||
if (e.target === this) {
|
||||
closeEditPlaylistModal();
|
||||
}
|
||||
});
|
||||
|
||||
// Close on escape key
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape') {
|
||||
const modal = document.getElementById('editPlaylistModal');
|
||||
if (modal && modal.style.display === 'flex') {
|
||||
closeEditPlaylistModal();
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* Toast animations */
|
||||
@keyframes fadeInUp {
|
||||
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); }
|
||||
}
|
||||
|
||||
/* Modal animations */
|
||||
@keyframes modalSlideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Modal overlay */
|
||||
.edit-playlist-modal-overlay {
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
/* Header button hover */
|
||||
.edit-playlist-modal-header button:hover {
|
||||
background: #3f3f3f !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
/* Input focus states */
|
||||
.edit-playlist-modal-content input:focus,
|
||||
.edit-playlist-modal-content textarea:focus {
|
||||
border-color: #e61e1e !important;
|
||||
box-shadow: 0 0 0 2px rgba(230, 30, 30, 0.2);
|
||||
}
|
||||
|
||||
/* Thumbnail container hover */
|
||||
#playlistThumbnailContainer:hover {
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
/* Button hover effects */
|
||||
.edit-playlist-modal-footer button:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* Delete button hover */
|
||||
button[onclick="deletePlaylist()"]:hover {
|
||||
background: #cc1a1a !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
/* Save button hover */
|
||||
button[type="submit"][form="editPlaylistForm"]:hover {
|
||||
background: #cc1a1a !important;
|
||||
}
|
||||
</style>
|
||||
@endif
|
||||
@endsection
|
||||
|
||||
@ -93,7 +93,7 @@
|
||||
position: absolute;
|
||||
bottom: 8px;
|
||||
right: 8px;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
background: rgba(0,0,0,0.8);
|
||||
color: white;
|
||||
padding: 3px 6px;
|
||||
border-radius: 4px;
|
||||
@ -137,8 +137,7 @@
|
||||
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;
|
||||
@ -193,9 +192,7 @@
|
||||
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 {
|
||||
@ -292,8 +289,7 @@
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.yt-channel-name,
|
||||
.yt-video-meta {
|
||||
.yt-channel-name, .yt-video-meta {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@ -502,13 +498,8 @@
|
||||
}
|
||||
|
||||
@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 */
|
||||
@ -527,7 +518,7 @@
|
||||
@section('content')
|
||||
<div class="channel-header">
|
||||
<div class="d-flex align-items-center gap-4 flex-wrap">
|
||||
@if ($user->avatar)
|
||||
@if($user->avatar)
|
||||
<img src="{{ asset('storage/avatars/' . $user->avatar) }}" alt="{{ $user->name }}" class="channel-avatar">
|
||||
@else
|
||||
<img src="https://i.pravatar.cc/150?u={{ $user->id }}" alt="{{ $user->name }}" class="channel-avatar">
|
||||
@ -539,18 +530,17 @@
|
||||
|
||||
<div class="channel-stats">
|
||||
<div>
|
||||
<span class="channel-stat-value">{{ $videos->count() }}</span>
|
||||
<span class="channel-stat-value">{{ $videos->total() }}</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>
|
||||
|
||||
@auth
|
||||
@if (Auth::user()->id === $user->id)
|
||||
@if(Auth::user()->id === $user->id)
|
||||
<div class="channel-actions">
|
||||
<a href="{{ route('videos.create') }}" class="yt-upload-btn">
|
||||
<i class="bi bi-plus-lg"></i> <span>Upload Video</span>
|
||||
@ -565,55 +555,13 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Playlists Section - Only show for own channel -->
|
||||
@auth
|
||||
@if (Auth::user()->id === $user->id && $playlists && $playlists->count() > 0)
|
||||
<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;">
|
||||
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;">
|
||||
@if ($playlist->thumbnail_url)
|
||||
<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);">
|
||||
<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;">
|
||||
{{ $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;">
|
||||
{{ $playlist->name }}
|
||||
</div>
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
@endauth
|
||||
|
||||
@if ($videos->isEmpty())
|
||||
@if($videos->isEmpty())
|
||||
<div class="yt-empty">
|
||||
<i class="bi bi-camera-video yt-empty-icon"></i>
|
||||
<h2 class="yt-empty-title">No videos yet</h2>
|
||||
<p class="yt-empty-text">This channel hasn't uploaded any videos.</p>
|
||||
@auth
|
||||
@if (Auth::user()->id === $user->id)
|
||||
@if(Auth::user()->id === $user->id)
|
||||
<a href="{{ route('videos.create') }}" class="yt-upload-btn">
|
||||
<i class="bi bi-plus-lg"></i> Upload First Video
|
||||
</a>
|
||||
@ -622,22 +570,22 @@
|
||||
</div>
|
||||
@else
|
||||
<div class="yt-video-grid">
|
||||
@foreach ($videos as $video)
|
||||
@foreach($videos as $video)
|
||||
<x-video-card :video="$video" size="small" />
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
{{-- Pagination hidden on all screens --}}
|
||||
<div class="mt-4">{{ $videos->links() }}</div>
|
||||
@endif
|
||||
|
||||
@include('layouts.partials.share-modal')
|
||||
@endsection
|
||||
|
||||
@section('scripts')
|
||||
<script>
|
||||
let currentPlayingVideo = null;
|
||||
<script>
|
||||
let currentPlayingVideo = null;
|
||||
|
||||
function playVideo(card) {
|
||||
function playVideo(card) {
|
||||
// Skip on touch devices - handled by touch events
|
||||
if ('ontouchstart' in window) return;
|
||||
|
||||
@ -651,29 +599,29 @@
|
||||
video.classList.add('active');
|
||||
currentPlayingVideo = card;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function stopVideo(card) {
|
||||
function stopVideo(card) {
|
||||
const video = card.querySelector('video');
|
||||
if (video) {
|
||||
video.pause();
|
||||
video.currentTime = 0;
|
||||
video.classList.remove('active');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Desktop hover behavior
|
||||
document.querySelectorAll('.yt-video-card').forEach(function(card) {
|
||||
// Desktop hover behavior
|
||||
document.querySelectorAll('.yt-video-card').forEach(function(card) {
|
||||
card.addEventListener('mouseenter', function() {
|
||||
playVideo(this);
|
||||
});
|
||||
card.addEventListener('mouseleave', function() {
|
||||
stopVideo(this);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Mobile touch support with tap-to-play/pause
|
||||
document.addEventListener('touchstart', function(e) {
|
||||
// Mobile touch support with tap-to-play/pause
|
||||
document.addEventListener('touchstart', function(e) {
|
||||
const card = e.target.closest('.yt-video-card');
|
||||
if (card) {
|
||||
// Stop currently playing video if different
|
||||
@ -698,12 +646,10 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}, {
|
||||
passive: true
|
||||
});
|
||||
}, { passive: true });
|
||||
|
||||
// Stop video when tapping outside
|
||||
document.addEventListener('touchstart', function(e) {
|
||||
// Stop video when tapping outside
|
||||
document.addEventListener('touchstart', function(e) {
|
||||
if (!e.target.closest('.yt-video-card')) {
|
||||
if (currentPlayingVideo) {
|
||||
stopVideo(currentPlayingVideo);
|
||||
@ -711,9 +657,9 @@
|
||||
currentPlayingVideo = null;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function deleteVideo(videoId, videoTitle) {
|
||||
function deleteVideo(videoId, videoTitle) {
|
||||
if (confirm('Are you sure you want to delete "' + videoTitle + '"? This action cannot be undone.')) {
|
||||
fetch(`/videos/${videoId}`, {
|
||||
method: 'DELETE',
|
||||
@ -736,64 +682,55 @@
|
||||
alert('Failed to delete video. Please try again.');
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Smooth scroll for pagination on mobile
|
||||
document.querySelectorAll('.pagination a').forEach(function(link) {
|
||||
// Smooth scroll for pagination on mobile
|
||||
document.querySelectorAll('.pagination a').forEach(function(link) {
|
||||
link.addEventListener('click', function(e) {
|
||||
// Add loading indicator
|
||||
document.querySelector('.yt-video-grid').style.opacity = '0.5';
|
||||
});
|
||||
});
|
||||
</script>
|
||||
});
|
||||
</script>
|
||||
@endsection
|
||||
|
||||
|
||||
<!-- Extra Mobile Styles -->
|
||||
<style>
|
||||
<!-- Extra Mobile Styles -->
|
||||
<style>
|
||||
@media (max-width: 480px) {
|
||||
.channel-header {
|
||||
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;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@ -4,187 +4,193 @@
|
||||
|
||||
@section('extra_styles')
|
||||
<style>
|
||||
.profile-header { background: var(--bg-secondary); border-radius: 12px; padding: 32px; margin-bottom: 24px; position: relative; overflow: hidden; }
|
||||
.profile-banner { position: absolute; top: 0; left: 0; right: 0; height: 120px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); z-index: 0; }
|
||||
.profile-content { position: relative; z-index: 1; margin-top: 60px; }
|
||||
.profile-avatar { width: 120px; height: 120px; border-radius: 50%; object-fit: cover; border: 4px solid var(--bg-secondary); margin-bottom: 16px; }
|
||||
.profile-name { font-size: 28px; font-weight: 600; margin-bottom: 4px; }
|
||||
.profile-username { color: var(--text-secondary); margin-bottom: 12px; font-size: 14px; }
|
||||
.profile-bio { color: var(--text-primary); margin-bottom: 16px; max-width: 600px; line-height: 1.6; }
|
||||
.profile-meta { display: flex; flex-wrap: wrap; gap: 16px; margin-bottom: 20px; color: var(--text-secondary); font-size: 14px; }
|
||||
.profile-meta-item { display: flex; align-items: center; gap: 6px; }
|
||||
.profile-stats { display: flex; gap: 32px; margin-top: 16px; padding-top: 20px; border-top: 1px solid var(--border-color); }
|
||||
.profile-stat { text-align: center; }
|
||||
.profile-stat-value { font-size: 22px; font-weight: 700; }
|
||||
.profile-stat-label { font-size: 13px; color: var(--text-secondary); }
|
||||
.social-links { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 16px; }
|
||||
.social-link { display: flex; align-items: center; justify-content: center; width: 40px; height: 40px; border-radius: 50%; background: var(--bg-primary); color: var(--text-primary); text-decoration: none; transition: all 0.2s; font-size: 18px; }
|
||||
.social-link:hover { transform: translateY(-2px); background: var(--brand-red); color: white; }
|
||||
.social-link.twitter:hover { background: #1da1f2; }
|
||||
.social-link.instagram:hover { background: #e4405f; }
|
||||
.social-link.facebook:hover { background: #1877f2; }
|
||||
.social-link.youtube:hover { background: #ff0000; }
|
||||
.social-link.linkedin:hover { background: #0077b5; }
|
||||
.social-link.tiktok:hover { background: #000000; }
|
||||
.form-card { background: var(--bg-secondary); border-radius: 12px; padding: 24px; margin-bottom: 24px; }
|
||||
.form-title { font-size: 18px; font-weight: 600; margin-bottom: 20px; padding-bottom: 12px; border-bottom: 1px solid var(--border-color); display: flex; align-items: center; gap: 8px; }
|
||||
.form-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; }
|
||||
.form-group { margin-bottom: 16px; }
|
||||
.form-label { display: block; margin-bottom: 8px; font-weight: 500; font-size: 14px; }
|
||||
.form-input, .form-textarea { width: 100%; background: #121212; border: 1px solid var(--border-color); border-radius: 8px; padding: 12px 16px; color: var(--text-primary); font-size: 14px; transition: border-color 0.2s; }
|
||||
.form-input:focus, .form-textarea:focus { outline: none; border-color: var(--brand-red); }
|
||||
.form-textarea { min-height: 100px; resize: vertical; }
|
||||
.form-hint { font-size: 12px; color: var(--text-secondary); margin-top: 4px; }
|
||||
.btn-primary { background: var(--brand-red); color: white; border: none; padding: 12px 32px; border-radius: 8px; font-size: 14px; font-weight: 500; cursor: pointer; transition: background 0.2s; display: inline-flex; align-items: center; gap: 8px; }
|
||||
.btn-primary:hover { background: #cc1a1a; }
|
||||
.btn-secondary { background: transparent; color: var(--text-primary); border: 1px solid var(--border-color); padding: 12px 24px; border-radius: 8px; font-size: 14px; font-weight: 500; cursor: pointer; transition: all 0.2s; display: inline-flex; align-items: center; gap: 8px; text-decoration: none; }
|
||||
.btn-secondary:hover { background: var(--bg-secondary); border-color: var(--text-secondary); }
|
||||
.alert { padding: 12px 16px; border-radius: 8px; margin-bottom: 20px; }
|
||||
.alert-success { background: rgba(34, 197, 94, 0.15); border: 1px solid #22c55e; color: #22c55e; }
|
||||
.section-title { font-size: 20px; font-weight: 600; margin-bottom: 20px; display: flex; align-items: center; gap: 8px; }
|
||||
.video-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 20px; }
|
||||
.empty-state { text-align: center; padding: 40px 20px; color: var(--text-secondary); }
|
||||
.empty-state i { font-size: 48px; margin-bottom: 16px; opacity: 0.5; }
|
||||
.w-100 { width: 100%; }
|
||||
.mt-4 { margin-top: 24px; }
|
||||
.text-center { text-align: center; }
|
||||
.gap-3 { gap: 12px; }
|
||||
.d-flex { display: flex; }
|
||||
.flex-column { flex-direction: column; }
|
||||
.video-card { background: var(--bg-primary); border-radius: 8px; overflow: hidden; transition: transform 0.2s; }
|
||||
.video-card:hover { transform: translateY(-4px); }
|
||||
.video-thumbnail { position: relative; aspect-ratio: 16/9; background: #000; }
|
||||
.video-thumbnail img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.video-duration { position: absolute; bottom: 8px; right: 8px; background: rgba(0,0,0,0.8); color: white; padding: 2px 6px; border-radius: 4px; font-size: 12px; }
|
||||
.video-info { padding: 12px; }
|
||||
.video-title { font-size: 14px; font-weight: 500; margin-bottom: 6px; line-height: 1.4; }
|
||||
.video-title a { color: var(--text-primary); text-decoration: none; }
|
||||
.video-title a:hover { color: var(--brand-red); }
|
||||
.video-meta { font-size: 12px; color: var(--text-secondary); }
|
||||
@media (max-width: 768px) { .profile-stats { justify-content: center; } .profile-meta { justify-content: center; } .social-links { justify-content: center; } }
|
||||
.profile-header {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 12px;
|
||||
padding: 32px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.profile-avatar {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.profile-name {
|
||||
font-size: 24px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.profile-email {
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.profile-stats {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.profile-stat {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.profile-stat-value {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.profile-stat-label {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.form-card {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.form-title {
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
width: 100%;
|
||||
background: #121212;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 12px 16px;
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--brand-red);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--brand-red);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 12px 24px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #cc1a1a;
|
||||
}
|
||||
|
||||
.alert {
|
||||
padding: 12px 16px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.alert-success {
|
||||
background: rgba(34, 197, 94, 0.2);
|
||||
border: 1px solid #22c55e;
|
||||
color: #22c55e;
|
||||
}
|
||||
</style>
|
||||
@endsection
|
||||
|
||||
@section('content')
|
||||
<div class="profile-header">
|
||||
<div class="profile-banner"></div>
|
||||
<div class="profile-content text-center">
|
||||
<div class="profile-header text-center">
|
||||
@if($user->avatar)
|
||||
<img src="{{ asset('storage/avatars/' . $user->avatar) }}" alt="{{ $user->name }}" class="profile-avatar">
|
||||
@else
|
||||
<img src="https://i.pravatar.cc/150?u={{ $user->id }}" alt="{{ $user->name }}" class="profile-avatar">
|
||||
@endif
|
||||
|
||||
<h1 class="profile-name">{{ $user->name }}</h1>
|
||||
@if($user->username)
|
||||
<p class="profile-username">@ {{ $user->username }}</p>
|
||||
@endif
|
||||
@if($user->bio)
|
||||
<p class="profile-bio">{{ $user->bio }}</p>
|
||||
@endif
|
||||
<div class="profile-meta justify-content-center">
|
||||
@if($user->location)
|
||||
<div class="profile-meta-item"><i class="bi bi-geo-alt"></i><span>{{ $user->location }}</span></div>
|
||||
@endif
|
||||
@if($user->website)
|
||||
<div class="profile-meta-item"><i class="bi bi-link-45deg"></i><a href="{{ $user->website_url }}" target="_blank" style="color: inherit;">{{ Str::limit($user->website, 30) }}</a></div>
|
||||
@endif
|
||||
<div class="profile-meta-item"><i class="bi bi-calendar3"></i><span>Joined {{ $user->created_at->format('M Y') }}</span></div>
|
||||
</div>
|
||||
@if($user->hasSocialLinks())
|
||||
<div class="social-links justify-content-center">
|
||||
@if($user->twitter)<a href="https://twitter.com/{{ $user->twitter }}" target="_blank" class="social-link twitter" title="Twitter"><i class="bi bi-twitter-x"></i></a>@endif
|
||||
@if($user->instagram)<a href="https://instagram.com/{{ $user->instagram }}" target="_blank" class="social-link instagram" title="Instagram"><i class="bi bi-instagram"></i></a>@endif
|
||||
@if($user->facebook)<a href="https://facebook.com/{{ $user->facebook }}" target="_blank" class="social-link facebook" title="Facebook"><i class="bi bi-facebook"></i></a>@endif
|
||||
@if($user->youtube)<a href="https://youtube.com/@{{ $user->youtube }}" target="_blank" class="social-link youtube" title="YouTube"><i class="bi bi-youtube"></i></a>@endif
|
||||
@if($user->linkedin)<a href="https://linkedin.com/in/{{ $user->linkedin }}" target="_blank" class="social-link linkedin" title="LinkedIn"><i class="bi bi-linkedin"></i></a>@endif
|
||||
@if($user->tiktok)<a href="https://tiktok.com/@{{ $user->tiktok }}" target="_blank" class="social-link tiktok" title="TikTok"><i class="bi bi-tiktok"></i></a>@endif
|
||||
@if($user->website)<a href="{{ $user->website_url }}" target="_blank" class="social-link" title="Website"><i class="bi bi-globe"></i></a>@endif
|
||||
</div>
|
||||
@endif
|
||||
<p class="profile-email">{{ $user->email }}</p>
|
||||
|
||||
<div class="profile-stats justify-content-center">
|
||||
<a href="{{ route('channel', $user->id) }}" class="profile-stat"><div class="profile-stat-value">{{ $user->videos->count() }}</div><div class="profile-stat-label">Videos</div></a>
|
||||
<div class="profile-stat"><div class="profile-stat-value">{{ number_format($user->subscriber_count) }}</div><div class="profile-stat-label">Subscribers</div></div>
|
||||
<div class="profile-stat"><div class="profile-stat-value">{{ \DB::table('video_views')->whereIn('video_id', $user->videos->pluck('id'))->count() }}</div><div class="profile-stat-label">Views</div></div>
|
||||
<div class="profile-stat"><div class="profile-stat-value">{{ $user->likes->count() }}</div><div class="profile-stat-label">Likes</div></div>
|
||||
<div class="profile-stat">
|
||||
<div class="profile-stat-value">{{ $user->videos->count() }}</div>
|
||||
<div class="profile-stat-label">Videos</div>
|
||||
</div>
|
||||
<div class="profile-stat">
|
||||
<div class="profile-stat-value">{{ $user->likes->count() }}</div>
|
||||
<div class="profile-stat-label">Likes</div>
|
||||
</div>
|
||||
<div class="profile-stat">
|
||||
<div class="profile-stat-value">{{ \DB::table('video_views')->whereIn('video_id', $user->videos->pluck('id'))->count() }}</div>
|
||||
<div class="profile-stat-label">Total Views</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-8">
|
||||
<div class="col-lg-6">
|
||||
<div class="form-card">
|
||||
<h2 class="section-title"><i class="bi bi-play-circle"></i> Recent Videos</h2>
|
||||
@if($user->videos->count() > 0)
|
||||
<div class="video-grid">
|
||||
@foreach($user->videos->take(4) as $video)
|
||||
<div class="video-card">
|
||||
<a href="{{ route('videos.show', $video) }}">
|
||||
<div class="video-thumbnail">
|
||||
<img src="{{ $video->thumbnail ? asset('storage/thumbnails/' . $video->thumbnail) : 'https://i.ytimg.com/vi/default.jpg' }}" alt="{{ $video->title }}">
|
||||
<span class="video-duration">{{ $video->duration ?? '0:00' }}</span>
|
||||
<h2 class="form-title">Edit Profile</h2>
|
||||
|
||||
@if(session('success'))
|
||||
<div class="alert alert-success">
|
||||
{{ session('success') }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<form method="POST" action="{{ route('profile.update') }}" enctype="multipart/form-data">
|
||||
@csrf
|
||||
@method('PUT')
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Name</label>
|
||||
<input type="text" name="name" class="form-input" value="{{ old('name', $user->name) }}" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Avatar</label>
|
||||
<input type="file" name="avatar" class="form-input" accept="image/*">
|
||||
<small class="text-muted">Max size: 5MB. Supported: JPG, PNG, WebP</small>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn-primary">Save Changes</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-6">
|
||||
<div class="form-card">
|
||||
<h2 class="form-title">Quick Links</h2>
|
||||
|
||||
<a href="{{ route('channel', $user->id) }}" class="btn-primary d-inline-block text-decoration-none">
|
||||
<i class="bi bi-play-btn"></i> View My Channel
|
||||
</a>
|
||||
<div class="video-info">
|
||||
<h3 class="video-title"><a href="{{ route('videos.show', $video) }}">{{ Str::limit($video->title, 50) }}</a></h3>
|
||||
<div class="video-meta"><span>{{ number_format($video->view_count ?? 0) }} views</span><span> • </span><span>{{ $video->created_at->diffForHumans() }}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@if($user->videos->count() > 4)
|
||||
<div class="text-center mt-4"><a href="{{ route('channel', $user->id) }}" class="btn-secondary">View All Videos <i class="bi bi-arrow-right"></i></a></div>
|
||||
@endif
|
||||
@else
|
||||
<div class="empty-state"><i class="bi bi-camera-video"></i><p>No videos yet</p></div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-4">
|
||||
<div class="form-card">
|
||||
<h2 class="form-title"><i class="bi bi-pencil-square"></i> Edit Profile</h2>
|
||||
@if(session('success'))<div class="alert alert-success">{{ session('success') }}</div>@endif
|
||||
<form method="POST" action="{{ route('profile.update') }}" enctype="multipart/form-data">
|
||||
@csrf @method('PUT')
|
||||
<div class="form-group"><label class="form-label">Name</label><input type="text" name="name" class="form-input" value="{{ old('name', $user->name) }}" required></div>
|
||||
<div class="form-group"><label class="form-label">Avatar</label><input type="file" name="avatar" class="form-input" accept="image/*"><small class="form-hint">Max: 5MB</small></div>
|
||||
<div class="form-group"><label class="form-label">Bio</label><textarea name="bio" class="form-textarea" placeholder="Tell us about yourself...">{{ old('bio', $user->bio) }}</textarea></div>
|
||||
<div class="form-group"><label class="form-label">Location</label><input type="text" name="location" class="form-input" value="{{ old('location', $user->location) }}" placeholder="City, Country"></div>
|
||||
<div class="form-group"><label class="form-label">Birthday</label><input type="date" name="birthday" class="form-input" value="{{ old('birthday', $user->birthday) }}"></div>
|
||||
<button type="submit" class="btn-primary w-100"><i class="bi bi-check-lg"></i> Save Changes</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="form-card">
|
||||
<h2 class="form-title"><i class="bi bi-share"></i> Social Links</h2>
|
||||
<form method="POST" action="{{ route('profile.update') }}" enctype="multipart/form-data">
|
||||
@csrf @method('PUT')
|
||||
<div class="form-row">
|
||||
<div class="form-group"><label class="form-label"><i class="bi bi-twitter-x"></i> Twitter</label><input type="text" name="twitter" class="form-input" value="{{ old('twitter', $user->twitter) }}" placeholder="username"></div>
|
||||
<div class="form-group"><label class="form-label"><i class="bi bi-instagram"></i> Instagram</label><input type="text" name="instagram" class="form-input" value="{{ old('instagram', $user->instagram) }}" placeholder="username"></div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group"><label class="form-label"><i class="bi bi-facebook"></i> Facebook</label><input type="text" name="facebook" class="form-input" value="{{ old('facebook', $user->facebook) }}" placeholder="username"></div>
|
||||
<div class="form-group"><label class="form-label"><i class="bi bi-youtube"></i> YouTube</label><input type="text" name="youtube" class="form-input" value="{{ old('youtube', $user->youtube) }}" placeholder="channel"></div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group"><label class="form-label"><i class="bi bi-linkedin"></i> LinkedIn</label><input type="text" name="linkedin" class="form-input" value="{{ old('linkedin', $user->linkedin) }}" placeholder="username"></div>
|
||||
<div class="form-group"><label class="form-label"><i class="bi bi-tiktok"></i> TikTok</label><input type="text" name="tiktok" class="form-input" value="{{ old('tiktok', $user->tiktok) }}" placeholder="username"></div>
|
||||
</div>
|
||||
<div class="form-group"><label class="form-label"><i class="bi bi-globe"></i> Website</label><input type="url" name="website" class="form-input" value="{{ old('website', $user->website) }}" placeholder="https://example.com"></div>
|
||||
<button type="submit" class="btn-primary w-100"><i class="bi bi-check-lg"></i> Save Social Links</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="form-card">
|
||||
<h2 class="form-title"><i class="bi bi-link-45deg"></i> Quick Links</h2>
|
||||
<div class="d-flex flex-column gap-3">
|
||||
<a href="{{ route('channel', $user->id) }}" class="btn-secondary text-center"><i class="bi bi-play-btn"></i> View My Channel</a>
|
||||
<a href="{{ route('settings') }}" class="btn-secondary text-center"><i class="bi bi-gear"></i> Account Settings</a>
|
||||
<a href="{{ route('history') }}" class="btn-secondary text-center"><i class="bi bi-clock-history"></i> Watch History</a>
|
||||
<a href="{{ route('liked') }}" class="btn-secondary text-center"><i class="bi bi-heart"></i> Liked Videos</a>
|
||||
</div>
|
||||
<a href="{{ route('settings') }}" class="btn-primary d-inline-block text-decoration-none ms-2">
|
||||
<i class="bi bi-gear"></i> Settings
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -29,11 +29,171 @@
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
@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-card {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Other styles unchanged */
|
||||
.yt-empty { text-align: center; padding: 80px 20px; }
|
||||
.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
|
||||
|
||||
@ -41,22 +201,120 @@
|
||||
@isset($query)
|
||||
<div class="search-info">
|
||||
<h2>Search results for "{{ $query }}"</h2>
|
||||
<p>{{ $videos->count() }} videos found</p>
|
||||
<p>{{ $videos->total() }} videos found</p>
|
||||
</div>
|
||||
@endisset
|
||||
@endif
|
||||
|
||||
@if ($videos->isEmpty())
|
||||
@if($videos->isEmpty())
|
||||
<div class="yt-empty">
|
||||
<h2>No videos found</h2>
|
||||
<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="{{ route('videos.create') }}" class="btn btn-primary">Upload First Video</a>
|
||||
<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)
|
||||
@include('components.video-card', ['video' => $video])
|
||||
@foreach($videos as $video)
|
||||
<x-video-card :video="$video" />
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
<div class="mt-4">{{ $videos->links() }}</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,339 +0,0 @@
|
||||
@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,79 +1,46 @@
|
||||
<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 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, #6b7280); font-size: 12px;">{{ $comment->created_at->diffForHumans() }}</span>
|
||||
<span style="color: var(--text-secondary); font-size: 12px;">{{ $comment->created_at->diffForHumans() }}</span>
|
||||
</div>
|
||||
|
||||
{{-- ✅ Comment Body - Prefixed class --}}
|
||||
<div class="_comment-body" style="font-size: 14px; line-height: 1.5; word-wrap: break-word;"
|
||||
data-_comment-enhanced="0">
|
||||
<div class="comment-body" style="font-size: 14px; line-height: 1.5; word-wrap: break-word;">
|
||||
{{ $comment->body }}
|
||||
</div>
|
||||
|
||||
{{-- ✅ Edit Form (only for comment owner) --}}
|
||||
@auth
|
||||
@if (Auth::id() === $comment->user_id)
|
||||
<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="_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="_comment.toggleReplyForm({{ $comment->id }})" class="_comment-btn _comment-btn-link">
|
||||
<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>
|
||||
@if (Auth::id() === $comment->user_id)
|
||||
<button onclick="_comment.startEditComment({{ $comment->id }})"
|
||||
class="_comment-btn _comment-btn-link">
|
||||
Edit
|
||||
</button>
|
||||
<button onclick="window._commentDelete({{ $comment->id }})" class="_comment-btn _comment-btn-link">
|
||||
@if(Auth::id() === $comment->user_id)
|
||||
<button onclick="deleteComment({{ $comment->id }})" style="background: none; border: none; color: var(--text-secondary); font-size: 12px; font-weight: 600; cursor: pointer; padding: 0;">
|
||||
Delete
|
||||
</button>
|
||||
@endif
|
||||
@endauth
|
||||
</div>
|
||||
|
||||
{{-- ✅ 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>
|
||||
<!-- 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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- ✅ Nested Replies --}}
|
||||
@if ($comment->replies && $comment->replies->count() > 0)
|
||||
<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)
|
||||
<!-- 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;">
|
||||
@foreach($comment->replies as $reply)
|
||||
@include('videos.partials.comment', ['comment' => $reply, 'video' => $video ?? null])
|
||||
@endforeach
|
||||
</div>
|
||||
@ -81,4 +48,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- ✅ NO <script> TAGS - All JavaScript is in video-comments.blade.php --}}
|
||||
<script>
|
||||
function toggleReplyForm(commentId) {
|
||||
const form = document.getElementById('replyForm' + commentId);
|
||||
form.style.display = form.style.display === 'none' ? 'block' : 'none';
|
||||
}
|
||||
</script>
|
||||
|
||||
@ -1,350 +0,0 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', 'Shorts - ' . config('app.name'))
|
||||
|
||||
@section('content')
|
||||
<div class="yt-main">
|
||||
<div class="yt-content">
|
||||
<!-- Shorts Header -->
|
||||
<div class="shorts-header">
|
||||
<div class="shorts-brand">
|
||||
<div class="shorts-logo-icon">
|
||||
<i class="bi bi-play-fill"></i>
|
||||
</div>
|
||||
<h1>Shorts</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Shorts Grid - YouTube Style -->
|
||||
<div class="shorts-container">
|
||||
@forelse($videos as $video)
|
||||
<div class="shorts-card">
|
||||
<a href="{{ route('videos.show', $video->id) }}" class="shorts-link">
|
||||
<div class="shorts-thumbnail-wrapper">
|
||||
<img src="{{ $video->thumbnail_url }}" alt="{{ $video->title }}" class="shorts-thumb">
|
||||
<div class="shorts-overlay">
|
||||
<span class="shorts-views">
|
||||
<i class="bi bi-play-fill"></i> {{ number_format($video->view_count) }}
|
||||
</span>
|
||||
</div>
|
||||
@if ($video->duration)
|
||||
<span class="shorts-time">{{ gmdate('i:s', $video->duration) }}</span>
|
||||
@endif
|
||||
</div>
|
||||
<div class="shorts-details">
|
||||
<h3 class="shorts-title">{{ $video->title }}</h3>
|
||||
<div class="shorts-channel-info">
|
||||
@if ($video->user)
|
||||
<div class="shorts-channel-row">
|
||||
@if ($video->user->avatar_url)
|
||||
<img src="{{ $video->user->avatar_url }}" class="shorts-avatar"
|
||||
alt="{{ $video->user->name }}">
|
||||
@else
|
||||
<div class="shorts-avatar-placeholder">
|
||||
{{ substr($video->user->name, 0, 1) }}</div>
|
||||
@endif
|
||||
<span class="shorts-channel-name">{{ $video->user->name }}</span>
|
||||
</div>
|
||||
@endif
|
||||
<span class="shorts-ago">{{ $video->created_at->diffForHumans() }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
@empty
|
||||
<div class="shorts-empty-state">
|
||||
<div class="shorts-empty-icon">
|
||||
<i class="bi bi-collection-play"></i>
|
||||
</div>
|
||||
<h2>No Shorts yet</h2>
|
||||
<p>Be the first to upload a Short!</p>
|
||||
@auth
|
||||
<a href="{{ route('videos.create') }}" class="shorts-upload-btn">
|
||||
<i class="bi bi-plus-lg"></i> Upload Short
|
||||
</a>
|
||||
@else
|
||||
<a href="{{ route('login') }}" class="shorts-upload-btn">
|
||||
<i class="bi bi-box-arrow-in-right"></i> Login to Upload
|
||||
</a>
|
||||
@endauth
|
||||
</div>
|
||||
@endforelse
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Shorts Page - YouTube Shorts Style */
|
||||
.shorts-header {
|
||||
margin-bottom: 20px;
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
.shorts-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.shorts-logo-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: linear-gradient(135deg, #ff0050, #ff6b6b);
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.shorts-brand h1 {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Shorts Grid - 4 columns on desktop */
|
||||
.shorts-container {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* Shorts Card */
|
||||
.shorts-card {
|
||||
background: transparent;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.shorts-card:hover {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.shorts-link {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.shorts-thumbnail-wrapper {
|
||||
position: relative;
|
||||
aspect-ratio: 9/16;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
background: #1a1a1a;
|
||||
}
|
||||
|
||||
.shorts-thumb {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.shorts-overlay {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 8px;
|
||||
background: linear-gradient(transparent, rgba(0, 0, 0, 0.7));
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.shorts-card:hover .shorts-overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.shorts-views {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.shorts-time {
|
||||
position: absolute;
|
||||
bottom: 8px;
|
||||
right: 8px;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
color: white;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.shorts-details {
|
||||
padding: 10px 4px;
|
||||
}
|
||||
|
||||
.shorts-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 8px;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.shorts-channel-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.shorts-channel-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.shorts-avatar,
|
||||
.shorts-avatar-placeholder {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.shorts-avatar-placeholder {
|
||||
background: #666;
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.shorts-channel-name {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.shorts-ago {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* Empty State */
|
||||
.shorts-empty-state {
|
||||
grid-column: 1 / -1;
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.shorts-empty-icon {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
margin: 0 auto 20px;
|
||||
background: linear-gradient(135deg, #ff0050, #ff6b6b);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.shorts-empty-icon i {
|
||||
font-size: 36px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.shorts-empty-state h2 {
|
||||
font-size: 20px;
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.shorts-empty-state p {
|
||||
color: var(--text-secondary);
|
||||
margin: 0 0 20px;
|
||||
}
|
||||
|
||||
.shorts-upload-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 20px;
|
||||
background: #ff0050;
|
||||
color: white;
|
||||
border-radius: 20px;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.shorts-upload-btn:hover {
|
||||
background: #e60048;
|
||||
}
|
||||
|
||||
/* Pagination */
|
||||
.shorts-pagination {
|
||||
margin-top: 30px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.shorts-pagination .pagination {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.shorts-pagination .page-link {
|
||||
background: var(--bg-secondary);
|
||||
border-color: var(--border-color);
|
||||
color: var(--text-primary);
|
||||
padding: 6px 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.shorts-pagination .page-item.active .page-link {
|
||||
background: #ff0050;
|
||||
border-color: #ff0050;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 1200px) {
|
||||
.shorts-container {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.shorts-container {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.shorts-container {
|
||||
grid-template-columns: 1fr;
|
||||
max-width: 320px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.shorts-header {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.shorts-brand {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@endsection
|
||||
@ -1,96 +1,19 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@push('head')
|
||||
<!-- Open Graph / WhatsApp / Facebook / Twitter / LinkedIn / Telegram Preview -->
|
||||
<!-- Open Graph / WhatsApp / Facebook / Twitter Preview -->
|
||||
<meta property="og:title" content="{{ $video->title }}">
|
||||
<meta property="og:description"
|
||||
content="{{ $video->description ? Str::limit($video->description, 200) : 'Watch ' . $video->title . ' on ' . config('app.name') }}">
|
||||
<meta property="og:image" content="{{ $video->open_graph_image }}">
|
||||
<meta property="og:image:width" content="{{ $video->thumbnail_width }}">
|
||||
<meta property="og:image:height" content="{{ $video->thumbnail_height }}">
|
||||
<meta property="og:image:alt" content="{{ $video->title }} - Video Thumbnail">
|
||||
<meta property="og:description" content="{{ $video->description ? Str::limit($video->description, 200) : 'Check out this video on ' . config('app.name') }}">
|
||||
<meta property="og:image" content="{{ $video->thumbnail_url }}">
|
||||
<meta property="og:url" content="{{ $video->share_url }}">
|
||||
<meta property="og:type" content="video.other">
|
||||
<meta property="og:site_name" content="{{ config('app.name') }}">
|
||||
<meta property="og:locale" content="en_US">
|
||||
<meta property="og:author" content="{{ $video->author_name }}">
|
||||
<meta property="og:published_time" content="{{ $video->created_at->toIso8601String() }}">
|
||||
|
||||
<!-- Video-specific Open Graph tags -->
|
||||
<meta property="video:duration" content="{{ $video->duration }}">
|
||||
<meta property="video:release_date" content="{{ $video->created_at->toIso8601String() }}">
|
||||
|
||||
<!-- Alternative video tag for some platforms -->
|
||||
<meta property="og:video" content="{{ $video->stream_url }}">
|
||||
<meta property="og:video:url" content="{{ $video->stream_url }}">
|
||||
<meta property="og:video:secure_url" content="{{ $video->stream_url }}">
|
||||
<meta property="og:video:type" content="video/mp4">
|
||||
<meta property="og:video:width" content="{{ $video->width ?? 1920 }}">
|
||||
<meta property="og:video:height" content="{{ $video->height ?? 1080 }}">
|
||||
|
||||
<!-- Twitter Card - Enhanced for video sharing -->
|
||||
<!-- Twitter Card -->
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:site" content="{{ config('app.name') }}">
|
||||
<meta name="twitter:creator" content="{{ $video->author_name }}">
|
||||
<meta name="twitter:title" content="{{ $video->title }}">
|
||||
<meta name="twitter:description"
|
||||
content="{{ $video->description ? Str::limit($video->description, 200) : 'Watch ' . $video->title . ' on ' . config('app.name') }}">
|
||||
<meta name="twitter:image" content="{{ $video->open_graph_image }}">
|
||||
<meta name="twitter:image:alt" content="{{ $video->title }} - Video Thumbnail">
|
||||
|
||||
<!-- Twitter Player card for video -->
|
||||
<meta name="twitter:player" content="{{ $video->share_url }}">
|
||||
<meta name="twitter:player:width" content="{{ $video->width ?? 1920 }}">
|
||||
<meta name="twitter:player:height" content="{{ $video->height ?? 1080 }}">
|
||||
<meta name="twitter:player:stream" content="{{ $video->stream_url }}">
|
||||
|
||||
<!-- LinkedIn specific -->
|
||||
<meta property="linkedin:owner" content="{{ $video->author_name }}">
|
||||
|
||||
<!-- Pinterest -->
|
||||
<meta name="pinterest-rich-pin" content="true">
|
||||
|
||||
<!-- WhatsApp specific (uses Open Graph) -->
|
||||
<!-- No additional meta needed - uses og: tags above -->
|
||||
|
||||
<!-- Schema.org VideoObject for search engines -->
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "VideoObject",
|
||||
"name": "{{ $video->title }}",
|
||||
"description": "{{ $video->description ? addslashes($video->description) : 'Watch ' . addslashes($video->title) . ' on ' . config('app.name') }}",
|
||||
"thumbnailUrl": "{{ $video->open_graph_image }}",
|
||||
"uploadDate": "{{ $video->created_at->toIso8601String() }}",
|
||||
"duration": "{{ $video->iso_duration }}",
|
||||
"contentUrl": "{{ $video->stream_url }}",
|
||||
"embedUrl": "{{ $video->share_url }}",
|
||||
"author": {
|
||||
"@type": "Person",
|
||||
"name": "{{ $video->author_name }}"
|
||||
},
|
||||
"publisher": {
|
||||
"@type": "Organization",
|
||||
"name": "{{ config('app.name') }}",
|
||||
"logo": {
|
||||
"@type": "ImageObject",
|
||||
"url": "{{ asset('storage/images/fullLogo.png') }}"
|
||||
}
|
||||
},
|
||||
"interactionStatistic": [
|
||||
{
|
||||
"@type": "InteractionCounter",
|
||||
"interactionType": "https://schema.org/WatchAction",
|
||||
"userInteractionCount": {{ $video->view_count }}
|
||||
},
|
||||
{
|
||||
"@type": "InteractionCounter",
|
||||
"interactionType": "https://schema.org/LikeAction",
|
||||
"userInteractionCount": {{ $video->like_count }}
|
||||
}
|
||||
]
|
||||
}
|
||||
</script>
|
||||
<meta name="twitter:description" content="{{ $video->description ? Str::limit($video->description, 200) : 'Check out this video on ' . config('app.name') }}">
|
||||
<meta name="twitter:image" content="{{ $video->thumbnail_url }}">
|
||||
@endpush
|
||||
|
||||
@section('title', $video->title . ' | ' . config('app.name'))
|
||||
@ -98,10 +21,7 @@
|
||||
@section('extra_styles')
|
||||
<style>
|
||||
/* Video Section */
|
||||
.yt-video-section {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.yt-video-section { flex: 1; min-width: 0; }
|
||||
|
||||
/* Video Player */
|
||||
.video-container {
|
||||
@ -124,26 +44,11 @@
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.video-container.portrait {
|
||||
aspect-ratio: 9/16;
|
||||
max-width: 50vh;
|
||||
}
|
||||
.video-container.portrait { aspect-ratio: 9/16; max-width: 50vh; }
|
||||
.video-container.square { aspect-ratio: 1/1; max-width: 70vh; }
|
||||
.video-container.ultrawide { aspect-ratio: 21/9; max-width: 100%; }
|
||||
|
||||
.video-container.square {
|
||||
aspect-ratio: 1/1;
|
||||
max-width: 70vh;
|
||||
}
|
||||
|
||||
.video-container.ultrawide {
|
||||
aspect-ratio: 21/9;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.video-container video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
.video-container video { width: 100%; height: 100%; object-fit: contain; }
|
||||
|
||||
/* Video Info */
|
||||
.video-title {
|
||||
@ -163,12 +68,7 @@
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.video-stats-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.video-stats-left { display: flex; align-items: center; gap: 16px; color: var(--text-secondary); }
|
||||
|
||||
.video-actions {
|
||||
display: flex;
|
||||
@ -190,13 +90,9 @@
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.yt-action-btn:hover {
|
||||
background: var(--border-color);
|
||||
}
|
||||
.yt-action-btn:hover { background: var(--border-color); }
|
||||
|
||||
.yt-action-btn.liked {
|
||||
color: var(--brand-red);
|
||||
}
|
||||
.yt-action-btn.liked { color: var(--brand-red); }
|
||||
|
||||
/* Channel Row */
|
||||
.channel-row {
|
||||
@ -213,10 +109,7 @@
|
||||
}
|
||||
|
||||
.channel-avatar {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
background: #555;
|
||||
width: 48px; height: 48px; border-radius: 50%; background: #555;
|
||||
}
|
||||
|
||||
.channel-name {
|
||||
@ -277,16 +170,9 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-thumb img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.sidebar-thumb img { width: 100%; height: 100%; object-fit: cover; }
|
||||
|
||||
.sidebar-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.sidebar-info { flex: 1; min-width: 0; }
|
||||
|
||||
.sidebar-title {
|
||||
font-size: 14px;
|
||||
@ -298,47 +184,28 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar-meta {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.sidebar-meta { font-size: 12px; color: var(--text-secondary); }
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 1300px) {
|
||||
.yt-sidebar-container {
|
||||
width: 300px;
|
||||
}
|
||||
.yt-sidebar-container { width: 300px; }
|
||||
}
|
||||
|
||||
@media (max-width: 991px) {
|
||||
.yt-main {
|
||||
margin-left: 0;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.yt-sidebar-container {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
|
||||
.sidebar-video-card {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sidebar-thumb {
|
||||
width: 100%;
|
||||
}
|
||||
.yt-main { margin-left: 0; flex-direction: column; }
|
||||
.yt-sidebar-container { width: 100%; }
|
||||
.yt-header-center { display: none; }
|
||||
.sidebar-video-card { flex-direction: column; }
|
||||
.sidebar-thumb { width: 100%; }
|
||||
|
||||
/* Video Layout Container - Stack vertically on tablet/mobile */
|
||||
.video-layout-container {
|
||||
flex-direction: column !important;
|
||||
}
|
||||
|
||||
.yt-video-section {
|
||||
width: 100% !important;
|
||||
flex: none !important;
|
||||
}
|
||||
|
||||
.yt-sidebar-container {
|
||||
width: 100% !important;
|
||||
margin-top: 16px;
|
||||
@ -346,72 +213,33 @@
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
.video-stats-row {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.video-actions {
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.yt-main {
|
||||
padding: 0 !important;
|
||||
max-width: 100% !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
/* Mobile video player fixes - always full width */
|
||||
.video-layout-container {
|
||||
max-width: 100% !important;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.yt-video-section {
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
padding: 0 !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
.video-stats-row { flex-direction: column; align-items: flex-start; }
|
||||
.video-actions { width: 100%; overflow-x: auto; justify-content: flex-start; }
|
||||
.yt-main { padding: 12px !important; }
|
||||
|
||||
/* Mobile video player fixes */
|
||||
.video-container {
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
max-height: 50vh !important;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
.video-container video {
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
object-fit: contain !important;
|
||||
}
|
||||
|
||||
.video-title {
|
||||
font-size: 16px !important;
|
||||
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%;
|
||||
}
|
||||
|
||||
.video-description {
|
||||
padding: 12px !important;
|
||||
}
|
||||
@ -427,15 +255,14 @@
|
||||
<!-- Video Section -->
|
||||
<div class="yt-video-section">
|
||||
<!-- Video Player -->
|
||||
<div class="video-container @if ($video->orientation === 'portrait') portrait @elseif($video->orientation === 'square') square @elseif($video->orientation === 'ultrawide') ultrawide @endif"
|
||||
id="videoContainer">
|
||||
<div class="video-container @if($video->orientation === 'portrait') portrait @elseif($video->orientation === 'square') square @elseif($video->orientation === 'ultrawide') ultrawide @endif" id="videoContainer">
|
||||
<video id="videoPlayer" controls playsinline preload="metadata" autoplay>
|
||||
<source src="{{ route('videos.stream', $video->id) }}" type="video/mp4">
|
||||
</video>
|
||||
</div>
|
||||
|
||||
@php
|
||||
$typeIcon = match ($video->type) {
|
||||
$typeIcon = match($video->type) {
|
||||
'music' => 'bi-music-note',
|
||||
'match' => 'bi-trophy',
|
||||
default => 'bi-film',
|
||||
@ -458,19 +285,16 @@
|
||||
<div class="video-actions">
|
||||
@auth
|
||||
<!-- Like Button -->
|
||||
<form method="POST"
|
||||
action="{{ $video->isLikedBy(Auth::user()) ? route('videos.unlike', $video->id) : route('videos.like', $video->id) }}"
|
||||
class="d-inline">
|
||||
<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="yt-action-btn {{ $video->isLikedBy(Auth::user()) ? 'liked' : '' }}">
|
||||
<i
|
||||
class="bi {{ $video->isLikedBy(Auth::user()) ? 'bi-hand-thumbs-up-fill' : 'bi-hand-thumbs-up' }}"></i>
|
||||
<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>
|
||||
|
||||
<!-- Edit Button - Only for video owner -->
|
||||
@if (Auth::id() === $video->user_id)
|
||||
@if(Auth::id() === $video->user_id)
|
||||
<button class="yt-action-btn" onclick="openEditVideoModal({{ $video->id }})">
|
||||
<i class="bi bi-pencil"></i> Edit
|
||||
</button>
|
||||
@ -480,51 +304,30 @@
|
||||
<i class="bi bi-hand-thumbs-up"></i> Like
|
||||
</a>
|
||||
@endauth
|
||||
@if ($video->isShareable())
|
||||
<button class="yt-action-btn"
|
||||
onclick="openShareModal('{{ $video->share_url }}', '{{ addslashes($video->title) }}')"><i
|
||||
class="bi bi-share"></i> Share</button>
|
||||
@if($video->isShareable())
|
||||
<button class="yt-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="yt-action-btn" onclick="openAddToPlaylistModal({{ $video->id }})">
|
||||
<i class="bi bi-collection-plus"></i> Save
|
||||
</button>
|
||||
@auth
|
||||
<!-- Quick Watch Later Button -->
|
||||
<form method="POST" action="{{ route('videos.watchLater', $video->id) }}" class="d-inline"
|
||||
style="display: inline;">
|
||||
@csrf
|
||||
<button type="submit" class="yt-action-btn" title="Watch Later">
|
||||
<i class="bi bi-clock"></i>
|
||||
</button>
|
||||
</form>
|
||||
@endauth
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Channel Row -->
|
||||
<div class="channel-row"
|
||||
style="display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 12px;">
|
||||
<div class="channel-row" style="display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 12px;">
|
||||
<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: 36px; height: 36px;" alt="{{ $video->user->name }}">
|
||||
<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: 36px; height: 36px;" alt="{{ $video->user->name }}">
|
||||
@else
|
||||
<div class="channel-avatar" style="width: 36px; height: 36px;"></div>
|
||||
@endif
|
||||
<div>
|
||||
<div class="channel-name">{{ $video->user->name ?? 'Unknown' }}</div>
|
||||
<div class="channel-subs">{{ number_format($video->user->subscriber_count ?? 0) }} subscribers
|
||||
</div>
|
||||
<div class="channel-subs">{{ number_format($video->user->subscriber_count ?? 0) }} subscribers</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
{{-- Subscribe Button --}}
|
||||
@auth
|
||||
@if (Auth::id() !== $video->user_id)
|
||||
@if(Auth::id() !== $video->user_id)
|
||||
<button class="subscribe-btn">Subscribe</button>
|
||||
@endif
|
||||
@else
|
||||
@ -534,15 +337,14 @@
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
@if ($video->description)
|
||||
@if($video->description)
|
||||
@php
|
||||
$fullDescription = $video->description;
|
||||
$shortDescription = Str::limit($fullDescription, 200);
|
||||
$needsExpand = strlen($fullDescription) > 200;
|
||||
@endphp
|
||||
|
||||
<div class="video-description-box"
|
||||
style="background: var(--bg-secondary); border-radius: 12px; padding: 12px; margin-top: 12px;">
|
||||
<div class="video-description-box" style="background: var(--bg-secondary); border-radius: 12px; padding: 12px; margin-top: 12px;">
|
||||
<div class="description-stats" style="font-size: 14px; font-weight: 500; margin-bottom: 8px;">
|
||||
<span>{{ number_format($video->view_count) }} views</span>
|
||||
<span>•</span>
|
||||
@ -550,7 +352,7 @@
|
||||
</div>
|
||||
<div style="border-bottom: 1px solid var(--border-color); margin: 8px 0;"></div>
|
||||
<div class="description-content">
|
||||
@if ($needsExpand)
|
||||
@if($needsExpand)
|
||||
<div class="description-short">
|
||||
<span class="description-text">{!! Str::markdown($shortDescription) !!}</span>
|
||||
<span style="color: var(--text-secondary);">... </span>
|
||||
@ -558,9 +360,7 @@
|
||||
<div class="description-full" style="display: none;">
|
||||
<span class="description-text">{!! Str::markdown($fullDescription) !!}</span>
|
||||
</div>
|
||||
<button onclick="toggleDescription()"
|
||||
style="background: none; border: none; color: var(--text-primary); font-weight: 600; font-size: 14px; cursor: pointer; padding: 0; margin-top: 4px;">Show
|
||||
more</button>
|
||||
<button onclick="toggleDescription()" style="background: none; border: none; color: var(--text-primary); font-weight: 600; font-size: 14px; cursor: pointer; padding: 0; margin-top: 4px;">Show more</button>
|
||||
@else
|
||||
<span class="description-text">{!! Str::markdown($fullDescription) !!}</span>
|
||||
@endif
|
||||
@ -585,184 +385,91 @@
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.video-description-box .description-text {
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.video-description-box .description-text p {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.video-description-box .description-text a {
|
||||
color: #3ea6ff;
|
||||
}
|
||||
.video-description-box .description-text { font-size: 14px; line-height: 1.5; color: var(--text-primary); }
|
||||
.video-description-box .description-text p { margin-bottom: 8px; }
|
||||
.video-description-box .description-text a { color: #3ea6ff; }
|
||||
</style>
|
||||
@endif
|
||||
|
||||
@include('components.video-comments', ['video' => $video])
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sidebar - Up Next / Recommendations -->
|
||||
<div class="yt-sidebar-container">
|
||||
@if ($playlist && $playlistVideos && $playlistVideos->count() > 0)
|
||||
<h3 style="font-size: 16px; font-weight: 500; margin-bottom: 12px;">
|
||||
<i class="bi bi-collection-play" style="margin-right: 8px;"></i>
|
||||
{{ $playlist->name }}
|
||||
<span
|
||||
style="font-weight: 400; color: var(--text-secondary); font-size: 14px;">({{ $playlistVideos->count() }}
|
||||
videos)</span>
|
||||
<!-- Comments Section -->
|
||||
<div class="comments-section" style="margin-top: 24px; padding-top: 16: 1px solid var(--borderpx; border-top-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>
|
||||
<div class="recommended-videos-list">
|
||||
@foreach ($playlistVideos as $index => $playlistVideo)
|
||||
@if ($playlistVideo->id !== $video->id)
|
||||
<div class="sidebar-video-card{{ $playlistVideo->id === $video->id ? ' current-video' : '' }}"
|
||||
onclick="window.location.href='{{ route('videos.show', $playlistVideo->id) }}?playlist={{ $playlist->id }}'">
|
||||
<div class="sidebar-thumb" style="position: relative;">
|
||||
@if ($playlistVideo->thumbnail)
|
||||
<img src="{{ asset('storage/thumbnails/' . $playlistVideo->thumbnail) }}"
|
||||
alt="{{ $playlistVideo->title }}">
|
||||
|
||||
@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;">
|
||||
<textarea id="commentBody" class="form-control" placeholder="Add a comment... Use @ to mention someone" rows="3" style="background: var(--bg-secondary); border: 1px solid var(--border-color); color: var(--text-primary); border-radius: 8px; padding: 12px; width: 100%; resize: none;"></textarea>
|
||||
<div style="display: flex; gap: 8px; margin-top: 8px; justify-content: flex-end;">
|
||||
<button type="button" class="yt-action-btn" onclick="document.getElementById('commentBody').value = ''">Cancel</button>
|
||||
<button type="button" class="yt-action-btn" style="background: var(--brand-red); color: white;" onclick="submitComment({{ $video->id }})">Comment</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<img src="https://picsum.photos/seed/{{ $playlistVideo->id }}/320/180"
|
||||
alt="{{ $playlistVideo->title }}">
|
||||
@endif
|
||||
@if ($playlistVideo->duration)
|
||||
<span class="yt-video-duration">{{ gmdate('i:s', $playlistVideo->duration) }}</span>
|
||||
@endif
|
||||
@if ($playlistVideo->is_shorts)
|
||||
<span class="yt-shorts-badge"
|
||||
style="position: absolute; top: 8px; left: 8px; font-size: 10px; padding: 2px 6px;">
|
||||
<i class="bi bi-collection-play-fill"></i> SHORTS
|
||||
</span>
|
||||
@endif
|
||||
<!-- Playlist position indicator -->
|
||||
<span
|
||||
style="position: absolute; bottom: 4px; left: 4px; background: rgba(0,0,0,0.8); color: white; padding: 2px 6px; border-radius: 4px; font-size: 12px; font-weight: 500;">
|
||||
{{ $index + 1 }}
|
||||
</span>
|
||||
<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>
|
||||
<div class="sidebar-info">
|
||||
<div class="sidebar-title">
|
||||
<i class="bi {{ match ($playlistVideo->type) {'music' => 'bi-music-note','match' => 'bi-trophy',default => 'bi-film'} }}"
|
||||
style="color: #ef4444; margin-right: 4px; font-size: 12px;"></i>
|
||||
{{ Str::limit($playlistVideo->title, 60) }}
|
||||
</div>
|
||||
<div class="sidebar-meta">
|
||||
<div>{{ $playlistVideo->user->name ?? 'Unknown' }}</div>
|
||||
<div>{{ number_format($playlistVideo->view_count) }} views •
|
||||
{{ $playlistVideo->created_at->diffForHumans() }}</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(r => r.json()).then(data => {
|
||||
if (data.success) { document.getElementById('commentBody').value = ''; location.reload(); }
|
||||
});
|
||||
}
|
||||
function deleteComment(commentId) {
|
||||
if (!confirm('Delete this comment?')) return;
|
||||
fetch(`/comments/${commentId}`, { method: 'DELETE', headers: { 'X-CSRF-TOKEN': '{{ csrf_token() }}' }})
|
||||
.then(r => r.json()).then(data => { if (data.success) location.reload(); });
|
||||
}
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
document.querySelectorAll('.comment-body').forEach(text => {
|
||||
text.innerHTML = text.innerHTML.replace(/@(\w+)/g, '<span style="color: #3ea6ff; font-weight: 500;">@$1</span>');
|
||||
});
|
||||
});
|
||||
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(r => r.json()).then(data => { if (data.success) location.reload(); });
|
||||
}
|
||||
</script>
|
||||
</div>
|
||||
@endif
|
||||
@endforeach
|
||||
</div>
|
||||
@if ($playlist->canEdit(Auth::user()))
|
||||
<a href="{{ route('playlists.show', $playlist->id) }}" class="yt-action-btn"
|
||||
style="margin-top: 12px; display: inline-block;">
|
||||
<i class="bi bi-pencil"></i> Edit Playlist
|
||||
</a>
|
||||
@endif
|
||||
@else
|
||||
|
||||
<!-- Sidebar -->
|
||||
<div class="yt-sidebar-container">
|
||||
<h3 style="font-size: 16px; font-weight: 500; margin-bottom: 12px;">Up Next</h3>
|
||||
@if ($recommendedVideos && $recommendedVideos->count() > 0)
|
||||
<div class="recommended-videos-list">
|
||||
@foreach ($recommendedVideos as $recVideo)
|
||||
<div class="sidebar-video-card"
|
||||
onclick="window.location.href='{{ route('videos.show', $recVideo->id) }}'">
|
||||
<div class="sidebar-thumb">
|
||||
@if ($recVideo->thumbnail)
|
||||
<img src="{{ asset('storage/thumbnails/' . $recVideo->thumbnail) }}"
|
||||
alt="{{ $recVideo->title }}">
|
||||
@else
|
||||
<img src="https://picsum.photos/seed/{{ $recVideo->id }}/320/180"
|
||||
alt="{{ $recVideo->title }}">
|
||||
@endif
|
||||
@if ($recVideo->duration)
|
||||
<span class="yt-video-duration">{{ gmdate('i:s', $recVideo->duration) }}</span>
|
||||
@endif
|
||||
@if ($recVideo->is_shorts)
|
||||
<span class="yt-shorts-badge"
|
||||
style="position: absolute; top: 8px; left: 8px; font-size: 10px; padding: 2px 6px;">
|
||||
<i class="bi bi-collection-play-fill"></i> SHORTS
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
<div class="sidebar-info">
|
||||
<div class="sidebar-title">
|
||||
<i class="bi {{ match ($recVideo->type) {'music' => 'bi-music-note','match' => 'bi-trophy',default => 'bi-film'} }}"
|
||||
style="color: #ef4444; margin-right: 4px; font-size: 12px;"></i>
|
||||
{{ Str::limit($recVideo->title, 60) }}
|
||||
</div>
|
||||
<div class="sidebar-meta">
|
||||
<div>{{ $recVideo->user->name ?? 'Unknown' }}</div>
|
||||
<div>{{ number_format($recVideo->view_count) }} views •
|
||||
{{ $recVideo->created_at->diffForHumans() }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@else
|
||||
<div class="text-secondary">No recommendations available yet. Check back later!</div>
|
||||
@endif
|
||||
@endif
|
||||
<div class="text-secondary">More videos coming soon...</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Mobile Bottom Action Bar -->
|
||||
<div class="mobile-bottom-bar">
|
||||
@auth
|
||||
<form method="POST"
|
||||
action="{{ $video->isLikedBy(Auth::user()) ? route('videos.unlike', $video->id) : route('videos.like', $video->id) }}"
|
||||
class="d-inline" style="flex:1;">
|
||||
@csrf
|
||||
<button type="submit" class="yt-action-btn {{ $video->isLikedBy(Auth::user()) ? 'liked' : '' }}"
|
||||
style="width:100%;">
|
||||
<i class="bi {{ $video->isLikedBy(Auth::user()) ? 'bi-hand-thumbs-up-fill' : 'bi-hand-thumbs-up' }}"></i>
|
||||
<span>{{ $video->like_count > 0 ? number_format($video->like_count) : 'Like' }}</span>
|
||||
</button>
|
||||
</form>
|
||||
@else
|
||||
<a href="{{ route('login') }}" class="yt-action-btn" style="flex:1;text-align:center;">
|
||||
<i class="bi bi-hand-thumbs-up"></i><span>Like</span>
|
||||
</a>
|
||||
@endauth
|
||||
|
||||
@if ($video->isShareable())
|
||||
<button class="yt-action-btn"
|
||||
onclick="openShareModal('{{ $video->share_url }}', '{{ addslashes($video->title) }}')" style="flex:1;">
|
||||
<i class="bi bi-share"></i><span>Share</span>
|
||||
</button>
|
||||
@endif
|
||||
|
||||
<button class="yt-action-btn" onclick="openAddToPlaylistModal({{ $video->id }})" style="flex:1;">
|
||||
<i class="bi bi-collection-plus"></i><span>Save</span>
|
||||
</button>
|
||||
|
||||
@auth
|
||||
@if (Auth::id() !== $video->user_id)
|
||||
<button class="yt-action-btn" style="flex:1;background:var(--brand-red);color:white;">
|
||||
<i class="bi bi-bell"></i><span>Subscribe</span>
|
||||
</button>
|
||||
@endif
|
||||
@else
|
||||
<a href="{{ route('login') }}" class="yt-action-btn"
|
||||
style="flex:1;background:var(--brand-red);color:white;text-align:center;">
|
||||
<i class="bi bi-bell"></i><span>Subscribe</span>
|
||||
</a>
|
||||
@endauth
|
||||
</div>
|
||||
|
||||
@include('layouts.partials.share-modal')
|
||||
@include('layouts.partials.edit-video-modal')
|
||||
@include('layouts.partials.add-to-playlist-modal')
|
||||
|
||||
@if (Session::has('openEditModal') && Session::get('openEditModal'))
|
||||
@if(Session::has('openEditModal') && Session::get('openEditModal'))
|
||||
@auth
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
@ -779,94 +486,39 @@
|
||||
videoPlayer.volume = 0.5;
|
||||
var playPromise = videoPlayer.play();
|
||||
if (playPromise !== undefined) {
|
||||
playPromise.then(function() {
|
||||
console.log('Video autoplayed');
|
||||
}).catch(function(error) {
|
||||
console.log('Autoplay blocked');
|
||||
});
|
||||
playPromise.then(function() { console.log('Video autoplayed'); }).catch(function(error) { console.log('Autoplay blocked'); });
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@endsection
|
||||
|
||||
|
||||
/* Mobile Bottom Action Bar */
|
||||
@media (max-width: 576px) {
|
||||
.mobile-bottom-bar {
|
||||
display: flex !important;
|
||||
}
|
||||
.desktop-actions {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.mobile-bottom-bar {
|
||||
display: none;
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--bg-primary);
|
||||
border-top: 1px solid var(--border-color);
|
||||
padding: 12px 16px;
|
||||
justify-content: space-around;
|
||||
z-index: 1000;
|
||||
gap: 8px;
|
||||
box-shadow: 0 -2px 10px rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
.mobile-bottom-bar .yt-action-btn {
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
padding: 12px 8px;
|
||||
font-size: 12px;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.mobile-bottom-bar .yt-action-btn i {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
.yt-video-section {
|
||||
padding-bottom: 70px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
<!-- Extra Mobile Styles -->
|
||||
<style>
|
||||
<!-- Extra Mobile Styles -->
|
||||
<style>
|
||||
@media (max-width: 400px) {
|
||||
.video-layout-container {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.video-title {
|
||||
font-size: 15px !important;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.video-stats-left {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.video-description-box {
|
||||
margin: 12px 4px;
|
||||
padding: 10px !important;
|
||||
}
|
||||
|
||||
.comments-section {
|
||||
margin-top: 16px;
|
||||
padding: 12px 4px;
|
||||
}
|
||||
|
||||
.comment-form {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.comment-form>img {
|
||||
.comment-form > img {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@ -1,199 +0,0 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', 'Trending Videos | ' . config('app.name'))
|
||||
|
||||
@section('extra_styles')
|
||||
<style>
|
||||
.trending-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 24px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.trending-header h1 {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.trending-icon {
|
||||
color: #f00;
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.trending-filters {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.trending-filters a {
|
||||
padding: 6px 14px;
|
||||
border-radius: 18px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-secondary);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.trending-filters a:hover {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.trending-filters a.active {
|
||||
background: var(--accent-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.trending-badge {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
background: rgba(0, 0, 0, 0.85);
|
||||
color: white;
|
||||
padding: 3px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.trending-badge i { color: #f00; }
|
||||
|
||||
.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;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.yt-video-card:hover .yt-video-thumb img { transform: scale(1.05); }
|
||||
|
||||
.yt-video-duration {
|
||||
position: absolute;
|
||||
bottom: 8px;
|
||||
right: 8px;
|
||||
background: rgba(0, 0, 0, 0.85);
|
||||
color: white;
|
||||
padding: 3px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.yt-video-meta {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
.yt-video-avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.yt-video-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.yt-video-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 6px;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.yt-video-channel {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.yt-video-channel a {
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.yt-video-stats { font-size: 13px; color: var(--text-secondary); }
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.yt-video-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.trending-header { flex-wrap: wrap; }
|
||||
.trending-filters { margin-left: 0; width: 100%; margin-top: 12px; }
|
||||
.yt-video-grid { grid-template-columns: 1fr; gap: 20px; }
|
||||
}
|
||||
|
||||
.empty-trending {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.empty-trending i { font-size: 64px; margin-bottom: 16px; opacity: 0.5; }
|
||||
.empty-trending h3 { font-size: 20px; color: var(--text-primary); margin-bottom: 8px; }
|
||||
</style>
|
||||
@endsection
|
||||
|
||||
@section('content')
|
||||
<div class="trending-header">
|
||||
<h1><i class="bi bi-fire trending-icon"></i> Trending</h1>
|
||||
<div class="trending-filters">
|
||||
<a href="{{ route('videos.trending', ['hours' => 24]) }}" class="{{ $hours == 24 ? 'active' : '' }}">Today</a>
|
||||
<a href="{{ route('videos.trending', ['hours' => 48]) }}" class="{{ $hours == 48 ? 'active' : '' }}">This Week</a>
|
||||
<a href="{{ route('videos.trending', ['hours' => 168]) }}" class="{{ $hours == 168 ? 'active' : '' }}">This Month</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if($videos->isEmpty())
|
||||
<div class="empty-trending">
|
||||
<i class="bi bi-play-circle"></i>
|
||||
<h3>No trending videos yet</h3>
|
||||
<p>Videos with high engagement will appear here</p>
|
||||
</div>
|
||||
@else
|
||||
<div class="yt-video-grid">
|
||||
@foreach($videos as $video)
|
||||
<x-video-card :video="$video" />
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
@endsection
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -5,10 +5,7 @@
|
||||
@section('extra_styles')
|
||||
<style>
|
||||
/* Video Section */
|
||||
.yt-video-section {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.yt-video-section { flex: 1; min-width: 0; }
|
||||
|
||||
/* Video Player */
|
||||
.video-container {
|
||||
@ -31,159 +28,11 @@
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.video-container.portrait {
|
||||
aspect-ratio: 9/16;
|
||||
max-width: 50vh;
|
||||
}
|
||||
.video-container.portrait { aspect-ratio: 9/16; max-width: 50vh; }
|
||||
.video-container.square { aspect-ratio: 1/1; max-width: 70vh; }
|
||||
.video-container.ultrawide { aspect-ratio: 21/9; max-width: 100%; }
|
||||
|
||||
.video-container.square {
|
||||
aspect-ratio: 1/1;
|
||||
max-width: 70vh;
|
||||
}
|
||||
|
||||
.video-container.ultrawide {
|
||||
aspect-ratio: 21/9;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.video-container video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
/* Playlist Navigation Controls */
|
||||
.playlist-controls {
|
||||
position: absolute;
|
||||
bottom: 60px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
padding: 8px 16px;
|
||||
border-radius: 24px;
|
||||
z-index: 10;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.video-container:hover .playlist-controls,
|
||||
.playlist-controls.visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.playlist-nav-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
padding: 8px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.playlist-nav-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.playlist-nav-btn:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.playlist-nav-btn:disabled:hover {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.playlist-nav-btn i {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.playlist-nav-label {
|
||||
font-size: 12px;
|
||||
color: #aaa;
|
||||
max-width: 120px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Autoplay Toggle */
|
||||
.autoplay-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 8px;
|
||||
border-left: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.autoplay-toggle label {
|
||||
font-size: 12px;
|
||||
color: #aaa;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.autoplay-switch {
|
||||
position: relative;
|
||||
width: 36px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.autoplay-switch input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.autoplay-slider {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: #666;
|
||||
transition: 0.3s;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.autoplay-slider:before {
|
||||
position: absolute;
|
||||
content: "";
|
||||
height: 14px;
|
||||
width: 14px;
|
||||
left: 3px;
|
||||
bottom: 3px;
|
||||
background-color: white;
|
||||
transition: 0.3s;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.autoplay-switch input:checked+.autoplay-slider {
|
||||
background-color: var(--brand-red);
|
||||
}
|
||||
|
||||
.autoplay-switch input:checked+.autoplay-slider:before {
|
||||
transform: translateX(16px);
|
||||
}
|
||||
|
||||
/* Keyboard hint */
|
||||
.keyboard-hint {
|
||||
position: absolute;
|
||||
bottom: 10px;
|
||||
right: 10px;
|
||||
font-size: 10px;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.video-container video { width: 100%; height: 100%; object-fit: contain; }
|
||||
|
||||
/* Video Info */
|
||||
.video-title {
|
||||
@ -203,14 +52,71 @@
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.video-stats-left {
|
||||
.video-stats-left { display: flex; align-items: center; gap: 16px; color: var(--text-secondary); }
|
||||
|
||||
.video-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.yt-action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 16px;
|
||||
border-radius: 20px;
|
||||
border: none;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.yt-action-btn:hover { background: var(--border-color); }
|
||||
|
||||
.yt-action-btn.liked { 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 {
|
||||
@ -248,16 +154,9 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-thumb img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.sidebar-thumb img { width: 100%; height: 100%; object-fit: cover; }
|
||||
|
||||
.sidebar-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.sidebar-info { flex: 1; min-width: 0; }
|
||||
|
||||
.sidebar-title {
|
||||
font-size: 14px;
|
||||
@ -269,45 +168,27 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar-meta {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.sidebar-meta { font-size: 12px; color: var(--text-secondary); }
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 1300px) {
|
||||
.yt-sidebar-container {
|
||||
width: 300px;
|
||||
}
|
||||
.yt-sidebar-container { width: 300px; }
|
||||
}
|
||||
|
||||
@media (max-width: 991px) {
|
||||
.yt-main {
|
||||
margin-left: 0;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.yt-sidebar-container {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sidebar-video-card {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sidebar-thumb {
|
||||
width: 100%;
|
||||
}
|
||||
.yt-main { margin-left: 0; flex-direction: column; }
|
||||
.yt-sidebar-container { width: 100%; }
|
||||
.yt-header-center { display: none; }
|
||||
.sidebar-video-card { flex-direction: column; }
|
||||
.sidebar-thumb { width: 100%; }
|
||||
|
||||
.video-layout-container {
|
||||
flex-direction: column !important;
|
||||
}
|
||||
|
||||
.yt-video-section {
|
||||
width: 100% !important;
|
||||
flex: none !important;
|
||||
}
|
||||
|
||||
.yt-sidebar-container {
|
||||
width: 100% !important;
|
||||
margin-top: 16px;
|
||||
@ -315,68 +196,35 @@
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
.video-stats-row {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.video-actions {
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.yt-main {
|
||||
padding: 12px !important;
|
||||
}
|
||||
.video-stats-row { flex-direction: column; align-items: flex-start; }
|
||||
.video-actions { width: 100%; overflow-x: auto; justify-content: flex-start; }
|
||||
.yt-main { padding: 12px !important; }
|
||||
|
||||
.video-container {
|
||||
max-height: 50vh !important;
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
.video-container video {
|
||||
object-fit: contain !important;
|
||||
}
|
||||
|
||||
.video-title {
|
||||
font-size: 16px !important;
|
||||
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%;
|
||||
}
|
||||
|
||||
.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
|
||||
@ -389,52 +237,10 @@
|
||||
<!-- Video Section -->
|
||||
<div class="yt-video-section">
|
||||
<!-- Video Player -->
|
||||
<div class="video-container @if ($video->orientation === 'portrait') portrait @elseif($video->orientation === 'square') square @elseif($video->orientation === 'ultrawide') ultrawide @endif"
|
||||
id="videoContainer">
|
||||
<div class="video-container @if($video->orientation === 'portrait') portrait @elseif($video->orientation === 'square') square @elseif($video->orientation === 'ultrawide') ultrawide @endif" id="videoContainer">
|
||||
<video id="videoPlayer" controls playsinline preload="metadata" autoplay>
|
||||
<source src="{{ route('videos.stream', $video->id) }}" type="video/mp4">
|
||||
</video>
|
||||
|
||||
<!-- Playlist Navigation Controls (only show when in playlist) -->
|
||||
@if ($nextVideo || $previousVideo)
|
||||
<div class="playlist-controls" id="playlistControls">
|
||||
<!-- Previous Video -->
|
||||
@if ($previousVideo)
|
||||
<button class="playlist-nav-btn" onclick="goToPreviousVideo()" title="Previous video">
|
||||
<i class="bi bi-skip-backward-fill"></i>
|
||||
</button>
|
||||
<span class="playlist-nav-label">{{ Str::limit($previousVideo->title, 20) }}</span>
|
||||
@else
|
||||
<button class="playlist-nav-btn" disabled title="No previous video">
|
||||
<i class="bi bi-skip-backward-fill"></i>
|
||||
</button>
|
||||
@endif
|
||||
|
||||
<!-- Autoplay Toggle -->
|
||||
<div class="autoplay-toggle">
|
||||
<label for="autoplayToggle">Autoplay</label>
|
||||
<label class="autoplay-switch">
|
||||
<input type="checkbox" id="autoplayToggle" checked>
|
||||
<span class="autoplay-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Next Video -->
|
||||
@if ($nextVideo)
|
||||
<span class="playlist-nav-label">{{ Str::limit($nextVideo->title, 20) }}</span>
|
||||
<button class="playlist-nav-btn" onclick="goToNextVideo()" title="Next video">
|
||||
<i class="bi bi-skip-forward-fill"></i>
|
||||
</button>
|
||||
@else
|
||||
<button class="playlist-nav-btn" disabled title="No next video">
|
||||
<i class="bi bi-skip-forward-fill"></i>
|
||||
</button>
|
||||
@endif
|
||||
</div>
|
||||
<div class="keyboard-hint">
|
||||
<i class="bi bi-arrow-left"></i> <i class="bi bi-arrow-right"></i>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<!-- Video Title with Film Icon (Generic Type) -->
|
||||
@ -452,18 +258,66 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<x-channel-row :video="$video" />
|
||||
<!-- 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="yt-action-btn" onclick="openEditVideoModal({{ $video->id }})">
|
||||
<i class="bi bi-pencil"></i> Edit
|
||||
</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="yt-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="yt-action-btn" onclick="openShareModal('{{ $video->share_url }}', '{{ addslashes($video->title) }}')">
|
||||
<i class="bi bi-share"></i> Share
|
||||
</button>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Description Box -->
|
||||
@if ($video->description)
|
||||
@if($video->description)
|
||||
@php
|
||||
$fullDescription = $video->description;
|
||||
$shortDescription = Str::limit($fullDescription, 200);
|
||||
$needsExpand = strlen($fullDescription) > 200;
|
||||
@endphp
|
||||
|
||||
<div class="video-description-box"
|
||||
style="background: var(--bg-secondary); border-radius: 12px; padding: 12px; margin-bottom: 16px;">
|
||||
<div class="video-description-box" style="background: var(--bg-secondary); border-radius: 12px; padding: 12px; margin-bottom: 16px;">
|
||||
<div class="description-stats" style="font-size: 14px; font-weight: 500; margin-bottom: 8px;">
|
||||
<span>{{ number_format($video->view_count) }} views</span>
|
||||
<span>•</span>
|
||||
@ -471,7 +325,7 @@
|
||||
</div>
|
||||
<div style="border-bottom: 1px solid var(--border-color); margin: 8px 0;"></div>
|
||||
<div class="description-content" id="descriptionContent">
|
||||
@if ($needsExpand)
|
||||
@if($needsExpand)
|
||||
<div class="description-short" id="descShort">
|
||||
<span class="description-text">{!! Str::markdown($shortDescription) !!}</span>
|
||||
<span class="description-ellipsis" style="color: var(--text-secondary);">... </span>
|
||||
@ -479,8 +333,7 @@
|
||||
<div class="description-full" id="descFull" style="display: none;">
|
||||
<span class="description-text">{!! Str::markdown($fullDescription) !!}</span>
|
||||
</div>
|
||||
<button onclick="toggleDescription()" id="descToggleBtn"
|
||||
style="background: none; border: none; color: var(--text-primary); font-weight: 600; font-size: 14px; cursor: pointer; padding: 0; margin-top: 4px;">
|
||||
<button onclick="toggleDescription()" id="descToggleBtn" style="background: none; border: none; color: var(--text-primary); font-weight: 600; font-size: 14px; cursor: pointer; padding: 0; margin-top: 4px;">
|
||||
Show more
|
||||
</button>
|
||||
@else
|
||||
@ -508,139 +361,124 @@
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.video-description-box .description-text {
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.video-description-box .description-text p {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.video-description-box .description-text a {
|
||||
color: #3ea6ff;
|
||||
}
|
||||
.video-description-box .description-text { font-size: 14px; line-height: 1.5; color: var(--text-primary); }
|
||||
.video-description-box .description-text p { margin-bottom: 8px; }
|
||||
.video-description-box .description-text a { color: #3ea6ff; }
|
||||
</style>
|
||||
@endif
|
||||
|
||||
<x-video-comments :video="$video" />
|
||||
</div>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
setTimeout(function() {
|
||||
if (typeof enhanceComments === 'function') {
|
||||
enhanceComments();
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- Sidebar - Up Next / Recommendations -->
|
||||
<div class="yt-sidebar-container">
|
||||
@if ($playlist && $playlistVideos && $playlistVideos->count() > 0)
|
||||
<h3 style="font-size: 16px; font-weight: 500; margin-bottom: 12px;">
|
||||
<i class="bi bi-collection-play" style="margin-right: 8px;"></i>
|
||||
{{ $playlist->name }}
|
||||
<span
|
||||
style="font-weight: 400; color: var(--text-secondary); font-size: 14px;">({{ $playlistVideos->count() }}
|
||||
videos)</span>
|
||||
<!-- 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>
|
||||
<div class="recommended-videos-list">
|
||||
@foreach ($playlistVideos as $index => $playlistVideo)
|
||||
@if ($playlistVideo->id !== $video->id)
|
||||
<div class="sidebar-video-card{{ $playlistVideo->id === $video->id ? ' current-video' : '' }}"
|
||||
onclick="window.location.href='{{ route('videos.show', $playlistVideo->id) }}?playlist={{ $playlist->id }}'">
|
||||
<div class="sidebar-thumb" style="position: relative;">
|
||||
@if ($playlistVideo->thumbnail)
|
||||
<img src="{{ asset('storage/thumbnails/' . $playlistVideo->thumbnail) }}"
|
||||
alt="{{ $playlistVideo->title }}">
|
||||
|
||||
@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;">
|
||||
<textarea
|
||||
id="commentBody"
|
||||
class="form-control"
|
||||
placeholder="Add a comment... Use @ to mention someone"
|
||||
rows="3"
|
||||
style="background: var(--bg-secondary); border: 1px solid var(--border-color); color: var(--text-primary); border-radius: 8px; padding: 12px; width: 100%; resize: none;"
|
||||
></textarea>
|
||||
<div style="display: flex; gap: 8px; margin-top: 8px; justify-content: flex-end;">
|
||||
<button type="button" class="yt-action-btn" onclick="document.getElementById('commentBody').value = ''">Cancel</button>
|
||||
<button type="button" class="yt-action-btn" style="background: var(--brand-red); color: white;" onclick="submitComment({{ $video->id }})">Comment</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<img src="https://picsum.photos/seed/{{ $playlistVideo->id }}/320/180"
|
||||
alt="{{ $playlistVideo->title }}">
|
||||
@endif
|
||||
@if ($playlistVideo->duration)
|
||||
<span
|
||||
class="yt-video-duration">{{ gmdate('i:s', $playlistVideo->duration) }}</span>
|
||||
@endif
|
||||
@if ($playlistVideo->is_shorts)
|
||||
<span class="yt-shorts-badge"
|
||||
style="position: absolute; top: 8px; left: 8px; font-size: 10px; padding: 2px 6px;">
|
||||
<i class="bi bi-collection-play-fill"></i> SHORTS
|
||||
</span>
|
||||
@endif
|
||||
<!-- Playlist position indicator -->
|
||||
<span
|
||||
style="position: absolute; bottom: 4px; left: 4px; background: rgba(0,0,0,0.8); color: white; padding: 2px 6px; border-radius: 4px; font-size: 12px; font-weight: 500;">
|
||||
{{ $index + 1 }}
|
||||
</span>
|
||||
<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>
|
||||
<div class="sidebar-info">
|
||||
<div class="sidebar-title">
|
||||
<i class="bi {{ match ($playlistVideo->type) {'music' => 'bi-music-note','match' => 'bi-trophy',default => 'bi-film'} }}"
|
||||
style="color: #ef4444; margin-right: 4px; font-size: 12px;"></i>
|
||||
{{ Str::limit($playlistVideo->title, 60) }}
|
||||
</div>
|
||||
<div class="sidebar-meta">
|
||||
<div>{{ $playlistVideo->user->name ?? 'Unknown' }}</div>
|
||||
<div>{{ number_format($playlistVideo->view_count) }} views •
|
||||
{{ $playlistVideo->created_at->diffForHumans() }}</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())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
document.getElementById('commentBody').value = '';
|
||||
location.reload();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function deleteComment(commentId) {
|
||||
if (!confirm('Are you sure you want to delete this comment?')) return;
|
||||
|
||||
fetch(`/comments/${commentId}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
||||
}
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
location.reload();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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>
|
||||
@endif
|
||||
@endforeach
|
||||
</div>
|
||||
@if ($playlist->canEdit(Auth::user()))
|
||||
<a href="{{ route('playlists.show', $playlist->id) }}" class="yt-action-btn"
|
||||
style="margin-top: 12px; display: inline-block;">
|
||||
<i class="bi bi-pencil"></i> Edit Playlist
|
||||
</a>
|
||||
@endif
|
||||
@else
|
||||
|
||||
<!-- Sidebar -->
|
||||
<div class="yt-sidebar-container">
|
||||
<h3 style="font-size: 16px; font-weight: 500; margin-bottom: 12px;">Up Next</h3>
|
||||
@if ($recommendedVideos && $recommendedVideos->count() > 0)
|
||||
<div class="recommended-videos-list">
|
||||
@foreach ($recommendedVideos as $recVideo)
|
||||
<div class="sidebar-video-card"
|
||||
onclick="window.location.href='{{ route('videos.show', $recVideo->id) }}'">
|
||||
<div class="sidebar-thumb">
|
||||
@if ($recVideo->thumbnail)
|
||||
<img src="{{ asset('storage/thumbnails/' . $recVideo->thumbnail) }}"
|
||||
alt="{{ $recVideo->title }}">
|
||||
@else
|
||||
<img src="https://picsum.photos/seed/{{ $recVideo->id }}/320/180"
|
||||
alt="{{ $recVideo->title }}">
|
||||
@endif
|
||||
@if ($recVideo->duration)
|
||||
<span class="yt-video-duration">{{ gmdate('i:s', $recVideo->duration) }}</span>
|
||||
@endif
|
||||
@if ($recVideo->is_shorts)
|
||||
<span class="yt-shorts-badge"
|
||||
style="position: absolute; top: 8px; left: 8px; font-size: 10px; padding: 2px 6px;">
|
||||
<i class="bi bi-collection-play-fill"></i> SHORTS
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
<div class="sidebar-info">
|
||||
<div class="sidebar-title">
|
||||
<i class="bi {{ match ($recVideo->type) {'music' => 'bi-music-note','match' => 'bi-trophy',default => 'bi-film'} }}"
|
||||
style="color: #ef4444; margin-right: 4px; font-size: 12px;"></i>
|
||||
{{ Str::limit($recVideo->title, 60) }}
|
||||
</div>
|
||||
<div class="sidebar-meta">
|
||||
<div>{{ $recVideo->user->name ?? 'Unknown' }}</div>
|
||||
<div>{{ number_format($recVideo->view_count) }} views •
|
||||
{{ $recVideo->created_at->diffForHumans() }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@else
|
||||
<div class="text-secondary">No recommendations available yet. Check back later!</div>
|
||||
@endif
|
||||
@endif
|
||||
<div class="text-secondary">More videos coming soon...</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@ -648,7 +486,7 @@
|
||||
@include('layouts.partials.share-modal')
|
||||
@include('layouts.partials.edit-video-modal')
|
||||
|
||||
@if (Session::has('openEditModal') && Session::get('openEditModal'))
|
||||
@if(Session::has('openEditModal') && Session::get('openEditModal'))
|
||||
@auth
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
@ -659,99 +497,15 @@
|
||||
@endif
|
||||
|
||||
<script>
|
||||
// Playlist navigation URLs
|
||||
const previousVideoUrl = @json($previousVideo ? route('videos.show', $previousVideo->id) . '?playlist=' . $playlist->id : null);
|
||||
const nextVideoUrl = @json($nextVideo ? route('videos.show', $nextVideo->id) . '?playlist=' . $playlist->id : null);
|
||||
|
||||
// Autoplay management
|
||||
let autoplayEnabled = true;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var videoPlayer = document.getElementById('videoPlayer');
|
||||
var playlistControls = document.getElementById('playlistControls');
|
||||
var autoplayToggle = document.getElementById('autoplayToggle');
|
||||
|
||||
if (videoPlayer) {
|
||||
videoPlayer.volume = 0.5;
|
||||
var playPromise = videoPlayer.play();
|
||||
if (playPromise !== undefined) {
|
||||
playPromise.then(function() {
|
||||
console.log('Video autoplayed');
|
||||
}).catch(function(error) {
|
||||
console.log('Autoplay blocked');
|
||||
});
|
||||
playPromise.then(function() { console.log('Video autoplayed'); }).catch(function(error) { console.log('Autoplay blocked'); });
|
||||
}
|
||||
|
||||
// Handle video ended - autoplay next
|
||||
if (nextVideoUrl) {
|
||||
videoPlayer.addEventListener('ended', function() {
|
||||
if (autoplayEnabled) {
|
||||
console.log('Video ended, going to next...');
|
||||
window.location.href = nextVideoUrl;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Show playlist controls on hover
|
||||
if (playlistControls) {
|
||||
const videoContainer = document.getElementById('videoContainer');
|
||||
videoContainer.addEventListener('mouseenter', function() {
|
||||
playlistControls.classList.add('visible');
|
||||
});
|
||||
videoContainer.addEventListener('mouseleave', function() {
|
||||
playlistControls.classList.remove('visible');
|
||||
});
|
||||
|
||||
// Initially show for a few seconds
|
||||
setTimeout(function() {
|
||||
playlistControls.classList.add('visible');
|
||||
}, 2000);
|
||||
setTimeout(function() {
|
||||
playlistControls.classList.remove('visible');
|
||||
}, 6000);
|
||||
}
|
||||
|
||||
// Autoplay toggle handler
|
||||
if (autoplayToggle) {
|
||||
autoplayToggle.addEventListener('change', function() {
|
||||
autoplayEnabled = this.checked;
|
||||
localStorage.setItem('playlistAutoplay', autoplayEnabled ? '1' : '0');
|
||||
});
|
||||
|
||||
// Load saved preference
|
||||
const savedAutoplay = localStorage.getItem('playlistAutoplay');
|
||||
if (savedAutoplay !== null) {
|
||||
autoplayEnabled = savedAutoplay === '1';
|
||||
autoplayToggle.checked = autoplayEnabled;
|
||||
}
|
||||
}
|
||||
|
||||
// Keyboard navigation
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') {
|
||||
return;
|
||||
}
|
||||
if (e.key === 'ArrowLeft') {
|
||||
e.preventDefault();
|
||||
goToPreviousVideo();
|
||||
} else if (e.key === 'ArrowRight') {
|
||||
e.preventDefault();
|
||||
goToNextVideo();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function goToPreviousVideo() {
|
||||
if (previousVideoUrl) {
|
||||
window.location.href = previousVideoUrl;
|
||||
}
|
||||
}
|
||||
|
||||
function goToNextVideo() {
|
||||
if (nextVideoUrl) {
|
||||
window.location.href = nextVideoUrl;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@endsection
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -5,10 +5,7 @@
|
||||
@section('extra_styles')
|
||||
<style>
|
||||
/* Video Section */
|
||||
.yt-video-section {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.yt-video-section { flex: 1; min-width: 0; }
|
||||
|
||||
/* Video Player */
|
||||
.video-container {
|
||||
@ -31,159 +28,11 @@
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.video-container.portrait {
|
||||
aspect-ratio: 9/16;
|
||||
max-width: 50vh;
|
||||
}
|
||||
.video-container.portrait { aspect-ratio: 9/16; max-width: 50vh; }
|
||||
.video-container.square { aspect-ratio: 1/1; max-width: 70vh; }
|
||||
.video-container.ultrawide { aspect-ratio: 21/9; max-width: 100%; }
|
||||
|
||||
.video-container.square {
|
||||
aspect-ratio: 1/1;
|
||||
max-width: 70vh;
|
||||
}
|
||||
|
||||
.video-container.ultrawide {
|
||||
aspect-ratio: 21/9;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.video-container video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
/* Playlist Navigation Controls */
|
||||
.playlist-controls {
|
||||
position: absolute;
|
||||
bottom: 60px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
padding: 8px 16px;
|
||||
border-radius: 24px;
|
||||
z-index: 10;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.video-container:hover .playlist-controls,
|
||||
.playlist-controls.visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.playlist-nav-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
padding: 8px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.playlist-nav-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.playlist-nav-btn:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.playlist-nav-btn:disabled:hover {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.playlist-nav-btn i {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.playlist-nav-label {
|
||||
font-size: 12px;
|
||||
color: #aaa;
|
||||
max-width: 120px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Autoplay Toggle */
|
||||
.autoplay-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 8px;
|
||||
border-left: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.autoplay-toggle label {
|
||||
font-size: 12px;
|
||||
color: #aaa;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.autoplay-switch {
|
||||
position: relative;
|
||||
width: 36px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.autoplay-switch input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.autoplay-slider {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: #666;
|
||||
transition: 0.3s;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.autoplay-slider:before {
|
||||
position: absolute;
|
||||
content: "";
|
||||
height: 14px;
|
||||
width: 14px;
|
||||
left: 3px;
|
||||
bottom: 3px;
|
||||
background-color: white;
|
||||
transition: 0.3s;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.autoplay-switch input:checked+.autoplay-slider {
|
||||
background-color: var(--brand-red);
|
||||
}
|
||||
|
||||
.autoplay-switch input:checked+.autoplay-slider:before {
|
||||
transform: translateX(16px);
|
||||
}
|
||||
|
||||
/* Keyboard hint */
|
||||
.keyboard-hint {
|
||||
position: absolute;
|
||||
bottom: 10px;
|
||||
right: 10px;
|
||||
font-size: 10px;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.video-container video { width: 100%; height: 100%; object-fit: contain; }
|
||||
|
||||
/* Video Info */
|
||||
.video-title {
|
||||
@ -203,12 +52,7 @@
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.video-stats-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.video-stats-left { display: flex; align-items: center; gap: 16px; color: var(--text-secondary); }
|
||||
|
||||
.video-actions {
|
||||
display: flex;
|
||||
@ -230,57 +74,50 @@
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.yt-action-btn:hover {
|
||||
background: var(--border-color);
|
||||
}
|
||||
.yt-action-btn:hover { background: var(--border-color); }
|
||||
|
||||
.action-btn,
|
||||
.comments-section .action-btn {
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 8px 14px;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-primary);
|
||||
.yt-action-btn.liked { color: var(--brand-red); }
|
||||
|
||||
/* Channel Row */
|
||||
.channel-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
transition: all 0.2s ease;
|
||||
justify-content: space-between;
|
||||
padding: 16px 0;
|
||||
}
|
||||
|
||||
.action-btn:hover,
|
||||
.comments-section .action-btn:hover {
|
||||
background: var(--border-color);
|
||||
transform: translateY(-1px);
|
||||
.channel-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.action-btn:active,
|
||||
.comments-section .action-btn:active {
|
||||
transform: translateY(0);
|
||||
.channel-avatar {
|
||||
width: 48px; height: 48px; border-radius: 50%; background: #555;
|
||||
}
|
||||
|
||||
.action-btn svg,
|
||||
.action-btn i,
|
||||
.comments-section .action-btn svg,
|
||||
.comments-section .action-btn i {
|
||||
flex-shrink: 0;
|
||||
.channel-name {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.action-btn.comment-btn {
|
||||
background: var(--brand-red);
|
||||
color: white;
|
||||
border-color: var(--brand-red);
|
||||
.channel-subs {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.yt-action-btn.liked {
|
||||
color: var(--brand-red);
|
||||
.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 {
|
||||
background: var(--bg-secondary);
|
||||
@ -317,16 +154,9 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-thumb img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.sidebar-thumb img { width: 100%; height: 100%; object-fit: cover; }
|
||||
|
||||
.sidebar-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.sidebar-info { flex: 1; min-width: 0; }
|
||||
|
||||
.sidebar-title {
|
||||
font-size: 14px;
|
||||
@ -338,45 +168,27 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar-meta {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.sidebar-meta { font-size: 12px; color: var(--text-secondary); }
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 1300px) {
|
||||
.yt-sidebar-container {
|
||||
width: 300px;
|
||||
}
|
||||
.yt-sidebar-container { width: 300px; }
|
||||
}
|
||||
|
||||
@media (max-width: 991px) {
|
||||
.yt-main {
|
||||
margin-left: 0;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.yt-sidebar-container {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sidebar-video-card {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sidebar-thumb {
|
||||
width: 100%;
|
||||
}
|
||||
.yt-main { margin-left: 0; flex-direction: column; }
|
||||
.yt-sidebar-container { width: 100%; }
|
||||
.yt-header-center { display: none; }
|
||||
.sidebar-video-card { flex-direction: column; }
|
||||
.sidebar-thumb { width: 100%; }
|
||||
|
||||
.video-layout-container {
|
||||
flex-direction: column !important;
|
||||
}
|
||||
|
||||
.yt-video-section {
|
||||
width: 100% !important;
|
||||
flex: none !important;
|
||||
}
|
||||
|
||||
.yt-sidebar-container {
|
||||
width: 100% !important;
|
||||
margin-top: 16px;
|
||||
@ -384,49 +196,32 @@
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
.video-stats-row {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.video-actions {
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.yt-main {
|
||||
padding: 12px !important;
|
||||
}
|
||||
.video-stats-row { flex-direction: column; align-items: flex-start; }
|
||||
.video-actions { width: 100%; overflow-x: auto; justify-content: flex-start; }
|
||||
.yt-main { padding: 12px !important; }
|
||||
|
||||
.video-container {
|
||||
max-height: 50vh !important;
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
.video-container video {
|
||||
object-fit: contain !important;
|
||||
}
|
||||
|
||||
.video-title {
|
||||
font-size: 16px !important;
|
||||
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%;
|
||||
}
|
||||
|
||||
.video-description {
|
||||
padding: 12px !important;
|
||||
}
|
||||
@ -442,52 +237,10 @@
|
||||
<!-- Video Section -->
|
||||
<div class="yt-video-section">
|
||||
<!-- Video Player -->
|
||||
<div class="video-container @if ($video->orientation === 'portrait') portrait @elseif($video->orientation === 'square') square @elseif($video->orientation === 'ultrawide') ultrawide @endif"
|
||||
id="videoContainer">
|
||||
<div class="video-container @if($video->orientation === 'portrait') portrait @elseif($video->orientation === 'square') square @elseif($video->orientation === 'ultrawide') ultrawide @endif" id="videoContainer">
|
||||
<video id="videoPlayer" controls playsinline preload="metadata" autoplay>
|
||||
<source src="{{ route('videos.stream', $video->id) }}" type="video/mp4">
|
||||
</video>
|
||||
|
||||
<!-- Playlist Navigation Controls (only show when in playlist) -->
|
||||
@if ($nextVideo || $previousVideo)
|
||||
<div class="playlist-controls" id="playlistControls">
|
||||
<!-- Previous Video -->
|
||||
@if ($previousVideo)
|
||||
<button class="playlist-nav-btn" onclick="goToPreviousVideo()" title="Previous video">
|
||||
<i class="bi bi-skip-backward-fill"></i>
|
||||
</button>
|
||||
<span class="playlist-nav-label">{{ Str::limit($previousVideo->title, 20) }}</span>
|
||||
@else
|
||||
<button class="playlist-nav-btn" disabled title="No previous video">
|
||||
<i class="bi bi-skip-backward-fill"></i>
|
||||
</button>
|
||||
@endif
|
||||
|
||||
<!-- Autoplay Toggle -->
|
||||
<div class="autoplay-toggle">
|
||||
<label for="autoplayToggle">Autoplay</label>
|
||||
<label class="autoplay-switch">
|
||||
<input type="checkbox" id="autoplayToggle" checked>
|
||||
<span class="autoplay-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Next Video -->
|
||||
@if ($nextVideo)
|
||||
<span class="playlist-nav-label">{{ Str::limit($nextVideo->title, 20) }}</span>
|
||||
<button class="playlist-nav-btn" onclick="goToNextVideo()" title="Next video">
|
||||
<i class="bi bi-skip-forward-fill"></i>
|
||||
</button>
|
||||
@else
|
||||
<button class="playlist-nav-btn" disabled title="No next video">
|
||||
<i class="bi bi-skip-forward-fill"></i>
|
||||
</button>
|
||||
@endif
|
||||
</div>
|
||||
<div class="keyboard-hint">
|
||||
<i class="bi bi-arrow-left"></i> <i class="bi bi-arrow-right"></i>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<!-- Video Title with Music Note Icon (Music Type) -->
|
||||
@ -506,18 +259,65 @@
|
||||
</div>
|
||||
|
||||
<!-- Channel Row - All in one line -->
|
||||
<x-channel-row :video="$video" />
|
||||
<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="yt-action-btn" onclick="openEditVideoModal({{ $video->id }})">
|
||||
<i class="bi bi-pencil"></i> Edit
|
||||
</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="yt-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="yt-action-btn" onclick="openShareModal('{{ $video->share_url }}', '{{ addslashes($video->title) }}')">
|
||||
<i class="bi bi-share"></i> Share
|
||||
</button>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Description Box -->
|
||||
@if ($video->description)
|
||||
@if($video->description)
|
||||
@php
|
||||
$fullDescription = $video->description;
|
||||
$shortDescription = Str::limit($fullDescription, 200);
|
||||
$needsExpand = strlen($fullDescription) > 200;
|
||||
@endphp
|
||||
|
||||
<div class="video-description-box"
|
||||
style="background: var(--bg-secondary); border-radius: 12px; padding: 12px; margin-bottom: 16px;">
|
||||
<div class="video-description-box" style="background: var(--bg-secondary); border-radius: 12px; padding: 12px; margin-bottom: 16px;">
|
||||
<div class="description-stats" style="font-size: 14px; font-weight: 500; margin-bottom: 8px;">
|
||||
<span>{{ number_format($video->view_count) }} views</span>
|
||||
<span>•</span>
|
||||
@ -525,7 +325,7 @@
|
||||
</div>
|
||||
<div style="border-bottom: 1px solid var(--border-color); margin: 8px 0;"></div>
|
||||
<div class="description-content" id="descriptionContent">
|
||||
@if ($needsExpand)
|
||||
@if($needsExpand)
|
||||
<div class="description-short" id="descShort">
|
||||
<span class="description-text">{!! Str::markdown($shortDescription) !!}</span>
|
||||
<span class="description-ellipsis" style="color: var(--text-secondary);">... </span>
|
||||
@ -533,8 +333,7 @@
|
||||
<div class="description-full" id="descFull" style="display: none;">
|
||||
<span class="description-text">{!! Str::markdown($fullDescription) !!}</span>
|
||||
</div>
|
||||
<button onclick="toggleDescription()" id="descToggleBtn"
|
||||
style="background: none; border: none; color: var(--text-primary); font-weight: 600; font-size: 14px; cursor: pointer; padding: 0; margin-top: 4px;">
|
||||
<button onclick="toggleDescription()" id="descToggleBtn" style="background: none; border: none; color: var(--text-primary); font-weight: 600; font-size: 14px; cursor: pointer; padding: 0; margin-top: 4px;">
|
||||
Show more
|
||||
</button>
|
||||
@else
|
||||
@ -562,131 +361,124 @@
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.video-description-box .description-text {
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.video-description-box .description-text p {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.video-description-box .description-text a {
|
||||
color: #3ea6ff;
|
||||
}
|
||||
.video-description-box .description-text { font-size: 14px; line-height: 1.5; color: var(--text-primary); }
|
||||
.video-description-box .description-text p { margin-bottom: 8px; }
|
||||
.video-description-box .description-text a { color: #3ea6ff; }
|
||||
</style>
|
||||
@endif
|
||||
|
||||
<x-video-comments :video="$video" />
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Sidebar - Up Next / Recommendations -->
|
||||
<div class="yt-sidebar-container">
|
||||
@if ($playlist && $playlistVideos && $playlistVideos->count() > 0)
|
||||
<h3 style="font-size: 16px; font-weight: 500; margin-bottom: 12px;">
|
||||
<i class="bi bi-collection-play" style="margin-right: 8px;"></i>
|
||||
{{ $playlist->name }}
|
||||
<span
|
||||
style="font-weight: 400; color: var(--text-secondary); font-size: 14px;">({{ $playlistVideos->count() }}
|
||||
videos)</span>
|
||||
<!-- 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>
|
||||
<div class="recommended-videos-list">
|
||||
@foreach ($playlistVideos as $index => $playlistVideo)
|
||||
@if ($playlistVideo->id !== $video->id)
|
||||
<div class="sidebar-video-card{{ $playlistVideo->id === $video->id ? ' current-video' : '' }}"
|
||||
onclick="window.location.href='{{ route('videos.show', $playlistVideo->id) }}?playlist={{ $playlist->id }}'">
|
||||
<div class="sidebar-thumb" style="position: relative;">
|
||||
@if ($playlistVideo->thumbnail)
|
||||
<img src="{{ asset('storage/thumbnails/' . $playlistVideo->thumbnail) }}"
|
||||
alt="{{ $playlistVideo->title }}">
|
||||
|
||||
@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;">
|
||||
<textarea
|
||||
id="commentBody"
|
||||
class="form-control"
|
||||
placeholder="Add a comment... Use @ to mention someone"
|
||||
rows="3"
|
||||
style="background: var(--bg-secondary); border: 1px solid var(--border-color); color: var(--text-primary); border-radius: 8px; padding: 12px; width: 100%; resize: none;"
|
||||
></textarea>
|
||||
<div style="display: flex; gap: 8px; margin-top: 8px; justify-content: flex-end;">
|
||||
<button type="button" class="yt-action-btn" onclick="document.getElementById('commentBody').value = ''">Cancel</button>
|
||||
<button type="button" class="yt-action-btn" style="background: var(--brand-red); color: white;" onclick="submitComment({{ $video->id }})">Comment</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<img src="https://picsum.photos/seed/{{ $playlistVideo->id }}/320/180"
|
||||
alt="{{ $playlistVideo->title }}">
|
||||
@endif
|
||||
@if ($playlistVideo->duration)
|
||||
<span
|
||||
class="yt-video-duration">{{ gmdate('i:s', $playlistVideo->duration) }}</span>
|
||||
@endif
|
||||
@if ($playlistVideo->is_shorts)
|
||||
<span class="yt-shorts-badge"
|
||||
style="position: absolute; top: 8px; left: 8px; font-size: 10px; padding: 2px 6px;">
|
||||
<i class="bi bi-collection-play-fill"></i> SHORTS
|
||||
</span>
|
||||
@endif
|
||||
<!-- Playlist position indicator -->
|
||||
<span
|
||||
style="position: absolute; bottom: 4px; left: 4px; background: rgba(0,0,0,0.8); color: white; padding: 2px 6px; border-radius: 4px; font-size: 12px; font-weight: 500;">
|
||||
{{ $index + 1 }}
|
||||
</span>
|
||||
<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>
|
||||
<div class="sidebar-info">
|
||||
<div class="sidebar-title">
|
||||
<i class="bi {{ match ($playlistVideo->type) {'music' => 'bi-music-note','match' => 'bi-trophy',default => 'bi-film'} }}"
|
||||
style="color: #ef4444; margin-right: 4px; font-size: 12px;"></i>
|
||||
{{ Str::limit($playlistVideo->title, 60) }}
|
||||
</div>
|
||||
<div class="sidebar-meta">
|
||||
<div>{{ $playlistVideo->user->name ?? 'Unknown' }}</div>
|
||||
<div>{{ number_format($playlistVideo->view_count) }} views •
|
||||
{{ $playlistVideo->created_at->diffForHumans() }}</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())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
document.getElementById('commentBody').value = '';
|
||||
location.reload();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function deleteComment(commentId) {
|
||||
if (!confirm('Are you sure you want to delete this comment?')) return;
|
||||
|
||||
fetch(`/comments/${commentId}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}'
|
||||
}
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
location.reload();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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>
|
||||
@endif
|
||||
@endforeach
|
||||
</div>
|
||||
@if ($playlist->canEdit(Auth::user()))
|
||||
<a href="{{ route('playlists.show', $playlist->id) }}" class="yt-action-btn"
|
||||
style="margin-top: 12px; display: inline-block;">
|
||||
<i class="bi bi-pencil"></i> Edit Playlist
|
||||
</a>
|
||||
@endif
|
||||
@else
|
||||
|
||||
<!-- Sidebar -->
|
||||
<div class="yt-sidebar-container">
|
||||
<h3 style="font-size: 16px; font-weight: 500; margin-bottom: 12px;">Up Next</h3>
|
||||
@if ($recommendedVideos && $recommendedVideos->count() > 0)
|
||||
<div class="recommended-videos-list">
|
||||
@foreach ($recommendedVideos as $recVideo)
|
||||
<div class="sidebar-video-card"
|
||||
onclick="window.location.href='{{ route('videos.show', $recVideo->id) }}'">
|
||||
<div class="sidebar-thumb">
|
||||
@if ($recVideo->thumbnail)
|
||||
<img src="{{ asset('storage/thumbnails/' . $recVideo->thumbnail) }}"
|
||||
alt="{{ $recVideo->title }}">
|
||||
@else
|
||||
<img src="https://picsum.photos/seed/{{ $recVideo->id }}/320/180"
|
||||
alt="{{ $recVideo->title }}">
|
||||
@endif
|
||||
@if ($recVideo->duration)
|
||||
<span class="yt-video-duration">{{ gmdate('i:s', $recVideo->duration) }}</span>
|
||||
@endif
|
||||
@if ($recVideo->is_shorts)
|
||||
<span class="yt-shorts-badge"
|
||||
style="position: absolute; top: 8px; left: 8px; font-size: 10px; padding: 2px 6px;">
|
||||
<i class="bi bi-collection-play-fill"></i> SHORTS
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
<div class="sidebar-info">
|
||||
<div class="sidebar-title">
|
||||
<i class="bi {{ match ($recVideo->type) {'music' => 'bi-music-note','match' => 'bi-trophy',default => 'bi-film'} }}"
|
||||
style="color: #ef4444; margin-right: 4px; font-size: 12px;"></i>
|
||||
{{ Str::limit($recVideo->title, 60) }}
|
||||
</div>
|
||||
<div class="sidebar-meta">
|
||||
<div>{{ $recVideo->user->name ?? 'Unknown' }}</div>
|
||||
<div>{{ number_format($recVideo->view_count) }} views •
|
||||
{{ $recVideo->created_at->diffForHumans() }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@else
|
||||
<div class="text-secondary">No recommendations available yet. Check back later!</div>
|
||||
@endif
|
||||
@endif
|
||||
<div class="text-secondary">More videos coming soon...</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@ -694,7 +486,7 @@
|
||||
@include('layouts.partials.share-modal')
|
||||
@include('layouts.partials.edit-video-modal')
|
||||
|
||||
@if (Session::has('openEditModal') && Session::get('openEditModal'))
|
||||
@if(Session::has('openEditModal') && Session::get('openEditModal'))
|
||||
@auth
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
@ -705,99 +497,15 @@
|
||||
@endif
|
||||
|
||||
<script>
|
||||
// Playlist navigation URLs
|
||||
const previousVideoUrl = @json($previousVideo ? route('videos.show', $previousVideo->id) . '?playlist=' . $playlist->id : null);
|
||||
const nextVideoUrl = @json($nextVideo ? route('videos.show', $nextVideo->id) . '?playlist=' . $playlist->id : null);
|
||||
|
||||
// Autoplay management
|
||||
let autoplayEnabled = true;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var videoPlayer = document.getElementById('videoPlayer');
|
||||
var playlistControls = document.getElementById('playlistControls');
|
||||
var autoplayToggle = document.getElementById('autoplayToggle');
|
||||
|
||||
if (videoPlayer) {
|
||||
videoPlayer.volume = 0.5;
|
||||
var playPromise = videoPlayer.play();
|
||||
if (playPromise !== undefined) {
|
||||
playPromise.then(function() {
|
||||
console.log('Video autoplayed');
|
||||
}).catch(function(error) {
|
||||
console.log('Autoplay blocked');
|
||||
});
|
||||
playPromise.then(function() { console.log('Video autoplayed'); }).catch(function(error) { console.log('Autoplay blocked'); });
|
||||
}
|
||||
|
||||
// Handle video ended - autoplay next
|
||||
if (nextVideoUrl) {
|
||||
videoPlayer.addEventListener('ended', function() {
|
||||
if (autoplayEnabled) {
|
||||
console.log('Video ended, going to next...');
|
||||
window.location.href = nextVideoUrl;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Show playlist controls on hover
|
||||
if (playlistControls) {
|
||||
const videoContainer = document.getElementById('videoContainer');
|
||||
videoContainer.addEventListener('mouseenter', function() {
|
||||
playlistControls.classList.add('visible');
|
||||
});
|
||||
videoContainer.addEventListener('mouseleave', function() {
|
||||
playlistControls.classList.remove('visible');
|
||||
});
|
||||
|
||||
// Initially show for a few seconds
|
||||
setTimeout(function() {
|
||||
playlistControls.classList.add('visible');
|
||||
}, 2000);
|
||||
setTimeout(function() {
|
||||
playlistControls.classList.remove('visible');
|
||||
}, 6000);
|
||||
}
|
||||
|
||||
// Autoplay toggle handler
|
||||
if (autoplayToggle) {
|
||||
autoplayToggle.addEventListener('change', function() {
|
||||
autoplayEnabled = this.checked;
|
||||
localStorage.setItem('playlistAutoplay', autoplayEnabled ? '1' : '0');
|
||||
});
|
||||
|
||||
// Load saved preference
|
||||
const savedAutoplay = localStorage.getItem('playlistAutoplay');
|
||||
if (savedAutoplay !== null) {
|
||||
autoplayEnabled = savedAutoplay === '1';
|
||||
autoplayToggle.checked = autoplayEnabled;
|
||||
}
|
||||
}
|
||||
|
||||
// Keyboard navigation
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') {
|
||||
return;
|
||||
}
|
||||
if (e.key === 'ArrowLeft') {
|
||||
e.preventDefault();
|
||||
goToPreviousVideo();
|
||||
} else if (e.key === 'ArrowRight') {
|
||||
e.preventDefault();
|
||||
goToNextVideo();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function goToPreviousVideo() {
|
||||
if (previousVideoUrl) {
|
||||
window.location.href = previousVideoUrl;
|
||||
}
|
||||
}
|
||||
|
||||
function goToNextVideo() {
|
||||
if (nextVideoUrl) {
|
||||
window.location.href = nextVideoUrl;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@endsection
|
||||
|
||||
File diff suppressed because one or more lines are too long
@ -1,26 +1,23 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\CommentController;
|
||||
use App\Http\Controllers\MatchEventController;
|
||||
use App\Http\Controllers\SuperAdminController;
|
||||
use App\Http\Controllers\UserController;
|
||||
use App\Http\Controllers\VideoController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use App\Http\Controllers\VideoController;
|
||||
use App\Http\Controllers\UserController;
|
||||
use App\Http\Controllers\SuperAdminController;
|
||||
use App\Http\Controllers\CommentController;
|
||||
|
||||
// Root route - show videos
|
||||
Route::get('/', [VideoController::class, 'index'])->name('home');
|
||||
// Redirect root to videos
|
||||
Route::get('/', function () {
|
||||
return redirect('/videos');
|
||||
});
|
||||
|
||||
// Video routes - public
|
||||
Route::get('/videos', [VideoController::class, 'index'])->name('videos.index');
|
||||
Route::get('/videos/search', [VideoController::class, 'search'])->name('videos.search');
|
||||
Route::get('/trending', [VideoController::class, 'trending'])->name('videos.trending');
|
||||
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');
|
||||
|
||||
// Video routes - auth required
|
||||
Route::middleware('auth')->group(function () {
|
||||
@ -62,37 +59,6 @@ Route::middleware('auth')->group(function () {
|
||||
// Channel - public for viewing, own channel requires auth
|
||||
Route::get('/channel/{userId?}', [UserController::class, 'channel'])->name('channel');
|
||||
|
||||
// Playlist routes
|
||||
use App\Http\Controllers\PlaylistController;
|
||||
|
||||
// Get user playlists (for dropdown) - MUST be before /playlists/{playlist} route
|
||||
Route::get('/user/playlists', [PlaylistController::class, 'userPlaylists'])->name('playlists.userPlaylists');
|
||||
|
||||
// Public playlist routes
|
||||
Route::get('/playlists', [PlaylistController::class, 'index'])->name('playlists.index');
|
||||
Route::get('/playlists/{playlist}', [PlaylistController::class, 'show'])->name('playlists.show');
|
||||
|
||||
// Authenticated playlist routes
|
||||
Route::middleware('auth')->group(function () {
|
||||
Route::get('/playlists/create', [PlaylistController::class, 'create'])->name('playlists.create');
|
||||
Route::post('/playlists', [PlaylistController::class, 'store'])->name('playlists.store');
|
||||
Route::get('/playlists/{playlist}/edit', [PlaylistController::class, 'edit'])->name('playlists.edit');
|
||||
Route::put('/playlists/{playlist}', [PlaylistController::class, 'update'])->name('playlists.update');
|
||||
Route::delete('/playlists/{playlist}', [PlaylistController::class, 'destroy'])->name('playlists.destroy');
|
||||
|
||||
// Playlist video management
|
||||
Route::post('/playlists/{playlist}/videos', [PlaylistController::class, 'addVideo'])->name('playlists.addVideo');
|
||||
Route::delete('/playlists/{playlist}/videos/{video}', [PlaylistController::class, 'removeVideo'])->name('playlists.removeVideo');
|
||||
Route::put('/playlists/{playlist}/reorder', [PlaylistController::class, 'reorder'])->name('playlists.reorder');
|
||||
|
||||
// Playlist actions
|
||||
Route::get('/playlists/{playlist}/play', [PlaylistController::class, 'playAll'])->name('playlists.playAll');
|
||||
Route::get('/playlists/{playlist}/shuffle', [PlaylistController::class, 'shuffle'])->name('playlists.shuffle');
|
||||
|
||||
// Watch Later
|
||||
Route::post('/videos/{video}/watch-later', [PlaylistController::class, 'watchLater'])->name('videos.watchLater');
|
||||
});
|
||||
|
||||
// Authentication Routes
|
||||
require __DIR__.'/auth.php';
|
||||
|
||||
@ -114,23 +80,3 @@ Route::middleware(['auth', 'super_admin'])->prefix('admin')->name('admin.')->gro
|
||||
Route::delete('/videos/{video}', [SuperAdminController::class, 'deleteVideo'])->name('videos.delete');
|
||||
});
|
||||
|
||||
// Match Events Routes (removed auth requirement for demo purposes)
|
||||
// In production, wrap with: Route::middleware('auth')->group(function () {
|
||||
|
||||
// Get match data - public for viewing
|
||||
Route::get('/videos/{video}/match-data', [MatchEventController::class, 'getMatchData'])->name('match.getData');
|
||||
|
||||
// Round CRUD
|
||||
Route::post('/videos/{video}/rounds', [MatchEventController::class, 'storeRound'])->name('match.storeRound');
|
||||
Route::put('/rounds/{round}', [MatchEventController::class, 'updateRound'])->name('match.updateRound');
|
||||
Route::delete('/rounds/{round}', [MatchEventController::class, 'destroyRound'])->name('match.destroyRound');
|
||||
|
||||
// Point CRUD
|
||||
Route::post('/videos/{video}/points', [MatchEventController::class, 'storePoint'])->name('match.storePoint');
|
||||
Route::put('/points/{point}', [MatchEventController::class, 'updatePoint'])->name('match.updatePoint');
|
||||
Route::delete('/points/{point}', [MatchEventController::class, 'destroyPoint'])->name('match.destroyPoint');
|
||||
|
||||
// Coach Review CRUD
|
||||
Route::post('/videos/{video}/reviews', [MatchEventController::class, 'storeReview'])->name('match.storeReview');
|
||||
Route::put('/reviews/{review}', [MatchEventController::class, 'updateReview'])->name('match.updateReview');
|
||||
Route::delete('/reviews/{review}', [MatchEventController::class, 'destroyReview'])->name('match.destroyReview');
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user