Compare commits

...

21 Commits

Author SHA1 Message Date
ghassan
64eadfaf56 converted comment section and the cannel info and the action buttons to components 2026-03-21 03:22:30 +03:00
ghassan
84fcbd84dc latest update 2026-03-21 02:24:27 +03:00
ghassan
3b09f4baed all is working great 2026-03-15 04:55:18 +03:00
ghassan
f850f40f78 all is working great 2026-03-15 04:06:35 +03:00
ghassan
69f5df163a update the match view 2026-03-12 03:59:52 +03:00
ghassan
062c0e896f latest update 2026-03-11 11:21:33 +03:00
ghassan
9ad842dcd5 Add trending videos page with YouTube-style algorithm
- Trending algorithm based on:
  - Recent views (last 48h): 70% weight
  - View velocity (views/hour): 15% weight
  - Recency bonus: 10% weight
  - Like count: 5% weight
- Excludes videos older than 10 days
- Filter options: Today/This Week/This Month
- Added route, controller method, and view
- Updated nav to link to trending page
2026-03-03 21:30:44 +03:00
ghassan
59870862db Add mobile responsive admin dashboard
- Hamburger menu toggle for mobile sidebar
- Slide-in sidebar overlay on mobile
- Responsive stats cards (2 per row tablet, 1 per row mobile)
- Touch-friendly targets (44px minimum)
- Table scroll wrapper for horizontal scroll
- Responsive typography and spacing
- Close sidebar on nav click and resize
2026-03-03 21:24:27 +03:00
ghassan
3f40316d53 Style cancel button to match upload button 2026-03-03 21:14:40 +03:00
ghassan
c2bac73984 Add spacing between video type and privacy selectors 2026-03-03 21:03:15 +03:00
ghassan
148bf6f45e Fix upload page: header HTML, cancel button, close button 2026-03-03 21:02:37 +03:00
ghassan
4b7b58e8cc Remove upload page header: title and subtitle 2026-03-03 20:55:04 +03:00
ghassan
f78e1f6d4a Fix: Convert upload modal to full page layout 2026-03-03 20:51:50 +03:00
ghassan
c2180e556d Fix: Remove red header, use dark theme for upload page 2026-03-03 20:33:51 +03:00
ghassan
0b893025a5 Remove red header from upload page 2026-03-03 20:31:47 +03:00
ghassan
98f55af8d7 Fix mobile upload page - full page not modal
- Add main_class yield to app.blade.php for proper CSS targeting
- Add upload-page-only upload-page-responsive classes to create view
- Now mobile upload (/videos/create) shows as full-width page, not modal
2026-03-03 19:58:34 +03:00
ghassan
79bcd95d36 Hide header upload button on mobile - use bottom nav instead 2026-03-03 19:51:13 +03:00
ghassan
9ed7fb47b9 Fix bottom nav: change Videos button to Upload with auth check 2026-03-03 19:40:31 +03:00
ghassan
76b4796ab2 Fix: channels.show route not found - changed to channel 2026-03-03 19:28:13 +03:00
ghassan
e0e6c803a9 Add YouTube-style mobile bottom navigation bar
- Fixed bottom nav with 5 buttons: Home, Trending, Videos, History, Profile
- Shows only on mobile (≤768px)
- Fixed to bottom with proper padding for main content
- Uses Bootstrap Icons for navigation icons
2026-03-03 19:19:40 +03:00
ghassan
94a73cc74d Add mobile bottom action bar for video detail page 2026-03-03 19:12:45 +03:00
63 changed files with 20557 additions and 4600 deletions

63
TODO.md
View File

@ -1,35 +1,40 @@
# Video Platform Enhancement Tasks - COMPLETED # TODO - Topbar Standardization - COMPLETED
## Phase 1: Database & Backend ✅ ## Task: Use same topbar across all pages
- [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 ✅ ### Summary:
- [x] Update generic.blade.php with video type icon and enhanced channel info - Topbar is in a separate file: `resources/views/layouts/partials/header.blade.php`
- [x] Update music.blade.php with video type icon and enhanced channel info - All layouts now include this header partial
- [x] Update match.blade.php with video type icon and enhanced channel info
## Phase 3: Comment Section ✅ ### Layouts and their pages:
- [x] Add comment section UI to video views
- [x] Add @ mention functionality
## Features Implemented: 1. **layouts/app.blade.php** (includes header + sidebar)
1. Video type icons in red color before title: - videos/index.blade.php
- music → 🎵 (bi-music-note) - videos/trending.blade.php
- match → 🏆 (bi-trophy) - videos/show.blade.php
- generic → 🎬 (bi-film) - 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. Enhanced channel info below title: 2. **layouts/plain.blade.php** (includes header, no sidebar)
- Channel picture - auth/login.blade.php
- Channel name - auth/register.blade.php
- Number of subscribers
- Number of views
- Like button with icon and count
- Edit & Share buttons
3. Comment section: 3. **admin/layout.blade.php** (includes header, admin sidebar)
- Users can comment on videos - admin/dashboard.blade.php
- @ mention support to mention other users/channels - 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)

35
TODO_drag_drop_reorder.md Normal file
View File

@ -0,0 +1,35 @@
# 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

41
TODO_new.md Normal file
View File

@ -0,0 +1,41 @@
# 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

View File

@ -0,0 +1,37 @@
# 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

31
TODO_open_graph.md Normal file
View File

@ -0,0 +1,31 @@
# 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

37
TODO_playlists.md Normal file
View File

@ -0,0 +1,37 @@
# 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

View File

@ -0,0 +1,51 @@
# 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

View File

@ -0,0 +1,15 @@
# 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)

View File

@ -17,6 +17,7 @@ class CommentController extends Controller
public function index(Video $video) public function index(Video $video)
{ {
$comments = $video->comments()->whereNull('parent_id')->with(['user', 'replies.user'])->get(); $comments = $video->comments()->whereNull('parent_id')->with(['user', 'replies.user'])->get();
return response()->json($comments); return response()->json($comments);
} }
@ -35,12 +36,14 @@ class CommentController extends Controller
// Handle mentions // Handle mentions
preg_match_all('/@(\w+)/', $request->body, $matches); preg_match_all('/@(\w+)/', $request->body, $matches);
if (!empty($matches[1])) { if (! empty($matches[1])) {
// Mentions found - in production, you would send notifications here // Mentions found - in production, you would send notifications here
// For now, we just parse them // For now, we just parse them
} }
return response()->json($comment->load('user')); $video->increment('comment_count');
return response()->json(['success' => true, 'comment' => $comment->load('user')]);
} }
public function update(Request $request, Comment $comment) public function update(Request $request, Comment $comment)
@ -67,6 +70,7 @@ class CommentController extends Controller
} }
$comment->delete(); $comment->delete();
return response()->json(['success' => true]); return response()->json(['success' => true]);
} }
} }

View File

@ -0,0 +1,294 @@
<?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,
]);
}
}

View File

@ -0,0 +1,389 @@
<?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,
]);
}
}

View File

@ -217,9 +217,10 @@ class SuperAdminController extends Controller
'visibility' => 'required|in:public,unlisted,private', 'visibility' => 'required|in:public,unlisted,private',
'type' => 'required|in:generic,music,match', 'type' => 'required|in:generic,music,match',
'status' => 'required|in:pending,processing,ready,failed', 'status' => 'required|in:pending,processing,ready,failed',
'is_shorts' => 'nullable|boolean',
]); ]);
$data = $request->only(['title', 'description', 'visibility', 'type', 'status']); $data = $request->only(['title', 'description', 'visibility', 'type', 'status', 'is_shorts']);
$video->update($data); $video->update($data);

View File

@ -21,6 +21,7 @@ class UserController extends Controller
public function profile() public function profile()
{ {
$user = Auth::user(); $user = Auth::user();
return view('user.profile', compact('user')); return view('user.profile', compact('user'));
} }
@ -32,16 +33,38 @@ class UserController extends Controller
$request->validate([ $request->validate([
'name' => 'required|string|max:255', 'name' => 'required|string|max:255',
'avatar' => 'nullable|image|mimes:jpg,jpeg,png,webp|max:5120', '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]; $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,
];
if ($request->hasFile('avatar')) { if ($request->hasFile('avatar')) {
// Delete old avatar // Delete old avatar
if ($user->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); $request->file('avatar')->storeAs('public/avatars', $filename);
$data['avatar'] = $filename; $data['avatar'] = $filename;
} }
@ -55,6 +78,7 @@ class UserController extends Controller
public function settings() public function settings()
{ {
$user = Auth::user(); $user = Auth::user();
return view('user.settings', compact('user')); return view('user.settings', compact('user'));
} }
@ -68,12 +92,12 @@ class UserController extends Controller
'new_password' => 'required|min:8|confirmed', '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']); return back()->withErrors(['current_password' => 'Current password is incorrect']);
} }
$user->update([ $user->update([
'password' => Hash::make($request->new_password) 'password' => Hash::make($request->new_password),
]); ]);
return redirect()->route('settings')->with('success', 'Password updated successfully!'); return redirect()->route('settings')->with('success', 'Password updated successfully!');
@ -94,14 +118,18 @@ class UserController extends Controller
$videos = Video::where('user_id', $user->id) $videos = Video::where('user_id', $user->id)
->latest() ->latest()
->paginate(12); ->paginate(12);
// Also get user's playlists for their own channel
$playlists = $user->playlists()->orderBy('created_at', 'desc')->get();
} else { } else {
$videos = Video::public() $videos = Video::public()
->where('user_id', $user->id) ->where('user_id', $user->id)
->latest() ->latest()
->paginate(12); ->paginate(12);
$playlists = null;
} }
return view('user.channel', compact('user', 'videos')); return view('user.channel', compact('user', 'videos', 'playlists'));
} }
// Watch history // Watch history
@ -118,7 +146,7 @@ class UserController extends Controller
->unique(); ->unique();
$videos = Video::whereIn('id', $videoIds) $videos = Video::whereIn('id', $videoIds)
->where(function($q) use ($user) { ->where(function ($q) use ($user) {
$q->where('visibility', '!=', 'private') $q->where('visibility', '!=', 'private')
->orWhere('user_id', $user->id); ->orWhere('user_id', $user->id);
}) })
@ -136,7 +164,7 @@ class UserController extends Controller
$user = Auth::user(); $user = Auth::user();
// Include private videos in liked (user's own private videos) // Include private videos in liked (user's own private videos)
$videos = $user->likes() $videos = $user->likes()
->where(function($q) use ($user) { ->where(function ($q) use ($user) {
$q->where('visibility', '!=', 'private') $q->where('visibility', '!=', 'private')
->orWhere('videos.user_id', $user->id); ->orWhere('videos.user_id', $user->id);
}) })
@ -151,7 +179,7 @@ class UserController extends Controller
{ {
$user = Auth::user(); $user = Auth::user();
if (!$video->isLikedBy($user)) { if (! $video->isLikedBy($user)) {
$video->likes()->attach($user->id); $video->likes()->attach($user->id);
} }
@ -183,8 +211,7 @@ class UserController extends Controller
return response()->json([ return response()->json([
'liked' => $liked, 'liked' => $liked,
'like_count' => $video->like_count 'like_count' => $video->like_count,
]); ]);
} }
} }

View File

@ -4,6 +4,7 @@ namespace App\Http\Controllers;
use App\Jobs\CompressVideoJob; use App\Jobs\CompressVideoJob;
use App\Mail\VideoUploaded; use App\Mail\VideoUploaded;
use App\Models\Playlist;
use App\Models\Video; use App\Models\Video;
use FFMpeg\FFMpeg; use FFMpeg\FFMpeg;
use FFMpeg\FFProbe; use FFMpeg\FFProbe;
@ -17,12 +18,13 @@ class VideoController extends Controller
{ {
public function __construct() public function __construct()
{ {
$this->middleware('auth')->except(['index', 'show', 'search', 'stream']); $this->middleware('auth')->except(['index', 'show', 'search', 'stream', 'trending', 'shorts']);
} }
public function index() public function index()
{ {
$videos = Video::public()->latest()->paginate(12); $videos = Video::public()->latest()->get();
return view('videos.index', compact('videos')); return view('videos.index', compact('videos'));
} }
@ -35,12 +37,12 @@ class VideoController extends Controller
} }
$videos = Video::public() $videos = Video::public()
->where(function($q) use ($query) { ->where(function ($q) use ($query) {
$q->where('title', 'like', "%{$query}%") $q->where('title', 'like', "%{$query}%")
->orWhere('description', 'like', "%{$query}%"); ->orWhere('description', 'like', "%{$query}%");
}) })
->latest() ->latest()
->paginate(12); ->get();
return view('videos.index', compact('videos', 'query')); return view('videos.index', compact('videos', 'query'));
} }
@ -62,7 +64,7 @@ class VideoController extends Controller
]); ]);
$videoFile = $request->file('video'); $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); $path = $videoFile->storeAs('public/videos', $filename);
// Get file info // Get file info
@ -71,32 +73,32 @@ class VideoController extends Controller
$thumbnailPath = null; $thumbnailPath = null;
if ($request->hasFile('thumbnail')) { 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); $thumbnailPath = $request->file('thumbnail')->storeAs('public/thumbnails', $thumbFilename);
} else { } else {
// Extract thumbnail from video using FFmpeg // Extract thumbnail from video using FFmpeg
try { try {
$ffmpeg = FFMpeg::create(); $ffmpeg = FFMpeg::create();
$videoPath = storage_path('app/' . $path); $videoPath = storage_path('app/'.$path);
if (file_exists($videoPath)) { if (file_exists($videoPath)) {
$video = $ffmpeg->open($videoPath); $video = $ffmpeg->open($videoPath);
$frame = $video->frame(\FFMpeg\Coordinate\TimeCode::fromSeconds(1)); $frame = $video->frame(\FFMpeg\Coordinate\TimeCode::fromSeconds(1));
$thumbFilename = Str::uuid() . '.jpg'; $thumbFilename = Str::uuid().'.jpg';
$thumbFullPath = storage_path('app/public/thumbnails/' . $thumbFilename); $thumbFullPath = storage_path('app/public/thumbnails/'.$thumbFilename);
// Ensure thumbnails directory exists // Ensure thumbnails directory exists
if (!file_exists(storage_path('app/public/thumbnails'))) { if (! file_exists(storage_path('app/public/thumbnails'))) {
mkdir(storage_path('app/public/thumbnails'), 0755, true); mkdir(storage_path('app/public/thumbnails'), 0755, true);
} }
$frame->save($thumbFullPath); $frame->save($thumbFullPath);
$thumbnailPath = 'public/thumbnails/' . $thumbFilename; $thumbnailPath = 'public/thumbnails/'.$thumbFilename;
} }
} catch (\Exception $e) { } catch (\Exception $e) {
// Log the error but don't fail the upload // Log the error but don't fail the upload
\Log::error('FFmpeg failed to extract thumbnail: ' . $e->getMessage()); \Log::error('FFmpeg failed to extract thumbnail: '.$e->getMessage());
} }
} }
@ -107,7 +109,7 @@ class VideoController extends Controller
try { try {
$ffprobe = FFProbe::create(); $ffprobe = FFProbe::create();
$videoPath = storage_path('app/' . $path); $videoPath = storage_path('app/'.$path);
if (file_exists($videoPath)) { if (file_exists($videoPath)) {
$streams = $ffprobe->streams($videoPath); $streams = $ffprobe->streams($videoPath);
@ -131,7 +133,7 @@ class VideoController extends Controller
} }
} catch (\Exception $e) { } catch (\Exception $e) {
// Log the error but don't fail the upload // Log the error but don't fail the upload
\Log::error('FFprobe failed to get video dimensions: ' . $e->getMessage()); \Log::error('FFprobe failed to get video dimensions: '.$e->getMessage());
// Use default orientation // Use default orientation
} }
@ -163,19 +165,19 @@ class VideoController extends Controller
Mail::to(Auth::user()->email)->send(new VideoUploaded($video, Auth::user()->name)); Mail::to(Auth::user()->email)->send(new VideoUploaded($video, Auth::user()->name));
} catch (\Exception $e) { } catch (\Exception $e) {
// Log the error but don't fail the upload // Log the error but don't fail the upload
\Log::error('Email notification failed: ' . $e->getMessage()); \Log::error('Email notification failed: '.$e->getMessage());
} }
return response()->json([ return response()->json([
'success' => true, 'success' => true,
'redirect' => route('videos.show', $video->id) 'redirect' => route('videos.show', $video->id),
]); ]);
} }
public function show(Video $video) public function show(Request $request, Video $video)
{ {
// Check if user can view this video // Check if user can view this video
if (!$video->canView(Auth::user())) { if (! $video->canView(Auth::user())) {
abort(404, 'Video not found'); abort(404, 'Video not found');
} }
@ -189,26 +191,62 @@ class VideoController extends Controller
->where('watched_at', '>', now()->subHour()) ->where('watched_at', '>', now()->subHour())
->first(); ->first();
if (!$existingView) { if (! $existingView) {
\DB::table('video_views')->insert([ \DB::table('video_views')->insert([
'user_id' => $user->id, 'user_id' => $user->id,
'video_id' => $video->id, 'video_id' => $video->id,
'watched_at' => now() 'watched_at' => now(),
]); ]);
} }
} }
// Load comments with user relationship // Load comments with user relationship
$video->load(['comments.user', 'comments.replies.user']); $video->load(['comments.user', 'comments.replies.user', 'matchRounds.points', 'coachReviews']);
// Handle playlist navigation if playlist parameter is provided
$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;
}
}
// Get recommended videos (exclude current video)
$recommendedVideos = Video::public()
->where('id', '!=', $video->id)
->latest()
->limit(20)
->get();
// Render the appropriate view based on video type // Render the appropriate view based on video type
$view = match($video->type) { $view = match ($video->type) {
'match' => 'videos.types.match', 'match' => 'videos.types.match',
'music' => 'videos.types.music', 'music' => 'videos.types.music',
default => 'videos.types.generic', default => 'videos.types.generic',
}; };
return view($view, compact('video')); 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(),
]);
} }
public function edit(Video $video, Request $request) public function edit(Video $video, Request $request)
@ -219,7 +257,7 @@ class VideoController extends Controller
} }
// If not AJAX request, redirect to show page with edit parameter // If not AJAX request, redirect to show page with edit parameter
if (!$request->expectsJson() && !$request->ajax()) { if (! $request->expectsJson() && ! $request->ajax()) {
return redirect()->route('videos.show', $video->id)->with('openEditModal', true); return redirect()->route('videos.show', $video->id)->with('openEditModal', true);
} }
@ -231,10 +269,10 @@ class VideoController extends Controller
'title' => $video->title, 'title' => $video->title,
'description' => $video->description, 'description' => $video->description,
'thumbnail' => $video->thumbnail, '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', 'visibility' => $video->visibility ?? 'public',
'type' => $video->type ?? 'generic', 'type' => $video->type ?? 'generic',
] ],
]); ]);
} }
@ -257,15 +295,15 @@ class VideoController extends Controller
if ($request->hasFile('thumbnail')) { if ($request->hasFile('thumbnail')) {
if ($video->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'] = $request->file('thumbnail')->storeAs('public/thumbnails', $thumbFilename);
$data['thumbnail'] = basename($data['thumbnail']); $data['thumbnail'] = basename($data['thumbnail']);
} }
// Set default visibility if not provided // Set default visibility if not provided
if (!isset($data['visibility'])) { if (! isset($data['visibility'])) {
unset($data['visibility']); unset($data['visibility']);
} }
@ -281,7 +319,7 @@ class VideoController extends Controller
'title' => $video->title, 'title' => $video->title,
'description' => $video->description, 'description' => $video->description,
'visibility' => $video->visibility, 'visibility' => $video->visibility,
] ],
]); ]);
} }
@ -297,9 +335,9 @@ class VideoController extends Controller
$videoTitle = $video->title; $videoTitle = $video->title;
Storage::delete('public/videos/' . $video->filename); Storage::delete('public/videos/'.$video->filename);
if ($video->thumbnail) { if ($video->thumbnail) {
Storage::delete('public/thumbnails/' . $video->thumbnail); Storage::delete('public/thumbnails/'.$video->thumbnail);
} }
$video->delete(); $video->delete();
@ -317,13 +355,13 @@ class VideoController extends Controller
public function stream(Video $video) public function stream(Video $video)
{ {
// Check if user can view this video // Check if user can view this video
if (!$video->canView(Auth::user())) { if (! $video->canView(Auth::user())) {
abort(404, 'Video not found'); abort(404, 'Video not found');
} }
$path = storage_path('app/public/videos/' . $video->filename); $path = storage_path('app/public/videos/'.$video->filename);
if (!file_exists($path)) { if (! file_exists($path)) {
abort(404, 'Video file not found'); abort(404, 'Video file not found');
} }
@ -331,7 +369,7 @@ class VideoController extends Controller
$mimeType = $video->mime_type ?: 'video/mp4'; $mimeType = $video->mime_type ?: 'video/mp4';
$handle = fopen($path, 'rb'); $handle = fopen($path, 'rb');
if (!$handle) { if (! $handle) {
abort(500, 'Cannot open video file'); abort(500, 'Cannot open video file');
} }
@ -346,9 +384,9 @@ class VideoController extends Controller
$length = $end - $start + 1; $length = $end - $start + 1;
header('HTTP/1.1 206 Partial Content'); header('HTTP/1.1 206 Partial Content');
header('Content-Type: ' . $mimeType); header('Content-Type: '.$mimeType);
header('Content-Length: ' . $length); header('Content-Length: '.$length);
header('Content-Range: bytes ' . $start . '-' . $end . '/' . $fileSize); header('Content-Range: bytes '.$start.'-'.$end.'/'.$fileSize);
header('Accept-Ranges: bytes'); header('Accept-Ranges: bytes');
header('Cache-Control: public, max-age=3600'); header('Cache-Control: public, max-age=3600');
@ -356,7 +394,7 @@ class VideoController extends Controller
$chunkSize = 8192; $chunkSize = 8192;
$bytesToRead = $length; $bytesToRead = $length;
while (!feof($handle) && $bytesToRead > 0) { while (! feof($handle) && $bytesToRead > 0) {
$buffer = fread($handle, min($chunkSize, $bytesToRead)); $buffer = fread($handle, min($chunkSize, $bytesToRead));
echo $buffer; echo $buffer;
flush(); flush();
@ -367,8 +405,8 @@ class VideoController extends Controller
exit; exit;
} else { } else {
// No range requested, stream entire file // No range requested, stream entire file
header('Content-Type: ' . $mimeType); header('Content-Type: '.$mimeType);
header('Content-Length: ' . $fileSize); header('Content-Length: '.$fileSize);
header('Accept-Ranges: bytes'); header('Accept-Ranges: bytes');
header('Cache-Control: public, max-age=3600'); header('Cache-Control: public, max-age=3600');
@ -381,18 +419,93 @@ class VideoController extends Controller
public function download(Video $video) public function download(Video $video)
{ {
// Check if user can view this video // Check if user can view this video
if (!$video->canView(Auth::user())) { if (! $video->canView(Auth::user())) {
abort(404, 'Video not found'); abort(404, 'Video not found');
} }
$path = storage_path('app/public/videos/' . $video->filename); $path = storage_path('app/public/videos/'.$video->filename);
if (!file_exists($path)) { if (! file_exists($path)) {
abort(404, 'Video file not found'); abort(404, 'Video file not found');
} }
$filename = $video->title . '.' . pathinfo($video->filename, PATHINFO_EXTENSION); $filename = $video->title.'.'.pathinfo($video->filename, PATHINFO_EXTENSION);
return response()->download($path, $filename); return response()->download($path, $filename);
} }
// Trending videos page
public function trending(Request $request)
{
$hours = $request->get('hours', 48); // Default: 48 hours
$limit = $request->get('limit', 50);
// Validate parameters
$hours = min(max($hours, 24), 168); // Between 24h and 7 days
$limit = min(max($limit, 10), 100);
// Get all public ready videos first
$videos = Video::public()
->where('status', 'ready')
->where('created_at', '>=', now()->subDays(10))
->with('user')
->get();
// Calculate trending score for each video
$videos = $videos->map(function ($video) use ($hours) {
$recentViews = \DB::table('video_views')
->where('video_id', $video->id)
->where('watched_at', '>=', now()->subHours($hours))
->count();
$likeCount = \DB::table('video_likes')
->where('video_id', $video->id)
->count();
// Calculate age in hours
$ageHours = $video->created_at->diffInHours(now());
// Calculate trending score
// 70% recent views, 15% velocity, 10% recency, 5% likes
$velocity = $recentViews / $hours;
$recencyBonus = max(0, 1 - ($ageHours / 240));
$score = ($recentViews * 0.70) +
($velocity * 100 * 0.15) +
($recencyBonus * 50 * 0.10) +
($likeCount * 0.1 * 0.05);
$video->trending_score = round($score, 2);
$video->view_count = $recentViews;
$video->like_count = $likeCount;
return $video;
});
// Filter and sort by trending score
$trendingVideos = $videos
->filter(fn ($v) => $v->trending_score > 0)
->sortByDesc('trending_score')
->take($limit)
->values();
return view('videos.trending', [
'videos' => $trendingVideos,
'hours' => $hours,
'limit' => $limit,
]);
}
// Shorts page
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'));
}
} }

View File

@ -0,0 +1,32 @@
<?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);
}
}

34
app/Models/MatchPoint.php Normal file
View File

@ -0,0 +1,34 @@
<?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');
}
}

34
app/Models/MatchRound.php Normal file
View File

@ -0,0 +1,34 @@
<?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');
}
}

321
app/Models/Playlist.php Normal file
View File

@ -0,0 +1,321 @@
<?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;
}
}

View File

@ -19,6 +19,16 @@ class User extends Authenticatable
'password', 'password',
'avatar', 'avatar',
'role', 'role',
'bio',
'website',
'twitter',
'instagram',
'facebook',
'youtube',
'linkedin',
'tiktok',
'birthday',
'location',
]; ];
protected $hidden = [ protected $hidden = [
@ -52,12 +62,18 @@ class User extends Authenticatable
return $this->hasMany(Comment::class); return $this->hasMany(Comment::class);
} }
public function playlists()
{
return $this->hasMany(Playlist::class);
}
public function getAvatarUrlAttribute() public function getAvatarUrlAttribute()
{ {
if ($this->avatar) { 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 // Role helper methods
@ -82,5 +98,39 @@ class User extends Authenticatable
// For now, return a placeholder - in production this would come from a subscriptions table // For now, return a placeholder - in production this would come from a subscriptions table
return rand(100, 10000); 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;
}
}

View File

@ -22,6 +22,7 @@ class Video extends Model
'status', 'status',
'visibility', 'visibility',
'type', 'type',
'is_shorts',
]; ];
protected $casts = [ protected $casts = [
@ -29,6 +30,7 @@ class Video extends Model
'size' => 'integer', 'size' => 'integer',
'width' => 'integer', 'width' => 'integer',
'height' => 'integer', 'height' => 'integer',
'is_shorts' => 'boolean',
]; ];
// Relationships // Relationships
@ -52,21 +54,26 @@ class Video extends Model
// Accessors // Accessors
public function getUrlAttribute() public function getUrlAttribute()
{ {
return asset('storage/videos/' . $this->filename); return asset('storage/videos/'.$this->filename);
} }
public function getThumbnailUrlAttribute() public function getThumbnailUrlAttribute()
{ {
if ($this->thumbnail) { if ($this->thumbnail) {
return asset('storage/thumbnails/' . $this->thumbnail); return asset('storage/thumbnails/'.$this->thumbnail);
} }
return asset('images/video-placeholder.jpg');
// Return null when no thumbnail - social platforms will use their own preview
return null;
} }
// Check if video is liked by user // Check if video is liked by user
public function isLikedBy($user) public function isLikedBy($user)
{ {
if (!$user) return false; if (! $user) {
return false;
}
return $this->likes()->where('user_id', $user->id)->exists(); return $this->likes()->where('user_id', $user->id)->exists();
} }
@ -88,6 +95,33 @@ class Video extends Model
return route('videos.show', $this->id); 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 // Visibility helpers
public function isPublic() public function isPublic()
{ {
@ -138,13 +172,14 @@ class Video extends Model
->orWhere('user_id', $user->id); ->orWhere('user_id', $user->id);
}); });
} }
return $query->where('visibility', '!=', 'private'); return $query->where('visibility', '!=', 'private');
} }
// Video type helpers // Video type helpers
public function getTypeIconAttribute() public function getTypeIconAttribute()
{ {
return match($this->type) { return match ($this->type) {
'music' => 'bi-music-note', 'music' => 'bi-music-note',
'match' => 'bi-trophy', 'match' => 'bi-trophy',
default => 'bi-film', default => 'bi-film',
@ -153,7 +188,7 @@ class Video extends Model
public function getTypeSymbolAttribute() public function getTypeSymbolAttribute()
{ {
return match($this->type) { return match ($this->type) {
'music' => '🎵', 'music' => '🎵',
'match' => '🏆', 'match' => '🏆',
default => '🎬', default => '🎬',
@ -175,6 +210,31 @@ class Video extends Model
return $this->type === 'match'; 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 // Comments relationship
public function comments() public function comments()
{ {
@ -185,4 +245,159 @@ class Video extends Model
{ {
return $this->comments()->count(); 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);
}
} }

View File

@ -16,7 +16,7 @@ return [
| |
*/ */
'name' => env('APP_NAME', 'Laravel'), 'name' => env('APP_NAME', 'TAKEONE'),
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
@ -57,7 +57,7 @@ return [
'url' => env('APP_URL', 'http://localhost'), 'url' => env('APP_URL', 'http://localhost'),
'asset_url' => env('ASSET_URL'), 'asset_url' => env('APP_URL'),
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
@ -70,7 +70,7 @@ return [
| |
*/ */
'timezone' => 'UTC', 'timezone' => 'Asia/Bahrain',
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------

View File

@ -0,0 +1,48 @@
<?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'
]);
});
}
};

View File

@ -0,0 +1,23 @@
<?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');
});
}
};

View File

@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
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');
}
};

View File

@ -0,0 +1,33 @@
<?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');
}
};

View File

@ -0,0 +1,26 @@
<?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');
}
};

View File

@ -0,0 +1,30 @@
<?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');
}
};

View File

@ -0,0 +1,28 @@
<?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');
}
};

View File

@ -0,0 +1,32 @@
<?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');
}
});
}
};

View File

@ -5,8 +5,8 @@
@section('content') @section('content')
<!-- Stats Cards --> <!-- Stats Cards -->
<div class="row mb-4"> <div class="row mb-4 stats-grid">
<div class="col-md-3"> <div class="col-6 col-lg-3">
<div class="stats-card"> <div class="stats-card">
<div class="stats-card-icon"> <div class="stats-card-icon">
<i class="bi bi-people"></i> <i class="bi bi-people"></i>
@ -15,7 +15,7 @@
<div class="stats-card-label">Total Users</div> <div class="stats-card-label">Total Users</div>
</div> </div>
</div> </div>
<div class="col-md-3"> <div class="col-6 col-lg-3">
<div class="stats-card"> <div class="stats-card">
<div class="stats-card-icon"> <div class="stats-card-icon">
<i class="bi bi-play-circle"></i> <i class="bi bi-play-circle"></i>
@ -24,7 +24,7 @@
<div class="stats-card-label">Total Videos</div> <div class="stats-card-label">Total Videos</div>
</div> </div>
</div> </div>
<div class="col-md-3"> <div class="col-6 col-lg-3">
<div class="stats-card"> <div class="stats-card">
<div class="stats-card-icon"> <div class="stats-card-icon">
<i class="bi bi-eye"></i> <i class="bi bi-eye"></i>
@ -33,7 +33,7 @@
<div class="stats-card-label">Total Views</div> <div class="stats-card-label">Total Views</div>
</div> </div>
</div> </div>
<div class="col-md-3"> <div class="col-6 col-lg-3">
<div class="stats-card"> <div class="stats-card">
<div class="stats-card-icon"> <div class="stats-card-icon">
<i class="bi bi-hand-thumbs-up"></i> <i class="bi bi-hand-thumbs-up"></i>
@ -47,12 +47,13 @@
<!-- Recent Users & Videos --> <!-- Recent Users & Videos -->
<div class="row"> <div class="row">
<!-- Recent Users --> <!-- Recent Users -->
<div class="col-lg-6"> <div class="col-lg-6 mb-4">
<div class="admin-card"> <div class="admin-card">
<div class="admin-card-header"> <div class="admin-card-header">
<h5 class="admin-card-title">Recent Users</h5> <h5 class="admin-card-title">Recent Users</h5>
<a href="{{ route('admin.users') }}" class="btn btn-outline-light btn-sm">View All</a> <a href="{{ route('admin.users') }}" class="btn btn-outline-light btn-sm">View All</a>
</div> </div>
<div class="table-responsive">
<table class="admin-table"> <table class="admin-table">
<thead> <thead>
<tr> <tr>
@ -69,7 +70,7 @@
<img src="{{ $user->avatar_url }}" alt="{{ $user->name }}" class="user-avatar"> <img src="{{ $user->avatar_url }}" alt="{{ $user->name }}" class="user-avatar">
<div> <div>
<div>{{ $user->name }}</div> <div>{{ $user->name }}</div>
<small class="text-secondary">{{ $user->email }}</small> <small class="text-secondary">{{ Str::limit($user->email, 20) }}</small>
</div> </div>
</div> </div>
</td> </td>
@ -93,14 +94,16 @@
</table> </table>
</div> </div>
</div> </div>
</div>
<!-- Recent Videos --> <!-- Recent Videos -->
<div class="col-lg-6"> <div class="col-lg-6 mb-4">
<div class="admin-card"> <div class="admin-card">
<div class="admin-card-header"> <div class="admin-card-header">
<h5 class="admin-card-title">Recent Videos</h5> <h5 class="admin-card-title">Recent Videos</h5>
<a href="{{ route('admin.videos') }}" class="btn btn-outline-light btn-sm">View All</a> <a href="{{ route('admin.videos') }}" class="btn btn-outline-light btn-sm">View All</a>
</div> </div>
<div class="table-responsive">
<table class="admin-table"> <table class="admin-table">
<thead> <thead>
<tr> <tr>
@ -115,9 +118,9 @@
<td> <td>
<div class="d-flex align-items-center gap-2"> <div class="d-flex align-items-center gap-2">
@if($video->thumbnail) @if($video->thumbnail)
<img src="{{ asset('storage/thumbnails/' . $video->thumbnail) }}" alt="{{ $video->title }}" style="width: 60px; height: 40px; object-fit: cover; border-radius: 4px;"> <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 @else
<div style="width: 60px; height: 40px; background: #333; border-radius: 4px; display: flex; align-items: center; justify-content: center;"> <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> <i class="bi bi-play-circle text-secondary"></i>
</div> </div>
@endif @endif
@ -154,12 +157,13 @@
</table> </table>
</div> </div>
</div> </div>
</div>
</div> </div>
<!-- Videos by Status & Visibility --> <!-- Videos by Status & Visibility -->
<div class="row"> <div class="row">
<!-- Videos by Status --> <!-- Videos by Status -->
<div class="col-lg-6"> <div class="col-lg-6 mb-4">
<div class="admin-card"> <div class="admin-card">
<div class="admin-card-header"> <div class="admin-card-header">
<h5 class="admin-card-title">Videos by Status</h5> <h5 class="admin-card-title">Videos by Status</h5>
@ -186,7 +190,7 @@
</div> </div>
<!-- Videos by Visibility --> <!-- Videos by Visibility -->
<div class="col-lg-6"> <div class="col-lg-6 mb-4">
<div class="admin-card"> <div class="admin-card">
<div class="admin-card-header"> <div class="admin-card-header">
<h5 class="admin-card-title">Videos by Visibility</h5> <h5 class="admin-card-title">Videos by Visibility</h5>

View File

@ -18,6 +18,8 @@
--border-color: #303030; --border-color: #303030;
--text-primary: #f1f1f1; --text-primary: #f1f1f1;
--text-secondary: #aaaaaa; --text-secondary: #aaaaaa;
--sidebar-width: 240px;
--header-height: 56px;
} }
* { box-sizing: border-box; } * { box-sizing: border-box; }
@ -31,13 +33,12 @@
overflow-x: hidden; overflow-x: hidden;
} }
/* Header */
.yt-header { .yt-header {
position: fixed; position: fixed;
top: 0; top: 0;
left: 0; left: 0;
right: 0; right: 0;
height: 56px; height: var(--header-height);
background: var(--bg-dark); background: var(--bg-dark);
display: flex; display: flex;
align-items: center; align-items: center;
@ -83,12 +84,15 @@
letter-spacing: -1px; letter-spacing: -1px;
} }
/* Search */
.yt-header-center { .yt-header-center {
flex: 1; flex: 1;
max-width: 640px; max-width: 640px;
margin: 0 40px; margin: 0 40px;
display: flex; display: none;
}
@media (min-width: 992px) {
.yt-header-center { display: flex; }
} }
.yt-search { .yt-search {
@ -124,7 +128,6 @@
.yt-search-btn:hover { background: #303030; } .yt-search-btn:hover { background: #303030; }
/* Header Right */
.yt-header-right { .yt-header-right {
display: flex; display: flex;
align-items: center; align-items: center;
@ -155,59 +158,17 @@
cursor: pointer; cursor: pointer;
} }
/* Sidebar */
.yt-sidebar {
position: fixed;
top: 56px;
left: 0;
bottom: 0;
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 { .admin-sidebar {
position: fixed; position: fixed;
top: 56px; top: var(--header-height);
left: 0; left: 0;
bottom: 0; bottom: 0;
width: 240px; width: var(--sidebar-width);
background: var(--bg-secondary); background: var(--bg-secondary);
padding: 20px 0; padding: 20px 0;
z-index: 1000; z-index: 999;
border-right: 1px solid var(--border-color); border-right: 1px solid var(--border-color);
transition: transform 0.3s ease;
} }
.admin-sidebar-brand { .admin-sidebar-brand {
@ -245,17 +206,35 @@
.admin-sidebar-link i { .admin-sidebar-link i {
font-size: 1.2rem; 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 { .admin-main {
margin-top: 56px; margin-top: var(--header-height);
margin-left: 240px; margin-left: var(--sidebar-width);
padding: 24px; padding: 24px;
min-height: calc(100vh - 56px); min-height: calc(100vh - var(--header-height));
transition: margin-left 0.3s ease;
} }
/* Upload Button */
.yt-upload-btn { .yt-upload-btn {
background: var(--brand-red); background: var(--brand-red);
color: white; color: white;
@ -272,7 +251,6 @@
.yt-upload-btn:hover { background: #cc1a1a; } .yt-upload-btn:hover { background: #cc1a1a; }
/* Dropdown */
.dropdown-menu-dark { .dropdown-menu-dark {
background: var(--bg-secondary); background: var(--bg-secondary);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
@ -287,7 +265,6 @@
color: var(--text-primary); color: var(--text-primary);
} }
/* Cards */
.admin-card { .admin-card {
background: var(--bg-secondary); background: var(--bg-secondary);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
@ -301,6 +278,8 @@
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
margin-bottom: 15px; margin-bottom: 15px;
flex-wrap: wrap;
gap: 10px;
} }
.admin-card-title { .admin-card-title {
@ -309,13 +288,13 @@
margin: 0; margin: 0;
} }
/* Stats Cards */
.stats-card { .stats-card {
background: var(--bg-secondary); background: var(--bg-secondary);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: 12px; border-radius: 12px;
padding: 20px; padding: 20px;
text-align: center; text-align: center;
height: 100%;
} }
.stats-card-icon { .stats-card-icon {
@ -335,10 +314,15 @@
font-size: 0.9rem; font-size: 0.9rem;
} }
/* Tables */ .table-responsive {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.admin-table { .admin-table {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
min-width: 500px;
} }
.admin-table th, .admin-table th,
@ -360,7 +344,6 @@
background: rgba(255,255,255,0.02); background: rgba(255,255,255,0.02);
} }
/* Forms */
.form-control, .form-select { .form-control, .form-select {
background: #282828; background: #282828;
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
@ -382,7 +365,6 @@
margin-bottom: 8px; margin-bottom: 8px;
} }
/* Buttons */
.btn-primary { .btn-primary {
background: var(--brand-red); background: var(--brand-red);
border-color: var(--brand-red); border-color: var(--brand-red);
@ -404,7 +386,6 @@
color: var(--text-primary); color: var(--text-primary);
} }
/* Badges */
.badge-role { .badge-role {
padding: 5px 10px; padding: 5px 10px;
border-radius: 20px; border-radius: 20px;
@ -428,12 +409,12 @@
color: white; color: white;
} }
/* Status badges */
.badge-status { .badge-status {
padding: 4px 8px; padding: 4px 8px;
border-radius: 4px; border-radius: 4px;
font-size: 0.75rem; font-size: 0.75rem;
font-weight: 500; font-weight: 500;
white-space: nowrap;
} }
.badge-ready { background: #198754; color: white; } .badge-ready { background: #198754; color: white; }
@ -441,31 +422,31 @@
.badge-pending { background: #ffc107; color: black; } .badge-pending { background: #ffc107; color: black; }
.badge-failed { background: #dc3545; color: white; } .badge-failed { background: #dc3545; color: white; }
/* Visibility badges */
.badge-public { background: #198754; color: white; } .badge-public { background: #198754; color: white; }
.badge-unlisted { background: #fd7e14; color: white; } .badge-unlisted { background: #fd7e14; color: white; }
.badge-private { background: #6c757d; color: white; } .badge-private { background: #6c757d; color: white; }
/* User avatar */
.user-avatar { .user-avatar {
width: 36px; width: 36px;
height: 36px; height: 36px;
border-radius: 50%; border-radius: 50%;
object-fit: cover; object-fit: cover;
flex-shrink: 0;
} }
/* Search */
.search-form { .search-form {
display: flex; display: flex;
gap: 10px; gap: 10px;
margin-bottom: 20px; margin-bottom: 20px;
flex-wrap: wrap;
} }
.search-form .form-control { .search-form .form-control {
flex: 1;
min-width: 200px;
max-width: 300px; max-width: 300px;
} }
/* Filters */
.filter-form { .filter-form {
display: flex; display: flex;
gap: 15px; gap: 15px;
@ -489,15 +470,16 @@
min-width: 150px; min-width: 150px;
} }
/* Pagination */
.pagination { .pagination {
margin-top: 20px; margin-top: 20px;
flex-wrap: wrap;
} }
.page-link { .page-link {
background: var(--bg-secondary); background: var(--bg-secondary);
border-color: var(--border-color); border-color: var(--border-color);
color: var(--text-primary); color: var(--text-primary);
padding: 8px 12px;
} }
.page-link:hover { .page-link:hover {
@ -511,7 +493,6 @@
border-color: var(--brand-red); border-color: var(--brand-red);
} }
/* Modal */
.modal-content { .modal-content {
background: var(--bg-secondary); background: var(--bg-secondary);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
@ -525,7 +506,6 @@
border-top-color: var(--border-color); border-top-color: var(--border-color);
} }
/* Alerts */
.alert-success { .alert-success {
background: #198754; background: #198754;
border: none; border: none;
@ -538,24 +518,102 @@
color: white; color: white;
} }
/* Responsive */ .page-title {
@media (max-width: 768px) { font-size: 1.5rem;
}
/* MOBILE STYLES */
@media (max-width: 991px) {
.admin-sidebar { .admin-sidebar {
width: 60px; transform: translateX(-100%);
width: 280px;
} }
.admin-sidebar-brand h4, .admin-sidebar.open {
.admin-sidebar-link span { transform: translateX(0);
display: none;
}
.admin-sidebar-link {
justify-content: center;
padding: 15px;
} }
.admin-main { .admin-main {
margin-left: 60px; margin-left: 0;
padding: 16px;
}
.admin-sidebar-link {
padding: 14px 20px;
}
.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;
} }
} }
</style> </style>
@ -563,13 +621,13 @@
@yield('extra_styles') @yield('extra_styles')
</head> </head>
<body> <body>
<!-- Header -->
@include('layouts.partials.header') @include('layouts.partials.header')
<!-- Sidebar --> <div class="sidebar-overlay" id="sidebarOverlay"></div>
<aside class="admin-sidebar">
<aside class="admin-sidebar" id="adminSidebar">
<div class="admin-sidebar-brand"> <div class="admin-sidebar-brand">
<h4><i class="bi bi-speedometer2"></i> Admin</h4> <h4><i class="bi bi-speedometer2"></i> <span class="d-none d-md-inline">Admin</span></h4>
</div> </div>
<nav> <nav>
@ -593,19 +651,60 @@
</nav> </nav>
</aside> </aside>
<!-- Main Content -->
<main class="admin-main"> <main class="admin-main">
<!-- Page Title --> <div class="d-flex justify-content-between align-items-center mb-4 flex-wrap gap-2">
<div class="d-flex justify-content-between align-items-center mb-4"> <h1 class="page-title" style="margin: 0; font-size: 1.8rem; font-weight: 600;">@yield('page_title', 'Dashboard')</h1>
<h1 style="margin: 0; font-size: 1.8rem; font-weight: 600;">@yield('page_title', 'Dashboard')</h1>
</div> </div>
<!-- Content -->
@yield('content') @yield('content')
</main> </main>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script> <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') @yield('scripts')
</body> </body>
</html> </html>

View File

@ -0,0 +1,66 @@
<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'])

View File

@ -0,0 +1,200 @@
@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>
</div>
</div>
</div>

View File

@ -12,6 +12,9 @@ $typeIcon = $video ? match($video->type) {
default => 'bi-film', default => 'bi-film',
} : 'bi-film'; } : 'bi-film';
// Check if video is shorts
$isShorts = $video && $video->isShorts();
// Check if current user is the owner of the video // Check if current user is the owner of the video
$isOwner = $video && auth()->check() && auth()->id() == $video->user_id; $isOwner = $video && auth()->check() && auth()->id() == $video->user_id;
@ -34,6 +37,11 @@ $sizeClasses = match($size) {
@if($video && $video->duration) @if($video && $video->duration)
<span class="yt-video-duration">{{ gmdate('i:s', $video->duration) }}</span> <span class="yt-video-duration">{{ gmdate('i:s', $video->duration) }}</span>
@endif @endif
@if($isShorts)
<span class="yt-shorts-badge">
<i class="bi bi-collection-play-fill"></i> SHORTS
</span>
@endif
</div> </div>
</a> </a>
<div class="yt-video-info"> <div class="yt-video-info">
@ -171,6 +179,16 @@ $sizeClasses = match($size) {
</div> </div>
</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 --> <!-- Thumbnail -->
<div class="cute-form-group"> <div class="cute-form-group">
<label><i class="bi bi-image"></i> Thumbnail</label> <label><i class="bi bi-image"></i> Thumbnail</label>
@ -267,6 +285,27 @@ $sizeClasses = match($size) {
font-weight: 500; 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 { .yt-video-card .yt-video-info {
display: flex; display: flex;
margin-top: 12px; margin-top: 12px;
@ -544,6 +583,56 @@ $sizeClasses = match($size) {
color: #ff6b8a; 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 */ /* Thumbnail Upload */
.cute-thumbnail-upload { .cute-thumbnail-upload {
border: 2px dashed #444; border: 2px dashed #444;
@ -658,6 +747,63 @@ $sizeClasses = match($size) {
</style> </style>
<script> <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) { function playVideo(element) {
const video = element.querySelector('video'); const video = element.querySelector('video');
if (video) { if (video) {
@ -721,6 +867,12 @@ function openEditVideoModal(videoId) {
} }
}); });
// Set shorts toggle
const shortsCheckbox = document.getElementById('edit-is-shorts-' + videoId);
if (shortsCheckbox) {
shortsCheckbox.checked = video.is_shorts === true || video.is_shorts === 1 || video.is_shorts === '1';
}
// Clear status // Clear status
const statusEl = document.getElementById('edit-status-' + videoId); const statusEl = document.getElementById('edit-status-' + videoId);
statusEl.className = 'cute-status'; statusEl.className = 'cute-status';

View File

@ -0,0 +1,275 @@
<div class="comments-section" style="margin-top: 24px; padding-top: 16px; border-top: 1px solid var(--border-color);">
<h3 style="font-size: 18px; font-weight: 600; margin-bottom: 16px;">
Comments <span style="color: var(--text-secondary); font-weight: 400;"
id="commentCount{{ $video->id }}">({{ isset($video->comment_count) ? $video->comment_count : 0 }})</span>
</h3>
@auth
<div class="comment-form" style="display: flex; gap: 12px; margin-bottom: 24px;">
<img src="{{ Auth::user()->avatar_url }}" class="channel-avatar" style="width: 40px; height: 40px;"
alt="{{ Auth::user()->name }}">
<div style="flex: 1; display: flex; align-items: center; gap: 8px;">
<textarea id="commentBody{{ $video->id }}" class="form-control"
placeholder="Add a comment... Use @mm.ss for timestamps (e.g. @1.30)" rows="1"
style="background: transparent; border: none; border-bottom: 2px solid var(--border-color); color: var(--text-primary); border-radius: 0; padding: 12px 0 8px 0; flex: 1; margin: 0; height: 40px; font-size: 14px; outline: none; overflow: hidden; resize: none;"></textarea>
<button type="button" class="action-btn" onclick="clearCommentForm('{{ $video->id }}')"
style="flex-shrink: 0;">
<i class="bi bi-x-lg"></i>
</button>
<button type="button" class="action-btn comment-btn" onclick="submitComment({{ $video->id }})"
style="flex-shrink: 0;">
<i class="bi bi-chat-dots"></i>
<span>Comment</span>
</button>
</div>
</div>
@else
<div
style="margin-bottom: 24px; padding: 16px; background: var(--bg-secondary); border-radius: 8px; text-align: center;">
<a href="{{ route('login') }}" style="color: var(--brand-red);">Sign in</a> to comment
</div>
@endauth
<div id="commentsList{{ $video->id }}">
@if (isset($video))
@forelse($video->comments()->whereNull('parent_id')->with('user', 'replies.user')->latest()->limit(20)->get() as $comment)
@include('videos.partials.comment', ['comment' => $comment])
@empty
<p style="color: var(--text-secondary); text-align: center; padding: 20px;">No comments yet. Be the
first to comment!</p>
@endforelse
@endif
</div>
</div>
<style>
/* Comment Section Specific Styles */
.comment-time-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 7px;
border-radius: 999px;
background: rgba(62, 166, 255, 0.15);
border: 1px solid rgba(62, 166, 255, 0.4);
color: #7dd3fc;
font-weight: 600;
font-size: 12px;
line-height: 1.2;
cursor: pointer;
text-decoration: none;
user-select: none;
margin: 0 2px;
transition: all 0.2s ease;
}
.comment-time-badge:hover {
background: rgba(62, 166, 255, 0.26);
border-color: rgba(125, 211, 252, 0.8);
color: #e0f2fe;
transform: translateY(-1px);
}
.comment-form textarea:focus {
border-bottom-color: var(--brand-red);
}
.action-btn.comment-btn:hover {
background: #dc2626 !important;
}
</style>
<script>
(function() {
const videoId = {{ $video->id ?? 0 }};
const scopePrefix = 'comments_' + videoId;
// Scoped functions
window[scopePrefix + '_submitComment'] = function() {
const body = document.getElementById('commentBody' + videoId).value.trim();
if (!body) return;
fetch(`/videos/${videoId}/comments`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute(
'content')
},
body: JSON.stringify({
body
})
})
.then(response => response.json())
.then(data => {
if (data?.success) {
document.getElementById('commentBody' + videoId).value = '';
window[scopePrefix + '_addCommentToList'](data.comment);
} else {
alert('Failed to post comment');
}
})
.catch(error => {
console.error('Error:', error);
alert('Failed to post comment');
});
};
window[scopePrefix + '_clearCommentForm'] = function(vid) {
document.getElementById('commentBody' + vid).value = '';
};
window[scopePrefix + '_addCommentToList'] = function(comment) {
const commentsList = document.getElementById('commentsList' + videoId);
const commentHtml = `
<div class="comment-item" style="display: flex; gap: 12px; margin-bottom: 16px;" id="comment-${comment.id}">
<img src="${comment.user.avatar_url}" class="channel-avatar" style="width: 36px; height: 36px; flex-shrink: 0;" alt="${comment.user.name}">
<div style="flex: 1;">
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 4px;">
<span style="font-weight: 600; font-size: 14px;">${comment.user.name}</span>
<span style="color: var(--text-secondary); font-size: 12px;">just now</span>
</div>
<div class="comment-body" style="font-size: 14px; line-height: 1.5; word-wrap: break-word;" data-time-enhanced="0">
${escapeHtml(comment.body)}
</div>
<div style="display: flex; gap: 12px; margin-top: 8px;">
<button onclick="toggleReplyForm(${comment.id})" style="background: none; border: none; color: var(--text-secondary); font-size: 12px; font-weight: 600; cursor: pointer; padding: 0;">
Reply
</button>
</div>
</div>
</div>
`;
commentsList.insertAdjacentHTML('afterbegin', commentHtml);
window[scopePrefix + '_enhanceCommentBodyWithTimeBadges'](commentsList);
window[scopePrefix + '_updateCommentCount'](1);
};
window[scopePrefix + '_updateCommentCount'] = function(delta = 0) {
const countEl = document.getElementById('commentCount' + videoId);
if (countEl) {
let count = parseInt(countEl.textContent.match(/\((\d+)\)/)?.[1] || 0) + delta;
countEl.textContent = `(${Math.max(0, count)})`;
}
};
window[scopePrefix + '_parseDotTimeToSeconds'] = function(dotTime) {
const parts = String(dotTime).trim().split('.');
if (parts.length !== 2) return null;
const mins = parseInt(parts[0], 10);
const secs = parseInt(parts[1], 10);
if (isNaN(mins) || isNaN(secs) || secs < 0 || secs > 59) return null;
return mins * 60 + secs;
};
let commentPlaybackStopHandler = null;
let commentPlaybackEndTime = null;
window[scopePrefix + '_clearCommentPlaybackHandler'] = function(videoPlayer) {
if (videoPlayer && commentPlaybackStopHandler) {
videoPlayer.removeEventListener('timeupdate', commentPlaybackStopHandler);
}
commentPlaybackStopHandler = null;
commentPlaybackEndTime = null;
};
window[scopePrefix + '_playCommentTimeRange'] = function(startSec, endSec = null) {
const videoPlayer = document.querySelector('#videoPlayer, video');
if (!videoPlayer) return;
window[scopePrefix + '_clearCommentPlaybackHandler'](videoPlayer);
const startPlayback = () => {
const playbackStart = Math.max(0, startSec - 1);
videoPlayer.currentTime = playbackStart;
videoPlayer.play().catch(e => console.warn('Autoplay prevented:', e));
if (endSec !== null && endSec > startSec) {
commentPlaybackEndTime = endSec;
commentPlaybackStopHandler = () => {
if (videoPlayer.currentTime >= commentPlaybackEndTime) {
videoPlayer.pause();
window[scopePrefix + '_clearCommentPlaybackHandler'](videoPlayer);
}
};
videoPlayer.addEventListener('timeupdate', commentPlaybackStopHandler);
}
};
startPlayback();
};
window[scopePrefix + '_enhanceCommentBodyWithTimeBadges'] = function(root = document) {
const commentBodies = root.querySelectorAll('.comment-body[data-time-enhanced="0"]');
const timeRangeRegex = /@(\\d{1,2}\\.\\d{2})(?:-(\\d{1,2}\\.\\d{2}))?/g;
const mentionRegex = /@(\\w+)/g;
commentBodies.forEach(bodyEl => {
const originalText = bodyEl.textContent || '';
if (!originalText.trim()) return;
let html = originalText.replace(timeRangeRegex, (match, start, end) => {
const startSec = window[scopePrefix + '_parseDotTimeToSeconds'](start);
const endSec = end ? window[scopePrefix + '_parseDotTimeToSeconds'](end) :
null;
if (startSec === null || (end && endSec === null)) return match;
if (endSec !== null && endSec <= startSec) return match;
const label = end ? `@${start}-${end}` : `@${start}`;
return `<span class="comment-time-badge" data-start="${startSec}" data-end="${endSec ?? ''}">${label}</span>`;
});
html = html.replace(mentionRegex, (m, u) => `@${u}`);
bodyEl.innerHTML = html.replace(/(^|[\\s>])@(\\w+)/g,
'$1<span style="color: #3ea6ff; font-weight: 500;">@$2</span>');
bodyEl.dataset.timeEnhanced = '1';
});
root.querySelectorAll('.comment-time-badge').forEach(badge => {
if (badge.dataset.bound === '1') return;
badge.addEventListener('click', function(e) {
e.preventDefault();
e.stopPropagation();
const start = parseFloat(this.dataset.start || '');
const end = this.dataset.end === '' ? null : parseFloat(this.dataset.end);
if (!isNaN(start)) {
window[scopePrefix + '_playCommentTimeRange'](start, end);
}
});
badge.dataset.bound = '1';
});
};
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Global functions for partials (edit/delete/reply)
window.submitComment = window[scopePrefix + '_submitComment'];
window.clearCommentForm = window[scopePrefix + '_clearCommentForm'];
window.deleteComment = function(commentId) {
if (confirm('Are you sure?')) {
fetch(`/comments/${commentId}`, {
method: 'DELETE',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute(
'content')
}
})
.then(res => res.json())
.then(data => {
if (data.success) {
document.getElementById('comment-' + commentId)?.remove();
window[scopePrefix + '_updateCommentCount'](-1);
}
});
}
};
// Initialize
document.addEventListener('DOMContentLoaded', () => {
window[scopePrefix + '_enhanceCommentBodyWithTimeBadges']();
});
})();
</script>

View File

@ -1,14 +1,41 @@
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
<style> <style>
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; } body {
.container { max-width: 600px; margin: 0 auto; padding: 20px; } font-family: Arial, sans-serif;
.header { background: #e61e1e; color: white; padding: 20px; text-align: center; } line-height: 1.6;
.content { padding: 20px; background: #f9f9f9; } color: #333;
.footer { text-align: center; padding: 20px; color: #666; font-size: 12px; } }
.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> </style>
</head> </head>
<body> <body>
<div class="container"> <div class="container">
<div class="header"> <div class="header">
@ -19,16 +46,44 @@
<p>Hi {{ $userName }},</p> <p>Hi {{ $userName }},</p>
<p>Your video <strong>"{{ $video->title }}"</strong> has been uploaded successfully!</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>Your video is now being processed and will be available shortly.</p>
<p>Video Details:</p>
<ul> <p>Check out your uploaded video:</p>
<li>Size: {{ round($video->size / 1024 / 1024, 2) }} MB</li> <div style="margin: 20px 0; text-align: center;">
<li>Orientation: {{ $video->orientation }}</li> @if ($video->thumbnail)
</ul> <a href="{{ url('/videos/' . $video->id) }}">
<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> <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> </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>
</div>
<div class="footer"> <div class="footer">
<p>TAKEONE Video Platform</p> <p>TAKEONE Video Platform</p>
</div> </div>
</div> </div>
</body> </body>
</html> </html>

View File

@ -5,6 +5,9 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>@yield('title', config('app.name'))</title> <title>@yield('title', config('app.name'))</title>
<!-- Open Graph meta tags default - will be overridden by page-specific tags -->
@stack('head')
<!-- Favicon --> <!-- Favicon -->
<link rel="icon" type="image/png" href="{{ asset('storage/images/logo.png') }}"> <link rel="icon" type="image/png" href="{{ asset('storage/images/logo.png') }}">
<link rel="apple-touch-icon" href="{{ asset('storage/images/logo.png') }}"> <link rel="apple-touch-icon" href="{{ asset('storage/images/logo.png') }}">
@ -84,12 +87,14 @@
letter-spacing: -1px; letter-spacing: -1px;
} }
/* Search */ /* Search */
.yt-header-center { .yt-header-center {
flex: 1; flex: 1;
max-width: 640px; max-width: 640px;
margin: 0 40px; margin: 0 40px;
display: flex; display: flex;
align-items: center;
justify-content: center;
} }
.yt-search { .yt-search {
@ -415,7 +420,7 @@
<!-- Mobile Search Overlay --> <!-- Mobile Search Overlay -->
<div class="mobile-search-overlay" id="mobileSearchOverlay"> <div class="mobile-search-overlay" id="mobileSearchOverlay">
<form action="{{ route('videos.search') }}" method="GET" class="mobile-search-form"> <form action="{{ route('videos.trending') }}" method="GET" class="mobile-search-form">
<input type="text" name="q" class="mobile-search-input" placeholder="Search" value="{{ request('q') }}"> <input type="text" name="q" class="mobile-search-input" placeholder="Search" value="{{ request('q') }}">
<button type="submit" class="mobile-search-submit"> <button type="submit" class="mobile-search-submit">
<i class="bi bi-search"></i> <i class="bi bi-search"></i>
@ -430,7 +435,7 @@
@include('layouts.partials.sidebar') @include('layouts.partials.sidebar')
<!-- Main Content --> <!-- Main Content -->
<main class="yt-main" id="main"> <main class="yt-main @yield('main_class')" id="main">
@yield('content') @yield('content')
</main> </main>
@ -438,8 +443,13 @@
@auth @auth
@include('layouts.partials.upload-modal') @include('layouts.partials.upload-modal')
@include('layouts.partials.edit-video-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 --> <!-- Delete Video Modal -->
@auth
<div class="modal fade" id="deleteVideoModal" tabindex="-1" aria-labelledby="deleteVideoModalLabel" aria-hidden="true"> <div class="modal fade" id="deleteVideoModal" tabindex="-1" aria-labelledby="deleteVideoModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered"> <div class="modal-dialog modal-dialog-centered">
<div class="modal-content" style="background: #1a1a1a; border: 1px solid #3f3f3f; border-radius: 12px;"> <div class="modal-content" style="background: #1a1a1a; border: 1px solid #3f3f3f; border-radius: 12px;">
@ -488,6 +498,30 @@
</div> </div>
@endauth @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-play-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 src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script> <script>
// Mobile search toggle function // Mobile search toggle function
@ -790,8 +824,9 @@
@media (max-width: 768px) { @media (max-width: 768px) {
.video-container { .video-container {
border-radius: 0 !important; border-radius: 0 !important;
margin: 0 -16px !important; margin: 0 !important;
max-width: calc(100% + 32px) !important; max-width: 100% !important;
width: 100% !important;
} }
} }
@ -807,4 +842,66 @@
background: var(--border-color); 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> </style>

View File

@ -0,0 +1,401 @@
<!-- 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;
}
</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();
});
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';
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>

View File

@ -0,0 +1,29 @@
<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

View File

@ -1,10 +1,196 @@
<!-- Header --> <!-- Header -->
<header class="yt-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"> <div class="yt-header-left">
<button class="yt-menu-btn" onclick="toggleSidebar()"> <button class="yt-menu-btn" onclick="toggleSidebar()">
<i class="bi bi-list fs-5"></i> <i class="bi bi-list fs-5"></i>
</button> </button>
<a href="/videos" class="yt-logo"> <a href="{{ route('home') }}" class="yt-logo">
<!-- Mobile logo (visible only on mobile) --> <!-- Mobile logo (visible only on mobile) -->
<img src="{{ asset('storage/images/logo.png') }}" alt="{{ config('app.name') }}" class="d-md-none" style="height: 30px;"> <img src="{{ asset('storage/images/logo.png') }}" alt="{{ config('app.name') }}" class="d-md-none" style="height: 30px;">
<!-- Desktop logo (visible only on desktop) --> <!-- Desktop logo (visible only on desktop) -->
@ -29,7 +215,7 @@
@auth @auth
<!-- Upload Button - Opens Modal --> <!-- Upload Button - Opens Modal -->
<button type="button" class="yt-upload-btn" data-bs-toggle="modal" data-bs-target="#uploadModal"> <button type="button" class="yt-upload-btn d-none d-md-flex" data-bs-toggle="modal" data-bs-target="#uploadModal">
<i class="bi bi-plus-lg"></i> <i class="bi bi-plus-lg"></i>
<span>Upload</span> <span>Upload</span>
</button> </button>

View File

@ -1,14 +1,18 @@
<!-- Sidebar --> <!-- Sidebar -->
<nav class="yt-sidebar" id="sidebar"> <nav class="yt-sidebar" id="sidebar">
<div class="yt-sidebar-section"> <div class="yt-sidebar-section">
<a href="/videos" class="yt-sidebar-link {{ request()->is('/') || request()->is('videos') ? 'active' : '' }}"> <a href="{{ route('home') }}" class="yt-sidebar-link {{ request()->is('/') || request()->is('videos') ? 'active' : '' }}">
<i class="bi bi-house-door-fill"></i> <i class="bi bi-house-door-fill"></i>
<span>Home</span> <span>Home</span>
</a> </a>
<a href="#" class="yt-sidebar-link"> <a href="{{ route('videos.shorts') }}" class="yt-sidebar-link {{ request()->is('shorts') ? 'active' : '' }}">
<i class="bi bi-play-btn"></i> <i class="bi bi-play-btn"></i>
<span>Shorts</span> <span>Shorts</span>
</a> </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 @auth
<a href="#" class="yt-sidebar-link"> <a href="#" class="yt-sidebar-link">
<i class="bi bi-collection-play"></i> <i class="bi bi-collection-play"></i>
@ -23,6 +27,10 @@
<i class="bi bi-person-video"></i> <i class="bi bi-person-video"></i>
<span>Your Channel</span> <span>Your Channel</span>
</a> </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"> <a href="{{ route('history') }}" class="yt-sidebar-link">
<i class="bi bi-clock-history"></i> <i class="bi bi-clock-history"></i>
<span>History</span> <span>History</span>

File diff suppressed because it is too large Load Diff

View File

@ -4,6 +4,10 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>@yield('title', config('app.name'))</title> <title>@yield('title', config('app.name'))</title>
<!-- Open Graph meta tags default - will be overridden by page-specific tags -->
@stack('head')
<!-- Favicon --> <!-- Favicon -->
<link rel="icon" type="image/png" href="{{ asset('storage/images/logo.png') }}"> <link rel="icon" type="image/png" href="{{ asset('storage/images/logo.png') }}">
<link rel="apple-touch-icon" href="{{ asset('storage/images/logo.png') }}"> <link rel="apple-touch-icon" href="{{ asset('storage/images/logo.png') }}">
@ -28,16 +32,122 @@
margin: 0; margin: 0;
padding: 0; padding: 0;
min-height: 100vh; 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; display: flex;
align-items: center; align-items: center;
justify-content: 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 { .plain-main {
width: 100%; width: 100%;
padding: 20px; 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 */
.dropdown-menu-dark { .dropdown-menu-dark {
background: var(--bg-secondary); background: var(--bg-secondary);
@ -57,7 +167,10 @@
@yield('extra_styles') @yield('extra_styles')
</head> </head>
<body> <body>
<!-- Main Content - No header or sidebar --> <!-- Header -->
@include('layouts.partials.header')
<!-- Main Content -->
<main class="plain-main"> <main class="plain-main">
@yield('content') @yield('content')
</main> </main>
@ -66,5 +179,4 @@
@yield('scripts') @yield('scripts')
</body> </body>
</html>

View File

@ -0,0 +1,522 @@
@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>

View File

@ -0,0 +1,838 @@
@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

View File

@ -555,6 +555,41 @@
</div> </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"> <div class="yt-empty">
<i class="bi bi-camera-video yt-empty-icon"></i> <i class="bi bi-camera-video yt-empty-icon"></i>

View File

@ -4,193 +4,187 @@
@section('extra_styles') @section('extra_styles')
<style> <style>
.profile-header { .profile-header { background: var(--bg-secondary); border-radius: 12px; padding: 32px; margin-bottom: 24px; position: relative; overflow: hidden; }
background: var(--bg-secondary); .profile-banner { position: absolute; top: 0; left: 0; right: 0; height: 120px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); z-index: 0; }
border-radius: 12px; .profile-content { position: relative; z-index: 1; margin-top: 60px; }
padding: 32px; .profile-avatar { width: 120px; height: 120px; border-radius: 50%; object-fit: cover; border: 4px solid var(--bg-secondary); margin-bottom: 16px; }
margin-bottom: 24px; .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-avatar { .profile-meta { display: flex; flex-wrap: wrap; gap: 16px; margin-bottom: 20px; color: var(--text-secondary); font-size: 14px; }
width: 120px; .profile-meta-item { display: flex; align-items: center; gap: 6px; }
height: 120px; .profile-stats { display: flex; gap: 32px; margin-top: 16px; padding-top: 20px; border-top: 1px solid var(--border-color); }
border-radius: 50%; .profile-stat { text-align: center; }
object-fit: cover; .profile-stat-value { font-size: 22px; font-weight: 700; }
margin-bottom: 16px; .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; }
.profile-name { .social-link:hover { transform: translateY(-2px); background: var(--brand-red); color: white; }
font-size: 24px; .social-link.twitter:hover { background: #1da1f2; }
font-weight: 500; .social-link.instagram:hover { background: #e4405f; }
margin-bottom: 4px; .social-link.facebook:hover { background: #1877f2; }
} .social-link.youtube:hover { background: #ff0000; }
.social-link.linkedin:hover { background: #0077b5; }
.profile-email { .social-link.tiktok:hover { background: #000000; }
color: var(--text-secondary); .form-card { background: var(--bg-secondary); border-radius: 12px; padding: 24px; margin-bottom: 24px; }
margin-bottom: 16px; .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; }
.profile-stats { .form-label { display: block; margin-bottom: 8px; font-weight: 500; font-size: 14px; }
display: flex; .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; }
gap: 24px; .form-input:focus, .form-textarea:focus { outline: none; border-color: var(--brand-red); }
margin-top: 16px; .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; }
.profile-stat { .btn-primary:hover { background: #cc1a1a; }
text-align: center; .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; }
.profile-stat-value { .alert-success { background: rgba(34, 197, 94, 0.15); border: 1px solid #22c55e; color: #22c55e; }
font-size: 20px; .section-title { font-size: 20px; font-weight: 600; margin-bottom: 20px; display: flex; align-items: center; gap: 8px; }
font-weight: 600; .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; }
.profile-stat-label { .w-100 { width: 100%; }
font-size: 14px; .mt-4 { margin-top: 24px; }
color: var(--text-secondary); .text-center { text-align: center; }
} .gap-3 { gap: 12px; }
.d-flex { display: flex; }
.form-card { .flex-column { flex-direction: column; }
background: var(--bg-secondary); .video-card { background: var(--bg-primary); border-radius: 8px; overflow: hidden; transition: transform 0.2s; }
border-radius: 12px; .video-card:hover { transform: translateY(-4px); }
padding: 24px; .video-thumbnail { position: relative; aspect-ratio: 16/9; background: #000; }
margin-bottom: 24px; .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; }
.form-title { .video-title { font-size: 14px; font-weight: 500; margin-bottom: 6px; line-height: 1.4; }
font-size: 18px; .video-title a { color: var(--text-primary); text-decoration: none; }
font-weight: 500; .video-title a:hover { color: var(--brand-red); }
margin-bottom: 20px; .video-meta { font-size: 12px; color: var(--text-secondary); }
padding-bottom: 12px; @media (max-width: 768px) { .profile-stats { justify-content: center; } .profile-meta { justify-content: center; } .social-links { justify-content: center; } }
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> </style>
@endsection @endsection
@section('content') @section('content')
<div class="profile-header text-center"> <div class="profile-header">
<div class="profile-banner"></div>
<div class="profile-content text-center">
@if($user->avatar) @if($user->avatar)
<img src="{{ asset('storage/avatars/' . $user->avatar) }}" alt="{{ $user->name }}" class="profile-avatar"> <img src="{{ asset('storage/avatars/' . $user->avatar) }}" alt="{{ $user->name }}" class="profile-avatar">
@else @else
<img src="https://i.pravatar.cc/150?u={{ $user->id }}" alt="{{ $user->name }}" class="profile-avatar"> <img src="https://i.pravatar.cc/150?u={{ $user->id }}" alt="{{ $user->name }}" class="profile-avatar">
@endif @endif
<h1 class="profile-name">{{ $user->name }}</h1> <h1 class="profile-name">{{ $user->name }}</h1>
<p class="profile-email">{{ $user->email }}</p> @if($user->username)
<p class="profile-username">@ {{ $user->username }}</p>
@endif
@if($user->bio)
<p class="profile-bio">{{ $user->bio }}</p>
@endif
<div class="profile-meta justify-content-center">
@if($user->location)
<div class="profile-meta-item"><i class="bi bi-geo-alt"></i><span>{{ $user->location }}</span></div>
@endif
@if($user->website)
<div class="profile-meta-item"><i class="bi bi-link-45deg"></i><a href="{{ $user->website_url }}" target="_blank" style="color: inherit;">{{ Str::limit($user->website, 30) }}</a></div>
@endif
<div class="profile-meta-item"><i class="bi bi-calendar3"></i><span>Joined {{ $user->created_at->format('M Y') }}</span></div>
</div>
@if($user->hasSocialLinks())
<div class="social-links justify-content-center">
@if($user->twitter)<a href="https://twitter.com/{{ $user->twitter }}" target="_blank" class="social-link twitter" title="Twitter"><i class="bi bi-twitter-x"></i></a>@endif
@if($user->instagram)<a href="https://instagram.com/{{ $user->instagram }}" target="_blank" class="social-link instagram" title="Instagram"><i class="bi bi-instagram"></i></a>@endif
@if($user->facebook)<a href="https://facebook.com/{{ $user->facebook }}" target="_blank" class="social-link facebook" title="Facebook"><i class="bi bi-facebook"></i></a>@endif
@if($user->youtube)<a href="https://youtube.com/@{{ $user->youtube }}" target="_blank" class="social-link youtube" title="YouTube"><i class="bi bi-youtube"></i></a>@endif
@if($user->linkedin)<a href="https://linkedin.com/in/{{ $user->linkedin }}" target="_blank" class="social-link linkedin" title="LinkedIn"><i class="bi bi-linkedin"></i></a>@endif
@if($user->tiktok)<a href="https://tiktok.com/@{{ $user->tiktok }}" target="_blank" class="social-link tiktok" title="TikTok"><i class="bi bi-tiktok"></i></a>@endif
@if($user->website)<a href="{{ $user->website_url }}" target="_blank" class="social-link" title="Website"><i class="bi bi-globe"></i></a>@endif
</div>
@endif
<div class="profile-stats justify-content-center"> <div class="profile-stats justify-content-center">
<div class="profile-stat"> <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-value">{{ $user->videos->count() }}</div> <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-label">Videos</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> <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>
</div> </div>
<div class="row"> <div class="row">
<div class="col-lg-6"> <div class="col-lg-8">
<div class="form-card"> <div class="form-card">
<h2 class="form-title">Edit Profile</h2> <h2 class="section-title"><i class="bi bi-play-circle"></i> Recent Videos</h2>
@if($user->videos->count() > 0)
@if(session('success')) <div class="video-grid">
<div class="alert alert-success"> @foreach($user->videos->take(4) as $video)
{{ session('success') }} <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> </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
</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 @endif
@else
<div class="empty-state"><i class="bi bi-camera-video"></i><p>No videos yet</p></div>
@endif
</div>
</div>
<div class="col-lg-4">
<div class="form-card">
<h2 class="form-title"><i class="bi bi-pencil-square"></i> Edit Profile</h2>
@if(session('success'))<div class="alert alert-success">{{ session('success') }}</div>@endif
<form method="POST" action="{{ route('profile.update') }}" enctype="multipart/form-data"> <form method="POST" action="{{ route('profile.update') }}" enctype="multipart/form-data">
@csrf @csrf @method('PUT')
@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"> <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>
<label class="form-label">Name</label> <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>
<input type="text" name="name" class="form-input" value="{{ old('name', $user->name) }}" required> <div class="form-group"><label class="form-label">Birthday</label><input type="date" name="birthday" class="form-input" value="{{ old('birthday', $user->birthday) }}"></div>
</div> <button type="submit" class="btn-primary w-100"><i class="bi bi-check-lg"></i> Save Changes</button>
<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> </form>
</div> </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>
<div class="col-lg-6">
<div class="form-card"> <div class="form-card">
<h2 class="form-title">Quick Links</h2> <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-primary d-inline-block text-decoration-none"> <a href="{{ route('channel', $user->id) }}" class="btn-secondary text-center"><i class="bi bi-play-btn"></i> View My Channel</a>
<i class="bi bi-play-btn"></i> View My Channel <a href="{{ route('settings') }}" class="btn-secondary text-center"><i class="bi bi-gear"></i> Account Settings</a>
</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>
<a href="{{ route('settings') }}" class="btn-primary d-inline-block text-decoration-none ms-2"> </div>
<i class="bi bi-gear"></i> Settings
</a>
</div> </div>
</div> </div>
</div> </div>
@endsection @endsection

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,7 @@
@extends('layouts.app') @extends('layouts.app')
@section('title', isset($query) ? 'Search: ' . $query . ' | ' . config('app.name') : 'Video Gallery | ' . config('app.name')) @section('title', isset($query) ? 'Search: ' . $query . ' | ' . config('app.name') : 'Video Gallery | ' .
config('app.name'))
@section('extra_styles') @section('extra_styles')
<style> <style>
@ -70,7 +71,7 @@
position: absolute; position: absolute;
bottom: 8px; bottom: 8px;
right: 8px; right: 8px;
background: rgba(0,0,0,0.8); background: rgba(0, 0, 0, 0.8);
color: white; color: white;
padding: 3px 6px; padding: 3px 6px;
border-radius: 4px; border-radius: 4px;
@ -113,7 +114,8 @@
text-decoration: none; text-decoration: none;
} }
.yt-channel-name, .yt-video-meta { .yt-channel-name,
.yt-video-meta {
color: var(--text-secondary); color: var(--text-secondary);
font-size: 14px; font-size: 14px;
} }
@ -151,7 +153,9 @@
font-size: 14px; font-size: 14px;
} }
.yt-more-dropdown-item:hover { background: var(--border-color); } .yt-more-dropdown-item:hover {
background: var(--border-color);
}
/* Empty State */ /* Empty State */
.yt-empty { .yt-empty {
@ -192,7 +196,10 @@
.yt-video-grid { .yt-video-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.yt-header-right .yt-icon-btn:not(:last-child) { display: none; }
.yt-header-right .yt-icon-btn:not(:last-child) {
display: none;
}
} }
</style> </style>
@endsection @endsection
@ -205,7 +212,7 @@
</div> </div>
@endif @endif
@if($videos->isEmpty()) @if ($videos->isEmpty())
<div class="yt-empty"> <div class="yt-empty">
<i class="bi bi-camera-video yt-empty-icon"></i> <i class="bi bi-camera-video yt-empty-icon"></i>
@isset($query) @isset($query)
@ -229,20 +236,19 @@
</div> </div>
@else @else
<div class="yt-video-grid"> <div class="yt-video-grid">
@foreach($videos as $video) @foreach ($videos as $video)
<x-video-card :video="$video" /> <x-video-card :video="$video" />
@endforeach @endforeach
</div> </div>
<div class="mt-4">{{ $videos->links() }}</div>
@endif @endif
@include('layouts.partials.share-modal') @include('layouts.partials.share-modal')
@endsection @endsection
@section('scripts') @section('scripts')
<script> <script>
function playVideo(card) { function playVideo(card) {
const video = card.querySelector('video'); const video = card.querySelector('video');
if (video) { if (video) {
video.currentTime = 0; video.currentTime = 0;
@ -252,19 +258,19 @@ function playVideo(card) {
}); });
video.classList.add('active'); video.classList.add('active');
} }
} }
function stopVideo(card) { function stopVideo(card) {
const video = card.querySelector('video'); const video = card.querySelector('video');
if (video) { if (video) {
video.pause(); video.pause();
video.currentTime = 0; video.currentTime = 0;
video.classList.remove('active'); video.classList.remove('active');
} }
} }
// Mobile touch support for hover-like behavior // Mobile touch support for hover-like behavior
document.addEventListener('touchstart', function(e) { document.addEventListener('touchstart', function(e) {
const card = e.target.closest('.yt-video-card'); const card = e.target.closest('.yt-video-card');
if (card) { if (card) {
// Stop any other playing videos first // Stop any other playing videos first
@ -275,46 +281,58 @@ document.addEventListener('touchstart', function(e) {
}); });
playVideo(card); playVideo(card);
} }
}, { passive: true }); }, {
passive: true
});
document.addEventListener('touchend', function(e) { document.addEventListener('touchend', function(e) {
const card = e.target.closest('.yt-video-card'); const card = e.target.closest('.yt-video-card');
if (card) { if (card) {
stopVideo(card); stopVideo(card);
} }
}, { passive: true }); }, {
</script> passive: true
});
</script>
@endsection @endsection
<!-- Extra Mobile Styles --> <!-- Extra Mobile Styles -->
<style> <style>
@media (max-width: 400px) { @media (max-width: 400px) {
.yt-video-grid { .yt-video-grid {
gap: 12px; gap: 12px;
} }
.yt-video-thumb { .yt-video-thumb {
border-radius: 8px; border-radius: 8px;
} }
.yt-video-info { .yt-video-info {
gap: 8px; gap: 8px;
margin-top: 8px; margin-top: 8px;
} }
.yt-channel-icon { .yt-channel-icon {
width: 28px; width: 28px;
height: 28px; height: 28px;
} }
.yt-video-title { .yt-video-title {
font-size: 13px; font-size: 13px;
} }
.yt-channel-name, .yt-video-meta {
.yt-channel-name,
.yt-video-meta {
font-size: 11px; font-size: 11px;
} }
.search-info { .search-info {
padding: 12px; padding: 12px;
} }
.search-info h2 { .search-info h2 {
font-size: 16px; font-size: 16px;
} }
} }
</style> </style>

View File

@ -1,20 +1,43 @@
<div class="comment-item" style="display: flex; gap: 12px; margin-bottom: 16px;" id="comment-{{ $comment->id }}"> <div class="comment-item" style="display: flex; gap: 12px; margin-bottom: 16px;" id="comment-{{ $comment->id }}">
<img src="{{ $comment->user->avatar_url }}" class="channel-avatar" style="width: 36px; height: 36px; flex-shrink: 0;" alt="{{ $comment->user->name }}"> <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="flex: 1;">
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 4px;"> <div style="display: flex; align-items: center; gap: 8px; margin-bottom: 4px;">
<span style="font-weight: 600; font-size: 14px;">{{ $comment->user->name }}</span> <span style="font-weight: 600; font-size: 14px;">{{ $comment->user->name }}</span>
<span style="color: var(--text-secondary); font-size: 12px;">{{ $comment->created_at->diffForHumans() }}</span> <span
style="color: var(--text-secondary); font-size: 12px;">{{ $comment->created_at->diffForHumans() }}</span>
</div> </div>
<div class="comment-body" style="font-size: 14px; line-height: 1.5; word-wrap: break-word;"> <div class="comment-body" style="font-size: 14px; line-height: 1.5; word-wrap: break-word;">
{{ $comment->body }} {{ $comment->body }}
</div> </div>
@auth
@if (Auth::id() === $comment->user_id)
<div id="commentEditWrap{{ $comment->id }}" style="display:none; margin-top:8px;">
<textarea id="commentEditInput{{ $comment->id }}" class="form-control" rows="3"
style="background: var(--bg-secondary); border: 1px solid var(--border-color); color: var(--text-primary); border-radius: 8px; padding: 8px; width: 100%; resize: vertical; font-size: 14px;">{{ $comment->body }}</textarea>
<div style="display:flex; gap:8px; justify-content:flex-end; margin-top:8px;">
<button type="button" class="yt-action-btn" style="font-size: 12px; padding: 6px 12px;"
onclick="cancelEditComment({{ $comment->id }})">Cancel</button>
<button type="button" class="yt-action-btn"
style="background: var(--brand-red); color: white; font-size: 12px; padding: 6px 12px;"
onclick="saveEditComment({{ $comment->id }})">Save</button>
</div>
</div>
@endif
@endauth
<div style="display: flex; gap: 12px; margin-top: 8px;"> <div style="display: flex; gap: 12px; margin-top: 8px;">
@auth @auth
<button onclick="toggleReplyForm({{ $comment->id }})" style="background: none; border: none; color: var(--text-secondary); font-size: 12px; font-weight: 600; cursor: pointer; padding: 0;"> <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 Reply
</button> </button>
@if(Auth::id() === $comment->user_id) @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;"> <button onclick="startEditComment({{ $comment->id }})"
style="background: none; border: none; color: var(--text-secondary); font-size: 12px; font-weight: 600; cursor: pointer; padding: 0;">
Edit
</button>
<button onclick="deleteComment({{ $comment->id }})"
style="background: none; border: none; color: var(--text-secondary); font-size: 12px; font-weight: 600; cursor: pointer; padding: 0;">
Delete Delete
</button> </button>
@endif @endif
@ -24,23 +47,23 @@
<!-- Reply Form --> <!-- Reply Form -->
<div id="replyForm{{ $comment->id }}" style="display: none; margin-top: 12px;"> <div id="replyForm{{ $comment->id }}" style="display: none; margin-top: 12px;">
<div style="display: flex; gap: 8px;"> <div style="display: flex; gap: 8px;">
<textarea <textarea class="form-control" placeholder="Write a reply..." rows="2"
class="form-control" 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>
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>
<div style="display: flex; gap: 8px; margin-top: 8px; justify-content: flex-end;"> <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="font-size: 12px; padding: 6px 12px;"
<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> 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>
</div> </div>
<!-- Replies --> <!-- Replies -->
@if($comment->replies && $comment->replies->count() > 0) @if ($comment->replies && $comment->replies->count() > 0)
<div style="margin-left: 24px; margin-top: 12px; border-left: 2px solid var(--border-color); padding-left: 12px;"> <div
@foreach($comment->replies as $reply) 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]) @include('videos.partials.comment', ['comment' => $reply, 'video' => $video ?? null])
@endforeach @endforeach
</div> </div>
@ -49,8 +72,64 @@
</div> </div>
<script> <script>
function toggleReplyForm(commentId) { function toggleReplyForm(commentId) {
const form = document.getElementById('replyForm' + commentId); const form = document.getElementById('replyForm' + commentId);
form.style.display = form.style.display === 'none' ? 'block' : 'none'; form.style.display = form.style.display === 'none' ? 'block' : 'none';
} }
function startEditComment(commentId) {
const wrap = document.getElementById('commentEditWrap' + commentId);
const body = document.querySelector('#comment-' + commentId + ' .comment-body');
const input = document.getElementById('commentEditInput' + commentId);
if (!wrap || !body || !input) return;
input.value = body.textContent.trim();
wrap.style.display = 'block';
body.style.display = 'none';
}
function cancelEditComment(commentId) {
const wrap = document.getElementById('commentEditWrap' + commentId);
const body = document.querySelector('#comment-' + commentId + ' .comment-body');
if (wrap) wrap.style.display = 'none';
if (body) body.style.display = 'block';
}
async function saveEditComment(commentId) {
const input = document.getElementById('commentEditInput' + commentId);
const bodyEl = document.querySelector('#comment-' + commentId + ' .comment-body');
const wrap = document.getElementById('commentEditWrap' + commentId);
if (!input || !bodyEl || !wrap) return;
const body = input.value.trim();
if (!body) return;
try {
const res = await fetch(`/comments/${commentId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': '{{ csrf_token() }}'
},
body: JSON.stringify({
body
})
});
const data = await res.json();
if (res.ok) {
bodyEl.textContent = data.body || body;
bodyEl.dataset.timeEnhanced = '';
if (typeof enhanceCommentBodyWithTimeBadges === 'function') {
enhanceCommentBodyWithTimeBadges(document.getElementById('comment-' + commentId));
}
wrap.style.display = 'none';
bodyEl.style.display = 'block';
} else {
alert(data.error || 'Failed to update comment');
}
} catch (e) {
alert('Failed to update comment: ' + e.message);
}
}
</script> </script>

View File

@ -0,0 +1,350 @@
@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

View File

@ -1,19 +1,96 @@
@extends('layouts.app') @extends('layouts.app')
@push('head') @push('head')
<!-- Open Graph / WhatsApp / Facebook / Twitter Preview --> <!-- Open Graph / WhatsApp / Facebook / Twitter / LinkedIn / Telegram Preview -->
<meta property="og:title" content="{{ $video->title }}"> <meta property="og:title" content="{{ $video->title }}">
<meta property="og:description" content="{{ $video->description ? Str::limit($video->description, 200) : 'Check out this video on ' . config('app.name') }}"> <meta property="og:description"
<meta property="og:image" content="{{ $video->thumbnail_url }}"> content="{{ $video->description ? Str::limit($video->description, 200) : 'Watch ' . $video->title . ' on ' . config('app.name') }}">
<meta property="og:image" content="{{ $video->open_graph_image }}">
<meta property="og:image:width" content="{{ $video->thumbnail_width }}">
<meta property="og:image:height" content="{{ $video->thumbnail_height }}">
<meta property="og:image:alt" content="{{ $video->title }} - Video Thumbnail">
<meta property="og:url" content="{{ $video->share_url }}"> <meta property="og:url" content="{{ $video->share_url }}">
<meta property="og:type" content="video.other"> <meta property="og:type" content="video.other">
<meta property="og:site_name" content="{{ config('app.name') }}"> <meta property="og:site_name" content="{{ config('app.name') }}">
<meta property="og:locale" content="en_US">
<meta property="og:author" content="{{ $video->author_name }}">
<meta property="og:published_time" content="{{ $video->created_at->toIso8601String() }}">
<!-- Twitter Card --> <!-- Video-specific Open Graph tags -->
<meta property="video:duration" content="{{ $video->duration }}">
<meta property="video:release_date" content="{{ $video->created_at->toIso8601String() }}">
<!-- Alternative video tag for some platforms -->
<meta property="og:video" content="{{ $video->stream_url }}">
<meta property="og:video:url" content="{{ $video->stream_url }}">
<meta property="og:video:secure_url" content="{{ $video->stream_url }}">
<meta property="og:video:type" content="video/mp4">
<meta property="og:video:width" content="{{ $video->width ?? 1920 }}">
<meta property="og:video:height" content="{{ $video->height ?? 1080 }}">
<!-- Twitter Card - Enhanced for video sharing -->
<meta name="twitter:card" content="summary_large_image"> <meta name="twitter:card" content="summary_large_image">
<meta name="twitter:site" content="{{ config('app.name') }}">
<meta name="twitter:creator" content="{{ $video->author_name }}">
<meta name="twitter:title" content="{{ $video->title }}"> <meta name="twitter:title" content="{{ $video->title }}">
<meta name="twitter:description" content="{{ $video->description ? Str::limit($video->description, 200) : 'Check out this video on ' . config('app.name') }}"> <meta name="twitter:description"
<meta name="twitter:image" content="{{ $video->thumbnail_url }}"> content="{{ $video->description ? Str::limit($video->description, 200) : 'Watch ' . $video->title . ' on ' . config('app.name') }}">
<meta name="twitter:image" content="{{ $video->open_graph_image }}">
<meta name="twitter:image:alt" content="{{ $video->title }} - Video Thumbnail">
<!-- Twitter Player card for video -->
<meta name="twitter:player" content="{{ $video->share_url }}">
<meta name="twitter:player:width" content="{{ $video->width ?? 1920 }}">
<meta name="twitter:player:height" content="{{ $video->height ?? 1080 }}">
<meta name="twitter:player:stream" content="{{ $video->stream_url }}">
<!-- LinkedIn specific -->
<meta property="linkedin:owner" content="{{ $video->author_name }}">
<!-- Pinterest -->
<meta name="pinterest-rich-pin" content="true">
<!-- WhatsApp specific (uses Open Graph) -->
<!-- No additional meta needed - uses og: tags above -->
<!-- Schema.org VideoObject for search engines -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "VideoObject",
"name": "{{ $video->title }}",
"description": "{{ $video->description ? addslashes($video->description) : 'Watch ' . addslashes($video->title) . ' on ' . config('app.name') }}",
"thumbnailUrl": "{{ $video->open_graph_image }}",
"uploadDate": "{{ $video->created_at->toIso8601String() }}",
"duration": "{{ $video->iso_duration }}",
"contentUrl": "{{ $video->stream_url }}",
"embedUrl": "{{ $video->share_url }}",
"author": {
"@type": "Person",
"name": "{{ $video->author_name }}"
},
"publisher": {
"@type": "Organization",
"name": "{{ config('app.name') }}",
"logo": {
"@type": "ImageObject",
"url": "{{ asset('storage/images/fullLogo.png') }}"
}
},
"interactionStatistic": [
{
"@type": "InteractionCounter",
"interactionType": "https://schema.org/WatchAction",
"userInteractionCount": {{ $video->view_count }}
},
{
"@type": "InteractionCounter",
"interactionType": "https://schema.org/LikeAction",
"userInteractionCount": {{ $video->like_count }}
}
]
}
</script>
@endpush @endpush
@section('title', $video->title . ' | ' . config('app.name')) @section('title', $video->title . ' | ' . config('app.name'))
@ -21,7 +98,10 @@
@section('extra_styles') @section('extra_styles')
<style> <style>
/* Video Section */ /* Video Section */
.yt-video-section { flex: 1; min-width: 0; } .yt-video-section {
flex: 1;
min-width: 0;
}
/* Video Player */ /* Video Player */
.video-container { .video-container {
@ -44,11 +124,26 @@
width: auto; width: auto;
} }
.video-container.portrait { aspect-ratio: 9/16; max-width: 50vh; } .video-container.portrait {
.video-container.square { aspect-ratio: 1/1; max-width: 70vh; } aspect-ratio: 9/16;
.video-container.ultrawide { aspect-ratio: 21/9; max-width: 100%; } max-width: 50vh;
}
.video-container video { width: 100%; height: 100%; object-fit: contain; } .video-container.square {
aspect-ratio: 1/1;
max-width: 70vh;
}
.video-container.ultrawide {
aspect-ratio: 21/9;
max-width: 100%;
}
.video-container video {
width: 100%;
height: 100%;
object-fit: contain;
}
/* Video Info */ /* Video Info */
.video-title { .video-title {
@ -68,7 +163,12 @@
gap: 12px; gap: 12px;
} }
.video-stats-left { display: flex; align-items: center; gap: 16px; color: var(--text-secondary); } .video-stats-left {
display: flex;
align-items: center;
gap: 16px;
color: var(--text-secondary);
}
.video-actions { .video-actions {
display: flex; display: flex;
@ -90,9 +190,13 @@
font-weight: 500; font-weight: 500;
} }
.yt-action-btn:hover { background: var(--border-color); } .yt-action-btn:hover {
background: var(--border-color);
}
.yt-action-btn.liked { color: var(--brand-red); } .yt-action-btn.liked {
color: var(--brand-red);
}
/* Channel Row */ /* Channel Row */
.channel-row { .channel-row {
@ -109,7 +213,10 @@
} }
.channel-avatar { .channel-avatar {
width: 48px; height: 48px; border-radius: 50%; background: #555; width: 48px;
height: 48px;
border-radius: 50%;
background: #555;
} }
.channel-name { .channel-name {
@ -170,9 +277,16 @@
flex-shrink: 0; flex-shrink: 0;
} }
.sidebar-thumb img { width: 100%; height: 100%; object-fit: cover; } .sidebar-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
}
.sidebar-info { flex: 1; min-width: 0; } .sidebar-info {
flex: 1;
min-width: 0;
}
.sidebar-title { .sidebar-title {
font-size: 14px; font-size: 14px;
@ -184,28 +298,47 @@
overflow: hidden; overflow: hidden;
} }
.sidebar-meta { font-size: 12px; color: var(--text-secondary); } .sidebar-meta {
font-size: 12px;
color: var(--text-secondary);
}
/* Responsive */ /* Responsive */
@media (max-width: 1300px) { @media (max-width: 1300px) {
.yt-sidebar-container { width: 300px; } .yt-sidebar-container {
width: 300px;
}
} }
@media (max-width: 991px) { @media (max-width: 991px) {
.yt-main { margin-left: 0; flex-direction: column; } .yt-main {
.yt-sidebar-container { width: 100%; } margin-left: 0;
.yt-header-center { display: none; } flex-direction: column;
.sidebar-video-card { flex-direction: column; } }
.sidebar-thumb { width: 100%; }
.yt-sidebar-container {
width: 100%;
}
.sidebar-video-card {
flex-direction: column;
}
.sidebar-thumb {
width: 100%;
}
/* Video Layout Container - Stack vertically on tablet/mobile */ /* Video Layout Container - Stack vertically on tablet/mobile */
.video-layout-container { .video-layout-container {
flex-direction: column !important; flex-direction: column !important;
} }
.yt-video-section { .yt-video-section {
width: 100% !important; width: 100% !important;
flex: none !important; flex: none !important;
} }
.yt-sidebar-container { .yt-sidebar-container {
width: 100% !important; width: 100% !important;
margin-top: 16px; margin-top: 16px;
@ -213,33 +346,72 @@
} }
@media (max-width: 576px) { @media (max-width: 576px) {
.video-stats-row { flex-direction: column; align-items: flex-start; } .video-stats-row {
.video-actions { width: 100%; overflow-x: auto; justify-content: flex-start; } flex-direction: column;
.yt-main { padding: 12px !important; } align-items: flex-start;
}
.video-actions {
width: 100%;
overflow-x: auto;
justify-content: flex-start;
}
.yt-main {
padding: 0 !important;
max-width: 100% !important;
margin: 0 !important;
}
/* Mobile video player fixes - always full width */
.video-layout-container {
max-width: 100% !important;
margin: 0 !important;
padding: 0 !important;
width: 100% !important;
}
.yt-video-section {
width: 100% !important;
max-width: 100% !important;
padding: 0 !important;
margin: 0 !important;
}
/* Mobile video player fixes */
.video-container { .video-container {
width: 100% !important;
max-width: 100% !important;
max-height: 50vh !important; max-height: 50vh !important;
margin: 0 !important;
padding: 0 !important;
border-radius: 0 !important; border-radius: 0 !important;
} }
.video-container video { .video-container video {
width: 100% !important;
max-width: 100% !important;
object-fit: contain !important; object-fit: contain !important;
} }
.video-title { .video-title {
font-size: 16px !important; font-size: 16px !important;
margin: 12px 0 6px !important; margin: 12px 0 6px !important;
} }
.channel-row { .channel-row {
flex-direction: column; flex-direction: column;
align-items: flex-start !important; align-items: flex-start !important;
gap: 12px; gap: 12px;
} }
.channel-info { .channel-info {
width: 100%; width: 100%;
} }
.subscribe-btn { .subscribe-btn {
width: 100%; width: 100%;
} }
.video-description { .video-description {
padding: 12px !important; padding: 12px !important;
} }
@ -255,14 +427,15 @@
<!-- Video Section --> <!-- Video Section -->
<div class="yt-video-section"> <div class="yt-video-section">
<!-- Video Player --> <!-- Video Player -->
<div class="video-container @if($video->orientation === 'portrait') portrait @elseif($video->orientation === 'square') square @elseif($video->orientation === 'ultrawide') ultrawide @endif" id="videoContainer"> <div class="video-container @if ($video->orientation === 'portrait') portrait @elseif($video->orientation === 'square') square @elseif($video->orientation === 'ultrawide') ultrawide @endif"
id="videoContainer">
<video id="videoPlayer" controls playsinline preload="metadata" autoplay> <video id="videoPlayer" controls playsinline preload="metadata" autoplay>
<source src="{{ route('videos.stream', $video->id) }}" type="video/mp4"> <source src="{{ route('videos.stream', $video->id) }}" type="video/mp4">
</video> </video>
</div> </div>
@php @php
$typeIcon = match($video->type) { $typeIcon = match ($video->type) {
'music' => 'bi-music-note', 'music' => 'bi-music-note',
'match' => 'bi-trophy', 'match' => 'bi-trophy',
default => 'bi-film', default => 'bi-film',
@ -285,16 +458,19 @@
<div class="video-actions"> <div class="video-actions">
@auth @auth
<!-- Like Button --> <!-- Like Button -->
<form method="POST" action="{{ $video->isLikedBy(Auth::user()) ? route('videos.unlike', $video->id) : route('videos.like', $video->id) }}" class="d-inline"> <form method="POST"
action="{{ $video->isLikedBy(Auth::user()) ? route('videos.unlike', $video->id) : route('videos.like', $video->id) }}"
class="d-inline">
@csrf @csrf
<button type="submit" class="yt-action-btn {{ $video->isLikedBy(Auth::user()) ? 'liked' : '' }}"> <button type="submit" class="yt-action-btn {{ $video->isLikedBy(Auth::user()) ? 'liked' : '' }}">
<i class="bi {{ $video->isLikedBy(Auth::user()) ? 'bi-hand-thumbs-up-fill' : 'bi-hand-thumbs-up' }}"></i> <i
class="bi {{ $video->isLikedBy(Auth::user()) ? 'bi-hand-thumbs-up-fill' : 'bi-hand-thumbs-up' }}"></i>
{{ $video->like_count > 0 ? number_format($video->like_count) : 'Like' }} {{ $video->like_count > 0 ? number_format($video->like_count) : 'Like' }}
</button> </button>
</form> </form>
<!-- Edit Button - Only for video owner --> <!-- Edit Button - Only for video owner -->
@if(Auth::id() === $video->user_id) @if (Auth::id() === $video->user_id)
<button class="yt-action-btn" onclick="openEditVideoModal({{ $video->id }})"> <button class="yt-action-btn" onclick="openEditVideoModal({{ $video->id }})">
<i class="bi bi-pencil"></i> Edit <i class="bi bi-pencil"></i> Edit
</button> </button>
@ -304,30 +480,51 @@
<i class="bi bi-hand-thumbs-up"></i> Like <i class="bi bi-hand-thumbs-up"></i> Like
</a> </a>
@endauth @endauth
@if($video->isShareable()) @if ($video->isShareable())
<button class="yt-action-btn" onclick="openShareModal('{{ $video->share_url }}', '{{ addslashes($video->title) }}')"><i class="bi bi-share"></i> Share</button> <button class="yt-action-btn"
onclick="openShareModal('{{ $video->share_url }}', '{{ addslashes($video->title) }}')"><i
class="bi bi-share"></i> Share</button>
@endif @endif
<!-- Save to Playlist Button -->
<button class="yt-action-btn" onclick="openAddToPlaylistModal({{ $video->id }})">
<i class="bi bi-collection-plus"></i> Save
</button>
@auth
<!-- Quick Watch Later Button -->
<form method="POST" action="{{ route('videos.watchLater', $video->id) }}" class="d-inline"
style="display: inline;">
@csrf
<button type="submit" class="yt-action-btn" title="Watch Later">
<i class="bi bi-clock"></i>
</button>
</form>
@endauth
</div> </div>
</div> </div>
<!-- Channel Row --> <!-- Channel Row -->
<div class="channel-row" style="display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 12px;"> <div class="channel-row"
style="display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 12px;">
<div style="display: flex; align-items: center; gap: 12px;"> <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;"> <a href="{{ route('channel', $video->user_id) }}" class="channel-info text-decoration-none"
@if($video->user) style="color: inherit; display: flex; align-items: center; gap: 12px;">
<img src="{{ $video->user->avatar_url }}" class="channel-avatar" style="width: 36px; height: 36px;" alt="{{ $video->user->name }}"> @if ($video->user)
<img src="{{ $video->user->avatar_url }}" class="channel-avatar"
style="width: 36px; height: 36px;" alt="{{ $video->user->name }}">
@else @else
<div class="channel-avatar" style="width: 36px; height: 36px;"></div> <div class="channel-avatar" style="width: 36px; height: 36px;"></div>
@endif @endif
<div> <div>
<div class="channel-name">{{ $video->user->name ?? 'Unknown' }}</div> <div class="channel-name">{{ $video->user->name ?? 'Unknown' }}</div>
<div class="channel-subs">{{ number_format($video->user->subscriber_count ?? 0) }} subscribers</div> <div class="channel-subs">{{ number_format($video->user->subscriber_count ?? 0) }} subscribers
</div>
</div> </div>
</a> </a>
{{-- Subscribe Button --}} {{-- Subscribe Button --}}
@auth @auth
@if(Auth::id() !== $video->user_id) @if (Auth::id() !== $video->user_id)
<button class="subscribe-btn">Subscribe</button> <button class="subscribe-btn">Subscribe</button>
@endif @endif
@else @else
@ -337,14 +534,15 @@
</div> </div>
<!-- Description --> <!-- Description -->
@if($video->description) @if ($video->description)
@php @php
$fullDescription = $video->description; $fullDescription = $video->description;
$shortDescription = Str::limit($fullDescription, 200); $shortDescription = Str::limit($fullDescription, 200);
$needsExpand = strlen($fullDescription) > 200; $needsExpand = strlen($fullDescription) > 200;
@endphp @endphp
<div class="video-description-box" style="background: var(--bg-secondary); border-radius: 12px; padding: 12px; margin-top: 12px;"> <div class="video-description-box"
style="background: var(--bg-secondary); border-radius: 12px; padding: 12px; margin-top: 12px;">
<div class="description-stats" style="font-size: 14px; font-weight: 500; margin-bottom: 8px;"> <div class="description-stats" style="font-size: 14px; font-weight: 500; margin-bottom: 8px;">
<span>{{ number_format($video->view_count) }} views</span> <span>{{ number_format($video->view_count) }} views</span>
<span></span> <span></span>
@ -352,7 +550,7 @@
</div> </div>
<div style="border-bottom: 1px solid var(--border-color); margin: 8px 0;"></div> <div style="border-bottom: 1px solid var(--border-color); margin: 8px 0;"></div>
<div class="description-content"> <div class="description-content">
@if($needsExpand) @if ($needsExpand)
<div class="description-short"> <div class="description-short">
<span class="description-text">{!! Str::markdown($shortDescription) !!}</span> <span class="description-text">{!! Str::markdown($shortDescription) !!}</span>
<span style="color: var(--text-secondary);">... </span> <span style="color: var(--text-secondary);">... </span>
@ -360,7 +558,9 @@
<div class="description-full" style="display: none;"> <div class="description-full" style="display: none;">
<span class="description-text">{!! Str::markdown($fullDescription) !!}</span> <span class="description-text">{!! Str::markdown($fullDescription) !!}</span>
</div> </div>
<button onclick="toggleDescription()" style="background: none; border: none; color: var(--text-primary); font-weight: 600; font-size: 14px; cursor: pointer; padding: 0; margin-top: 4px;">Show more</button> <button onclick="toggleDescription()"
style="background: none; border: none; color: var(--text-primary); font-weight: 600; font-size: 14px; cursor: pointer; padding: 0; margin-top: 4px;">Show
more</button>
@else @else
<span class="description-text">{!! Str::markdown($fullDescription) !!}</span> <span class="description-text">{!! Str::markdown($fullDescription) !!}</span>
@endif @endif
@ -385,91 +585,184 @@
</script> </script>
<style> <style>
.video-description-box .description-text { font-size: 14px; line-height: 1.5; color: var(--text-primary); } .video-description-box .description-text {
.video-description-box .description-text p { margin-bottom: 8px; } font-size: 14px;
.video-description-box .description-text a { color: #3ea6ff; } line-height: 1.5;
color: var(--text-primary);
}
.video-description-box .description-text p {
margin-bottom: 8px;
}
.video-description-box .description-text a {
color: #3ea6ff;
}
</style> </style>
@endif @endif
<!-- Comments Section --> @include('components.video-comments', ['video' => $video])
<div class="comments-section" style="margin-top: 24px; padding-top: 16: 1px solid var(--borderpx; border-top-color);"> </div>
<h3 style="font-size: 18px; font-weight: 600; margin-bottom: 16px;"> </div>
Comments <span style="color: var(--text-secondary); font-weight: 400;">({{ $video->comment_count }})</span>
</h3>
@auth <!-- Sidebar - Up Next / Recommendations -->
<div class="comment-form" style="display: flex; gap: 12px; margin-bottom: 24px;"> <div class="yt-sidebar-container">
<img src="{{ Auth::user()->avatar_url }}" class="channel-avatar" style="width: 40px; height: 40px;" alt="{{ Auth::user()->name }}"> @if ($playlist && $playlistVideos && $playlistVideos->count() > 0)
<div style="flex: 1;"> <h3 style="font-size: 16px; font-weight: 500; margin-bottom: 12px;">
<textarea id="commentBody" class="form-control" placeholder="Add a comment... Use @ to mention someone" rows="3" style="background: var(--bg-secondary); border: 1px solid var(--border-color); color: var(--text-primary); border-radius: 8px; padding: 12px; width: 100%; resize: none;"></textarea> <i class="bi bi-collection-play" style="margin-right: 8px;"></i>
<div style="display: flex; gap: 8px; margin-top: 8px; justify-content: flex-end;"> {{ $playlist->name }}
<button type="button" class="yt-action-btn" onclick="document.getElementById('commentBody').value = ''">Cancel</button> <span
<button type="button" class="yt-action-btn" style="background: var(--brand-red); color: white;" onclick="submitComment({{ $video->id }})">Comment</button> style="font-weight: 400; color: var(--text-secondary); font-size: 14px;">({{ $playlistVideos->count() }}
videos)</span>
</h3>
<div class="recommended-videos-list">
@foreach ($playlistVideos as $index => $playlistVideo)
@if ($playlistVideo->id !== $video->id)
<div class="sidebar-video-card{{ $playlistVideo->id === $video->id ? ' current-video' : '' }}"
onclick="window.location.href='{{ route('videos.show', $playlistVideo->id) }}?playlist={{ $playlist->id }}'">
<div class="sidebar-thumb" style="position: relative;">
@if ($playlistVideo->thumbnail)
<img src="{{ asset('storage/thumbnails/' . $playlistVideo->thumbnail) }}"
alt="{{ $playlistVideo->title }}">
@else
<img src="https://picsum.photos/seed/{{ $playlistVideo->id }}/320/180"
alt="{{ $playlistVideo->title }}">
@endif
@if ($playlistVideo->duration)
<span class="yt-video-duration">{{ gmdate('i:s', $playlistVideo->duration) }}</span>
@endif
@if ($playlistVideo->is_shorts)
<span class="yt-shorts-badge"
style="position: absolute; top: 8px; left: 8px; font-size: 10px; padding: 2px 6px;">
<i class="bi bi-collection-play-fill"></i> SHORTS
</span>
@endif
<!-- Playlist position indicator -->
<span
style="position: absolute; bottom: 4px; left: 4px; background: rgba(0,0,0,0.8); color: white; padding: 2px 6px; border-radius: 4px; font-size: 12px; font-weight: 500;">
{{ $index + 1 }}
</span>
</div>
<div class="sidebar-info">
<div class="sidebar-title">
<i class="bi {{ match ($playlistVideo->type) {'music' => 'bi-music-note','match' => 'bi-trophy',default => 'bi-film'} }}"
style="color: #ef4444; margin-right: 4px; font-size: 12px;"></i>
{{ Str::limit($playlistVideo->title, 60) }}
</div>
<div class="sidebar-meta">
<div>{{ $playlistVideo->user->name ?? 'Unknown' }}</div>
<div>{{ number_format($playlistVideo->view_count) }} views
{{ $playlistVideo->created_at->diffForHumans() }}</div>
</div> </div>
</div> </div>
</div> </div>
@endif
@endforeach
</div>
@if ($playlist->canEdit(Auth::user()))
<a href="{{ route('playlists.show', $playlist->id) }}" class="yt-action-btn"
style="margin-top: 12px; display: inline-block;">
<i class="bi bi-pencil"></i> Edit Playlist
</a>
@endif
@else
<h3 style="font-size: 16px; font-weight: 500; margin-bottom: 12px;">Up Next</h3>
@if ($recommendedVideos && $recommendedVideos->count() > 0)
<div class="recommended-videos-list">
@foreach ($recommendedVideos as $recVideo)
<div class="sidebar-video-card"
onclick="window.location.href='{{ route('videos.show', $recVideo->id) }}'">
<div class="sidebar-thumb">
@if ($recVideo->thumbnail)
<img src="{{ asset('storage/thumbnails/' . $recVideo->thumbnail) }}"
alt="{{ $recVideo->title }}">
@else
<img src="https://picsum.photos/seed/{{ $recVideo->id }}/320/180"
alt="{{ $recVideo->title }}">
@endif
@if ($recVideo->duration)
<span class="yt-video-duration">{{ gmdate('i:s', $recVideo->duration) }}</span>
@endif
@if ($recVideo->is_shorts)
<span class="yt-shorts-badge"
style="position: absolute; top: 8px; left: 8px; font-size: 10px; padding: 2px 6px;">
<i class="bi bi-collection-play-fill"></i> SHORTS
</span>
@endif
</div>
<div class="sidebar-info">
<div class="sidebar-title">
<i class="bi {{ match ($recVideo->type) {'music' => 'bi-music-note','match' => 'bi-trophy',default => 'bi-film'} }}"
style="color: #ef4444; margin-right: 4px; font-size: 12px;"></i>
{{ Str::limit($recVideo->title, 60) }}
</div>
<div class="sidebar-meta">
<div>{{ $recVideo->user->name ?? 'Unknown' }}</div>
<div>{{ number_format($recVideo->view_count) }} views
{{ $recVideo->created_at->diffForHumans() }}</div>
</div>
</div>
</div>
@endforeach
</div>
@else @else
<div style="margin-bottom: 24px; padding: 16px; background: var(--bg-secondary); border-radius: 8px; text-align: center;"> <div class="text-secondary">No recommendations available yet. Check back later!</div>
<a href="{{ route('login') }}" style="color: var(--brand-red);">Sign in</a> to comment @endif
@endif
</div> </div>
</div>
<!-- Mobile Bottom Action Bar -->
<div class="mobile-bottom-bar">
@auth
<form method="POST"
action="{{ $video->isLikedBy(Auth::user()) ? route('videos.unlike', $video->id) : route('videos.like', $video->id) }}"
class="d-inline" style="flex:1;">
@csrf
<button type="submit" class="yt-action-btn {{ $video->isLikedBy(Auth::user()) ? 'liked' : '' }}"
style="width:100%;">
<i class="bi {{ $video->isLikedBy(Auth::user()) ? 'bi-hand-thumbs-up-fill' : 'bi-hand-thumbs-up' }}"></i>
<span>{{ $video->like_count > 0 ? number_format($video->like_count) : 'Like' }}</span>
</button>
</form>
@else
<a href="{{ route('login') }}" class="yt-action-btn" style="flex:1;text-align:center;">
<i class="bi bi-hand-thumbs-up"></i><span>Like</span>
</a>
@endauth @endauth
<div id="commentsList"> @if ($video->isShareable())
@forelse($video->comments()->whereNull('parent_id')->with('user', 'replies.user')->latest()->get() as $comment) <button class="yt-action-btn"
@include('videos.partials.comment', ['comment' => $comment]) onclick="openShareModal('{{ $video->share_url }}', '{{ addslashes($video->title) }}')" style="flex:1;">
@empty <i class="bi bi-share"></i><span>Share</span>
<p style="color: var(--text-secondary); text-align: center; padding: 20px;">No comments yet. Be the first to comment!</p> </button>
@endforelse @endif
</div>
</div>
<script> <button class="yt-action-btn" onclick="openAddToPlaylistModal({{ $video->id }})" style="flex:1;">
function submitComment(videoId) { <i class="bi bi-collection-plus"></i><span>Save</span>
const body = document.getElementById('commentBody').value.trim(); </button>
if (!body) return;
fetch(`/videos/${videoId}/comments`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': '{{ csrf_token() }}' },
body: JSON.stringify({ body: body })
}).then(r => r.json()).then(data => {
if (data.success) { document.getElementById('commentBody').value = ''; location.reload(); }
});
}
function deleteComment(commentId) {
if (!confirm('Delete this comment?')) return;
fetch(`/comments/${commentId}`, { method: 'DELETE', headers: { 'X-CSRF-TOKEN': '{{ csrf_token() }}' }})
.then(r => r.json()).then(data => { if (data.success) location.reload(); });
}
document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('.comment-body').forEach(text => {
text.innerHTML = text.innerHTML.replace(/@(\w+)/g, '<span style="color: #3ea6ff; font-weight: 500;">@$1</span>');
});
});
function submitReply(videoId, parentId) {
const textarea = document.querySelector(`#replyForm${parentId} textarea`);
const body = textarea.value.trim();
if (!body) return;
fetch(`/videos/${videoId}/comments`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': '{{ csrf_token() }}' },
body: JSON.stringify({ body: body, parent_id: parentId })
}).then(r => r.json()).then(data => { if (data.success) location.reload(); });
}
</script>
</div>
<!-- Sidebar -->
<div class="yt-sidebar-container">
<h3 style="font-size: 16px; font-weight: 500; margin-bottom: 12px;">Up Next</h3>
<div class="text-secondary">More videos coming soon...</div>
</div>
@auth
@if (Auth::id() !== $video->user_id)
<button class="yt-action-btn" style="flex:1;background:var(--brand-red);color:white;">
<i class="bi bi-bell"></i><span>Subscribe</span>
</button>
@endif
@else
<a href="{{ route('login') }}" class="yt-action-btn"
style="flex:1;background:var(--brand-red);color:white;text-align:center;">
<i class="bi bi-bell"></i><span>Subscribe</span>
</a>
@endauth
</div> </div>
@include('layouts.partials.share-modal') @include('layouts.partials.share-modal')
@include('layouts.partials.edit-video-modal') @include('layouts.partials.edit-video-modal')
@include('layouts.partials.add-to-playlist-modal')
@if(Session::has('openEditModal') && Session::get('openEditModal')) @if (Session::has('openEditModal') && Session::get('openEditModal'))
@auth @auth
<script> <script>
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
@ -486,39 +779,94 @@
videoPlayer.volume = 0.5; videoPlayer.volume = 0.5;
var playPromise = videoPlayer.play(); var playPromise = videoPlayer.play();
if (playPromise !== undefined) { if (playPromise !== undefined) {
playPromise.then(function() { console.log('Video autoplayed'); }).catch(function(error) { console.log('Autoplay blocked'); }); playPromise.then(function() {
console.log('Video autoplayed');
}).catch(function(error) {
console.log('Autoplay blocked');
});
} }
} }
}); });
</script> </script>
@endsection @endsection
<!-- Extra Mobile Styles -->
<style> /* Mobile Bottom Action Bar */
@media (max-width: 576px) {
.mobile-bottom-bar {
display: flex !important;
}
.desktop-actions {
display: none;
}
}
.mobile-bottom-bar {
display: none;
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: var(--bg-primary);
border-top: 1px solid var(--border-color);
padding: 12px 16px;
justify-content: space-around;
z-index: 1000;
gap: 8px;
box-shadow: 0 -2px 10px rgba(0,0,0,0.3);
}
.mobile-bottom-bar .yt-action-btn {
flex: 1;
justify-content: center;
padding: 12px 8px;
font-size: 12px;
min-height: 44px;
}
.mobile-bottom-bar .yt-action-btn i {
font-size: 18px;
}
@media (max-width: 576px) {
.yt-video-section {
padding-bottom: 70px;
}
}
<!-- Extra Mobile Styles -->
<style>
@media (max-width: 400px) { @media (max-width: 400px) {
.video-layout-container { .video-layout-container {
padding: 0 !important; padding: 0 !important;
} }
.video-title { .video-title {
font-size: 15px !important; font-size: 15px !important;
padding: 0 4px; padding: 0 4px;
} }
.video-stats-left { .video-stats-left {
font-size: 12px; font-size: 12px;
} }
.video-description-box { .video-description-box {
margin: 12px 4px; margin: 12px 4px;
padding: 10px !important; padding: 10px !important;
} }
.comments-section { .comments-section {
margin-top: 16px; margin-top: 16px;
padding: 12px 4px; padding: 12px 4px;
} }
.comment-form { .comment-form {
flex-direction: column; flex-direction: column;
} }
.comment-form > img {
.comment-form>img {
display: none; display: none;
} }
} }
</style> </style>

View File

@ -0,0 +1,199 @@
@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

View File

@ -5,7 +5,10 @@
@section('extra_styles') @section('extra_styles')
<style> <style>
/* Video Section */ /* Video Section */
.yt-video-section { flex: 1; min-width: 0; } .yt-video-section {
flex: 1;
min-width: 0;
}
/* Video Player */ /* Video Player */
.video-container { .video-container {
@ -28,11 +31,159 @@
width: auto; width: auto;
} }
.video-container.portrait { aspect-ratio: 9/16; max-width: 50vh; } .video-container.portrait {
.video-container.square { aspect-ratio: 1/1; max-width: 70vh; } aspect-ratio: 9/16;
.video-container.ultrawide { aspect-ratio: 21/9; max-width: 100%; } max-width: 50vh;
}
.video-container video { width: 100%; height: 100%; object-fit: contain; } .video-container.square {
aspect-ratio: 1/1;
max-width: 70vh;
}
.video-container.ultrawide {
aspect-ratio: 21/9;
max-width: 100%;
}
.video-container video {
width: 100%;
height: 100%;
object-fit: contain;
}
/* Playlist Navigation Controls */
.playlist-controls {
position: absolute;
bottom: 60px;
left: 50%;
transform: translateX(-50%);
display: flex;
align-items: center;
gap: 12px;
background: rgba(0, 0, 0, 0.8);
padding: 8px 16px;
border-radius: 24px;
z-index: 10;
opacity: 0;
transition: opacity 0.3s ease;
}
.video-container:hover .playlist-controls,
.playlist-controls.visible {
opacity: 1;
}
.playlist-nav-btn {
background: transparent;
border: none;
color: white;
cursor: pointer;
padding: 8px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s ease;
}
.playlist-nav-btn:hover {
background: rgba(255, 255, 255, 0.2);
}
.playlist-nav-btn:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.playlist-nav-btn:disabled:hover {
background: transparent;
}
.playlist-nav-btn i {
font-size: 20px;
}
.playlist-nav-label {
font-size: 12px;
color: #aaa;
max-width: 120px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Autoplay Toggle */
.autoplay-toggle {
display: flex;
align-items: center;
gap: 8px;
padding: 0 8px;
border-left: 1px solid rgba(255, 255, 255, 0.2);
}
.autoplay-toggle label {
font-size: 12px;
color: #aaa;
cursor: pointer;
white-space: nowrap;
}
.autoplay-switch {
position: relative;
width: 36px;
height: 20px;
}
.autoplay-switch input {
opacity: 0;
width: 0;
height: 0;
}
.autoplay-slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #666;
transition: 0.3s;
border-radius: 20px;
}
.autoplay-slider:before {
position: absolute;
content: "";
height: 14px;
width: 14px;
left: 3px;
bottom: 3px;
background-color: white;
transition: 0.3s;
border-radius: 50%;
}
.autoplay-switch input:checked+.autoplay-slider {
background-color: var(--brand-red);
}
.autoplay-switch input:checked+.autoplay-slider:before {
transform: translateX(16px);
}
/* Keyboard hint */
.keyboard-hint {
position: absolute;
bottom: 10px;
right: 10px;
font-size: 10px;
color: rgba(255, 255, 255, 0.5);
background: rgba(0, 0, 0, 0.6);
padding: 4px 8px;
border-radius: 4px;
}
/* Video Info */ /* Video Info */
.video-title { .video-title {
@ -52,32 +203,13 @@
gap: 12px; gap: 12px;
} }
.video-stats-left { display: flex; align-items: center; gap: 16px; color: var(--text-secondary); } .video-stats-left {
.video-actions {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 16px;
color: var(--text-secondary);
} }
.yt-action-btn {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
border-radius: 20px;
border: none;
background: var(--bg-secondary);
color: var(--text-primary);
cursor: pointer;
font-size: 14px;
font-weight: 500;
}
.yt-action-btn:hover { background: var(--border-color); }
.yt-action-btn.liked { color: var(--brand-red); }
/* Channel Row */ /* Channel Row */
.channel-row { .channel-row {
display: flex; display: flex;
@ -93,7 +225,10 @@
} }
.channel-avatar { .channel-avatar {
width: 48px; height: 48px; border-radius: 50%; background: #555; width: 48px;
height: 48px;
border-radius: 50%;
background: #555;
} }
.channel-name { .channel-name {
@ -106,18 +241,6 @@
color: var(--text-secondary); color: var(--text-secondary);
} }
.subscribe-btn {
background: white;
color: black;
border: none;
padding: 8px 16px;
border-radius: 18px;
font-weight: 600;
font-size: 14px;
cursor: pointer;
white-space: nowrap;
}
/* Description */ /* Description */
.video-description { .video-description {
background: var(--bg-secondary); background: var(--bg-secondary);
@ -154,9 +277,16 @@
flex-shrink: 0; flex-shrink: 0;
} }
.sidebar-thumb img { width: 100%; height: 100%; object-fit: cover; } .sidebar-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
}
.sidebar-info { flex: 1; min-width: 0; } .sidebar-info {
flex: 1;
min-width: 0;
}
.sidebar-title { .sidebar-title {
font-size: 14px; font-size: 14px;
@ -168,27 +298,45 @@
overflow: hidden; overflow: hidden;
} }
.sidebar-meta { font-size: 12px; color: var(--text-secondary); } .sidebar-meta {
font-size: 12px;
color: var(--text-secondary);
}
/* Responsive */ /* Responsive */
@media (max-width: 1300px) { @media (max-width: 1300px) {
.yt-sidebar-container { width: 300px; } .yt-sidebar-container {
width: 300px;
}
} }
@media (max-width: 991px) { @media (max-width: 991px) {
.yt-main { margin-left: 0; flex-direction: column; } .yt-main {
.yt-sidebar-container { width: 100%; } margin-left: 0;
.yt-header-center { display: none; } flex-direction: column;
.sidebar-video-card { flex-direction: column; } }
.sidebar-thumb { width: 100%; }
.yt-sidebar-container {
width: 100%;
}
.sidebar-video-card {
flex-direction: column;
}
.sidebar-thumb {
width: 100%;
}
.video-layout-container { .video-layout-container {
flex-direction: column !important; flex-direction: column !important;
} }
.yt-video-section { .yt-video-section {
width: 100% !important; width: 100% !important;
flex: none !important; flex: none !important;
} }
.yt-sidebar-container { .yt-sidebar-container {
width: 100% !important; width: 100% !important;
margin-top: 16px; margin-top: 16px;
@ -196,32 +344,49 @@
} }
@media (max-width: 576px) { @media (max-width: 576px) {
.video-stats-row { flex-direction: column; align-items: flex-start; } .video-stats-row {
.video-actions { width: 100%; overflow-x: auto; justify-content: flex-start; } flex-direction: column;
.yt-main { padding: 12px !important; } align-items: flex-start;
}
.video-actions {
width: 100%;
overflow-x: auto;
justify-content: flex-start;
}
.yt-main {
padding: 12px !important;
}
.video-container { .video-container {
max-height: 50vh !important; max-height: 50vh !important;
border-radius: 0 !important; border-radius: 0 !important;
} }
.video-container video { .video-container video {
object-fit: contain !important; object-fit: contain !important;
} }
.video-title { .video-title {
font-size: 16px !important; font-size: 16px !important;
margin: 12px 0 6px !important; margin: 12px 0 6px !important;
} }
.channel-row { .channel-row {
flex-direction: column; flex-direction: column;
align-items: flex-start !important; align-items: flex-start !important;
gap: 12px; gap: 12px;
} }
.channel-info { .channel-info {
width: 100%; width: 100%;
} }
.subscribe-btn { .subscribe-btn {
width: 100%; width: 100%;
} }
.video-description { .video-description {
padding: 12px !important; padding: 12px !important;
} }
@ -237,10 +402,52 @@
<!-- Video Section --> <!-- Video Section -->
<div class="yt-video-section"> <div class="yt-video-section">
<!-- Video Player --> <!-- Video Player -->
<div class="video-container @if($video->orientation === 'portrait') portrait @elseif($video->orientation === 'square') square @elseif($video->orientation === 'ultrawide') ultrawide @endif" id="videoContainer"> <div class="video-container @if ($video->orientation === 'portrait') portrait @elseif($video->orientation === 'square') square @elseif($video->orientation === 'ultrawide') ultrawide @endif"
id="videoContainer">
<video id="videoPlayer" controls playsinline preload="metadata" autoplay> <video id="videoPlayer" controls playsinline preload="metadata" autoplay>
<source src="{{ route('videos.stream', $video->id) }}" type="video/mp4"> <source src="{{ route('videos.stream', $video->id) }}" type="video/mp4">
</video> </video>
<!-- Playlist Navigation Controls (only show when in playlist) -->
@if ($nextVideo || $previousVideo)
<div class="playlist-controls" id="playlistControls">
<!-- Previous Video -->
@if ($previousVideo)
<button class="playlist-nav-btn" onclick="goToPreviousVideo()" title="Previous video">
<i class="bi bi-skip-backward-fill"></i>
</button>
<span class="playlist-nav-label">{{ Str::limit($previousVideo->title, 20) }}</span>
@else
<button class="playlist-nav-btn" disabled title="No previous video">
<i class="bi bi-skip-backward-fill"></i>
</button>
@endif
<!-- Autoplay Toggle -->
<div class="autoplay-toggle">
<label for="autoplayToggle">Autoplay</label>
<label class="autoplay-switch">
<input type="checkbox" id="autoplayToggle" checked>
<span class="autoplay-slider"></span>
</label>
</div>
<!-- Next Video -->
@if ($nextVideo)
<span class="playlist-nav-label">{{ Str::limit($nextVideo->title, 20) }}</span>
<button class="playlist-nav-btn" onclick="goToNextVideo()" title="Next video">
<i class="bi bi-skip-forward-fill"></i>
</button>
@else
<button class="playlist-nav-btn" disabled title="No next video">
<i class="bi bi-skip-forward-fill"></i>
</button>
@endif
</div>
<div class="keyboard-hint">
<i class="bi bi-arrow-left"></i> <i class="bi bi-arrow-right"></i>
</div>
@endif
</div> </div>
<!-- Video Title with Film Icon (Generic Type) --> <!-- Video Title with Film Icon (Generic Type) -->
@ -258,66 +465,18 @@
</div> </div>
</div> </div>
<!-- Channel Row - All in one line --> <x-channel-row :video="$video" />
<div class="channel-row" style="display: flex; align-items: center; justify-content: space-between; padding: 12px 0; flex-wrap: wrap; gap: 16px;">
<div style="display: flex; align-items: center; gap: 12px;">
<a href="{{ route('channel', $video->user_id) }}" class="channel-info text-decoration-none" style="color: inherit; display: flex; align-items: center; gap: 12px;">
@if($video->user)
<img src="{{ $video->user->avatar_url }}" class="channel-avatar" style="width: 40px; height: 40px; border-radius: 50%;" alt="{{ $video->user->name }}">
@else
<div class="channel-avatar" style="width: 40px; height: 40px; border-radius: 50%;"></div>
@endif
<div>
<div class="channel-name" style="font-weight: 600;">{{ $video->user->name ?? 'Unknown' }}</div>
<div class="channel-subs" style="font-size: 12px; color: var(--text-secondary);">{{ number_format($video->user->subscriber_count ?? 0) }} subscribers</div>
</div>
</a>
</div>
<div class="video-actions" style="display: flex; align-items: center; gap: 8px; flex-wrap: wrap;">
@auth
@if(Auth::id() !== $video->user_id)
<button class="subscribe-btn">Subscribe</button>
@else
<button class="yt-action-btn" onclick="openEditVideoModal({{ $video->id }})">
<i class="bi bi-pencil"></i> Edit
</button>
@endif
@else
<a href="{{ route('login') }}" class="subscribe-btn">Subscribe</a>
@endauth
@auth
<form method="POST" action="{{ $video->isLikedBy(Auth::user()) ? route('videos.unlike', $video->id) : route('videos.like', $video->id) }}" class="d-inline">
@csrf
<button type="submit" class="yt-action-btn {{ $video->isLikedBy(Auth::user()) ? 'liked' : '' }}">
<i class="bi {{ $video->isLikedBy(Auth::user()) ? 'bi-hand-thumbs-up-fill' : 'bi-hand-thumbs-up' }}"></i>
{{ $video->like_count > 0 ? number_format($video->like_count) : 'Like' }}
</button>
</form>
@else
<a href="{{ route('login') }}" class="yt-action-btn">
<i class="bi bi-hand-thumbs-up"></i> Like
</a>
@endauth
@if($video->isShareable())
<button class="yt-action-btn" onclick="openShareModal('{{ $video->share_url }}', '{{ addslashes($video->title) }}')">
<i class="bi bi-share"></i> Share
</button>
@endif
</div>
</div>
<!-- Description Box --> <!-- Description Box -->
@if($video->description) @if ($video->description)
@php @php
$fullDescription = $video->description; $fullDescription = $video->description;
$shortDescription = Str::limit($fullDescription, 200); $shortDescription = Str::limit($fullDescription, 200);
$needsExpand = strlen($fullDescription) > 200; $needsExpand = strlen($fullDescription) > 200;
@endphp @endphp
<div class="video-description-box" style="background: var(--bg-secondary); border-radius: 12px; padding: 12px; margin-bottom: 16px;"> <div class="video-description-box"
style="background: var(--bg-secondary); border-radius: 12px; padding: 12px; margin-bottom: 16px;">
<div class="description-stats" style="font-size: 14px; font-weight: 500; margin-bottom: 8px;"> <div class="description-stats" style="font-size: 14px; font-weight: 500; margin-bottom: 8px;">
<span>{{ number_format($video->view_count) }} views</span> <span>{{ number_format($video->view_count) }} views</span>
<span></span> <span></span>
@ -325,7 +484,7 @@
</div> </div>
<div style="border-bottom: 1px solid var(--border-color); margin: 8px 0;"></div> <div style="border-bottom: 1px solid var(--border-color); margin: 8px 0;"></div>
<div class="description-content" id="descriptionContent"> <div class="description-content" id="descriptionContent">
@if($needsExpand) @if ($needsExpand)
<div class="description-short" id="descShort"> <div class="description-short" id="descShort">
<span class="description-text">{!! Str::markdown($shortDescription) !!}</span> <span class="description-text">{!! Str::markdown($shortDescription) !!}</span>
<span class="description-ellipsis" style="color: var(--text-secondary);">... </span> <span class="description-ellipsis" style="color: var(--text-secondary);">... </span>
@ -333,7 +492,8 @@
<div class="description-full" id="descFull" style="display: none;"> <div class="description-full" id="descFull" style="display: none;">
<span class="description-text">{!! Str::markdown($fullDescription) !!}</span> <span class="description-text">{!! Str::markdown($fullDescription) !!}</span>
</div> </div>
<button onclick="toggleDescription()" id="descToggleBtn" style="background: none; border: none; color: var(--text-primary); font-weight: 600; font-size: 14px; cursor: pointer; padding: 0; margin-top: 4px;"> <button onclick="toggleDescription()" id="descToggleBtn"
style="background: none; border: none; color: var(--text-primary); font-weight: 600; font-size: 14px; cursor: pointer; padding: 0; margin-top: 4px;">
Show more Show more
</button> </button>
@else @else
@ -361,37 +521,52 @@
</script> </script>
<style> <style>
.video-description-box .description-text { font-size: 14px; line-height: 1.5; color: var(--text-primary); } .video-description-box .description-text {
.video-description-box .description-text p { margin-bottom: 8px; } font-size: 14px;
.video-description-box .description-text a { color: #3ea6ff; } line-height: 1.5;
color: var(--text-primary);
}
.video-description-box .description-text p {
margin-bottom: 8px;
}
.video-description-box .description-text a {
color: #3ea6ff;
}
</style> </style>
@endif @endif
<!-- Comment Section --> <!-- Comment Section -->
<div class="comments-section" style="margin-top: 24px; padding-top: 16px; border-top: 1px solid var(--border-color);"> <div class="comments-section"
style="margin-top: 24px; padding-top: 16px; border-top: 1px solid var(--border-color);">
<h3 style="font-size: 18px; font-weight: 600; margin-bottom: 16px;"> <h3 style="font-size: 18px; font-weight: 600; margin-bottom: 16px;">
Comments <span style="color: var(--text-secondary); font-weight: 400;">({{ $video->comment_count }})</span> Comments <span
style="color: var(--text-secondary); font-weight: 400;">({{ $video->comment_count }})</span>
</h3> </h3>
@auth @auth
<div class="comment-form" style="display: flex; gap: 12px; margin-bottom: 24px;"> <div class="comment-form" style="display: flex; gap: 12px; margin-bottom: 24px;">
<img src="{{ Auth::user()->avatar_url }}" class="channel-avatar" style="width: 40px; height: 40px;" alt="{{ Auth::user()->name }}"> <img src="{{ Auth::user()->avatar_url }}" class="channel-avatar" style="width: 40px; height: 40px;"
<div style="flex: 1;"> alt="{{ Auth::user()->name }}">
<textarea <div style="flex: 1; display: flex; align-items: center; gap: 8px;">
id="commentBody" <textarea id="commentBody" class="form-control" placeholder="Add a comment... Use @ to mention someone" rows="1"
class="form-control" style="background: transparent; border: none; border-bottom: 2px solid var(--border-color); color: var(--text-primary); border-radius: 0; padding: 12px 0 8px 0; flex: 1; margin: 0; height: 40px; font-size: 14px; outline: none; overflow: hidden;"></textarea>
placeholder="Add a comment... Use @ to mention someone" <button type="button" class="action-btn"
rows="3" onclick="document.getElementById('commentBody').value = ''" style="flex-shrink: 0;">
style="background: var(--bg-secondary); border: 1px solid var(--border-color); color: var(--text-primary); border-radius: 8px; padding: 12px; width: 100%; resize: none;" <i class="bi bi-x-lg"></i>
></textarea> <span>Cancel</span>
<div style="display: flex; gap: 8px; margin-top: 8px; justify-content: flex-end;"> </button>
<button type="button" class="yt-action-btn" onclick="document.getElementById('commentBody').value = ''">Cancel</button> <button type="button" class="action-btn comment-btn" onclick="submitComment({{ $video->id }})"
<button type="button" class="yt-action-btn" style="background: var(--brand-red); color: white;" onclick="submitComment({{ $video->id }})">Comment</button> style="flex-shrink: 0;">
</div> <i class="bi bi-chat-dots"></i>
<span>Comment</span>
</button>
</div> </div>
</div> </div>
@else @else
<div style="margin-bottom: 24px; padding: 16px; background: var(--bg-secondary); border-radius: 8px; text-align: center;"> <div
style="margin-bottom: 24px; padding: 16px; background: var(--bg-secondary); border-radius: 8px; text-align: center;">
<a href="{{ route('login') }}" style="color: var(--brand-red);">Sign in</a> to comment <a href="{{ route('login') }}" style="color: var(--brand-red);">Sign in</a> to comment
</div> </div>
@endauth @endauth
@ -400,7 +575,8 @@
@forelse($video->comments()->whereNull('parent_id')->with('user', 'replies.user')->latest()->get() as $comment) @forelse($video->comments()->whereNull('parent_id')->with('user', 'replies.user')->latest()->get() as $comment)
@include('videos.partials.comment', ['comment' => $comment]) @include('videos.partials.comment', ['comment' => $comment])
@empty @empty
<p style="color: var(--text-secondary); text-align: center; padding: 20px;">No comments yet. Be the first to comment!</p> <p style="color: var(--text-secondary); text-align: center; padding: 20px;">No comments yet. Be the
first to comment!</p>
@endforelse @endforelse
</div> </div>
</div> </div>
@ -416,20 +592,56 @@
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'X-CSRF-TOKEN': '{{ csrf_token() }}' 'X-CSRF-TOKEN': '{{ csrf_token() }}'
}, },
body: JSON.stringify({ body: body }) body: JSON.stringify({
body: body
})
}) })
.then(response => response.json()) .then(response => response.json())
.catch(error => {
console.error('Error:', error);
alert('Failed to post comment: ' + error);
})
.then(data => { .then(data => {
if (data.success) { if (data && data.success) {
document.getElementById('commentBody').value = ''; document.getElementById('commentBody').value = '';
location.reload(); addCommentToList(data.comment);
} else {
alert('Failed to post comment');
} }
}); });
} }
function deleteComment(commentId) { function addCommentToList(comment) {
if (!confirm('Are you sure you want to delete this comment?')) return; const commentsList = document.getElementById('commentsList');
const commentHtml = `
<div class="comment-item" style="display: flex; gap: 12px; margin-bottom: 16px;" id="comment-${comment.id}">
<img src="${comment.user.avatar_url}" class="channel-avatar" style="width: 36px; height: 36px; flex-shrink: 0;" alt="${comment.user.name}">
<div style="flex: 1;">
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 4px;">
<span style="font-weight: 600; font-size: 14px;">${comment.user.name}</span>
<span style="color: var(--text-secondary); font-size: 12px;">just now</span>
</div>
<div class="comment-body" style="font-size: 14px; line-height: 1.5; word-wrap: break-word;">
${comment.body.replace(/@(\\w+)/g, '<span style="color: #3ea6ff; font-weight: 500;">@$1</span>')}
</div>
<div style="display: flex; gap: 12px; margin-top: 8px;">
<button onclick="toggleReplyForm(${comment.id})" style="background: none; border: none; color: var(--text-secondary); font-size: 12px; font-weight: 600; cursor: pointer; padding: 0;">
Reply
</button>
</div>
</div>
</div>
`;
commentsList.insertAdjacentHTML('afterbegin', commentHtml);
const commentCount = document.querySelector('h3 span');
if (commentCount) {
const count = parseInt(commentCount.textContent.match(/\\((\\d+)\\)/)?.[2] || 0) + 1;
commentCount.textContent = `(${count})`;
}
}
function deleteComment(commentId) {
if (confirm('Are you sure you want to delete this comment?')) {
fetch(`/comments/${commentId}`, { fetch(`/comments/${commentId}`, {
method: 'DELETE', method: 'DELETE',
headers: { headers: {
@ -439,15 +651,22 @@
.then(response => response.json()) .then(response => response.json())
.then(data => { .then(data => {
if (data.success) { if (data.success) {
location.reload(); document.getElementById('comment-' + commentId).remove();
const commentCount = document.querySelector('h3 span');
if (commentCount) {
const count = parseInt(commentCount.textContent.match(/\\((\\d+)\\)/)?.[2] || 0) - 1;
commentCount.textContent = `(${count})`;
}
} }
}); });
} }
}
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
const commentTexts = document.querySelectorAll('.comment-body'); const commentTexts = document.querySelectorAll('.comment-body');
commentTexts.forEach(text => { commentTexts.forEach(text => {
const html = text.innerHTML.replace(/@(\w+)/g, '<span style="color: #3ea6ff; font-weight: 500;">@$1</span>'); const html = text.innerHTML.replace(/@(\w+)/g,
'<span style="color: #3ea6ff; font-weight: 500;">@$1</span>');
text.innerHTML = html; text.innerHTML = html;
}); });
}); });
@ -463,7 +682,10 @@
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'X-CSRF-TOKEN': '{{ csrf_token() }}' 'X-CSRF-TOKEN': '{{ csrf_token() }}'
}, },
body: JSON.stringify({ body: body, parent_id: parentId }) body: JSON.stringify({
body: body,
parent_id: parentId
})
}) })
.then(response => response.json()) .then(response => response.json())
.then(data => { .then(data => {
@ -475,10 +697,111 @@
</script> </script>
</div> </div>
<!-- Sidebar --> <!-- Sidebar - Up Next / Recommendations -->
<div class="yt-sidebar-container"> <div class="yt-sidebar-container">
@if ($playlist && $playlistVideos && $playlistVideos->count() > 0)
<h3 style="font-size: 16px; font-weight: 500; margin-bottom: 12px;">
<i class="bi bi-collection-play" style="margin-right: 8px;"></i>
{{ $playlist->name }}
<span
style="font-weight: 400; color: var(--text-secondary); font-size: 14px;">({{ $playlistVideos->count() }}
videos)</span>
</h3>
<div class="recommended-videos-list">
@foreach ($playlistVideos as $index => $playlistVideo)
@if ($playlistVideo->id !== $video->id)
<div class="sidebar-video-card{{ $playlistVideo->id === $video->id ? ' current-video' : '' }}"
onclick="window.location.href='{{ route('videos.show', $playlistVideo->id) }}?playlist={{ $playlist->id }}'">
<div class="sidebar-thumb" style="position: relative;">
@if ($playlistVideo->thumbnail)
<img src="{{ asset('storage/thumbnails/' . $playlistVideo->thumbnail) }}"
alt="{{ $playlistVideo->title }}">
@else
<img src="https://picsum.photos/seed/{{ $playlistVideo->id }}/320/180"
alt="{{ $playlistVideo->title }}">
@endif
@if ($playlistVideo->duration)
<span
class="yt-video-duration">{{ gmdate('i:s', $playlistVideo->duration) }}</span>
@endif
@if ($playlistVideo->is_shorts)
<span class="yt-shorts-badge"
style="position: absolute; top: 8px; left: 8px; font-size: 10px; padding: 2px 6px;">
<i class="bi bi-collection-play-fill"></i> SHORTS
</span>
@endif
<!-- Playlist position indicator -->
<span
style="position: absolute; bottom: 4px; left: 4px; background: rgba(0,0,0,0.8); color: white; padding: 2px 6px; border-radius: 4px; font-size: 12px; font-weight: 500;">
{{ $index + 1 }}
</span>
</div>
<div class="sidebar-info">
<div class="sidebar-title">
<i class="bi {{ match ($playlistVideo->type) {'music' => 'bi-music-note','match' => 'bi-trophy',default => 'bi-film'} }}"
style="color: #ef4444; margin-right: 4px; font-size: 12px;"></i>
{{ Str::limit($playlistVideo->title, 60) }}
</div>
<div class="sidebar-meta">
<div>{{ $playlistVideo->user->name ?? 'Unknown' }}</div>
<div>{{ number_format($playlistVideo->view_count) }} views
{{ $playlistVideo->created_at->diffForHumans() }}</div>
</div>
</div>
</div>
@endif
@endforeach
</div>
@if ($playlist->canEdit(Auth::user()))
<a href="{{ route('playlists.show', $playlist->id) }}" class="yt-action-btn"
style="margin-top: 12px; display: inline-block;">
<i class="bi bi-pencil"></i> Edit Playlist
</a>
@endif
@else
<h3 style="font-size: 16px; font-weight: 500; margin-bottom: 12px;">Up Next</h3> <h3 style="font-size: 16px; font-weight: 500; margin-bottom: 12px;">Up Next</h3>
<div class="text-secondary">More videos coming soon...</div> @if ($recommendedVideos && $recommendedVideos->count() > 0)
<div class="recommended-videos-list">
@foreach ($recommendedVideos as $recVideo)
<div class="sidebar-video-card"
onclick="window.location.href='{{ route('videos.show', $recVideo->id) }}'">
<div class="sidebar-thumb">
@if ($recVideo->thumbnail)
<img src="{{ asset('storage/thumbnails/' . $recVideo->thumbnail) }}"
alt="{{ $recVideo->title }}">
@else
<img src="https://picsum.photos/seed/{{ $recVideo->id }}/320/180"
alt="{{ $recVideo->title }}">
@endif
@if ($recVideo->duration)
<span class="yt-video-duration">{{ gmdate('i:s', $recVideo->duration) }}</span>
@endif
@if ($recVideo->is_shorts)
<span class="yt-shorts-badge"
style="position: absolute; top: 8px; left: 8px; font-size: 10px; padding: 2px 6px;">
<i class="bi bi-collection-play-fill"></i> SHORTS
</span>
@endif
</div>
<div class="sidebar-info">
<div class="sidebar-title">
<i class="bi {{ match ($recVideo->type) {'music' => 'bi-music-note','match' => 'bi-trophy',default => 'bi-film'} }}"
style="color: #ef4444; margin-right: 4px; font-size: 12px;"></i>
{{ Str::limit($recVideo->title, 60) }}
</div>
<div class="sidebar-meta">
<div>{{ $recVideo->user->name ?? 'Unknown' }}</div>
<div>{{ number_format($recVideo->view_count) }} views
{{ $recVideo->created_at->diffForHumans() }}</div>
</div>
</div>
</div>
@endforeach
</div>
@else
<div class="text-secondary">No recommendations available yet. Check back later!</div>
@endif
@endif
</div> </div>
</div> </div>
@ -486,7 +809,7 @@
@include('layouts.partials.share-modal') @include('layouts.partials.share-modal')
@include('layouts.partials.edit-video-modal') @include('layouts.partials.edit-video-modal')
@if(Session::has('openEditModal') && Session::get('openEditModal')) @if (Session::has('openEditModal') && Session::get('openEditModal'))
@auth @auth
<script> <script>
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
@ -497,15 +820,99 @@
@endif @endif
<script> <script>
// Playlist navigation URLs
const previousVideoUrl = @json($previousVideo ? route('videos.show', $previousVideo->id) . '?playlist=' . $playlist->id : null);
const nextVideoUrl = @json($nextVideo ? route('videos.show', $nextVideo->id) . '?playlist=' . $playlist->id : null);
// Autoplay management
let autoplayEnabled = true;
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
var videoPlayer = document.getElementById('videoPlayer'); var videoPlayer = document.getElementById('videoPlayer');
var playlistControls = document.getElementById('playlistControls');
var autoplayToggle = document.getElementById('autoplayToggle');
if (videoPlayer) { if (videoPlayer) {
videoPlayer.volume = 0.5; videoPlayer.volume = 0.5;
var playPromise = videoPlayer.play(); var playPromise = videoPlayer.play();
if (playPromise !== undefined) { if (playPromise !== undefined) {
playPromise.then(function() { console.log('Video autoplayed'); }).catch(function(error) { console.log('Autoplay blocked'); }); playPromise.then(function() {
console.log('Video autoplayed');
}).catch(function(error) {
console.log('Autoplay blocked');
});
} }
// Handle video ended - autoplay next
if (nextVideoUrl) {
videoPlayer.addEventListener('ended', function() {
if (autoplayEnabled) {
console.log('Video ended, going to next...');
window.location.href = nextVideoUrl;
} }
}); });
}
}
// Show playlist controls on hover
if (playlistControls) {
const videoContainer = document.getElementById('videoContainer');
videoContainer.addEventListener('mouseenter', function() {
playlistControls.classList.add('visible');
});
videoContainer.addEventListener('mouseleave', function() {
playlistControls.classList.remove('visible');
});
// Initially show for a few seconds
setTimeout(function() {
playlistControls.classList.add('visible');
}, 2000);
setTimeout(function() {
playlistControls.classList.remove('visible');
}, 6000);
}
// Autoplay toggle handler
if (autoplayToggle) {
autoplayToggle.addEventListener('change', function() {
autoplayEnabled = this.checked;
localStorage.setItem('playlistAutoplay', autoplayEnabled ? '1' : '0');
});
// Load saved preference
const savedAutoplay = localStorage.getItem('playlistAutoplay');
if (savedAutoplay !== null) {
autoplayEnabled = savedAutoplay === '1';
autoplayToggle.checked = autoplayEnabled;
}
}
// Keyboard navigation
document.addEventListener('keydown', function(e) {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') {
return;
}
if (e.key === 'ArrowLeft') {
e.preventDefault();
goToPreviousVideo();
} else if (e.key === 'ArrowRight') {
e.preventDefault();
goToNextVideo();
}
});
});
function goToPreviousVideo() {
if (previousVideoUrl) {
window.location.href = previousVideoUrl;
}
}
function goToNextVideo() {
if (nextVideoUrl) {
window.location.href = nextVideoUrl;
}
}
</script> </script>
@endsection @endsection

File diff suppressed because it is too large Load Diff

View File

@ -5,7 +5,10 @@
@section('extra_styles') @section('extra_styles')
<style> <style>
/* Video Section */ /* Video Section */
.yt-video-section { flex: 1; min-width: 0; } .yt-video-section {
flex: 1;
min-width: 0;
}
/* Video Player */ /* Video Player */
.video-container { .video-container {
@ -28,11 +31,159 @@
width: auto; width: auto;
} }
.video-container.portrait { aspect-ratio: 9/16; max-width: 50vh; } .video-container.portrait {
.video-container.square { aspect-ratio: 1/1; max-width: 70vh; } aspect-ratio: 9/16;
.video-container.ultrawide { aspect-ratio: 21/9; max-width: 100%; } max-width: 50vh;
}
.video-container video { width: 100%; height: 100%; object-fit: contain; } .video-container.square {
aspect-ratio: 1/1;
max-width: 70vh;
}
.video-container.ultrawide {
aspect-ratio: 21/9;
max-width: 100%;
}
.video-container video {
width: 100%;
height: 100%;
object-fit: contain;
}
/* Playlist Navigation Controls */
.playlist-controls {
position: absolute;
bottom: 60px;
left: 50%;
transform: translateX(-50%);
display: flex;
align-items: center;
gap: 12px;
background: rgba(0, 0, 0, 0.8);
padding: 8px 16px;
border-radius: 24px;
z-index: 10;
opacity: 0;
transition: opacity 0.3s ease;
}
.video-container:hover .playlist-controls,
.playlist-controls.visible {
opacity: 1;
}
.playlist-nav-btn {
background: transparent;
border: none;
color: white;
cursor: pointer;
padding: 8px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s ease;
}
.playlist-nav-btn:hover {
background: rgba(255, 255, 255, 0.2);
}
.playlist-nav-btn:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.playlist-nav-btn:disabled:hover {
background: transparent;
}
.playlist-nav-btn i {
font-size: 20px;
}
.playlist-nav-label {
font-size: 12px;
color: #aaa;
max-width: 120px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Autoplay Toggle */
.autoplay-toggle {
display: flex;
align-items: center;
gap: 8px;
padding: 0 8px;
border-left: 1px solid rgba(255, 255, 255, 0.2);
}
.autoplay-toggle label {
font-size: 12px;
color: #aaa;
cursor: pointer;
white-space: nowrap;
}
.autoplay-switch {
position: relative;
width: 36px;
height: 20px;
}
.autoplay-switch input {
opacity: 0;
width: 0;
height: 0;
}
.autoplay-slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #666;
transition: 0.3s;
border-radius: 20px;
}
.autoplay-slider:before {
position: absolute;
content: "";
height: 14px;
width: 14px;
left: 3px;
bottom: 3px;
background-color: white;
transition: 0.3s;
border-radius: 50%;
}
.autoplay-switch input:checked+.autoplay-slider {
background-color: var(--brand-red);
}
.autoplay-switch input:checked+.autoplay-slider:before {
transform: translateX(16px);
}
/* Keyboard hint */
.keyboard-hint {
position: absolute;
bottom: 10px;
right: 10px;
font-size: 10px;
color: rgba(255, 255, 255, 0.5);
background: rgba(0, 0, 0, 0.6);
padding: 4px 8px;
border-radius: 4px;
}
/* Video Info */ /* Video Info */
.video-title { .video-title {
@ -52,7 +203,12 @@
gap: 12px; gap: 12px;
} }
.video-stats-left { display: flex; align-items: center; gap: 16px; color: var(--text-secondary); } .video-stats-left {
display: flex;
align-items: center;
gap: 16px;
color: var(--text-secondary);
}
.video-actions { .video-actions {
display: flex; display: flex;
@ -74,9 +230,54 @@
font-weight: 500; font-weight: 500;
} }
.yt-action-btn:hover { background: var(--border-color); } .yt-action-btn:hover {
background: var(--border-color);
}
.yt-action-btn.liked { color: var(--brand-red); } .action-btn,
.comments-section .action-btn {
border: none;
border-radius: 8px;
padding: 8px 14px;
font-size: 0.82rem;
font-weight: 500;
cursor: pointer;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
color: var(--text-primary);
display: flex;
align-items: center;
gap: 6px;
transition: all 0.2s ease;
}
.action-btn:hover,
.comments-section .action-btn:hover {
background: var(--border-color);
transform: translateY(-1px);
}
.action-btn:active,
.comments-section .action-btn:active {
transform: translateY(0);
}
.action-btn svg,
.action-btn i,
.comments-section .action-btn svg,
.comments-section .action-btn i {
flex-shrink: 0;
}
.action-btn.comment-btn {
background: var(--brand-red);
color: white;
border-color: var(--brand-red);
}
.yt-action-btn.liked {
color: var(--brand-red);
}
/* Channel Row */ /* Channel Row */
.channel-row { .channel-row {
@ -93,7 +294,10 @@
} }
.channel-avatar { .channel-avatar {
width: 48px; height: 48px; border-radius: 50%; background: #555; width: 48px;
height: 48px;
border-radius: 50%;
background: #555;
} }
.channel-name { .channel-name {
@ -154,9 +358,16 @@
flex-shrink: 0; flex-shrink: 0;
} }
.sidebar-thumb img { width: 100%; height: 100%; object-fit: cover; } .sidebar-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
}
.sidebar-info { flex: 1; min-width: 0; } .sidebar-info {
flex: 1;
min-width: 0;
}
.sidebar-title { .sidebar-title {
font-size: 14px; font-size: 14px;
@ -168,27 +379,45 @@
overflow: hidden; overflow: hidden;
} }
.sidebar-meta { font-size: 12px; color: var(--text-secondary); } .sidebar-meta {
font-size: 12px;
color: var(--text-secondary);
}
/* Responsive */ /* Responsive */
@media (max-width: 1300px) { @media (max-width: 1300px) {
.yt-sidebar-container { width: 300px; } .yt-sidebar-container {
width: 300px;
}
} }
@media (max-width: 991px) { @media (max-width: 991px) {
.yt-main { margin-left: 0; flex-direction: column; } .yt-main {
.yt-sidebar-container { width: 100%; } margin-left: 0;
.yt-header-center { display: none; } flex-direction: column;
.sidebar-video-card { flex-direction: column; } }
.sidebar-thumb { width: 100%; }
.yt-sidebar-container {
width: 100%;
}
.sidebar-video-card {
flex-direction: column;
}
.sidebar-thumb {
width: 100%;
}
.video-layout-container { .video-layout-container {
flex-direction: column !important; flex-direction: column !important;
} }
.yt-video-section { .yt-video-section {
width: 100% !important; width: 100% !important;
flex: none !important; flex: none !important;
} }
.yt-sidebar-container { .yt-sidebar-container {
width: 100% !important; width: 100% !important;
margin-top: 16px; margin-top: 16px;
@ -196,32 +425,49 @@
} }
@media (max-width: 576px) { @media (max-width: 576px) {
.video-stats-row { flex-direction: column; align-items: flex-start; } .video-stats-row {
.video-actions { width: 100%; overflow-x: auto; justify-content: flex-start; } flex-direction: column;
.yt-main { padding: 12px !important; } align-items: flex-start;
}
.video-actions {
width: 100%;
overflow-x: auto;
justify-content: flex-start;
}
.yt-main {
padding: 12px !important;
}
.video-container { .video-container {
max-height: 50vh !important; max-height: 50vh !important;
border-radius: 0 !important; border-radius: 0 !important;
} }
.video-container video { .video-container video {
object-fit: contain !important; object-fit: contain !important;
} }
.video-title { .video-title {
font-size: 16px !important; font-size: 16px !important;
margin: 12px 0 6px !important; margin: 12px 0 6px !important;
} }
.channel-row { .channel-row {
flex-direction: column; flex-direction: column;
align-items: flex-start !important; align-items: flex-start !important;
gap: 12px; gap: 12px;
} }
.channel-info { .channel-info {
width: 100%; width: 100%;
} }
.subscribe-btn { .subscribe-btn {
width: 100%; width: 100%;
} }
.video-description { .video-description {
padding: 12px !important; padding: 12px !important;
} }
@ -237,10 +483,52 @@
<!-- Video Section --> <!-- Video Section -->
<div class="yt-video-section"> <div class="yt-video-section">
<!-- Video Player --> <!-- Video Player -->
<div class="video-container @if($video->orientation === 'portrait') portrait @elseif($video->orientation === 'square') square @elseif($video->orientation === 'ultrawide') ultrawide @endif" id="videoContainer"> <div class="video-container @if ($video->orientation === 'portrait') portrait @elseif($video->orientation === 'square') square @elseif($video->orientation === 'ultrawide') ultrawide @endif"
id="videoContainer">
<video id="videoPlayer" controls playsinline preload="metadata" autoplay> <video id="videoPlayer" controls playsinline preload="metadata" autoplay>
<source src="{{ route('videos.stream', $video->id) }}" type="video/mp4"> <source src="{{ route('videos.stream', $video->id) }}" type="video/mp4">
</video> </video>
<!-- Playlist Navigation Controls (only show when in playlist) -->
@if ($nextVideo || $previousVideo)
<div class="playlist-controls" id="playlistControls">
<!-- Previous Video -->
@if ($previousVideo)
<button class="playlist-nav-btn" onclick="goToPreviousVideo()" title="Previous video">
<i class="bi bi-skip-backward-fill"></i>
</button>
<span class="playlist-nav-label">{{ Str::limit($previousVideo->title, 20) }}</span>
@else
<button class="playlist-nav-btn" disabled title="No previous video">
<i class="bi bi-skip-backward-fill"></i>
</button>
@endif
<!-- Autoplay Toggle -->
<div class="autoplay-toggle">
<label for="autoplayToggle">Autoplay</label>
<label class="autoplay-switch">
<input type="checkbox" id="autoplayToggle" checked>
<span class="autoplay-slider"></span>
</label>
</div>
<!-- Next Video -->
@if ($nextVideo)
<span class="playlist-nav-label">{{ Str::limit($nextVideo->title, 20) }}</span>
<button class="playlist-nav-btn" onclick="goToNextVideo()" title="Next video">
<i class="bi bi-skip-forward-fill"></i>
</button>
@else
<button class="playlist-nav-btn" disabled title="No next video">
<i class="bi bi-skip-forward-fill"></i>
</button>
@endif
</div>
<div class="keyboard-hint">
<i class="bi bi-arrow-left"></i> <i class="bi bi-arrow-right"></i>
</div>
@endif
</div> </div>
<!-- Video Title with Music Note Icon (Music Type) --> <!-- Video Title with Music Note Icon (Music Type) -->
@ -259,28 +547,33 @@
</div> </div>
<!-- Channel Row - All in one line --> <!-- Channel Row - All in one line -->
<div class="channel-row" style="display: flex; align-items: center; justify-content: space-between; padding: 12px 0; flex-wrap: wrap; gap: 16px;"> <div class="channel-row"
style="display: flex; align-items: center; justify-content: space-between; padding: 12px 0; flex-wrap: wrap; gap: 16px;">
<div style="display: flex; align-items: center; gap: 12px;"> <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;"> <a href="{{ route('channel', $video->user_id) }}" class="channel-info text-decoration-none"
@if($video->user) style="color: inherit; display: flex; align-items: center; gap: 12px;">
<img src="{{ $video->user->avatar_url }}" class="channel-avatar" style="width: 40px; height: 40px; border-radius: 50%;" alt="{{ $video->user->name }}"> @if ($video->user)
<img src="{{ $video->user->avatar_url }}" class="channel-avatar"
style="width: 40px; height: 40px; border-radius: 50%;" alt="{{ $video->user->name }}">
@else @else
<div class="channel-avatar" style="width: 40px; height: 40px; border-radius: 50%;"></div> <div class="channel-avatar" style="width: 40px; height: 40px; border-radius: 50%;"></div>
@endif @endif
<div> <div>
<div class="channel-name" style="font-weight: 600;">{{ $video->user->name ?? 'Unknown' }}</div> <div class="channel-name" style="font-weight: 600;">{{ $video->user->name ?? 'Unknown' }}</div>
<div class="channel-subs" style="font-size: 12px; color: var(--text-secondary);">{{ number_format($video->user->subscriber_count ?? 0) }} subscribers</div> <div class="channel-subs" style="font-size: 12px; color: var(--text-secondary);">
{{ number_format($video->user->subscriber_count ?? 0) }} subscribers</div>
</div> </div>
</a> </a>
</div> </div>
<div class="video-actions" style="display: flex; align-items: center; gap: 8px; flex-wrap: wrap;"> <div class="video-actions" style="display: flex; align-items: center; gap: 8px; flex-wrap: wrap;">
@auth @auth
@if(Auth::id() !== $video->user_id) @if (Auth::id() !== $video->user_id)
<button class="subscribe-btn">Subscribe</button> <button class="subscribe-btn">Subscribe</button>
@else @else
<button class="yt-action-btn" onclick="openEditVideoModal({{ $video->id }})"> <button class="action-btn" onclick="openEditVideoModal({{ $video->id }})">
<i class="bi bi-pencil"></i> Edit <i class="bi bi-pencil"></i>
<span>Edit</span>
</button> </button>
@endif @endif
@else @else
@ -288,10 +581,13 @@
@endauth @endauth
@auth @auth
<form method="POST" action="{{ $video->isLikedBy(Auth::user()) ? route('videos.unlike', $video->id) : route('videos.like', $video->id) }}" class="d-inline"> <form method="POST"
action="{{ $video->isLikedBy(Auth::user()) ? route('videos.unlike', $video->id) : route('videos.like', $video->id) }}"
class="d-inline">
@csrf @csrf
<button type="submit" class="yt-action-btn {{ $video->isLikedBy(Auth::user()) ? 'liked' : '' }}"> <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> <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' }} {{ $video->like_count > 0 ? number_format($video->like_count) : 'Like' }}
</button> </button>
</form> </form>
@ -301,23 +597,36 @@
</a> </a>
@endauth @endauth
@if($video->isShareable()) @if ($video->isShareable())
<button class="yt-action-btn" onclick="openShareModal('{{ $video->share_url }}', '{{ addslashes($video->title) }}')"> <button class="action-btn"
onclick="openShareModal('{{ $video->share_url }}', '{{ addslashes($video->title) }}')">
<i class="bi bi-share"></i> Share <i class="bi bi-share"></i> Share
</button> </button>
@endif @endif
<!-- Save to Playlist Button -->
<button class="action-btn" onclick="openAddToPlaylistModal({{ $video->id }})">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"></path>
</svg>
<span>Save</span>
</button>
</div> </div>
</div> </div>
<!-- Description Box --> <!-- Description Box -->
@if($video->description) @if ($video->description)
@php @php
$fullDescription = $video->description; $fullDescription = $video->description;
$shortDescription = Str::limit($fullDescription, 200); $shortDescription = Str::limit($fullDescription, 200);
$needsExpand = strlen($fullDescription) > 200; $needsExpand = strlen($fullDescription) > 200;
@endphp @endphp
<div class="video-description-box" style="background: var(--bg-secondary); border-radius: 12px; padding: 12px; margin-bottom: 16px;"> <div class="video-description-box"
style="background: var(--bg-secondary); border-radius: 12px; padding: 12px; margin-bottom: 16px;">
<div class="description-stats" style="font-size: 14px; font-weight: 500; margin-bottom: 8px;"> <div class="description-stats" style="font-size: 14px; font-weight: 500; margin-bottom: 8px;">
<span>{{ number_format($video->view_count) }} views</span> <span>{{ number_format($video->view_count) }} views</span>
<span></span> <span></span>
@ -325,7 +634,7 @@
</div> </div>
<div style="border-bottom: 1px solid var(--border-color); margin: 8px 0;"></div> <div style="border-bottom: 1px solid var(--border-color); margin: 8px 0;"></div>
<div class="description-content" id="descriptionContent"> <div class="description-content" id="descriptionContent">
@if($needsExpand) @if ($needsExpand)
<div class="description-short" id="descShort"> <div class="description-short" id="descShort">
<span class="description-text">{!! Str::markdown($shortDescription) !!}</span> <span class="description-text">{!! Str::markdown($shortDescription) !!}</span>
<span class="description-ellipsis" style="color: var(--text-secondary);">... </span> <span class="description-ellipsis" style="color: var(--text-secondary);">... </span>
@ -333,7 +642,8 @@
<div class="description-full" id="descFull" style="display: none;"> <div class="description-full" id="descFull" style="display: none;">
<span class="description-text">{!! Str::markdown($fullDescription) !!}</span> <span class="description-text">{!! Str::markdown($fullDescription) !!}</span>
</div> </div>
<button onclick="toggleDescription()" id="descToggleBtn" style="background: none; border: none; color: var(--text-primary); font-weight: 600; font-size: 14px; cursor: pointer; padding: 0; margin-top: 4px;"> <button onclick="toggleDescription()" id="descToggleBtn"
style="background: none; border: none; color: var(--text-primary); font-weight: 600; font-size: 14px; cursor: pointer; padding: 0; margin-top: 4px;">
Show more Show more
</button> </button>
@else @else
@ -361,37 +671,53 @@
</script> </script>
<style> <style>
.video-description-box .description-text { font-size: 14px; line-height: 1.5; color: var(--text-primary); } .video-description-box .description-text {
.video-description-box .description-text p { margin-bottom: 8px; } font-size: 14px;
.video-description-box .description-text a { color: #3ea6ff; } line-height: 1.5;
color: var(--text-primary);
}
.video-description-box .description-text p {
margin-bottom: 8px;
}
.video-description-box .description-text a {
color: #3ea6ff;
}
</style> </style>
@endif @endif
<!-- Comment Section --> <!-- Comment Section -->
<div class="comments-section" style="margin-top: 24px; padding-top: 16px; border-top: 1px solid var(--border-color);"> <div class="comments-section"
style="margin-top: 24px; padding-top: 16px; border-top: 1px solid var(--border-color);">
<h3 style="font-size: 18px; font-weight: 600; margin-bottom: 16px;"> <h3 style="font-size: 18px; font-weight: 600; margin-bottom: 16px;">
Comments <span style="color: var(--text-secondary); font-weight: 400;">({{ $video->comment_count }})</span> Comments <span
style="color: var(--text-secondary); font-weight: 400;">({{ $video->comment_count }})</span>
</h3> </h3>
@auth @auth
<div class="comment-form" style="display: flex; gap: 12px; margin-bottom: 24px;"> <div class="comment-form" style="display: flex; gap: 12px; margin-bottom: 24px;">
<img src="{{ Auth::user()->avatar_url }}" class="channel-avatar" style="width: 40px; height: 40px;" alt="{{ Auth::user()->name }}"> <img src="{{ Auth::user()->avatar_url }}" class="channel-avatar" style="width: 40px; height: 40px;"
<div style="flex: 1;"> alt="{{ Auth::user()->name }}">
<textarea <div style="flex: 1; display: flex; align-items: center; gap: 8px;">
id="commentBody" <textarea id="commentBody" class="form-control" placeholder="Add a comment... Use @ to mention someone"
class="form-control" rows="1"
placeholder="Add a comment... Use @ to mention someone" style="background: transparent; border: none; border-bottom: 2px solid var(--border-color); color: var(--text-primary); border-radius: 0; padding: 12px 0 8px 0; flex: 1; margin: 0; height: 40px; font-size: 14px; outline: none; overflow: hidden;"></textarea>
rows="3" <button type="button" class="action-btn"
style="background: var(--bg-secondary); border: 1px solid var(--border-color); color: var(--text-primary); border-radius: 8px; padding: 12px; width: 100%; resize: none;" onclick="document.getElementById('commentBody').value = ''" style="flex-shrink: 0;">
></textarea> <i class="bi bi-x-lg"></i>
<div style="display: flex; gap: 8px; margin-top: 8px; justify-content: flex-end;"> <span>Cancel</span>
<button type="button" class="yt-action-btn" onclick="document.getElementById('commentBody').value = ''">Cancel</button> </button>
<button type="button" class="yt-action-btn" style="background: var(--brand-red); color: white;" onclick="submitComment({{ $video->id }})">Comment</button> <button type="button" class="action-btn comment-btn"
</div> onclick="submitComment({{ $video->id }})" style="flex-shrink: 0;">
<i class="bi bi-chat-dots"></i>
<span>Comment</span>
</button>
</div> </div>
</div> </div>
@else @else
<div style="margin-bottom: 24px; padding: 16px; background: var(--bg-secondary); border-radius: 8px; text-align: center;"> <div
style="margin-bottom: 24px; padding: 16px; background: var(--bg-secondary); border-radius: 8px; text-align: center;">
<a href="{{ route('login') }}" style="color: var(--brand-red);">Sign in</a> to comment <a href="{{ route('login') }}" style="color: var(--brand-red);">Sign in</a> to comment
</div> </div>
@endauth @endauth
@ -400,7 +726,8 @@
@forelse($video->comments()->whereNull('parent_id')->with('user', 'replies.user')->latest()->get() as $comment) @forelse($video->comments()->whereNull('parent_id')->with('user', 'replies.user')->latest()->get() as $comment)
@include('videos.partials.comment', ['comment' => $comment]) @include('videos.partials.comment', ['comment' => $comment])
@empty @empty
<p style="color: var(--text-secondary); text-align: center; padding: 20px;">No comments yet. Be the first to comment!</p> <p style="color: var(--text-secondary); text-align: center; padding: 20px;">No comments yet. Be the
first to comment!</p>
@endforelse @endforelse
</div> </div>
</div> </div>
@ -416,20 +743,56 @@
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'X-CSRF-TOKEN': '{{ csrf_token() }}' 'X-CSRF-TOKEN': '{{ csrf_token() }}'
}, },
body: JSON.stringify({ body: body }) body: JSON.stringify({
body: body
})
}) })
.then(response => response.json()) .then(response => response.json())
.catch(error => {
console.error('Error:', error);
alert('Failed to post comment: ' + error);
})
.then(data => { .then(data => {
if (data.success) { if (data && data.success) {
document.getElementById('commentBody').value = ''; document.getElementById('commentBody').value = '';
location.reload(); addCommentToList(data.comment);
} else {
alert('Failed to post comment');
} }
}); });
} }
function deleteComment(commentId) { function addCommentToList(comment) {
if (!confirm('Are you sure you want to delete this comment?')) return; const commentsList = document.getElementById('commentsList');
const commentHtml = `
<div class="comment-item" style="display: flex; gap: 12px; margin-bottom: 16px;" id="comment-${comment.id}">
<img src="${comment.user.avatar_url}" class="channel-avatar" style="width: 36px; height: 36px; flex-shrink: 0;" alt="${comment.user.name}">
<div style="flex: 1;">
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 4px;">
<span style="font-weight: 600; font-size: 14px;">${comment.user.name}</span>
<span style="color: var(--text-secondary); font-size: 12px;">just now</span>
</div>
<div class="comment-body" style="font-size: 14px; line-height: 1.5; word-wrap: break-word;">
${comment.body.replace(/@(\\w+)/g, '<span style="color: #3ea6ff; font-weight: 500;">@$1</span>')}
</div>
<div style="display: flex; gap: 12px; margin-top: 8px;">
<button onclick="toggleReplyForm(${comment.id})" style="background: none; border: none; color: var(--text-secondary); font-size: 12px; font-weight: 600; cursor: pointer; padding: 0;">
Reply
</button>
</div>
</div>
</div>
`;
commentsList.insertAdjacentHTML('afterbegin', commentHtml);
const commentCount = document.querySelector('h3 span');
if (commentCount) {
const count = parseInt(commentCount.textContent.match(/\\((\\d+)\\)/)?.[2] || 0) + 1;
commentCount.textContent = `(${count})`;
}
}
function deleteComment(commentId) {
if (confirm('Are you sure you want to delete this comment?')) {
fetch(`/comments/${commentId}`, { fetch(`/comments/${commentId}`, {
method: 'DELETE', method: 'DELETE',
headers: { headers: {
@ -439,15 +802,22 @@
.then(response => response.json()) .then(response => response.json())
.then(data => { .then(data => {
if (data.success) { if (data.success) {
location.reload(); document.getElementById('comment-' + commentId).remove();
const commentCount = document.querySelector('h3 span');
if (commentCount) {
const count = parseInt(commentCount.textContent.match(/\\((\\d+)\\)/)?.[2] || 0) - 1;
commentCount.textContent = `(${count})`;
}
} }
}); });
} }
}
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
const commentTexts = document.querySelectorAll('.comment-body'); const commentTexts = document.querySelectorAll('.comment-body');
commentTexts.forEach(text => { commentTexts.forEach(text => {
const html = text.innerHTML.replace(/@(\w+)/g, '<span style="color: #3ea6ff; font-weight: 500;">@$1</span>'); const html = text.innerHTML.replace(/@(\w+)/g,
'<span style="color: #3ea6ff; font-weight: 500;">@$1</span>');
text.innerHTML = html; text.innerHTML = html;
}); });
}); });
@ -463,7 +833,10 @@
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'X-CSRF-TOKEN': '{{ csrf_token() }}' 'X-CSRF-TOKEN': '{{ csrf_token() }}'
}, },
body: JSON.stringify({ body: body, parent_id: parentId }) body: JSON.stringify({
body: body,
parent_id: parentId
})
}) })
.then(response => response.json()) .then(response => response.json())
.then(data => { .then(data => {
@ -475,10 +848,111 @@
</script> </script>
</div> </div>
<!-- Sidebar --> <!-- Sidebar - Up Next / Recommendations -->
<div class="yt-sidebar-container"> <div class="yt-sidebar-container">
@if ($playlist && $playlistVideos && $playlistVideos->count() > 0)
<h3 style="font-size: 16px; font-weight: 500; margin-bottom: 12px;">
<i class="bi bi-collection-play" style="margin-right: 8px;"></i>
{{ $playlist->name }}
<span
style="font-weight: 400; color: var(--text-secondary); font-size: 14px;">({{ $playlistVideos->count() }}
videos)</span>
</h3>
<div class="recommended-videos-list">
@foreach ($playlistVideos as $index => $playlistVideo)
@if ($playlistVideo->id !== $video->id)
<div class="sidebar-video-card{{ $playlistVideo->id === $video->id ? ' current-video' : '' }}"
onclick="window.location.href='{{ route('videos.show', $playlistVideo->id) }}?playlist={{ $playlist->id }}'">
<div class="sidebar-thumb" style="position: relative;">
@if ($playlistVideo->thumbnail)
<img src="{{ asset('storage/thumbnails/' . $playlistVideo->thumbnail) }}"
alt="{{ $playlistVideo->title }}">
@else
<img src="https://picsum.photos/seed/{{ $playlistVideo->id }}/320/180"
alt="{{ $playlistVideo->title }}">
@endif
@if ($playlistVideo->duration)
<span
class="yt-video-duration">{{ gmdate('i:s', $playlistVideo->duration) }}</span>
@endif
@if ($playlistVideo->is_shorts)
<span class="yt-shorts-badge"
style="position: absolute; top: 8px; left: 8px; font-size: 10px; padding: 2px 6px;">
<i class="bi bi-collection-play-fill"></i> SHORTS
</span>
@endif
<!-- Playlist position indicator -->
<span
style="position: absolute; bottom: 4px; left: 4px; background: rgba(0,0,0,0.8); color: white; padding: 2px 6px; border-radius: 4px; font-size: 12px; font-weight: 500;">
{{ $index + 1 }}
</span>
</div>
<div class="sidebar-info">
<div class="sidebar-title">
<i class="bi {{ match ($playlistVideo->type) {'music' => 'bi-music-note','match' => 'bi-trophy',default => 'bi-film'} }}"
style="color: #ef4444; margin-right: 4px; font-size: 12px;"></i>
{{ Str::limit($playlistVideo->title, 60) }}
</div>
<div class="sidebar-meta">
<div>{{ $playlistVideo->user->name ?? 'Unknown' }}</div>
<div>{{ number_format($playlistVideo->view_count) }} views
{{ $playlistVideo->created_at->diffForHumans() }}</div>
</div>
</div>
</div>
@endif
@endforeach
</div>
@if ($playlist->canEdit(Auth::user()))
<a href="{{ route('playlists.show', $playlist->id) }}" class="yt-action-btn"
style="margin-top: 12px; display: inline-block;">
<i class="bi bi-pencil"></i> Edit Playlist
</a>
@endif
@else
<h3 style="font-size: 16px; font-weight: 500; margin-bottom: 12px;">Up Next</h3> <h3 style="font-size: 16px; font-weight: 500; margin-bottom: 12px;">Up Next</h3>
<div class="text-secondary">More videos coming soon...</div> @if ($recommendedVideos && $recommendedVideos->count() > 0)
<div class="recommended-videos-list">
@foreach ($recommendedVideos as $recVideo)
<div class="sidebar-video-card"
onclick="window.location.href='{{ route('videos.show', $recVideo->id) }}'">
<div class="sidebar-thumb">
@if ($recVideo->thumbnail)
<img src="{{ asset('storage/thumbnails/' . $recVideo->thumbnail) }}"
alt="{{ $recVideo->title }}">
@else
<img src="https://picsum.photos/seed/{{ $recVideo->id }}/320/180"
alt="{{ $recVideo->title }}">
@endif
@if ($recVideo->duration)
<span class="yt-video-duration">{{ gmdate('i:s', $recVideo->duration) }}</span>
@endif
@if ($recVideo->is_shorts)
<span class="yt-shorts-badge"
style="position: absolute; top: 8px; left: 8px; font-size: 10px; padding: 2px 6px;">
<i class="bi bi-collection-play-fill"></i> SHORTS
</span>
@endif
</div>
<div class="sidebar-info">
<div class="sidebar-title">
<i class="bi {{ match ($recVideo->type) {'music' => 'bi-music-note','match' => 'bi-trophy',default => 'bi-film'} }}"
style="color: #ef4444; margin-right: 4px; font-size: 12px;"></i>
{{ Str::limit($recVideo->title, 60) }}
</div>
<div class="sidebar-meta">
<div>{{ $recVideo->user->name ?? 'Unknown' }}</div>
<div>{{ number_format($recVideo->view_count) }} views
{{ $recVideo->created_at->diffForHumans() }}</div>
</div>
</div>
</div>
@endforeach
</div>
@else
<div class="text-secondary">No recommendations available yet. Check back later!</div>
@endif
@endif
</div> </div>
</div> </div>
@ -486,7 +960,7 @@
@include('layouts.partials.share-modal') @include('layouts.partials.share-modal')
@include('layouts.partials.edit-video-modal') @include('layouts.partials.edit-video-modal')
@if(Session::has('openEditModal') && Session::get('openEditModal')) @if (Session::has('openEditModal') && Session::get('openEditModal'))
@auth @auth
<script> <script>
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
@ -497,15 +971,99 @@
@endif @endif
<script> <script>
// Playlist navigation URLs
const previousVideoUrl = @json($previousVideo ? route('videos.show', $previousVideo->id) . '?playlist=' . $playlist->id : null);
const nextVideoUrl = @json($nextVideo ? route('videos.show', $nextVideo->id) . '?playlist=' . $playlist->id : null);
// Autoplay management
let autoplayEnabled = true;
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
var videoPlayer = document.getElementById('videoPlayer'); var videoPlayer = document.getElementById('videoPlayer');
var playlistControls = document.getElementById('playlistControls');
var autoplayToggle = document.getElementById('autoplayToggle');
if (videoPlayer) { if (videoPlayer) {
videoPlayer.volume = 0.5; videoPlayer.volume = 0.5;
var playPromise = videoPlayer.play(); var playPromise = videoPlayer.play();
if (playPromise !== undefined) { if (playPromise !== undefined) {
playPromise.then(function() { console.log('Video autoplayed'); }).catch(function(error) { console.log('Autoplay blocked'); }); playPromise.then(function() {
console.log('Video autoplayed');
}).catch(function(error) {
console.log('Autoplay blocked');
});
} }
// Handle video ended - autoplay next
if (nextVideoUrl) {
videoPlayer.addEventListener('ended', function() {
if (autoplayEnabled) {
console.log('Video ended, going to next...');
window.location.href = nextVideoUrl;
} }
}); });
}
}
// Show playlist controls on hover
if (playlistControls) {
const videoContainer = document.getElementById('videoContainer');
videoContainer.addEventListener('mouseenter', function() {
playlistControls.classList.add('visible');
});
videoContainer.addEventListener('mouseleave', function() {
playlistControls.classList.remove('visible');
});
// Initially show for a few seconds
setTimeout(function() {
playlistControls.classList.add('visible');
}, 2000);
setTimeout(function() {
playlistControls.classList.remove('visible');
}, 6000);
}
// Autoplay toggle handler
if (autoplayToggle) {
autoplayToggle.addEventListener('change', function() {
autoplayEnabled = this.checked;
localStorage.setItem('playlistAutoplay', autoplayEnabled ? '1' : '0');
});
// Load saved preference
const savedAutoplay = localStorage.getItem('playlistAutoplay');
if (savedAutoplay !== null) {
autoplayEnabled = savedAutoplay === '1';
autoplayToggle.checked = autoplayEnabled;
}
}
// Keyboard navigation
document.addEventListener('keydown', function(e) {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') {
return;
}
if (e.key === 'ArrowLeft') {
e.preventDefault();
goToPreviousVideo();
} else if (e.key === 'ArrowRight') {
e.preventDefault();
goToNextVideo();
}
});
});
function goToPreviousVideo() {
if (previousVideoUrl) {
window.location.href = previousVideoUrl;
}
}
function goToNextVideo() {
if (nextVideoUrl) {
window.location.href = nextVideoUrl;
}
}
</script> </script>
@endsection @endsection

File diff suppressed because one or more lines are too long

View File

@ -1,23 +1,25 @@
<?php <?php
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; 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;
// Redirect root to videos // Root route - show videos
Route::get('/', function () { Route::get('/', [VideoController::class, 'index'])->name('home');
return redirect('/videos');
});
// Video routes - public // Video routes - public
Route::get('/videos', [VideoController::class, 'index'])->name('videos.index'); Route::get('/videos', [VideoController::class, 'index'])->name('videos.index');
Route::get('/videos/search', [VideoController::class, 'search'])->name('videos.search'); 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/create', [VideoController::class, 'create'])->name('videos.create');
Route::get('/videos/{video}', [VideoController::class, 'show'])->name('videos.show'); Route::get('/videos/{video}', [VideoController::class, 'show'])->name('videos.show');
Route::get('/videos/{video}/stream', [VideoController::class, 'stream'])->name('videos.stream'); Route::get('/videos/{video}/stream', [VideoController::class, 'stream'])->name('videos.stream');
Route::get('/videos/{video}/download', [VideoController::class, 'download'])->name('videos.download'); Route::get('/videos/{video}/download', [VideoController::class, 'download'])->name('videos.download');
Route::get('/videos/{video}/recommendations', [VideoController::class, 'recommendations'])->name('videos.recommendations');
// Video routes - auth required // Video routes - auth required
Route::middleware('auth')->group(function () { Route::middleware('auth')->group(function () {
@ -59,6 +61,37 @@ Route::middleware('auth')->group(function () {
// Channel - public for viewing, own channel requires auth // Channel - public for viewing, own channel requires auth
Route::get('/channel/{userId?}', [UserController::class, 'channel'])->name('channel'); 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 // Authentication Routes
require __DIR__.'/auth.php'; require __DIR__.'/auth.php';
@ -80,3 +113,23 @@ Route::middleware(['auth', 'super_admin'])->prefix('admin')->name('admin.')->gro
Route::delete('/videos/{video}', [SuperAdminController::class, 'deleteVideo'])->name('videos.delete'); 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');

View File