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,25 +94,21 @@ 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
|
||||
public function history()
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
|
||||
// Get videos the user has watched, ordered by most recently watched
|
||||
// Include private videos since they are the user's own
|
||||
$videoIds = \DB::table('video_views')
|
||||
@ -146,9 +118,9 @@ 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);
|
||||
->orWhere('user_id', $user->id);
|
||||
})
|
||||
->get()
|
||||
->sortByDesc(function ($video) use ($videoIds) {
|
||||
@ -164,9 +136,9 @@ 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);
|
||||
->orWhere('videos.user_id', $user->id);
|
||||
})
|
||||
->latest()
|
||||
->paginate(12);
|
||||
@ -178,8 +150,8 @@ class UserController extends Controller
|
||||
public function like(Video $video)
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
if (! $video->isLikedBy($user)) {
|
||||
|
||||
if (!$video->isLikedBy($user)) {
|
||||
$video->likes()->attach($user->id);
|
||||
}
|
||||
|
||||
@ -190,7 +162,7 @@ class UserController extends Controller
|
||||
public function unlike(Video $video)
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
|
||||
$video->likes()->detach($user->id);
|
||||
|
||||
return back();
|
||||
@ -200,7 +172,7 @@ class UserController extends Controller
|
||||
public function toggleLike(Video $video)
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
|
||||
if ($video->isLikedBy($user)) {
|
||||
$video->likes()->detach($user->id);
|
||||
$liked = false;
|
||||
@ -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,55 +17,31 @@ 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'));
|
||||
}
|
||||
|
||||
public function search(Request $request)
|
||||
{
|
||||
$query = $request->get('q', '');
|
||||
|
||||
|
||||
if (empty($query)) {
|
||||
return redirect()->route('videos.index');
|
||||
}
|
||||
|
||||
|
||||
$videos = Video::public()
|
||||
->where(function ($q) use ($query) {
|
||||
->where(function($q) use ($query) {
|
||||
$q->where('title', 'like', "%{$query}%")
|
||||
->orWhere('description', 'like', "%{$query}%");
|
||||
->orWhere('description', 'like', "%{$query}%");
|
||||
})
|
||||
->latest()
|
||||
->get();
|
||||
|
||||
->paginate(12);
|
||||
|
||||
return view('videos.index', compact('videos', 'query'));
|
||||
}
|
||||
|
||||
@ -87,66 +62,77 @@ 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
|
||||
$fileSize = $videoFile->getSize();
|
||||
$mimeType = $videoFile->getMimeType();
|
||||
|
||||
$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);
|
||||
|
||||
if (! file_exists(storage_path('app/public/thumbnails'))) {
|
||||
|
||||
$thumbFilename = Str::uuid() . '.jpg';
|
||||
$thumbFullPath = storage_path('app/public/thumbnails/' . $thumbFilename);
|
||||
|
||||
// Ensure thumbnails directory exists
|
||||
if (!file_exists(storage_path('app/public/thumbnails'))) {
|
||||
mkdir(storage_path('app/public/thumbnails'), 0755, true);
|
||||
}
|
||||
|
||||
|
||||
$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);
|
||||
$videoStream = $streams->videos()->first();
|
||||
|
||||
|
||||
if ($videoStream) {
|
||||
$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']);
|
||||
|
||||
$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) {
|
||||
|
||||
// Load comments with user relationship
|
||||
$video->load(['comments.user', 'comments.replies.user']);
|
||||
|
||||
// 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);
|
||||
|
||||
if (! file_exists($path)) {
|
||||
|
||||
$path = storage_path('app/public/videos/' . $video->filename);
|
||||
|
||||
if (!file_exists($path)) {
|
||||
abort(404, 'Video file not found');
|
||||
}
|
||||
|
||||
@ -426,99 +331,68 @@ 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;
|
||||
|
||||
|
||||
$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');
|
||||
|
||||
|
||||
fseek($handle, $start);
|
||||
$chunkSize = 8192;
|
||||
$bytesToRead = $length;
|
||||
|
||||
while (! feof($handle) && $bytesToRead > 0) {
|
||||
|
||||
while (!feof($handle) && $bytesToRead > 0) {
|
||||
$buffer = fread($handle, min($chunkSize, $bytesToRead));
|
||||
echo $buffer;
|
||||
flush();
|
||||
$bytesToRead -= strlen($buffer);
|
||||
}
|
||||
|
||||
|
||||
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');
|
||||
|
||||
|
||||
fpassthru($handle);
|
||||
fclose($handle);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
$path = storage_path('app/public/videos/' . $video->filename);
|
||||
|
||||
if (!file_exists($path)) {
|
||||
abort(404, 'Video file not found');
|
||||
}
|
||||
|
||||
if (! $video->has_hls) {
|
||||
abort(404, 'HLS unavailable');
|
||||
}
|
||||
|
||||
$hlsPath = storage_path('app/' . $video->hls_path . '/' . $file);
|
||||
|
||||
if (! file_exists($hlsPath)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$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;
|
||||
$filename = $video->title . '.' . pathinfo($video->filename, PATHINFO_EXTENSION);
|
||||
|
||||
return response()->download($path, $filename);
|
||||
}
|
||||
|
||||
// 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;
|
||||
@ -28,10 +27,10 @@ class CompressVideoJob implements ShouldQueue
|
||||
public function handle()
|
||||
{
|
||||
$video = $this->video;
|
||||
|
||||
|
||||
// Get original file path
|
||||
$originalPath = storage_path('app/' . $video->path);
|
||||
|
||||
|
||||
if (!file_exists($originalPath)) {
|
||||
Log::error('CompressVideoJob: Original file not found: ' . $originalPath);
|
||||
return;
|
||||
@ -44,47 +43,39 @@ class CompressVideoJob implements ShouldQueue
|
||||
try {
|
||||
$ffmpeg = FFMpeg::create();
|
||||
$ffmpegVideo = $ffmpeg->open($originalPath);
|
||||
|
||||
|
||||
// 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', 'h264_nvenc');
|
||||
foreach ($videoPasses as $pass) {
|
||||
$format->addLegacyOption($pass);
|
||||
}
|
||||
foreach ($audioPasses as $pass) {
|
||||
$format->addLegacyOption($pass);
|
||||
}
|
||||
$format = new X264('aac', 'libx264');
|
||||
$format->setKiloBitrate(0); // 0 = use CRF
|
||||
$format->setAudioKiloBitrate(192);
|
||||
|
||||
// Add CRF option for high quality
|
||||
$ffmpegVideo->save($format, $compressedPath);
|
||||
|
||||
|
||||
// Check if compressed file was created and is smaller
|
||||
if (file_exists($compressedPath)) {
|
||||
$originalSize = filesize($originalPath);
|
||||
$compressedSize = filesize($compressedPath);
|
||||
|
||||
|
||||
// Only use compressed file if it's smaller
|
||||
if ($compressedSize < $originalSize) {
|
||||
// Delete original and rename compressed
|
||||
unlink($originalPath);
|
||||
rename($compressedPath, $originalPath);
|
||||
|
||||
|
||||
// Update video record
|
||||
$video->update([
|
||||
'size' => $compressedSize,
|
||||
'filename' => $video->filename, // Keep same filename
|
||||
'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
|
||||
@ -92,12 +83,9 @@ class CompressVideoJob implements ShouldQueue
|
||||
Log::info('CompressVideoJob: Compression made file larger, keeping original');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
$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()
|
||||
{
|
||||
@ -172,17 +135,16 @@ class Video extends Model
|
||||
if ($user) {
|
||||
return $query->where(function ($q) use ($user) {
|
||||
$q->where('visibility', '!=', 'private')
|
||||
->orWhere('user_id', $user->id);
|
||||
->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,115 +47,111 @@
|
||||
<!-- 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>
|
||||
<th>User</th>
|
||||
<th>Role</th>
|
||||
<th>Joined</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@forelse($recentUsers as $user)
|
||||
<tr>
|
||||
<td>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<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>
|
||||
</div>
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>User</th>
|
||||
<th>Role</th>
|
||||
<th>Joined</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@forelse($recentUsers as $user)
|
||||
<tr>
|
||||
<td>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<img src="{{ $user->avatar_url }}" alt="{{ $user->name }}" class="user-avatar">
|
||||
<div>
|
||||
<div>{{ $user->name }}</div>
|
||||
<small class="text-secondary">{{ $user->email }}</small>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
@if($user->role === 'super_admin')
|
||||
<span class="badge-role badge-super-admin">Super Admin</span>
|
||||
@elseif($user->role === 'admin')
|
||||
<span class="badge-role badge-admin">Admin</span>
|
||||
@else
|
||||
<span class="badge-role badge-user">User</span>
|
||||
@endif
|
||||
</td>
|
||||
<td>{{ $user->created_at->diffForHumans() }}</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="3" class="text-center text-secondary">No users found</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
@if($user->role === 'super_admin')
|
||||
<span class="badge-role badge-super-admin">Super Admin</span>
|
||||
@elseif($user->role === 'admin')
|
||||
<span class="badge-role badge-admin">Admin</span>
|
||||
@else
|
||||
<span class="badge-role badge-user">User</span>
|
||||
@endif
|
||||
</td>
|
||||
<td>{{ $user->created_at->diffForHumans() }}</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="3" class="text-center text-secondary">No users found</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</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>
|
||||
<th>Video</th>
|
||||
<th>Status</th>
|
||||
<th>Uploaded</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@forelse($recentVideos as $video)
|
||||
<tr>
|
||||
<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;">
|
||||
@else
|
||||
<div style="width: 60px; height: 40px; background: #333; border-radius: 4px; display: flex; align-items: center; justify-content: center; flex-shrink: 0;">
|
||||
<i class="bi bi-play-circle text-secondary"></i>
|
||||
</div>
|
||||
@endif
|
||||
<div>
|
||||
<div style="max-width: 200px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">{{ $video->title }}</div>
|
||||
<small class="text-secondary">by {{ $video->user->name }}</small>
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Video</th>
|
||||
<th>Status</th>
|
||||
<th>Uploaded</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@forelse($recentVideos as $video)
|
||||
<tr>
|
||||
<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;">
|
||||
@else
|
||||
<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
|
||||
<div>
|
||||
<div style="max-width: 200px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">{{ $video->title }}</div>
|
||||
<small class="text-secondary">by {{ $video->user->name }}</small>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
@switch($video->status)
|
||||
@case('ready')
|
||||
<span class="badge-status badge-ready">Ready</span>
|
||||
@break
|
||||
@case('processing')
|
||||
<span class="badge-status badge-processing">Processing</span>
|
||||
@break
|
||||
@case('pending')
|
||||
<span class="badge-status badge-pending">Pending</span>
|
||||
@break
|
||||
@case('failed')
|
||||
<span class="badge-status badge-failed">Failed</span>
|
||||
@break
|
||||
@endswitch
|
||||
</td>
|
||||
<td>{{ $video->created_at->diffForHumans() }}</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="3" class="text-center text-secondary">No videos found</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
@switch($video->status)
|
||||
@case('ready')
|
||||
<span class="badge-status badge-ready">Ready</span>
|
||||
@break
|
||||
@case('processing')
|
||||
<span class="badge-status badge-processing">Processing</span>
|
||||
@break
|
||||
@case('pending')
|
||||
<span class="badge-status badge-pending">Pending</span>
|
||||
@break
|
||||
@case('failed')
|
||||
<span class="badge-status badge-failed">Failed</span>
|
||||
@break
|
||||
@endswitch
|
||||
</td>
|
||||
<td>{{ $video->created_at->diffForHumans() }}</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="3" class="text-center text-secondary">No videos found</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -163,7 +159,7 @@
|
||||
<!-- 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,9 +59,9 @@
|
||||
<!-- 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">
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
@ -136,7 +136,7 @@ All Users ({{ $users->count() }})
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Pagination -->
|
||||
<div class="d-flex justify-content-center">
|
||||
{{ $users->links() }}
|
||||
@ -184,7 +184,7 @@ All Users ({{ $users->count() }})
|
||||
currentDeleteUserId = userId;
|
||||
document.getElementById('deleteUserName').textContent = userName;
|
||||
document.getElementById('deleteUserForm').action = '/admin/users/' + userId;
|
||||
|
||||
|
||||
const modal = new bootstrap.Modal(document.getElementById('deleteUserModal'));
|
||||
modal.show();
|
||||
}
|
||||
|
||||
@ -78,9 +78,9 @@
|
||||
<!-- 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">
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
@ -190,7 +190,7 @@ All Videos ({{ $videos->count() }})
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Pagination -->
|
||||
<div class="d-flex justify-content-center">
|
||||
{{ $videos->links() }}
|
||||
@ -235,7 +235,7 @@ All Videos ({{ $videos->count() }})
|
||||
function confirmDeleteVideo(videoId, videoTitle) {
|
||||
document.getElementById('deleteVideoTitle').textContent = videoTitle;
|
||||
document.getElementById('deleteVideoForm').action = '/admin/videos/' + videoId;
|
||||
|
||||
|
||||
const modal = new bootstrap.Modal(document.getElementById('deleteVideoModal'));
|
||||
modal.show();
|
||||
}
|
||||
|
||||
@ -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>
|
||||
@ -2,8 +2,8 @@
|
||||
|
||||
@php
|
||||
$videoUrl = $video ? asset('storage/videos/' . $video->filename) : null;
|
||||
$thumbnailUrl = $video && $video->thumbnail
|
||||
? asset('storage/thumbnails/' . $video->thumbnail)
|
||||
$thumbnailUrl = $video && $video->thumbnail
|
||||
? asset('storage/thumbnails/' . $video->thumbnail)
|
||||
: ($video ? 'https://picsum.photos/seed/' . $video->id . '/640/360' : 'https://picsum.photos/seed/random/640/360');
|
||||
|
||||
$typeIcon = $video ? match($video->type) {
|
||||
@ -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">
|
||||
@ -147,7 +139,7 @@ $sizeClasses = match($size) {
|
||||
<form id="edit-video-form-{{ $video->id ?? '' }}" enctype="multipart/form-data">
|
||||
@csrf
|
||||
@method('PUT')
|
||||
|
||||
|
||||
<!-- Title -->
|
||||
<div class="cute-form-group">
|
||||
<label><i class="bi bi-card-heading"></i> Title</label>
|
||||
@ -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;
|
||||
@ -739,7 +650,7 @@ $sizeClasses = match($size) {
|
||||
max-width: 320px;
|
||||
margin: 10px;
|
||||
}
|
||||
|
||||
|
||||
.cute-type-options, .cute-privacy-options {
|
||||
flex-direction: column;
|
||||
}
|
||||
@ -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) {
|
||||
@ -831,7 +685,7 @@ function openEditVideoModal(videoId) {
|
||||
const modalId = 'editVideoModal' + (videoId || '');
|
||||
const modal = new bootstrap.Modal(document.getElementById(modalId));
|
||||
modal.show();
|
||||
|
||||
|
||||
// Fetch video data
|
||||
fetch(`/videos/${videoId}/edit`, {
|
||||
headers: {
|
||||
@ -846,7 +700,7 @@ function openEditVideoModal(videoId) {
|
||||
const video = data.video;
|
||||
document.getElementById('edit-title-' + videoId).value = video.title || '';
|
||||
document.getElementById('edit-description-' + videoId).value = video.description || '';
|
||||
|
||||
|
||||
// Set type
|
||||
const typeOptions = document.querySelectorAll('#' + modalId + ' .cute-type-option');
|
||||
typeOptions.forEach(opt => {
|
||||
@ -856,7 +710,7 @@ function openEditVideoModal(videoId) {
|
||||
opt.querySelector('input').checked = true;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// Set privacy
|
||||
const privacyOptions = document.querySelectorAll('#' + modalId + ' .cute-privacy-option');
|
||||
privacyOptions.forEach(opt => {
|
||||
@ -866,13 +720,7 @@ function openEditVideoModal(videoId) {
|
||||
opt.querySelector('input').checked = true;
|
||||
}
|
||||
});
|
||||
|
||||
// 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';
|
||||
@ -932,10 +780,10 @@ document.addEventListener('submit', function(e) {
|
||||
const formData = new FormData(form);
|
||||
const statusEl = document.getElementById('edit-status-' + videoId);
|
||||
const submitBtn = form.querySelector('.cute-btn-save');
|
||||
|
||||
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = 'Saving...';
|
||||
|
||||
|
||||
fetch(`/videos/${videoId}`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
|
||||
@ -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>
|
||||
</ul>
|
||||
</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>
|
||||
<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>
|
||||
|
||||
<div class="footer">
|
||||
<p>TAKEONE Video Platform</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||
@ -4,14 +4,11 @@
|
||||
<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') }}">
|
||||
|
||||
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
|
||||
<style>
|
||||
@ -23,18 +20,18 @@
|
||||
--text-primary: #f1f1f1;
|
||||
--text-secondary: #aaaaaa;
|
||||
}
|
||||
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
background-color: var(--bg-dark);
|
||||
color: var(--text-primary);
|
||||
font-family: "Roboto", "Arial", sans-serif;
|
||||
|
||||
body {
|
||||
background-color: var(--bg-dark);
|
||||
color: var(--text-primary);
|
||||
font-family: "Roboto", "Arial", sans-serif;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
|
||||
/* Header */
|
||||
.yt-header {
|
||||
position: fixed;
|
||||
@ -50,13 +47,13 @@
|
||||
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;
|
||||
@ -70,39 +67,37 @@
|
||||
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 */
|
||||
|
||||
/* Search */
|
||||
.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;
|
||||
@ -113,12 +108,12 @@
|
||||
color: var(--text-primary);
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
|
||||
.yt-search-input:focus {
|
||||
outline: none;
|
||||
border-color: #1c62b9;
|
||||
}
|
||||
|
||||
|
||||
.yt-search-btn {
|
||||
width: 64px;
|
||||
background: #222;
|
||||
@ -127,9 +122,9 @@
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
|
||||
.yt-search-btn:hover { background: #303030; }
|
||||
|
||||
|
||||
.yt-search-voice {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
@ -140,14 +135,14 @@
|
||||
margin-left: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
|
||||
/* Header Right */
|
||||
.yt-header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
|
||||
.yt-icon-btn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
@ -161,9 +156,9 @@
|
||||
color: var(--text-primary);
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
|
||||
.yt-icon-btn:hover { background: var(--border-color); }
|
||||
|
||||
|
||||
.yt-user-avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
@ -171,7 +166,7 @@
|
||||
background: #555;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
|
||||
/* Sidebar */
|
||||
.yt-sidebar {
|
||||
position: fixed;
|
||||
@ -185,13 +180,13 @@
|
||||
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;
|
||||
@ -204,16 +199,16 @@
|
||||
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; }
|
||||
|
||||
|
||||
/* Mobile sidebar overlay */
|
||||
.yt-sidebar-overlay {
|
||||
position: fixed;
|
||||
@ -225,9 +220,9 @@
|
||||
z-index: 998;
|
||||
display: none;
|
||||
}
|
||||
|
||||
|
||||
.yt-sidebar-overlay.show { display: block; }
|
||||
|
||||
|
||||
/* Main Content */
|
||||
.yt-main {
|
||||
margin-top: 56px;
|
||||
@ -236,7 +231,7 @@
|
||||
min-height: calc(100vh - 56px);
|
||||
transition: margin-left 0.3s;
|
||||
}
|
||||
|
||||
|
||||
/* Upload Button */
|
||||
.yt-upload-btn {
|
||||
background: var(--brand-red);
|
||||
@ -251,9 +246,9 @@
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
|
||||
.yt-upload-btn:hover { background: #cc1a1a; }
|
||||
|
||||
|
||||
/* Mobile circle button */
|
||||
@media (max-width: 576px) {
|
||||
.yt-upload-btn {
|
||||
@ -263,10 +258,10 @@
|
||||
border-radius: 50%;
|
||||
justify-content: center;
|
||||
}
|
||||
.yt-upload-btn span {
|
||||
display: none;
|
||||
.yt-upload-btn span {
|
||||
display: none;
|
||||
}
|
||||
|
||||
|
||||
/* Show text on larger mobile */
|
||||
@media (min-width: 400px) {
|
||||
.yt-upload-btn {
|
||||
@ -279,39 +274,39 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Responsive */
|
||||
@media (min-width: 992px) {
|
||||
.yt-sidebar.collapsed {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
|
||||
|
||||
.yt-main.collapsed {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@media (max-width: 991px) {
|
||||
.yt-sidebar {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
|
||||
|
||||
.yt-sidebar.open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
|
||||
.yt-main {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
|
||||
.yt-search-voice { display: none; }
|
||||
}
|
||||
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.yt-header-center { display: none; }
|
||||
.yt-main { padding: 16px; }
|
||||
}
|
||||
|
||||
|
||||
/* Mobile Search Toggle Button */
|
||||
.yt-mobile-search-toggle {
|
||||
width: 40px;
|
||||
@ -326,11 +321,11 @@
|
||||
color: var(--text-primary);
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
|
||||
.yt-mobile-search-toggle:hover {
|
||||
background: var(--border-color);
|
||||
}
|
||||
|
||||
|
||||
/* Mobile Search Overlay */
|
||||
.mobile-search-overlay {
|
||||
display: none;
|
||||
@ -345,18 +340,18 @@
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
|
||||
.mobile-search-overlay.active {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
|
||||
.mobile-search-form {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
|
||||
.mobile-search-input {
|
||||
flex: 1;
|
||||
background: #121212;
|
||||
@ -367,12 +362,12 @@
|
||||
color: var(--text-primary);
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
|
||||
.mobile-search-input:focus {
|
||||
outline: none;
|
||||
border-color: #1c62b9;
|
||||
}
|
||||
|
||||
|
||||
.mobile-search-submit {
|
||||
width: 50px;
|
||||
background: #222;
|
||||
@ -384,26 +379,26 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
|
||||
.mobile-search-submit:hover {
|
||||
background: #303030;
|
||||
}
|
||||
|
||||
|
||||
/* Dropdown */
|
||||
.dropdown-menu-dark {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
|
||||
.dropdown-item {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
|
||||
.dropdown-item:hover {
|
||||
background: var(--border-color);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
|
||||
/* Modal input focus */
|
||||
#deleteVideoInput:focus {
|
||||
outline: none;
|
||||
@ -411,31 +406,31 @@
|
||||
box-shadow: 0 0 0 2px rgba(239, 68, 68, 0.2);
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@yield('extra_styles')
|
||||
</head>
|
||||
<body class="{{ $bodyClass ?? '' }}">
|
||||
<!-- Header -->
|
||||
@include('layouts.partials.header')
|
||||
|
||||
|
||||
<!-- 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>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Sidebar Overlay (Mobile) -->
|
||||
<div class="yt-sidebar-overlay" onclick="toggleSidebar()"></div>
|
||||
|
||||
|
||||
<!-- Sidebar -->
|
||||
@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;">
|
||||
@ -478,9 +468,9 @@
|
||||
<label for="deleteVideoInput" style="color: #aaa; font-size: 14px; margin-bottom: 8px; display: block;">
|
||||
To confirm deletion, type <strong style="color: #fff;">"<span id="deleteVideoName"></span>"</strong> below:
|
||||
</label>
|
||||
<input type="text"
|
||||
id="deleteVideoInput"
|
||||
class="form-control"
|
||||
<input type="text"
|
||||
id="deleteVideoInput"
|
||||
class="form-control"
|
||||
style="background: #282828; border: 1px solid #3f3f3f; color: #fff; padding: 12px 16px; border-radius: 8px; font-size: 14px;"
|
||||
placeholder="Enter video name">
|
||||
</div>
|
||||
@ -498,37 +488,13 @@
|
||||
</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
|
||||
function toggleMobileSearch() {
|
||||
const overlay = document.getElementById('mobileSearchOverlay');
|
||||
overlay.classList.toggle('active');
|
||||
|
||||
|
||||
// Focus input when overlay opens
|
||||
if (overlay.classList.contains('active')) {
|
||||
setTimeout(function() {
|
||||
@ -536,7 +502,7 @@
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Close mobile search on escape key
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape') {
|
||||
@ -546,16 +512,16 @@
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// Sidebar toggle function
|
||||
function toggleSidebar() {
|
||||
const sidebar = document.getElementById('sidebar');
|
||||
const overlay = document.querySelector('.yt-sidebar-overlay');
|
||||
const main = document.getElementById('main');
|
||||
|
||||
|
||||
// Check if we're on mobile or desktop
|
||||
const isMobile = window.innerWidth < 992;
|
||||
|
||||
|
||||
if (isMobile) {
|
||||
// Mobile behavior - use 'open' class
|
||||
sidebar.classList.toggle('open');
|
||||
@ -564,19 +530,19 @@
|
||||
// Desktop behavior - use 'collapsed' class
|
||||
sidebar.classList.toggle('collapsed');
|
||||
main.classList.toggle('collapsed');
|
||||
|
||||
|
||||
// Save state to localStorage
|
||||
const isCollapsed = sidebar.classList.contains('collapsed');
|
||||
localStorage.setItem('sidebarCollapsed', isCollapsed);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Restore sidebar state from localStorage on page load
|
||||
function restoreSidebarState() {
|
||||
const sidebar = document.getElementById('sidebar');
|
||||
const main = document.getElementById('main');
|
||||
const isMobile = window.innerWidth < 992;
|
||||
|
||||
|
||||
// Only restore on desktop
|
||||
if (!isMobile) {
|
||||
const savedState = localStorage.getItem('sidebarCollapsed');
|
||||
@ -586,15 +552,15 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Set active sidebar link based on current route
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Restore sidebar state
|
||||
restoreSidebarState();
|
||||
|
||||
|
||||
const currentPath = window.location.pathname;
|
||||
const sidebarLinks = document.querySelectorAll('.yt-sidebar-link');
|
||||
|
||||
|
||||
sidebarLinks.forEach(link => {
|
||||
const href = link.getAttribute('href');
|
||||
if (href === currentPath || (currentPath.startsWith(href) && href !== '/')) {
|
||||
@ -604,13 +570,13 @@
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// Handle window resize to reset state for mobile
|
||||
window.addEventListener('resize', function() {
|
||||
const sidebar = document.getElementById('sidebar');
|
||||
const main = document.getElementById('main');
|
||||
const isMobile = window.innerWidth < 992;
|
||||
|
||||
|
||||
if (isMobile) {
|
||||
// On mobile, remove collapsed state
|
||||
sidebar.classList.remove('collapsed');
|
||||
@ -620,35 +586,35 @@
|
||||
restoreSidebarState();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// Delete video modal functions
|
||||
let currentDeleteVideoId = null;
|
||||
let currentDeleteVideoTitle = '';
|
||||
|
||||
|
||||
function showDeleteModal(videoId, videoTitle) {
|
||||
currentDeleteVideoId = videoId;
|
||||
currentDeleteVideoTitle = videoTitle;
|
||||
|
||||
|
||||
document.getElementById('deleteVideoName').textContent = videoTitle;
|
||||
document.getElementById('deleteVideoInput').value = '';
|
||||
|
||||
|
||||
const deleteBtn = document.getElementById('confirmDeleteBtn');
|
||||
deleteBtn.disabled = true;
|
||||
deleteBtn.style.opacity = '0.5';
|
||||
deleteBtn.style.cursor = 'not-allowed';
|
||||
|
||||
|
||||
// Close the dropdown first
|
||||
const dropdown = document.querySelector('.dropdown-menu.show');
|
||||
if (dropdown) {
|
||||
dropdown.classList.remove('show');
|
||||
}
|
||||
|
||||
|
||||
// Show the modal
|
||||
const modalElement = document.getElementById('deleteVideoModal');
|
||||
const modal = new bootstrap.Modal(modalElement);
|
||||
modal.show();
|
||||
}
|
||||
|
||||
|
||||
document.getElementById('deleteVideoInput').addEventListener('input', function(e) {
|
||||
const deleteBtn = document.getElementById('confirmDeleteBtn');
|
||||
if (e.target.value === currentDeleteVideoTitle) {
|
||||
@ -661,16 +627,16 @@
|
||||
deleteBtn.style.cursor = 'not-allowed';
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
function confirmDeleteVideo() {
|
||||
if (!currentDeleteVideoId || !currentDeleteVideoTitle) return;
|
||||
|
||||
|
||||
const inputValue = document.getElementById('deleteVideoInput').value;
|
||||
if (inputValue !== currentDeleteVideoTitle) {
|
||||
alert('Video name does not match. Please try again.');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
fetch(`/videos/${currentDeleteVideoId}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
@ -679,7 +645,7 @@
|
||||
}
|
||||
})
|
||||
.then(response => {
|
||||
// Check for successful response
|
||||
// Check for successful response
|
||||
if (response.status === 200 || response.status === 302 || response.redirected) {
|
||||
// Close modal first
|
||||
const modalElement = document.getElementById('deleteVideoModal');
|
||||
@ -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) {
|
||||
@ -707,7 +673,7 @@
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@yield('scripts')
|
||||
</body>
|
||||
</html>
|
||||
@ -721,17 +687,17 @@
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
|
||||
/* Reduce padding on main content */
|
||||
.yt-main {
|
||||
padding: 12px 8px !important;
|
||||
}
|
||||
|
||||
|
||||
/* Smaller video title */
|
||||
.yt-video-title, .video-title {
|
||||
font-size: 14px !important;
|
||||
}
|
||||
|
||||
|
||||
/* Channel info compact */
|
||||
.channel-info {
|
||||
gap: 8px !important;
|
||||
@ -746,12 +712,12 @@
|
||||
.channel-subs {
|
||||
font-size: 12px !important;
|
||||
}
|
||||
|
||||
|
||||
/* Video meta smaller */
|
||||
.yt-channel-name, .yt-video-meta {
|
||||
font-size: 12px !important;
|
||||
}
|
||||
|
||||
|
||||
/* Action buttons horizontal scroll on mobile */
|
||||
.video-actions {
|
||||
overflow-x: auto;
|
||||
@ -759,13 +725,13 @@
|
||||
padding-bottom: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
|
||||
.yt-action-btn {
|
||||
flex-shrink: 0;
|
||||
padding: 8px 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
|
||||
/* Comment improvements */
|
||||
.comment-item {
|
||||
flex-direction: column;
|
||||
@ -774,36 +740,36 @@
|
||||
width: 32px !important;
|
||||
height: 32px !important;
|
||||
}
|
||||
|
||||
|
||||
/* Search input mobile */
|
||||
.yt-search-input {
|
||||
font-size: 14px;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
|
||||
/* Header spacing */
|
||||
.yt-header {
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
|
||||
/* User dropdown full width on mobile */
|
||||
.dropdown-menu {
|
||||
width: 100%;
|
||||
min-width: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Very small screens */
|
||||
@media (max-width: 360px) {
|
||||
.yt-main {
|
||||
padding: 8px 4px !important;
|
||||
}
|
||||
|
||||
|
||||
.yt-header-right .yt-icon-btn:not(:first-child) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Landscape mobile */
|
||||
@media (max-height: 500px) and (orientation: landscape) {
|
||||
.yt-sidebar {
|
||||
@ -813,23 +779,22 @@
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Prevent horizontal scroll */
|
||||
body {
|
||||
overflow-x: hidden;
|
||||
max-width: 100vw;
|
||||
}
|
||||
|
||||
|
||||
/* Better video player on mobile */
|
||||
@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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Sidebar link padding for touch */
|
||||
@media (hover: none) {
|
||||
.yt-sidebar-link {
|
||||
@ -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,203 +1,17 @@
|
||||
<!-- 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) -->
|
||||
<img src="{{ asset('storage/images/fullLogo.png') }}" alt="{{ config('app.name') }}" class="d-none d-md-block" style="height: 30px;">
|
||||
</a>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="yt-header-center d-none d-md-flex">
|
||||
<form action="{{ route('videos.search') }}" method="GET" class="yt-search">
|
||||
<input type="text" name="q" class="yt-search-input" placeholder="Search" value="{{ request('q') }}">
|
||||
@ -206,20 +20,20 @@
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="yt-header-right">
|
||||
<!-- Mobile Search Toggle Button -->
|
||||
<button type="button" class="yt-mobile-search-toggle d-md-none" onclick="toggleMobileSearch()">
|
||||
<i class="bi bi-search"></i>
|
||||
</button>
|
||||
|
||||
|
||||
@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>
|
||||
|
||||
|
||||
<!-- User Dropdown -->
|
||||
<div class="dropdown">
|
||||
<button class="yt-icon-btn" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
|
||||
@ -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>
|
||||
@ -20,17 +16,13 @@
|
||||
</a>
|
||||
@endauth
|
||||
</div>
|
||||
|
||||
|
||||
@auth
|
||||
<div class="yt-sidebar-section">
|
||||
<a href="{{ route('channel', Auth::user()->id) }}" class="yt-sidebar-link">
|
||||
<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') }}">
|
||||
@ -22,161 +18,53 @@
|
||||
--text-primary: #f1f1f1;
|
||||
--text-secondary: #aaaaaa;
|
||||
}
|
||||
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
background-color: var(--bg-dark);
|
||||
color: var(--text-primary);
|
||||
font-family: "Roboto", "Arial", sans-serif;
|
||||
|
||||
body {
|
||||
background-color: var(--bg-dark);
|
||||
color: var(--text-primary);
|
||||
font-family: "Roboto", "Arial", sans-serif;
|
||||
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);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
|
||||
.dropdown-item {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
|
||||
.dropdown-item:hover {
|
||||
background: var(--border-color);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@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>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
|
||||
|
||||
@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
|
||||
|
||||
@ -10,7 +10,7 @@
|
||||
padding: 32px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
|
||||
.channel-avatar {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
@ -18,44 +18,44 @@
|
||||
object-fit: cover;
|
||||
border: 3px solid var(--border-color);
|
||||
}
|
||||
|
||||
|
||||
.channel-name {
|
||||
font-size: 24px;
|
||||
font-weight: 500;
|
||||
margin: 16px 0 4px;
|
||||
}
|
||||
|
||||
|
||||
.channel-meta {
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
|
||||
.channel-stats {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
|
||||
.channel-stat-value {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
|
||||
/* Video Grid */
|
||||
.yt-video-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
|
||||
.yt-video-card {
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
|
||||
.yt-video-card:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
|
||||
.yt-video-thumb {
|
||||
position: relative;
|
||||
aspect-ratio: 16/9;
|
||||
@ -63,7 +63,7 @@
|
||||
overflow: hidden;
|
||||
background: #1a1a1a;
|
||||
}
|
||||
|
||||
|
||||
.yt-video-thumb img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
@ -72,7 +72,7 @@
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
|
||||
.yt-video-thumb video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
@ -84,29 +84,29 @@
|
||||
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);
|
||||
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;
|
||||
@ -114,12 +114,12 @@
|
||||
background: #555;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
|
||||
.yt-video-details {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
|
||||
.yt-video-title {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
@ -131,21 +131,20 @@
|
||||
overflow: hidden;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
|
||||
.yt-video-title a {
|
||||
color: inherit;
|
||||
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;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
|
||||
/* More button */
|
||||
.yt-more-btn {
|
||||
background: transparent;
|
||||
@ -161,15 +160,15 @@
|
||||
justify-content: center;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
|
||||
.yt-more-btn:hover {
|
||||
background: var(--border-color);
|
||||
}
|
||||
|
||||
|
||||
.yt-more-btn:active {
|
||||
background: var(--border-color);
|
||||
}
|
||||
|
||||
|
||||
.yt-more-dropdown {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
@ -177,7 +176,7 @@
|
||||
padding: 8px 0;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
|
||||
.yt-more-dropdown-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@ -192,32 +191,30 @@
|
||||
text-align: left;
|
||||
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 {
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
.yt-upload-btn {
|
||||
background: var(--brand-red);
|
||||
color: white;
|
||||
@ -232,15 +229,15 @@
|
||||
text-decoration: none;
|
||||
transition: transform 0.2s, background 0.2s;
|
||||
}
|
||||
|
||||
|
||||
.yt-upload-btn:hover {
|
||||
background: #cc1a1a;
|
||||
}
|
||||
|
||||
|
||||
.yt-upload-btn:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
|
||||
/* Channel action buttons container */
|
||||
.channel-actions {
|
||||
display: flex;
|
||||
@ -248,251 +245,250 @@
|
||||
margin-top: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
|
||||
/* ============================================
|
||||
MOBILE RESPONSIVE STYLES
|
||||
============================================ */
|
||||
|
||||
MOBILE RESPONSIVE STYLES
|
||||
============================================ */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.channel-header {
|
||||
padding: 20px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
|
||||
.channel-avatar {
|
||||
width: 90px;
|
||||
height: 90px;
|
||||
}
|
||||
|
||||
|
||||
.channel-name {
|
||||
font-size: 20px;
|
||||
margin: 12px 0 4px;
|
||||
}
|
||||
|
||||
|
||||
.channel-meta {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
|
||||
.channel-stats {
|
||||
gap: 16px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
|
||||
.yt-video-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
|
||||
.yt-video-title {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
|
||||
.yt-channel-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.yt-channel-name,
|
||||
.yt-video-meta {
|
||||
|
||||
.yt-channel-name, .yt-video-meta {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
|
||||
.yt-more-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
|
||||
.channel-actions {
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
|
||||
.channel-actions .yt-upload-btn {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.channel-header {
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
|
||||
.channel-header .d-flex {
|
||||
flex-direction: column;
|
||||
align-items: flex-start !important;
|
||||
gap: 16px !important;
|
||||
}
|
||||
|
||||
|
||||
.channel-avatar {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
}
|
||||
|
||||
|
||||
.channel-name {
|
||||
font-size: 18px;
|
||||
margin: 8px 0 4px;
|
||||
}
|
||||
|
||||
|
||||
.channel-meta {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
|
||||
.channel-stats {
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
|
||||
.channel-stat-value {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
|
||||
.yt-video-grid {
|
||||
gap: 12px;
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
|
||||
.yt-video-thumb {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
|
||||
.yt-video-info {
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
|
||||
.yt-channel-icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
|
||||
.yt-video-details {
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
|
||||
.yt-video-title {
|
||||
font-size: 13px;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
|
||||
.yt-empty {
|
||||
padding: 40px 16px;
|
||||
}
|
||||
|
||||
|
||||
.yt-empty-icon {
|
||||
font-size: 60px;
|
||||
}
|
||||
|
||||
|
||||
.yt-empty-title {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Very small screens */
|
||||
@media (max-width: 360px) {
|
||||
.channel-avatar {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
}
|
||||
|
||||
|
||||
.channel-name {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
|
||||
.channel-stats {
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
|
||||
.yt-video-info {
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
|
||||
.yt-video-details {
|
||||
max-width: calc(100% - 36px);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Landscape mobile */
|
||||
@media (max-height: 500px) and (orientation: landscape) {
|
||||
.channel-header {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
|
||||
.channel-avatar {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
}
|
||||
|
||||
|
||||
.channel-name {
|
||||
font-size: 16px;
|
||||
margin: 8px 0 2px;
|
||||
}
|
||||
|
||||
|
||||
.channel-meta {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
|
||||
.channel-stats {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
|
||||
.yt-video-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Pagination improvements */
|
||||
.pagination {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
|
||||
.pagination .page-link {
|
||||
background: var(--bg-secondary);
|
||||
border-color: var(--border-color);
|
||||
color: var(--text-primary);
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
|
||||
.pagination .page-link:hover {
|
||||
background: var(--border-color);
|
||||
border-color: var(--border-color);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
|
||||
.pagination .page-item.active .page-link {
|
||||
background: var(--brand-red);
|
||||
border-color: var(--brand-red);
|
||||
}
|
||||
|
||||
|
||||
.pagination .page-item.disabled .page-link {
|
||||
background: var(--bg-secondary);
|
||||
border-color: var(--border-color);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.pagination {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
|
||||
.pagination .page-link {
|
||||
padding: 6px 10px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
|
||||
.pagination .page-item:not(.active):not(.disabled) .page-link {
|
||||
min-width: 36px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Smooth scroll behavior */
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
|
||||
/* Video card skeleton loading */
|
||||
.skeleton {
|
||||
background: linear-gradient(90deg, var(--bg-secondary) 25%, var(--border-color) 50%, var(--bg-secondary) 75%);
|
||||
@ -500,23 +496,18 @@
|
||||
animation: shimmer 1.5s infinite;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
|
||||
@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 */
|
||||
@media (hover: none) {
|
||||
.yt-video-card:hover .yt-video-thumb video {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
|
||||
.yt-video-card.playing .yt-video-thumb video {
|
||||
opacity: 1;
|
||||
}
|
||||
@ -527,30 +518,29 @@
|
||||
@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">
|
||||
@endif
|
||||
|
||||
|
||||
<div class="channel-info">
|
||||
<h1 class="channel-name">{{ $user->name }}</h1>
|
||||
<p class="channel-meta">Joined {{ $user->created_at->format('F d, Y') }}</p>
|
||||
|
||||
|
||||
<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>
|
||||
@ -564,56 +554,14 @@
|
||||
</div>
|
||||
</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,178 +570,167 @@
|
||||
</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) {
|
||||
// Skip on touch devices - handled by touch events
|
||||
if ('ontouchstart' in window) return;
|
||||
function playVideo(card) {
|
||||
// Skip on touch devices - handled by touch events
|
||||
if ('ontouchstart' in window) return;
|
||||
|
||||
const video = card.querySelector('video');
|
||||
if (video) {
|
||||
video.currentTime = 0;
|
||||
video.volume = 0.10;
|
||||
video.play().catch(function(e) {
|
||||
// Auto-play may be blocked, ignore error
|
||||
});
|
||||
video.classList.add('active');
|
||||
currentPlayingVideo = card;
|
||||
}
|
||||
}
|
||||
|
||||
const video = card.querySelector('video');
|
||||
if (video) {
|
||||
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) {
|
||||
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) {
|
||||
const card = e.target.closest('.yt-video-card');
|
||||
if (card) {
|
||||
// Stop currently playing video if different
|
||||
if (currentPlayingVideo && currentPlayingVideo !== card) {
|
||||
stopVideo(currentPlayingVideo);
|
||||
}
|
||||
|
||||
const video = card.querySelector('video');
|
||||
if (video) {
|
||||
if (card.classList.contains('playing')) {
|
||||
// Pause this video
|
||||
video.pause();
|
||||
card.classList.remove('playing');
|
||||
} else {
|
||||
// Play this video
|
||||
video.currentTime = 0;
|
||||
video.volume = 0.10;
|
||||
video.play().catch(function(e) {
|
||||
// Auto-play may be blocked, ignore error
|
||||
});
|
||||
video.play().catch(function(e) {});
|
||||
video.classList.add('active');
|
||||
card.classList.add('playing');
|
||||
currentPlayingVideo = card;
|
||||
}
|
||||
}
|
||||
}
|
||||
}, { passive: true });
|
||||
|
||||
function stopVideo(card) {
|
||||
const video = card.querySelector('video');
|
||||
if (video) {
|
||||
video.pause();
|
||||
video.currentTime = 0;
|
||||
video.classList.remove('active');
|
||||
}
|
||||
// Stop video when tapping outside
|
||||
document.addEventListener('touchstart', function(e) {
|
||||
if (!e.target.closest('.yt-video-card')) {
|
||||
if (currentPlayingVideo) {
|
||||
stopVideo(currentPlayingVideo);
|
||||
currentPlayingVideo.classList.remove('playing');
|
||||
currentPlayingVideo = null;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 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) {
|
||||
const card = e.target.closest('.yt-video-card');
|
||||
if (card) {
|
||||
// Stop currently playing video if different
|
||||
if (currentPlayingVideo && currentPlayingVideo !== card) {
|
||||
stopVideo(currentPlayingVideo);
|
||||
}
|
||||
|
||||
const video = card.querySelector('video');
|
||||
if (video) {
|
||||
if (card.classList.contains('playing')) {
|
||||
// Pause this video
|
||||
video.pause();
|
||||
card.classList.remove('playing');
|
||||
} else {
|
||||
// Play this video
|
||||
video.currentTime = 0;
|
||||
video.volume = 0.10;
|
||||
video.play().catch(function(e) {});
|
||||
video.classList.add('active');
|
||||
card.classList.add('playing');
|
||||
currentPlayingVideo = card;
|
||||
}
|
||||
}
|
||||
function deleteVideo(videoId, videoTitle) {
|
||||
if (confirm('Are you sure you want to delete "' + videoTitle + '"? This action cannot be undone.')) {
|
||||
fetch(`/videos/${videoId}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}',
|
||||
'Accept': 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
}
|
||||
}, {
|
||||
passive: true
|
||||
});
|
||||
|
||||
// Stop video when tapping outside
|
||||
document.addEventListener('touchstart', function(e) {
|
||||
if (!e.target.closest('.yt-video-card')) {
|
||||
if (currentPlayingVideo) {
|
||||
stopVideo(currentPlayingVideo);
|
||||
currentPlayingVideo.classList.remove('playing');
|
||||
currentPlayingVideo = null;
|
||||
}
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success || data.redirect) {
|
||||
window.location.reload();
|
||||
} else {
|
||||
alert('Failed to delete video. Please try again.');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
alert('Failed to delete video. Please try again.');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function deleteVideo(videoId, videoTitle) {
|
||||
if (confirm('Are you sure you want to delete "' + videoTitle + '"? This action cannot be undone.')) {
|
||||
fetch(`/videos/${videoId}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}',
|
||||
'Accept': 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
}
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success || data.redirect) {
|
||||
window.location.reload();
|
||||
} else {
|
||||
alert('Failed to delete video. Please try again.');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
alert('Failed to delete video. Please try again.');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 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>
|
||||
// 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>
|
||||
@endsection
|
||||
|
||||
|
||||
<!-- Extra Mobile Styles -->
|
||||
<style>
|
||||
@media (max-width: 480px) {
|
||||
.channel-header {
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
<!-- 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;
|
||||
}
|
||||
}
|
||||
|
||||
.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">
|
||||
@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 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>
|
||||
<p class="profile-email">{{ $user->email }}</p>
|
||||
|
||||
<div class="profile-stats justify-content-center">
|
||||
<div class="profile-stat">
|
||||
<div class="profile-stat-value">{{ $user->videos->count() }}</div>
|
||||
<div class="profile-stat-label">Videos</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
|
||||
<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->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>
|
||||
</div>
|
||||
</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
|
||||
<h2 class="form-title">Edit Profile</h2>
|
||||
|
||||
@if(session('success'))
|
||||
<div class="alert alert-success">
|
||||
{{ session('success') }}
|
||||
</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
|
||||
|
||||
<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-4">
|
||||
<div class="col-lg-6">
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<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
@ -11,29 +11,189 @@
|
||||
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;
|
||||
}
|
||||
|
||||
@media (max-width: 992px) { .yt-video-grid { grid-template-columns: repeat(2, 1fr); } }
|
||||
@media (max-width: 576px) { .yt-video-grid { grid-template-columns: 1fr; } }
|
||||
|
||||
/* Other styles unchanged */
|
||||
.yt-empty { text-align: center; padding: 80px 20px; }
|
||||
|
||||
.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
|
||||
|
||||
@ -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
|
||||
|
||||
@if ($videos->isEmpty())
|
||||
@endif
|
||||
|
||||
@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
|
||||
File diff suppressed because it is too large
Load Diff
@ -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
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
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 () {
|
||||
@ -29,7 +26,7 @@ Route::middleware('auth')->group(function () {
|
||||
Route::get('/videos/{video}/edit', [VideoController::class, 'edit'])->name('videos.edit');
|
||||
Route::put('/videos/{video}', [VideoController::class, 'update'])->name('videos.update');
|
||||
Route::delete('/videos/{video}', [VideoController::class, 'destroy'])->name('videos.destroy');
|
||||
|
||||
|
||||
// Like/unlike routes
|
||||
Route::post('/videos/{video}/like', [UserController::class, 'like'])->name('videos.like');
|
||||
Route::post('/videos/{video}/unlike', [UserController::class, 'unlike'])->name('videos.unlike');
|
||||
@ -49,11 +46,11 @@ Route::middleware('auth')->group(function () {
|
||||
// Profile
|
||||
Route::get('/profile', [UserController::class, 'profile'])->name('profile');
|
||||
Route::put('/profile', [UserController::class, 'updateProfile'])->name('profile.update');
|
||||
|
||||
|
||||
// Settings
|
||||
Route::get('/settings', [UserController::class, 'settings'])->name('settings');
|
||||
Route::put('/settings', [UserController::class, 'updateSettings'])->name('settings.update');
|
||||
|
||||
|
||||
// History & Liked
|
||||
Route::get('/history', [UserController::class, 'history'])->name('history');
|
||||
Route::get('/liked', [UserController::class, 'liked'])->name('liked');
|
||||
@ -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';
|
||||
|
||||
@ -100,13 +66,13 @@ require __DIR__.'/auth.php';
|
||||
Route::middleware(['auth', 'super_admin'])->prefix('admin')->name('admin.')->group(function () {
|
||||
// Dashboard
|
||||
Route::get('/dashboard', [SuperAdminController::class, 'dashboard'])->name('dashboard');
|
||||
|
||||
|
||||
// User Management
|
||||
Route::get('/users', [SuperAdminController::class, 'users'])->name('users');
|
||||
Route::get('/users/{user}/edit', [SuperAdminController::class, 'editUser'])->name('users.edit');
|
||||
Route::put('/users/{user}', [SuperAdminController::class, 'updateUser'])->name('users.update');
|
||||
Route::delete('/users/{user}', [SuperAdminController::class, 'deleteUser'])->name('users.delete');
|
||||
|
||||
|
||||
// Video Management
|
||||
Route::get('/videos', [SuperAdminController::class, 'videos'])->name('videos');
|
||||
Route::get('/videos/{video}/edit', [SuperAdminController::class, 'editVideo'])->name('videos.edit');
|
||||
@ -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